Spaces:
Running
Running
| import re | |
| from typing import List | |
| from engine.models.ability import ( | |
| Ability, | |
| AbilityCostType, | |
| Condition, | |
| ConditionType, | |
| Cost, | |
| Effect, | |
| EffectType, | |
| TargetType, | |
| TriggerType, | |
| ) | |
| class AbilityParser: | |
| def parse_ability_text(text: str) -> List[Ability]: | |
| abilities = [] | |
| # Split by newlines (blocks) - handle both literal and escaped newlines, and <br> | |
| text = text.replace("<br>", "\n") | |
| blocks = re.split(r"\\n|\n", text) | |
| last_ability = None | |
| for block in blocks: | |
| block = block.strip() | |
| if not block: | |
| continue | |
| # Split block into sentences. Standard Japanese uses '。' as period. | |
| # We split on '。' and optional space. | |
| sentences = [s.strip() for s in re.split(r"。\s*", block) if s.strip()] | |
| parenthesis_stack = 0 | |
| for i, line in enumerate(sentences): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| if not line: | |
| continue | |
| # Track parenthesis nesting across the block | |
| open_parens = line.count("(") + line.count("(") | |
| close_parens = line.count(")") + line.count(")") | |
| # Identify if this is a continuation of the previous ability | |
| starts_with_continuation = ( | |
| line.startswith("・") | |
| or line.startswith("-") | |
| or line.startswith("-") | |
| or any( | |
| line.startswith(kw) | |
| for kw in [ | |
| "回答が", | |
| "選んだ場合", | |
| "条件が", | |
| "それ以外", | |
| "その", | |
| "それら", | |
| "残り", | |
| "そし", | |
| "その後", | |
| "そこから", | |
| "もよい", | |
| "を自分", | |
| "ライブ終了時まで", | |
| "この能力", | |
| "この効果", | |
| "(", | |
| "(", | |
| "そうした場合", | |
| # NOTE: Removed "この" as it's too aggressive - blocks valid first-sentence | |
| # triggers like "このメンバーが登場したとき". More specific "この効果" and | |
| # "この能力" cover the actual continuation cases. | |
| "ただし", # However | |
| "かつ", # And | |
| "または", # Or | |
| "もしくは", | |
| "および", | |
| "代わりに", | |
| "このメンバー", | |
| "そのメンバー", | |
| "選んだ", | |
| "選んだエリア", | |
| "」", | |
| ")」", | |
| ] | |
| ) | |
| ) | |
| # Logic: If it starts with a continuation keyword OR we are currently inside an open parenthesis from previous sentence | |
| is_continuation = starts_with_continuation or (i > 0 and parenthesis_stack > 0) | |
| parenthesis_stack += open_parens - close_parens | |
| trigger = TriggerType.NONE | |
| if not is_continuation: | |
| # --- Trigger Parsing --- | |
| triggers = [] | |
| line_lower = line.lower() | |
| # Robust Trigger Identification with Tiered Priority | |
| # Tier 1: Explicit Icons (filenames) - Strongest | |
| # Tier 2: Specific Phrases - Medium | |
| # Tier 3: Generic Kanji - Weakest (prevent false positives from description text) | |
| matches = [] # List of (index, priority, TriggerType) | |
| # --- Tier 1: Icons --- | |
| if "toujyou" in line_lower: | |
| matches.append((line_lower.find("toujyou"), 1, TriggerType.ON_PLAY)) | |
| if "live_start" in line_lower: | |
| matches.append((line_lower.find("live_start"), 1, TriggerType.ON_LIVE_START)) | |
| if "live_success" in line_lower: | |
| # Special check for "only activates when" | |
| has_only_activates_when = "この能力は" in line and "のみ発動する" in line and "公開" in line | |
| if not has_only_activates_when: | |
| matches.append((line_lower.find("live_success"), 1, TriggerType.ON_LIVE_SUCCESS)) | |
| # --- Tier 2: Specific Phrases --- | |
| if "エールにより公開" in line or "エールで公開" in line: | |
| matches.append((line.find("公開"), 2, TriggerType.ON_REVEAL)) | |
| # --- Tier 3: Kanji / Keywords --- | |
| # Only add if not found via icon to avoid duplicates, or just add and let sorting handle it | |
| if "登場" in line: | |
| matches.append((line.find("登場"), 3, TriggerType.ON_PLAY)) | |
| if "ライブ開始" in line: | |
| matches.append((line.find("ライブ開始"), 3, TriggerType.ON_LIVE_START)) | |
| if "ライブの開始" in line: | |
| matches.append((line.find("ライブの開始"), 3, TriggerType.ON_LIVE_START)) | |
| if "ライブ成功" in line: | |
| matches.append((line.find("ライブ成功"), 3, TriggerType.ON_LIVE_SUCCESS)) | |
| if "kidou" in line_lower: | |
| matches.append((line_lower.find("kidou"), 1, TriggerType.ACTIVATED)) | |
| elif "起動" in line: | |
| matches.append((line.find("起動"), 3, TriggerType.ACTIVATED)) | |
| if "jyouji" in line_lower: | |
| matches.append((line_lower.find("jyouji"), 1, TriggerType.CONSTANT)) | |
| elif "常時" in line: | |
| matches.append((line.find("常時"), 3, TriggerType.CONSTANT)) | |
| if "エールで出た" in line: | |
| matches.append((line.find("エールで出た"), 2, TriggerType.CONSTANT)) | |
| if "jidou" in line_lower: | |
| matches.append((line_lower.find("jidou"), 1, TriggerType.ON_LEAVES)) | |
| elif "自動" in line: | |
| matches.append((line.find("自動"), 3, TriggerType.ON_LEAVES)) | |
| if "ターン開始" in line: | |
| matches.append((line.find("ターン開始"), 3, TriggerType.TURN_START)) | |
| if "ターン終了" in line: | |
| matches.append((line.find("ターン終了"), 3, TriggerType.TURN_END)) | |
| elif "live_end" in line_lower: | |
| matches.append((line_lower.find("live_end"), 1, TriggerType.TURN_END)) | |
| elif "ライブ終了" in line: | |
| matches.append((line.find("ライブ終了"), 3, TriggerType.TURN_END)) | |
| # Filter Logic | |
| # 1. Look Ahead filtering (ignore "Has [Start] Ability") | |
| valid_matches = [] | |
| for idx, tier, t_type in matches: | |
| if idx == -1: | |
| continue | |
| look_ahead = line[idx : idx + 20] | |
| if any(kw in look_ahead for kw in ["能力", "スキル", "を持つ", "を持たない", "がない"]): | |
| continue | |
| valid_matches.append((idx, tier, t_type)) | |
| if valid_matches: | |
| # 2. Find best tier | |
| best_tier = min(m[1] for m in valid_matches) | |
| # 3. Filter to only matches of best tier | |
| best_matches = [m for m in valid_matches if m[1] == best_tier] | |
| # 4. Sort by index (Earliest Wins) | |
| best_matches.sort(key=lambda x: x[0]) | |
| trigger = best_matches[0][2] | |
| # Priority Override: Event Triggers > Constant (Refined) | |
| # If we have ON_LIVE_SUCCESS/START/PLAY mixed with CONSTANT, prefer the Event Trigger | |
| event_triggers = { | |
| TriggerType.ON_LIVE_SUCCESS, | |
| TriggerType.ON_LIVE_START, | |
| TriggerType.ON_PLAY, | |
| TriggerType.ON_REVEAL, | |
| TriggerType.ON_LEAVES, | |
| TriggerType.TURN_START, | |
| TriggerType.TURN_END, | |
| } | |
| has_event = any(m[2] in event_triggers for m in best_matches) | |
| has_constant = any(m[2] == TriggerType.CONSTANT for m in best_matches) | |
| if has_event and has_constant: | |
| # Pick the first event trigger | |
| trigger = next(m[2] for m in best_matches if m[2] in event_triggers) | |
| elif i == 0 and not is_continuation: | |
| # Fallback for first sentence without icon: only if it contains action keywords | |
| if any( | |
| kw in line | |
| for kw in [ | |
| "引", | |
| "スコア", | |
| "プラス", | |
| "+", | |
| "ブレード", | |
| "ハート", | |
| "控", | |
| "戻", | |
| "エネ", | |
| "デッキ", | |
| "山札", | |
| "見る", | |
| "公開", | |
| "選ぶ", | |
| "選び", | |
| "選ぶ。", | |
| ] | |
| ): | |
| trigger = TriggerType.ACTIVATED | |
| conditions = [] | |
| effects = [] | |
| costs = [] | |
| # --- Split into Cost and effect early to find colon index --- | |
| full_content = re.sub(r"(.*?)|\(.*?\)", "", line) | |
| colon_idx = full_content.find(":") | |
| if colon_idx == -1: | |
| colon_idx = full_content.find(":") | |
| cost_part = full_content[:colon_idx] if colon_idx != -1 else None | |
| content = full_content # Use full content for condition parsing initially | |
| # We will truncate 'content' later specifically for effects. | |
| # --- Once per turn --- | |
| is_once_per_turn = any( | |
| kw in line | |
| for kw in [ | |
| "1ターンに1回", | |
| "ターン終了時まで1回", | |
| "に限る", | |
| "ターン1回", | |
| "[ターン1回]", | |
| "【ターン1回】", | |
| ] | |
| ) | |
| if "[Turn 1]" in line or "ターン1" in line: | |
| conditions.append(Condition(ConditionType.TURN_1, {"turn": 1})) | |
| # --- Zone Context --- | |
| context_zone = None | |
| zone_map = { | |
| "右サイドエリア": "RIGHT_STAGE", | |
| "左サイドエリア": "LEFT_STAGE", | |
| "センターエリア": "CENTER_STAGE", | |
| "成功ライブカード置き場": "SUCCESS_LIVE", | |
| "ライブ成功カード置き場": "SUCCESS_LIVE", | |
| "ライブ成功": "SUCCESS_LIVE", | |
| "エネルギー置き場": "ENERGY", | |
| "エネルギーデッキ": "ENERGY_DECK", | |
| "ライブカード置き場": "LIVE_ZONE", | |
| "ライブエリア": "LIVE_AREA", | |
| "控え室": "DISCARD", | |
| "手札": "HAND", | |
| "ステージ": "STAGE", | |
| "山札": "DECK", | |
| "デッキ": "DECK", | |
| "ライブ中": "LIVE_ZONE", | |
| } | |
| # Enhanced zone detection with keyword proximity | |
| best_zone_idx = -1 | |
| for keyword, zone_id in zone_map.items(): | |
| idx = content.find(keyword) | |
| if idx != -1: | |
| if best_zone_idx == -1 or idx < best_zone_idx: | |
| best_zone_idx = idx | |
| context_zone = zone_id | |
| if context_zone: | |
| # Look for "相手" (opponent) near the zone keyword | |
| # "相手のセンターエリア" vs "自分のセンターエリア" | |
| prefix_text = content[max(0, best_zone_idx - 8) : best_zone_idx] | |
| if "相手" in prefix_text: | |
| context_zone = "OPPONENT_" + context_zone | |
| elif "自分" in prefix_text: | |
| # Explicitly yours, keep as is | |
| pass | |
| elif "相手" in content and best_zone_idx > content.find("相手"): | |
| # Fallback: if opponent is mentioned before the zone keyword | |
| context_zone = "OPPONENT_" + context_zone | |
| # Success Live Count: 成功ライブカード置き場にカードがX枚以上ある場合 | |
| if "成功ライブカード置き場" in content and "枚以上" in content: | |
| match = re.search(r"(\d+)枚以上", content) | |
| if match: | |
| conditions.append(Condition(ConditionType.COUNT_SUCCESS_LIVE, {"min": int(match.group(1))})) | |
| # Live Zone Count: ライブ中のカードがX枚以上ある場合 | |
| if "ライブ中のカード" in content and "枚以上" in content: | |
| match = re.search(r"(\d+)枚以上", content) | |
| if match: | |
| conditions.append(Condition(ConditionType.COUNT_LIVE_ZONE, {"min": int(match.group(1))})) | |
| # Heart Comparison (Opponent) | |
| if "ハートの総数" in content and "相手" in content and any(kw in content for kw in ["多い", "少ない"]): | |
| comp = "GT" if "多い" in content else "LT" | |
| conditions.append( | |
| Condition( | |
| ConditionType.SCORE_COMPARE, {"comparison": comp, "target": "opponent", "type": "heart"} | |
| ) | |
| ) | |
| # Cheer Count Comparison (Opponent) | |
| if "エール" in content and "枚数" in content and "相手" in content: | |
| conditions.append( | |
| Condition(ConditionType.SCORE_COMPARE, {"target": "opponent", "type": "cheer_count"}) | |
| ) | |
| # Heart Inclusion (Specific Colors) | |
| if "ハートの中に" in content and "がある場合" in content: | |
| conditions.append( | |
| Condition( | |
| ConditionType.HAS_KEYWORD, {"keyword": "Specific Heart", "context": "heart_inclusion"} | |
| ) | |
| ) | |
| # Opponent Hand Diff: 相手の手札の枚数が自分よりX枚以上多い場合 | |
| if match := re.search(r"相手の手札の枚数が自分より(\d+)枚以上多い場合", content): | |
| conditions.append(Condition(ConditionType.OPPONENT_HAND_DIFF, {"diff": int(match.group(1))})) | |
| # Opponent Energy Diff: 相手のエネルギーが自分よりX枚以上多い場合 or just 多い場合 | |
| if match := re.search(r"相手のエネルギーが自分より(\d+)枚以上多い場合", content): | |
| conditions.append(Condition(ConditionType.OPPONENT_ENERGY_DIFF, {"diff": int(match.group(1))})) | |
| elif "相手のエネルギーが自分より多い場合" in content: | |
| conditions.append(Condition(ConditionType.OPPONENT_ENERGY_DIFF, {"diff": 1})) | |
| # ALL Blade Rule (Meta Rule) | |
| if "ALLブレード" in content and any( | |
| kw in content for kw in ["ハートとして扱う", "ハートとして内容を確認", "いずれかの色のハート"] | |
| ): | |
| trigger = TriggerType.CONSTANT | |
| effects.append( | |
| Effect(EffectType.META_RULE, target=TargetType.PLAYER, params={"type": "heart_rule"}) | |
| ) | |
| # --- Condition Parsing --- | |
| # Detect group filters early for propagation | |
| sentence_groups = re.findall(r"『(.*?)』", content) | |
| # Issue Gap-1: Has Moved (Standalone check - must be separate to capture alongside group conditions) | |
| if "移動している場合" in content: | |
| if not any(c.type == ConditionType.HAS_MOVED for c in conditions): | |
| conditions.append(Condition(ConditionType.HAS_MOVED)) | |
| # Group count | |
| if match := re.search(r"『(.*?)』.*?(\d+)(枚|人)以上", content): | |
| params = {"group": match.group(1), "min": int(match.group(2))} | |
| if context_zone: | |
| params["zone"] = context_zone | |
| conditions.append(Condition(ConditionType.COUNT_GROUP, params)) | |
| # Issue 541: Opponent Hand > Self | |
| elif match := re.search( | |
| r"相手の手札(の枚数)?が自分(の手札)?より(多い|少ない|(\d+)枚以上多い)", content | |
| ): | |
| diff = 1 | |
| comp = "GT" if "多い" in content else "LT" | |
| if match.group(4): | |
| diff = int(match.group(4)) | |
| conditions.append(Condition(ConditionType.OPPONENT_HAND_DIFF, {"diff": diff, "comparison": comp})) | |
| # Issue 177: Hand Increased | |
| elif match := re.search(r"このターンに自分の手札が(\d+)枚以上増えている", content): | |
| conditions.append(Condition(ConditionType.HAND_INCREASED, {"min": int(match.group(1))})) | |
| # Issue 541: Opponent Hand > Self (Enhanced - separate to avoid overlap) | |
| elif match := re.search(r"相手の手札(?:の枚数)?が自分(?:の手札)?より(\d+)枚以上多い場合", content): | |
| if not any(c.type == ConditionType.OPPONENT_HAND_DIFF for c in conditions): | |
| conditions.append( | |
| Condition( | |
| ConditionType.OPPONENT_HAND_DIFF, {"diff": int(match.group(1)), "comparison": "GE"} | |
| ) | |
| ) | |
| # Issue 269: Live Zone Group Check (Zone count part) | |
| elif ( | |
| context_zone | |
| and context_zone != "SUCCESS_LIVE" | |
| and (match := re.search(r"(\d+)(枚|人)以上", content)) | |
| ): | |
| params = {"count": int(match.group(1)), "zone": context_zone} | |
| conditions.append( | |
| Condition( | |
| ConditionType.COUNT_DISCARD if context_zone == "DISCARD" else ConditionType.COUNT_STAGE, | |
| params, | |
| ) | |
| ) | |
| # Issue 558: Energy Count Check (Moved Up) | |
| if match := re.search(r"エネルギーが(\d+)枚以上", content): | |
| conditions.append(Condition(ConditionType.COUNT_ENERGY, {"min": int(match.group(1))})) | |
| # Generic count (STAGE fallback) | |
| if ( | |
| (match := re.search(r"(\d+)枚以上ある場合", content)) | |
| and not conditions | |
| and "エネルギー" not in content | |
| ): | |
| params = {"min": int(match.group(1))} | |
| if context_zone: | |
| params["zone"] = context_zone | |
| conditions.append(Condition(ConditionType.COUNT_STAGE, params)) | |
| # "If all are X" | |
| if match := re.search(r"(?:それらが|カードが)?すべて(.*?)の場合", content): | |
| conditions.append( | |
| Condition(ConditionType.GROUP_FILTER, {"group": match.group(1).strip(), "context": "revealed"}) | |
| ) | |
| # Group filter 『...』 | |
| for g in sentence_groups: | |
| if not any(c.type == ConditionType.COUNT_GROUP and c.params.get("group") == g for c in conditions): | |
| params = {"group": g} | |
| if "名前の異なる" in content: | |
| params["distinct_names"] = True | |
| if context_zone: | |
| params["zone"] = context_zone | |
| if match := re.search(rf"『{re.escape(g)}』.*?(\d+)(人|枚)以上", content): | |
| params["count"] = int(match.group(1)) | |
| conditions.append(Condition(ConditionType.COUNT_GROUP, params)) | |
| elif any( | |
| kw in content | |
| for kw in ["場合", "なら", "に限る", "ないかぎり", "のメンバーがいる", "がいるとき"] | |
| ): | |
| if "ほかの" in content or "他の" in content: | |
| params["exclude_self"] = True | |
| conditions.append(Condition(ConditionType.GROUP_FILTER, params)) | |
| # Issue 269: "その中に『...』がある場合" (referencing previous zone check) | |
| elif "その中に" in content and "がある場合" in content: | |
| params = { | |
| "group": g, | |
| "context": "live_zone" | |
| if "ライブ中" in content | |
| or "live" in content | |
| or any( | |
| c.type == ConditionType.COUNT_STAGE and c.params.get("zone") == "LIVE_ZONE" | |
| for c in conditions | |
| ) | |
| else "stage", | |
| } | |
| conditions.append(Condition(ConditionType.GROUP_FILTER, params)) | |
| # Specific Member names 「...」 | |
| if any(kw in content for kw in ["がある場合", "がいる場合", "登場している場合"]): | |
| found_names = set() | |
| for area_name, member_name in re.findall(r"([左中右センター].*?エリア)に「(.*?)」", content): | |
| area_id = ( | |
| "LEFT_STAGE" | |
| if "左" in area_name | |
| else "RIGHT_STAGE" | |
| if "右" in area_name | |
| else "CENTER_STAGE" | |
| ) | |
| conditions.append( | |
| Condition(ConditionType.HAS_MEMBER, {"name": member_name, "area": area_id, "zone": "STAGE"}) | |
| ) | |
| found_names.add(member_name) | |
| for n in re.findall(r"「(.*?)」", content): | |
| if n not in found_names: | |
| params = {"name": n} | |
| if context_zone: | |
| params["zone"] = context_zone | |
| conditions.append(Condition(ConditionType.HAS_MEMBER, params)) | |
| # Negation | |
| if any(kw in content for kw in ["以外", "でない場合", "ではない場合"]) and conditions: | |
| conditions[-1].is_negated = True | |
| # Center Area specific | |
| if "センターエリア" in content and "場合" in content: | |
| if not any(c.type == ConditionType.IS_CENTER for c in conditions): | |
| conditions.append(Condition(ConditionType.IS_CENTER)) | |
| # Multiplier Factor (1枚につき... / 人につき...) | |
| if match := re.search(r"(?:(\d+)枚|(\d+)人)につき", content): | |
| mult = int(match.group(1) or match.group(2)) | |
| # Propagate to future effects in this sentence | |
| current_multiplier = mult | |
| else: | |
| current_multiplier = None | |
| # center, life lead, score lead, opponent has, modal answer | |
| if ( | |
| "センターエリア" in content | |
| and "場合" in content | |
| and not any(c.params.get("area") == "CENTER_STAGE" for c in conditions) | |
| ): | |
| conditions.append(Condition(ConditionType.IS_CENTER)) | |
| if any(kw in content for kw in ["ライフが相手より多い", "ライフが相手より少ない"]): | |
| conditions.append(Condition(ConditionType.LIFE_LEAD)) | |
| if "ブレードハートを持つ" in content: | |
| conditions.append(Condition(ConditionType.HAS_KEYWORD, {"keyword": "Blade Heart"})) | |
| # Score/Cost Interaction/Comparison (Enhanced) | |
| if any(kw in content for kw in ["スコア", "ライブの合計", "コストの合計", "コスト"]): | |
| # print(f"DEBUG: Found score/cost keyword in {content}") | |
| if any( | |
| re.search(p, content) | |
| for p in [ | |
| r"相手.*?より高い", | |
| r"相手.*?同じか高い", | |
| r"自分.*?同じか高い", | |
| r"相手.*?より低い", | |
| r"相手.*?同じか低い", | |
| r"自分.*?同じか低い", | |
| r"相手.*?より多い", | |
| r"相手.*?同じか多い", | |
| r"自分.*?同じか多い", | |
| r"相手.*?より少ない", | |
| r"相手.*?同じか少ない", | |
| r"自分.*?同じか少ない", | |
| r"相手.*?と同じ", | |
| r"自分.*?と同じ", | |
| r"相手.*?より高く", | |
| r"相手.*?より低く", | |
| ] | |
| ): | |
| comp = ( | |
| "GE" | |
| if re.search(r"同じか高い|以上|高ければ", content) | |
| else "LE" | |
| if re.search(r"同じか低い|以下|低ければ", content) | |
| else "GT" | |
| if re.search(r"高い|高く|多い", content) | |
| else "LT" | |
| if re.search(r"低い|低く|少ない", content) | |
| else "EQ" | |
| ) | |
| # Prioritize score if both are present but score is closer to comparison | |
| if "スコア" in content and "コスト" in content: | |
| ctype = ( | |
| "score" if re.search(r"スコア.*?同じ|スコア.*?高い|スコア.*?低い", content) else "cost" | |
| ) | |
| else: | |
| ctype = "cost" if "コスト" in content else "score" | |
| # print(f"DEBUG: Added SCORE_COMPARE {ctype} {comp}") | |
| params = {"comparison": comp, "target": "opponent", "type": ctype} | |
| if context_zone: | |
| params["zone"] = context_zone | |
| conditions.append(Condition(ConditionType.SCORE_COMPARE, params)) | |
| elif match := re.search( | |
| r"(?:スコア|コスト)(?:が|の合計が|の合計|の数)?.*?(\d+|1|2|3|4|5|6|7|8|9|0|[一二三四五六七八九〇])(つ|個|枚|人)?(以上|以下)", | |
| content, | |
| ): | |
| val_str = match.group(1) | |
| val_map = { | |
| "1": 1, | |
| "2": 2, | |
| "3": 3, | |
| "4": 4, | |
| "5": 5, | |
| "6": 6, | |
| "7": 7, | |
| "8": 8, | |
| "9": 9, | |
| "0": 0, | |
| "一": 1, | |
| "二": 2, | |
| "三": 3, | |
| "四": 4, | |
| "五": 5, | |
| "六": 6, | |
| "七": 7, | |
| "八": 8, | |
| "九": 9, | |
| "〇": 0, | |
| } | |
| val = int(val_map.get(val_str, val_str)) if not val_str.isdigit() else int(val_str) | |
| ctype = "cost" if "コスト" in content else "score" | |
| conditions.append( | |
| Condition( | |
| ConditionType.SCORE_COMPARE, | |
| {"comparison": "GE" if match.group(3) == "以上" else "LE", "value": val, "type": ctype}, | |
| ) | |
| ) | |
| elif "同じ場合" in content or "スコアが同じ" in content: | |
| conditions.append( | |
| Condition(ConditionType.SCORE_COMPARE, {"comparison": "EQ", "target": "opponent"}) | |
| ) | |
| if "相手" in content and any(kw in content for kw in ["ある場合", "いる場合", "のとき"]): | |
| conditions.append(Condition(ConditionType.OPPONENT_HAS)) | |
| is_modal_answer_branch = False | |
| if match := re.search(r"回答が(.*?)の場合", content): | |
| is_modal_answer_branch = True | |
| # Do not add condition, instead we treat this as a branch for SELECT_MODE | |
| # Opponent Choice Detection (opponent makes a decision) | |
| if "相手" in content and any(kw in content for kw in ["選ぶ", "選び", "選んで"]): | |
| conditions.append(Condition(ConditionType.OPPONENT_CHOICE, {"type": "select"})) | |
| elif "相手" in content and "控え室に置いてもよい" in content: | |
| conditions.append(Condition(ConditionType.OPPONENT_CHOICE, {"type": "discard_optional"})) | |
| elif "相手" in content and any(kw in content for kw in ["手札から", "捨てる", "選ばせる"]): | |
| conditions.append(Condition(ConditionType.OPPONENT_CHOICE, {"type": "discard"})) | |
| # Enhanced Choice Detection (player chooses) | |
| choice_patterns = [ | |
| r"1つを選ぶ", | |
| r"のうち.*?選ぶ", | |
| r"どちらか.*?選ぶ", | |
| r"選んでもよい", | |
| r"好きな.*?選", | |
| r"以下から.*?選ぶ", | |
| r"か.*?か.*?のうち", | |
| r"[一二三123]つを選ぶ", | |
| r"メンバー(\d+)人を?選ぶ", | |
| r"を(\d+)枚選ぶ", | |
| ] | |
| if any(re.search(p, content) for p in choice_patterns): | |
| conditions.append(Condition(ConditionType.HAS_CHOICE)) | |
| elif "1枚選ぶ" in content and any(kw in content for kw in ["控え室", "登場", "デッキ", "山札"]): | |
| conditions.append(Condition(ConditionType.HAS_CHOICE)) | |
| # Cost/Blade filter: コスト(\d+)(以下|以上), ブレード(の数)?が(\d+)(以下|以上) | |
| if match := re.search( | |
| r"(?:コスト|ブレード(?:の数)?)(?:が)?.*?(\d+|1|2|3|4|5|6|7|8|9|0|[一二三四五六七八九〇])(つ|個|枚|人)?(以下|以上)", | |
| content, | |
| ): | |
| val_str = match.group(1) | |
| val_map = { | |
| "1": 1, | |
| "2": 2, | |
| "3": 3, | |
| "4": 4, | |
| "5": 5, | |
| "6": 6, | |
| "7": 7, | |
| "8": 8, | |
| "9": 9, | |
| "0": 0, | |
| "一": 1, | |
| "二": 2, | |
| "三": 3, | |
| "四": 4, | |
| "五": 5, | |
| "六": 6, | |
| "七": 7, | |
| "八": 8, | |
| "九": 9, | |
| "〇": 0, | |
| } | |
| val = int(val_map.get(val_str, val_str)) if not val_str.isdigit() else int(val_str) | |
| ctype = "blade" if "ブレード" in content else "cost" | |
| conditions.append( | |
| Condition( | |
| ConditionType.COST_CHECK if ctype == "cost" else ConditionType.COUNT_BLADES, | |
| { | |
| "value" if ctype == "cost" else "min": val, | |
| "comparison": "LE" if match.group(3) == "以下" else "GE", | |
| }, | |
| ) | |
| ) | |
| if "余剰ハート" in content: | |
| is_neg = any(kw in content for kw in ["持たない", "ない場合", "でない場合"]) | |
| params = {"context": "excess"} | |
| if is_neg: | |
| params["min"] = 1 | |
| conditions.append(Condition(ConditionType.COUNT_HEARTS, params, is_negated=is_neg)) | |
| if "デッキの上から" in content and "公開" in content: | |
| context_zone = "DECK" | |
| # --- Split content for effect parsing --- | |
| if colon_idx != -1: | |
| content = content[colon_idx + 1 :] | |
| if cost_part: | |
| cost_is_optional = any(kw in cost_part for kw in ["もよい", "支払うことで", "支払えば"]) | |
| if "このメンバーをウェイトにし" in cost_part or "このメンバーをウェイトにする" in cost_part: | |
| costs.append(Cost(AbilityCostType.TAP_SELF, is_optional=cost_is_optional)) | |
| elif "相手" in cost_part and "ウェイト" in cost_part: | |
| costs.append( | |
| Cost( | |
| AbilityCostType.TAP_MEMBER, | |
| 1, | |
| params={"target": "opponent"}, | |
| is_optional=cost_is_optional, | |
| ) | |
| ) | |
| # Discard Hand Cost | |
| if any(kw in cost_part for kw in ["控え室に置", "捨て"]) and "手札" in cost_part: | |
| count_discard = 1 | |
| if match := re.search(r"(\d+)枚", cost_part): | |
| count_discard = int(match.group(1)) | |
| elif "すべて" in cost_part or "全て" in cost_part: | |
| count_discard = 99 | |
| costs.append(Cost(AbilityCostType.DISCARD_HAND, count_discard, is_optional=cost_is_optional)) | |
| if "手札をすべて公開する" in cost_part: | |
| costs.append(Cost(AbilityCostType.REVEAL_HAND_ALL, is_optional=cost_is_optional)) | |
| if "このメンバー" in cost_part and ("控え室に置" in cost_part or "捨て" in cost_part): | |
| costs.append(Cost(AbilityCostType.SACRIFICE_SELF, is_optional=cost_is_optional)) | |
| if "下に置かれているカードを" in cost_part and "控え室に置く" in cost_part: | |
| costs.append(Cost(AbilityCostType.SACRIFICE_UNDER, is_optional=cost_is_optional)) | |
| if "エネルギーを" in cost_part and "控え室に置く" in cost_part: | |
| costs.append(Cost(AbilityCostType.DISCARD_ENERGY, 1, is_optional=cost_is_optional)) | |
| if "手札に戻す" in cost_part and "このメンバー" in cost_part: | |
| costs.append(Cost(AbilityCostType.RETURN_HAND, is_optional=cost_is_optional)) | |
| # Parse "Return Discard to Deck" cost | |
| if ( | |
| "控え室" in cost_part | |
| and ("デッキ" in cost_part or "山札" in cost_part) | |
| and ("下に置" in cost_part or "戻" in cost_part) | |
| ): | |
| cost_type = AbilityCostType.RETURN_DISCARD_TO_DECK | |
| if "ライブカード" in cost_part: | |
| cost_type = ( | |
| AbilityCostType.RETURN_LIVE_TO_DECK | |
| ) # Or Discard with filter? Using 29 for now as it maps semantically | |
| elif "メンバー" in cost_part: | |
| cost_type = AbilityCostType.RETURN_MEMBER_TO_DECK | |
| count_deck = 1 | |
| if match := re.search(r"(\d+)枚", cost_part): | |
| count_deck = int(match.group(1)) | |
| costs.append(Cost(cost_type, count_deck, is_optional=cost_is_optional)) | |
| # Generic Tap Member Cost (e.g. choose other member to tap) | |
| if "ウェイト" in cost_part and "このメンバー" not in cost_part and "相手" not in cost_part: | |
| count_tap = 1 | |
| if match := re.search(r"(\d+)枚|(\d+)人", cost_part): | |
| count_tap = int(match.group(1) or match.group(2)) | |
| costs.append(Cost(AbilityCostType.TAP_MEMBER, count_tap, is_optional=cost_is_optional)) | |
| if energy_icons := len(re.findall(r"\{\{icon_energy.*?\}\}", cost_part)): | |
| costs.append(Cost(AbilityCostType.ENERGY, energy_icons, is_optional=cost_is_optional)) | |
| # Checks that can look at full content but result in Conditions/Costs | |
| # (COUNT_ENERGY check moved up to line 266 region) | |
| # Live card present condition: ライブカードがある場合 | |
| if "ライブカードがある場合" in content: | |
| conditions.append(Condition(ConditionType.HAS_LIVE_CARD)) | |
| # Hand check: 公開した手札の中にライブカードがない場合 | |
| if "公開した手札" in content and "ライブカードがない" in content: | |
| conditions.append(Condition(ConditionType.HAND_HAS_NO_LIVE)) | |
| # --- Effect Parsing --- | |
| # Gap Closure 2: Flavor Action (e.g. 何が好き?と聞く) | |
| if "何が好き?" in content or "何が好き" in content: | |
| effects.append(Effect(EffectType.FLAVOR_ACTION, params={"question": "What do you like?"})) | |
| # Gap Closure 3: Set Blades (e.g. 元々持つ...数は3つになる) | |
| if match := re.search( | |
| r"元々持つ(?:{{icon_blade.png\|ブレード}}|ブレード)(?:の数)?は(\d+)つになる", content | |
| ): | |
| effects.append(Effect(EffectType.SET_BLADES, int(match.group(1)))) | |
| # Gap Closure 1: Draw per Energy (e.g. エネルギー6枚につき、カードを1枚引く) | |
| if "エネルギー" in content and "につき" in content and "引く" in content: | |
| match = re.search(r"エネルギー(\d+)枚につき.*?(\d+)枚", content) | |
| req = int(match.group(1)) if match else 1 | |
| draw_amt = int(match.group(2)) if match else 1 | |
| effects.append( | |
| Effect(EffectType.DRAW, draw_amt, params={"multiplier": "energy", "req_per_unit": req}) | |
| ) | |
| # Consumed '枚' and '引く', so prevent generic parsing below | |
| content = content.replace("枚", "").replace("引く", "").replace("につき", "") | |
| # Gap Closure 2: Re-Cheer / Lose Blade Heart (e.g. ブレードハートを失い、もう一度エールを行う) | |
| if "ブレードハート" in content and "失い" in content: | |
| effects.append(Effect(EffectType.META_RULE, 1, params={"type": "lose_blade_heart"})) | |
| if "もう一度エール" in content: | |
| effects.append(Effect(EffectType.META_RULE, 1, params={"type": "re_cheer"})) | |
| # Gap Closure 3: Deck Refresh Condition (e.g. デッキがリフレッシュしていた場合) | |
| if "デッキ" in content and "リフレッシュ" in content: | |
| conditions.append(Condition(ConditionType.DECK_REFRESHED, {})) | |
| # Gap Closure 4: Cheer Count Modifier (e.g. エールによって公開される...枚数が...減る) | |
| if "公開" in content and "枚数" in content and ("減る" in content or "増える" in content): | |
| match = re.search(r"(\d+)枚(減る|増える)", content) | |
| val = int(match.group(1)) if match else 0 | |
| if "減る" in content: | |
| val = -val | |
| effects.append(Effect(EffectType.META_RULE, val, params={"type": "cheer_mod"})) | |
| # Flavor Actions (Questions) | |
| if "?" in content and any(kw in content for kw in ["聞く", "質問"]): | |
| effects.append(Effect(EffectType.FLAVOR_ACTION, 1)) | |
| # Gap Closure 5: Heart Req Increase/Decrease (多くなる/少なくなる) | |
| if "必要ハート" in content and ("多くなる" in content or "少なくなる" in content): | |
| # Usually "heart0多くなる" -> +1? Or just "多くなる" = +1 default | |
| val = 1 | |
| if "少なくなる" in content: | |
| val = -1 | |
| target = TargetType.OPPONENT if "相手" in content else TargetType.PLAYER | |
| effects.append(Effect(EffectType.REDUCE_HEART_REQ, val, target=target)) | |
| # Gap Closure 6: Score Limit Filter (e.g. スコア3以下の) | |
| score_filter = None | |
| if "スコア" in content and "以下" in content: | |
| if match := re.search(r"スコア(\d+)以下", content): | |
| score_filter = int(match.group(1)) | |
| # Refined DRAW: Prioritize "カードをX枚" to avoid catching condition values (e.g. Energy 7) | |
| # Also exclude "引き入れる" which means "bring in/under" not "draw" | |
| if "引き入れ" not in content: # Exclude "bring in under" pattern | |
| if match := re.search(r"カードを\s*(\d+)枚[^。]*?引", content): | |
| effects.append( | |
| Effect(EffectType.DRAW, int(match.group(1)), TargetType.PLAYER, params={"from": "deck"}) | |
| ) | |
| elif match := re.search(r"(\d+)枚[^。]*?引", content): | |
| # Check if the number before "枚" isn't part of a known condition like "Energy X" | |
| is_valid_draw = True | |
| if "エネルギー" in content: | |
| # If energy count is right before 枚, it's likely the energy count, not draw count | |
| if re.search(rf"エネルギー\s*{match.group(1)}枚", content): | |
| is_valid_draw = False | |
| if is_valid_draw: | |
| effects.append( | |
| Effect(EffectType.DRAW, int(match.group(1)), TargetType.PLAYER, params={"from": "deck"}) | |
| ) | |
| elif "引く" in content and "置いた枚数分" not in content: | |
| effects.append(Effect(EffectType.DRAW, 1, TargetType.PLAYER, params={"from": "deck"})) | |
| # --- Discard up to X, Draw X (SELECT_MODE) --- | |
| if match := re.search(r"手札を(\d+)枚まで控え室に置いてもよい.*?置いた枚数分カードを引く", line): | |
| max_discard = int(match.group(1)) | |
| # Create Modal Options for 0 to Max | |
| modal_options = [] | |
| # Option 0: Do nothing | |
| modal_options.append( | |
| [Effect(EffectType.META_RULE, 0, TargetType.PLAYER, params={"message": "キャンセル (0枚)"})] | |
| ) | |
| for i in range(1, max_discard + 1): | |
| opts = [] | |
| # Discard i cards (Using SWAP_CARDS 11 logic or DISCARD effect if available) | |
| # Based on effect_mixin, usually DISCARD is a COST. | |
| # But here it's an effect choice. | |
| # Using EffectType.SWAP_CARDS (11) as observed in other cards | |
| opts.append( | |
| Effect( | |
| EffectType.SWAP_CARDS, | |
| i, | |
| TargetType.CARD_HAND, | |
| params={"target": "discard", "from": "hand", "count": i}, | |
| ) | |
| ) | |
| # Draw i cards | |
| opts.append(Effect(EffectType.DRAW, i, TargetType.PLAYER)) | |
| modal_options.append(opts) | |
| effects.append(Effect(EffectType.SELECT_MODE, 1, TargetType.PLAYER, modal_options=modal_options)) | |
| # Construct Ability immediately and stop processing to avoid phantom effects | |
| abilities.append( | |
| Ability( | |
| raw_text=line.strip(), | |
| trigger=trigger, | |
| effects=effects, | |
| conditions=conditions, | |
| costs=costs, | |
| is_once_per_turn=is_once_per_turn, | |
| ) | |
| ) | |
| continue | |
| # DEBUG: Trace LOOK_DECK check | |
| if "見る" in content or "デッキ" in content: | |
| print(f"DEBUG: Checking LOOK_DECK on: '{content}'") | |
| if match := re.search(r"(?:デッキ|山札).*?(\d+)枚.*?(?:見る|見て)", content): | |
| print(f"DEBUG: MATCHED LOOK_DECK! Count={match.group(1)}") | |
| effects.append(Effect(EffectType.LOOK_DECK, int(match.group(1)))) | |
| has_look_and_choose = False | |
| if any(kw in content for kw in ["その中から", "その中"]): | |
| params = {"source": "looked"} | |
| # Capture filters for choice | |
| if match := re.search(r"『(.*?)』", content): | |
| params["group"] = match.group(1) | |
| if match := re.search(r"コスト(\d+)以下", content): | |
| params["cost_max"] = int(match.group(1)) | |
| if "ライブカード" in content: | |
| params["filter"] = "live" | |
| elif "メンバー" in content: | |
| params["filter"] = "member" | |
| if "控え室に置く" in content or "残りを控え室" in content: | |
| params["on_fail"] = "discard" | |
| # "好きな枚数を好きな順番でデッキの上に置き" = Put any number on top in any order | |
| if ( | |
| "好きな枚数" in content | |
| and any(kw in content for kw in ["デッキの上", "山札の上"]) | |
| and any(kw in content for kw in ["置", "戻"]) | |
| ): | |
| params["destination"] = "deck_top" | |
| params["any_number"] = True | |
| params["reorder"] = True | |
| # Extract look count | |
| if match := re.search(r"(\d+)枚.*?見て", content): | |
| effects.append(Effect(EffectType.LOOK_DECK, int(match.group(1)))) | |
| effects.append(Effect(EffectType.LOOK_AND_CHOOSE, 1, params=params)) | |
| has_look_and_choose = True | |
| if match := re.search(r"(\d+)枚.*?公開", content): | |
| if not has_look_and_choose: | |
| params = {} | |
| if "デッキ" in content: | |
| params["from"] = "deck" | |
| effects.append(Effect(EffectType.REVEAL_CARDS, int(match.group(1)), params=params)) | |
| elif "公開" in content and "エール" not in content: | |
| if not has_look_and_choose: | |
| params = {} | |
| if "デッキ" in content: | |
| params["from"] = "deck" | |
| effects.append(Effect(EffectType.REVEAL_CARDS, 1, params=params)) | |
| # Optionality | |
| if "てもよい" in content and effects: | |
| effects[-1].is_optional = True | |
| # Recovery/Add | |
| if "控え室" in content and ("手札に加え" in content or "手札に戻" in content): | |
| filters = {} | |
| if match := re.search(r"コスト(\d+)以下", content): | |
| filters["cost_max"] = int(match.group(1)) | |
| if score_filter: | |
| filters["score_max"] = score_filter | |
| eff_type = EffectType.RECOVER_LIVE if "ライブカード" in content else EffectType.RECOVER_MEMBER | |
| # Ensure it's not a live card if "member card" is specified | |
| if "メンバーカード" in content: | |
| eff_type = EffectType.RECOVER_MEMBER | |
| # Explicitly set from zone to discard - overrides any context_zone | |
| from_zone = "opponent_discard" if "相手" in content else "discard" | |
| effects.append( | |
| Effect( | |
| eff_type, 1, TargetType.CARD_DISCARD, params={"to": "hand", "from": from_zone, **filters} | |
| ) | |
| ) | |
| if any(kw in content for kw in ["ハート", "heart"]): | |
| effects[-1].params["filter"] = "heart_req" | |
| # Capture specific group filter if explicitly mentioned near recover target | |
| if match := re.search(r"『(.*?)』", content): | |
| effects[-1].params["group"] = match.group(1) | |
| # Check for ability filter (e.g. "「アクティブにする」を持つ") | |
| if "アクティブにする" in content or "【起動】" in content: | |
| effects[-1].params["has_ability"] = "active" | |
| elif "手札に加え" in content and not any( | |
| e.effect_type in (EffectType.RECOVER_LIVE, EffectType.RECOVER_MEMBER) for e in effects | |
| ): | |
| # Skip if LOOK_AND_CHOOSE already covers the "add to hand" semantic | |
| has_look_and_choose = any(e.effect_type == EffectType.LOOK_AND_CHOOSE for e in effects) | |
| if not has_look_and_choose: | |
| params = {"to": "hand"} | |
| if any(kw in content for kw in ["デッキ", "山札"]): | |
| params["from"] = "deck" | |
| elif "成功ライブカード" in content: | |
| params["from"] = "success_live" | |
| elif "ライブカード置き場" in content: | |
| params["from"] = "live_zone" | |
| elif "控え室" in content: | |
| params["from"] = ( | |
| "opponent_discard" if "相手" in content and "自身" in content else "discard" | |
| ) | |
| elif "自身" in content and "控え室" in content: | |
| params["from"] = "discard" | |
| # Filter extraction | |
| if "アクティブにする" in content or "【起動】" in content: | |
| params["has_ability"] = "active" | |
| if "ライブカード" in content: | |
| effects.append(Effect(EffectType.RECOVER_LIVE, 1, params=params)) | |
| else: | |
| if match := re.search(r"コスト(\d+)以下", content): | |
| params["cost_max"] = int(match.group(1)) | |
| if score_filter: | |
| params["score_max"] = score_filter | |
| effects.append(Effect(EffectType.ADD_TO_HAND, 1, params=params)) | |
| if any(kw in content for kw in ["エールにより公開", "エールで公開"]) and not any( | |
| kw in content for kw in ["場合", "なら", "とき"] | |
| ): | |
| effects.append(Effect(EffectType.CHEER_REVEAL, 1)) | |
| if "デッキ" in content and any(kw in content for kw in ["探", "サーチ"]): | |
| effects.append(Effect(EffectType.SEARCH_DECK, 1)) | |
| # Buffs | |
| # Target identification (PLAYER vs OPPONENT vs ALL) | |
| # For ADD_TO_HAND/RECOVER_MEMBER, if "自分は" is present, target is PLAYER even if "相手" is mentioned as source. | |
| target = TargetType.MEMBER_NAMED if (match := re.search(r" 「(.*?)」.*?は", content)) else None | |
| if not target: | |
| if any(kw in content for kw in ["自分と相手", "自分も相手も", "全員", "自分および相手"]): | |
| target = TargetType.ALL_PLAYERS | |
| elif "自分は" in content and "手札に加え" in content: | |
| target = TargetType.PLAYER | |
| elif "相手は" in content and not any(kw in content for kw in ["自分は", "自分を"]): | |
| target = TargetType.OPPONENT | |
| elif "相手" in content and any(kw in content for kw in ["ウェイト", "させる", "選ばせる"]): | |
| target = TargetType.OPPONENT | |
| elif "相手" in content: | |
| target = TargetType.OPPONENT | |
| else: | |
| target = TargetType.MEMBER_SELF | |
| target_params = {"target_name": match.group(1)} if target == TargetType.MEMBER_NAMED else {} | |
| if "ブレード" in content and "得る" in content: | |
| icon_count = len(re.findall(r"icon_blade\.png", content)) | |
| count = ( | |
| int(match.group(1)) if (match := re.search(r"ブレード.*?(\d+)", content)) else icon_count or 1 | |
| ) | |
| if "ALLブレード" in content: | |
| target_params["all_blade"] = True | |
| effects.append(Effect(EffectType.ADD_BLADES, count, target, params=target_params)) | |
| # Blade counting condition (ブレードがX以上) | |
| if match := re.search(r"ブレード.*?(\d+)(つ|個)以上", content): | |
| conditions.append(Condition(ConditionType.COUNT_BLADES, {"min": int(match.group(1))})) | |
| elif "ブレード" in content and "合計" in content and (match := re.search(r"合計.*?(\d+)", content)): | |
| conditions.append(Condition(ConditionType.COUNT_BLADES, {"min": int(match.group(1))})) | |
| if any(kw in content for kw in ["ハート", "heart", "heart_"]) and any( | |
| kw in content for kw in ["得る", "加える", "増える"] | |
| ): | |
| params = target_params.copy() | |
| if "icon_all.png" in content or "icon_heart_all.png" in content or "all.png" in content: | |
| params["color"] = 6 | |
| count = len(re.findall(r"icon_all\.png|icon_heart_all\.png|all\.png", content)) or 1 | |
| else: | |
| count = ( | |
| int(match.group(1)) | |
| if (match := re.search(r"[++](\d+)", content)) | |
| else len(re.findall(r"heart_\d+\.png", content)) or 1 | |
| ) | |
| # Try to find specific color | |
| if color_match := re.search(r"heart_(\d+)\.png", content): | |
| params["color"] = int(color_match.group(1)) | |
| effects.append(Effect(EffectType.ADD_HEARTS, count, target, params=params)) | |
| # Heart counting condition (ハートが合計X個以上 / ハートに...X個以上持つ) | |
| heart_match = re.search(r"(?:ハート|heart).*?(\d+)(つ|個)以上", full_content) | |
| if heart_match or ( | |
| "ハート" in full_content | |
| and "合計" in full_content | |
| and (heart_match := re.search(r"(?:合計|持ち).*?(\d+)(つ|個|枚)?", full_content)) | |
| ): | |
| heart_color = None | |
| if ( | |
| "icon_all.png" in full_content | |
| or "icon_heart_all.png" in full_content | |
| or "all.png" in full_content | |
| ): | |
| heart_color = 6 | |
| else: | |
| # Search for color icon BEFORE the count match if possible, or anywhere in full_content | |
| search_limit = heart_match.start() | |
| preceding_text = full_content[:search_limit] | |
| color_icons = re.findall(r"heart_(\d+)\.png", preceding_text) | |
| if not color_icons: | |
| color_icons = re.findall(r"heart_(\d+)\.png", full_content) | |
| if color_icons: | |
| heart_color = int(color_icons[0]) - 1 | |
| is_gating = colon_idx == -1 or heart_match.start() < colon_idx | |
| if "heart" in full_content or "ハート" in full_content: | |
| print( | |
| f"DEBUG: {full_content[:40]}... | colon={colon_idx} | heart={heart_match.start()} | gating={is_gating}" | |
| ) | |
| conditions.append( | |
| Condition( | |
| ConditionType.COUNT_HEARTS, | |
| {"min": int(heart_match.group(1)), "color": heart_color, "gating": is_gating}, | |
| ) | |
| ) | |
| if any(kw in content for kw in ["必要ハート", "heart"]): | |
| is_increase = any(kw in content for kw in ["増やす", "増える", "増加"]) | |
| is_decrease = any(kw in content for kw in ["減らす", "少なくなる", "減る", "マイナス", "-"]) | |
| if is_increase or is_decrease: | |
| # Prioritize counting heart images for requirement effects | |
| # We use heart_00\.png or just heart_.*?\.png since for requirement it's usually any/white | |
| heart_images = len(re.findall(r"heart_.*?\.png", content)) | |
| if heart_images > 0: | |
| count = heart_images | |
| else: | |
| # Try to find a count, but avoid "枚" and skip previous score buffs (+2) | |
| # Focus on numbers close to the increase keywords | |
| count_match = re.search(r"(\d+)(増やす|増える|減らす|減る)", content) | |
| if count_match: | |
| count = int(count_match.group(1)) | |
| else: | |
| # Fallback to latest digit before images or keywords, ignoring "枚" | |
| count_matches = re.findall(r"(\d+)(?!枚|人)", content) | |
| if count_matches: | |
| count = int(count_matches[-1]) | |
| else: | |
| count = 1 | |
| if is_increase: | |
| count = -count | |
| effects.append( | |
| Effect(EffectType.REDUCE_HEART_REQ, count, TargetType.PLAYER, params=target_params) | |
| ) | |
| if "エネルギー" in content and any(kw in content for kw in ["置く", "加える", "チャージ", "し"]): | |
| if "デッキ" in content or "山札" in content: | |
| # Handled by MOVE_TO_DECK below | |
| pass | |
| else: | |
| target_e = TargetType.OPPONENT if "相手" in content else TargetType.PLAYER | |
| count = 1 | |
| if match := re.search(r"エネルギーを(\d+)枚", content): | |
| count = int(match.group(1)) | |
| effects.append(Effect(EffectType.ENERGY_CHARGE, count, target_e, params={})) | |
| # Exclude "好きな枚数" (any number) from multiplier detection to avoid false positives | |
| if any(kw in content for kw in ["につき", "1人につき", "人につき"]) or ( | |
| "枚数" in content and "好きな枚数" not in content | |
| ): | |
| eff_params = {"multiplier": True} | |
| if "成功ライブカード" in content or "ライブカード" in content: | |
| eff_params["per_live"] = True | |
| elif "エネ" in content: | |
| eff_params["per_energy"] = True | |
| elif "自分と相手" in content and ("メンバー" in content or "人につき" in content): | |
| eff_params["per_member_all"] = True | |
| elif "メンバー" in content or "人につき" in content: | |
| eff_params["per_member"] = True | |
| # Attach to the LAST effect if applicable | |
| if effects and effects[-1].effect_type in ( | |
| EffectType.ADD_BLADES, | |
| EffectType.ADD_HEARTS, | |
| EffectType.BUFF_POWER, | |
| ): | |
| effects[-1].params.update(eff_params) | |
| else: | |
| effects.append(Effect(EffectType.BUFF_POWER, 1, params=eff_params)) | |
| # Implicit/Generic Buff (only if explicit keywords like Blade/Heart/Score are NOT preventing it) | |
| elif (match := re.search(r"[++](\d+)", content)) and not any( | |
| kw in content for kw in ["ブレード", "ハート", "スコア"] | |
| ): | |
| effects.append(Effect(EffectType.BUFF_POWER, int(match.group(1)))) | |
| if ( | |
| ("ポジションチェンジ" in content) | |
| or ("エリア" in content and "移動" in content) | |
| or ("場所を入れ替える" in content) | |
| or ("移動させ" in content) | |
| ): | |
| if any(kw in content for kw in ["場合", "なら", "とき"]) and "ポジションチェンジ" not in content: | |
| # Check if HAS_MOVED condition already exists to avoid duplicates | |
| if not any(c.type == ConditionType.HAS_MOVED for c in conditions): | |
| conditions.append(Condition(ConditionType.HAS_MOVED)) | |
| else: | |
| effects.append(Effect(EffectType.MOVE_MEMBER, 1)) | |
| if "シャッフル" in content: | |
| target_s = TargetType.OPPONENT if "相手" in content else TargetType.PLAYER | |
| effects.append(Effect(EffectType.META_RULE, 0, target_s, params={"type": "shuffle", "deck": True})) | |
| if ( | |
| any(kw in content for kw in ["デッキ", "山札"]) | |
| and any(kw in content for kw in ["戻す", "置く", "置き"]) | |
| and not any(e.effect_type == EffectType.LOOK_AND_CHOOSE for e in effects) | |
| ): | |
| # If it's "Place in discard", it's SWAP_CARDS (Target: Discard), not MOVE_TO_DECK | |
| is_discard_dest = "控え室に" in content and "控え室から" not in content | |
| if not is_discard_dest: | |
| pos = "bottom" if "下" in content else "top" if "上" in content else "any" | |
| to_energy = "エネルギーデッキ" in content | |
| params = {"position": pos} | |
| if to_energy: | |
| params["to_energy_deck"] = True | |
| if "控え室" in content: | |
| params["from"] = "discard" | |
| elif "ステージ" in content: | |
| params["from"] = "stage" | |
| effects.append(Effect(EffectType.MOVE_TO_DECK, 1, params=params)) | |
| if "アクティブに" in content and not ("手札" in content and "加え" in content): | |
| count = int(match.group(1)) if (match := re.search(r"(\d+)枚", content)) else 1 | |
| target_type = TargetType.MEMBER_SELF if "このメンバー" in content else TargetType.MEMBER_SELECT | |
| effects.append( | |
| Effect( | |
| EffectType.ACTIVATE_MEMBER, | |
| count, | |
| target_type, | |
| params={"target": "energy"} if "エネルギー" in content else {}, | |
| ) | |
| ) | |
| # Duration | |
| dur = ( | |
| {"until": "live_end"} | |
| if "ライブ終了時まで" in content | |
| else {"until": "turn_end"} | |
| if any(kw in content for kw in ["ターン終了まで", "終了時まで"]) | |
| else {} | |
| ) | |
| if dur: | |
| if not effects: | |
| effects.append(Effect(EffectType.BUFF_POWER, 1, params={**dur, "temporary": True})) | |
| else: | |
| for eff in effects: | |
| eff.params.update(dur) | |
| if any(kw in content for kw in ["必要ハート", "ハート条件", "heart"]) and any( | |
| kw in content for kw in ["扱う", "確認", "する"] | |
| ): | |
| src = "all_blade" if "ALLブレード" in content else "blade" if "ブレード" in content else None | |
| if "オール" in content: | |
| src = "all" | |
| effects.append( | |
| Effect(EffectType.META_RULE, 0, params={"type": "heart_rule", "live": True, "source": src}) | |
| ) | |
| if "スコア" in content and any(kw in content for kw in ["加算", "合算"]): | |
| effects.append(Effect(EffectType.BOOST_SCORE, 1, params={"type": "score_rule", "live": True})) | |
| if "ライブカード" in content and not effects: | |
| # Catch-all for live interaction if no other effect parsed | |
| effects.append(Effect(EffectType.META_RULE, 0, params={"live": True})) | |
| if any(kw in content for kw in ["選ばれない", "選べない", "置けない"]): | |
| effects.append(Effect(EffectType.IMMUNITY, 1)) | |
| if "として扱う" in content and "すべての領域" in content: | |
| # Group Alias / Multi-Group | |
| groups = [] | |
| for m in re.finditer(r"『(.*?)』", content): | |
| groups.append(m.group(1)) | |
| if groups: | |
| effects.append( | |
| Effect(EffectType.META_RULE, 1, params={"type": "group_alias", "groups": groups}) | |
| ) | |
| if "登場させる" in content: | |
| count = int(match.group(1)) if (match := re.search(r"(\d+)枚", content)) else 1 | |
| src = "hand" if "手札" in content else "discard" | |
| effects.append( | |
| Effect( | |
| EffectType.RECOVER_MEMBER, | |
| count, | |
| TargetType.CARD_DISCARD, | |
| {"auto_play": True, "from": src, **({"score_max": score_filter} if score_filter else {})}, | |
| ) | |
| ) | |
| # Cluster 5: Remote Ability Triggering | |
| if "能力" in content and any(kw in content for kw in ["発動させる", "発動する"]): | |
| if "この能力は" in content: | |
| # This is a condition on the current ability, not triggering another | |
| if "エールによって公開されている" in content: | |
| conditions.append(Condition(ConditionType.HAS_KEYWORD, {"keyword": "Revealed by Cheer"})) | |
| else: | |
| zone = "discard" if "控え室" in content else "stage" | |
| if zone == "stage" and "控え室" in text and "そのカード" in content: | |
| zone = "discard" | |
| if zone == "stage" and context_zone: | |
| zone = context_zone.lower() | |
| # Remote trigger almost always implies choosing a target | |
| if not any(c.type == ConditionType.HAS_CHOICE for c in conditions): | |
| conditions.append(Condition(ConditionType.HAS_CHOICE)) | |
| effects.append(Effect(EffectType.TRIGGER_REMOTE, 1, params={"from": zone})) | |
| effects.append(Effect(EffectType.TRIGGER_REMOTE, 1, params={"from": zone})) | |
| # Effect: Place Under (Stacking) | |
| # Matches "Under X" or "Place under this member" but NOT "Place under deck" | |
| if ( | |
| "の下に置" in content | |
| and "コスト" not in content | |
| and "払" not in content | |
| and "代わり" not in content | |
| ): | |
| count_pu = 1 | |
| if match := re.search(r"(\d+)枚", content): | |
| count_pu = int(match.group(1)) | |
| target_pu = TargetType.MEMBER_SELECT | |
| if "このメンバー" in content: | |
| target_pu = TargetType.MEMBER_SELF | |
| params_pu = {} | |
| if "エネルギー" in content: | |
| params_pu["from"] = "energy" | |
| elif "手札" in content: | |
| params_pu["from"] = "hand" | |
| elif "控え室" in content: | |
| params_pu["from"] = "discard" | |
| # If the text implies putting a card under *this* member | |
| if "このメンバーの下" in content: | |
| target_pu = TargetType.MEMBER_SELF | |
| effects.append(Effect(EffectType.PLACE_UNDER, count_pu, target_pu, params=params_pu)) | |
| # Final pass: Apply optionality to effects in this sentence if "may" is present | |
| if "てもよい" in full_content: | |
| for eff in effects: | |
| eff.is_optional = True | |
| if "控" in content and any(kw in content for kw in ["置", "送"]): | |
| # Retroactive fix for "Discard Remaining" appearing in a separate sentence | |
| if "残り" in content and last_ability and last_ability.effects: | |
| last_eff = last_ability.effects[-1] | |
| if last_eff.effect_type == EffectType.LOOK_AND_CHOOSE: | |
| last_eff.params["on_fail"] = "discard" | |
| # We can skip parsing this as a new effect since we attached it to the previous one | |
| continue | |
| # Prevent parsing "Sacrifice Self" or "Cost Discard" as generic discard effect | |
| # Also skip if LOOK_AND_CHOOSE with on_fail:discard already handles remaining cards | |
| has_look_and_choose_discard = any( | |
| e.effect_type == EffectType.LOOK_AND_CHOOSE and e.params.get("on_fail") == "discard" | |
| for e in effects | |
| ) or ( | |
| last_ability | |
| and any( | |
| e.effect_type == EffectType.LOOK_AND_CHOOSE and e.params.get("on_fail") == "discard" | |
| for e in last_ability.effects | |
| ) | |
| ) | |
| has_discard_cost = any(c.type == AbilityCostType.DISCARD_HAND for c in costs) | |
| skip_swap = ( | |
| "このメンバー" in content | |
| or (has_look_and_choose_discard and "残り" in content) | |
| or (has_discard_cost and "手札" in content and "てもよい" in content) # Cost pattern | |
| or any(e.effect_type in (EffectType.RECOVER_LIVE, EffectType.RECOVER_MEMBER) for e in effects) | |
| ) | |
| if not skip_swap: | |
| count = ( | |
| int(match.group(1)) | |
| if (match := re.search(r"(?:手札|から).*?(\d+)枚", content)) | |
| else int(match.group(1)) | |
| if (match := re.search(r"(\d+)枚", content)) | |
| else 1 | |
| ) | |
| src = "deck" if "デッキ" in content else "hand" if "手札" in content else None | |
| if not src and "控え室" in content: | |
| src = "discard" | |
| # Determine card type filter for discard | |
| swap_params = {"target": "discard"} | |
| if src: | |
| swap_params["from"] = src | |
| if "ライブカード" in content or "ライブ" in content: | |
| swap_params["filter"] = "live" | |
| elif "メンバーカード" in content or "メンバー" in content: | |
| swap_params["filter"] = "member" | |
| effects.append(Effect(EffectType.SWAP_CARDS, count, params=swap_params)) | |
| if any(kw in content for kw in ["ウェイトにする", "ウェイト状態にする", "休み"]): | |
| # If this is "Tap Self" effect but we already have a "Tap Self" cost, skip it | |
| # (unless it's explicitly an effect that also taps the opponent) | |
| is_tap_self = "このメンバー" in content and "相手" not in content | |
| if is_tap_self and any(c.type == AbilityCostType.TAP_SELF for c in costs): | |
| pass | |
| else: | |
| count = ( | |
| int(match.group(1)) if (match := re.search(r"(\d+|1|2|3|[一二三])人", content)) else 1 | |
| ) | |
| # Full-width mapping if needed | |
| val_map = {"1": 1, "2": 2, "3": 3, "一": 1, "二": 2, "三": 3} | |
| if match and match.group(1) in val_map: | |
| count = val_map[match.group(1)] | |
| target_tap = TargetType.OPPONENT if "相手" in content else TargetType.PLAYER | |
| effects.append( | |
| Effect( | |
| EffectType.TAP_OPPONENT if target_tap == TargetType.OPPONENT else EffectType.TAP_MEMBER, | |
| count, | |
| target_tap, | |
| {"all": True} if "すべて" in content else {}, | |
| ) | |
| ) | |
| if match := re.search(r"スコア.*?[++](\d+|1|2|3|[一二三])", content): | |
| val_str = match.group(1) | |
| val_map = {"1": 1, "2": 2, "3": 3, "一": 1, "二": 2, "三": 3} | |
| val = int(val_map.get(val_str, val_str)) if not val_str.isdigit() else int(val_str) | |
| if "として扱う" in content or re.search(r"スコア[^、。]*?を得る", content): | |
| effects.append(Effect(EffectType.MODIFY_SCORE_RULE, val, params={"until": "live_end"})) | |
| else: | |
| effects.append(Effect(EffectType.BOOST_SCORE, val)) | |
| elif "スコア" in content and any(kw in content for kw in ["得る", "+", "プラス"]): | |
| if "として扱う" in content or re.search(r"スコア[^、。]*?を得る", content): | |
| effects.append(Effect(EffectType.MODIFY_SCORE_RULE, 1, params={"until": "live_end"})) | |
| else: | |
| effects.append(Effect(EffectType.BOOST_SCORE, 1)) | |
| # Yell Score Modifier rule | |
| if "スコア" in content and "加算" in content and "エールで出た" in content: | |
| match = re.search(r"スコアの合計に(\d+)を加算", content) | |
| val = int(match.group(1)) if match else 1 | |
| effects.append( | |
| Effect(EffectType.MODIFY_SCORE_RULE, val, params={"multiplier_source": "yell_score_icon"}) | |
| ) | |
| trigger = TriggerType.CONSTANT | |
| if "コスト" in content and any(kw in content for kw in ["減", "-"]): | |
| effects.append(Effect(EffectType.REDUCE_COST, 1)) | |
| if match := re.search(r"ハートの必要数を([+\+]?|-|-|ー)(\d+)する", content): | |
| op = match.group(1) | |
| val = int(match.group(2)) | |
| if "-" in op or "-" in op or "ー" in op: | |
| val = -val | |
| effects.append(Effect(EffectType.REDUCE_HEART_REQ, val)) | |
| if any(kw in content for kw in ["必要ハート", "必要なハート"]) and any( | |
| kw in content for kw in ["なる", "扱う", "置く"] | |
| ): | |
| effects.append(Effect(EffectType.REDUCE_HEART_REQ, 1)) | |
| if "なる" in content and "ハート" in content: | |
| effects.append(Effect(EffectType.TRANSFORM_COLOR, 1, params={"target": "heart"})) | |
| if (match := re.search(r"ブレード[^スコア場合]*?[++](\d+|1|2|3|[一二三])", content)) or ( | |
| "ブレード" in content and any(kw in content for kw in ["得る", "得る。"]) and "持つ" not in content | |
| ): | |
| val = 1 | |
| if match: | |
| val_str = match.group(1) | |
| val_map = {"1": 1, "2": 2, "3": 3, "一": 1, "二": 2, "三": 3} | |
| val = int(val_map.get(val_str, val_str)) if not val_str.isdigit() else int(val_str) | |
| elif match_v := re.search(r"(\d+|1|2|3|[一二三])(?:つ|個).*?ブレード", content): | |
| val_str = match_v.group(1) | |
| val_map = {"1": 1, "2": 2, "3": 3, "一": 1, "二": 2, "三": 3} | |
| val = int(val_map.get(val_str, val_str)) if not val_str.isdigit() else int(val_str) | |
| if not any(e.effect_type == EffectType.ADD_BLADES for e in effects): | |
| effects.append(Effect(EffectType.ADD_BLADES, val)) | |
| # Separately check for Hearts (Buffs or Add Hearts) | |
| # "Heart +1" or "Gain Heart" -> Type 2 (ADD_HEARTS) | |
| if (match := re.search(r"ハート[^スコア場合]*?[++](\d+|1|2|3|[一二三])", content)) or ( | |
| "ハート" in content and any(kw in content for kw in ["得る", "得る。"]) and "持つ" not in content | |
| ): | |
| val = 1 | |
| if match: | |
| val_str = match.group(1) | |
| val_map = {"1": 1, "2": 2, "3": 3, "一": 1, "二": 2, "三": 3} | |
| val = int(val_map.get(val_str, val_str)) if not val_str.isdigit() else int(val_str) | |
| elif match_v := re.search(r"(\d+|1|2|3|[一二三])(?:つ|個).*?ハート", content): | |
| val_str = match_v.group(1) | |
| val_map = {"1": 1, "2": 2, "3": 3, "一": 1, "二": 2, "三": 3} | |
| val = int(val_map.get(val_str, val_str)) if not val_str.isdigit() else int(val_str) | |
| if not any(e.effect_type == EffectType.ADD_HEARTS for e in effects): | |
| effects.append(Effect(EffectType.ADD_HEARTS, val)) | |
| if any(kw in content for kw in ["無効", "キャンセル"]): | |
| effects.append(Effect(EffectType.NEGATE_EFFECT, 1)) | |
| if "デッキ" in content: | |
| if "順番" in content and not any( | |
| e.effect_type == EffectType.LOOK_AND_CHOOSE and e.params.get("reorder") for e in effects | |
| ): | |
| effects.append(Effect(EffectType.ORDER_DECK, 1)) | |
| elif ("一番上" in content or "一番下" in content) and "シャッフル" in content and "合計" in content: | |
| count = int(match.group(1)) if (match := re.search(r"合計(\d+)枚", content)) else 0 | |
| params = { | |
| "shuffle": True, | |
| "position": "bottom" if "一番下" in content else "top", | |
| "target_zone": "discard" if "控え室" in content else None, | |
| } | |
| if names := re.findall(r"「(.*?)」", content): | |
| params["target_names"] = names | |
| effects.append(Effect(EffectType.ORDER_DECK, count, params=params)) | |
| elif "一番上" in content: | |
| effects.append(Effect(EffectType.MOVE_TO_DECK, 1, params={"position": "top"})) | |
| elif "一番下" in content: | |
| effects.append(Effect(EffectType.MOVE_TO_DECK, 1, params={"position": "bottom"})) | |
| if "登場させ" in content: | |
| val = 1 | |
| if match := re.search(r"(\d+)人", content): | |
| val = int(match.group(1)) | |
| params = {"is_optional": "もよい" in content} | |
| if match := re.search( | |
| r"コスト(\d+|1|2|3|4|5|6|7|8|9|0|[一二三四五六七八九〇])以下", content | |
| ): | |
| val_map = { | |
| "1": 1, | |
| "2": 2, | |
| "3": 3, | |
| "4": 4, | |
| "5": 5, | |
| "6": 6, | |
| "7": 7, | |
| "8": 8, | |
| "9": 9, | |
| "0": 0, | |
| "一": 1, | |
| "二": 2, | |
| "三": 3, | |
| "四": 4, | |
| "五": 5, | |
| "六": 6, | |
| "七": 7, | |
| "八": 8, | |
| "九": 9, | |
| "〇": 0, | |
| } | |
| params["cost_max"] = int(val_map.get(match.group(1), match.group(1))) | |
| if sentence_groups: | |
| params["group"] = sentence_groups[0] | |
| effects.append(Effect(EffectType.PLAY_MEMBER_FROM_HAND, val, params=params)) | |
| if effects and effects[-1].effect_type in ( | |
| EffectType.ORDER_DECK, | |
| EffectType.LOOK_AND_CHOOSE, | |
| EffectType.MOVE_TO_DECK, | |
| EffectType.ADD_TO_HAND, | |
| EffectType.RECOVER_MEMBER, | |
| EffectType.RECOVER_LIVE, | |
| EffectType.SWAP_CARDS, | |
| ): | |
| if "ライブ" in content: | |
| effects[-1].params["filter"] = "live" | |
| if effects[-1].effect_type == EffectType.RECOVER_MEMBER: | |
| effects[-1].effect_type = EffectType.RECOVER_LIVE | |
| elif "メンバー" in content: | |
| effects[-1].params["filter"] = "member" | |
| if "エネルギー" in content: | |
| effects[-1].params["filter"] = "energy" | |
| if match := re.search( | |
| r"(?:以下から|のうち、)(\d+|1|2|3|4|5|一|二|三|四|五)(つ|枚|回|個)?を選ぶ", content | |
| ): | |
| val_str = match.group(1) | |
| # Simple mapping for common Japanese numerals | |
| val_map = {"1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5} | |
| val = int(val_map.get(val_str, val_str)) if not val_str.isdigit() else int(val_str) | |
| effects.append(Effect(EffectType.SELECT_MODE, val)) | |
| elif "以下から1つを選ぶ" in content: | |
| effects.append(Effect(EffectType.SELECT_MODE, 1)) | |
| if any(kw in content for kw in ["ハートの色を1つ指定", "好きなハートの色を"]): | |
| effects.append(Effect(EffectType.COLOR_SELECT, 1)) | |
| if "必要ハート" in content and "選んだ1つ" in content: | |
| effects.append(Effect(EffectType.REDUCE_HEART_REQ, 0, params={"mode": "select_requirement"})) | |
| if "メンバーの下に" in content and "置" in content: | |
| params = {} | |
| if "エネルギー" in content: | |
| params["type"] = "energy" | |
| effects.append(Effect(EffectType.PLACE_UNDER, 1, params=params)) | |
| if "聞く" in content: | |
| effects.append(Effect(EffectType.FLAVOR_ACTION, 1)) | |
| if "ライブできない" in content: | |
| effects.append(Effect(EffectType.RESTRICTION, 1, params={"type": "live"})) | |
| if "置くことができない" in content: | |
| effects.append(Effect(EffectType.RESTRICTION, 1, params={"type": "placement"})) | |
| if ( | |
| "なる" in content | |
| and "すべて" in content | |
| and (match := re.search(r"すべて\[(.*?)\]になる", content)) | |
| ): | |
| effects.append(Effect(EffectType.TRANSFORM_COLOR, 1, params={"target_color": match.group(1)})) | |
| if "バトンタッチ" in content and "2人" in content: | |
| effects.append(Effect(EffectType.BATON_TOUCH_MOD, 2)) | |
| if match := re.search(r"スコアは([01234567890123456789]+)になる", content): | |
| effects.append(Effect(EffectType.SET_SCORE, int(match.group(1).replace("4", "4")))) | |
| if ( | |
| "公開" in content | |
| and any(kw in content for kw in ["置き場", "加える"]) | |
| and not any(e.effect_type == EffectType.LOOK_AND_CHOOSE for e in effects) | |
| ): | |
| effects.append(Effect(EffectType.SWAP_ZONE, 1)) | |
| if ("手札に加える" in content or "引く" in content) and not effects: | |
| # Check if it mentions "Live card" or "Member card" | |
| params = {"generic_add": True} | |
| if "ライブ" in content: | |
| params["filter"] = "live" | |
| elif "メンバー" in content: | |
| params["filter"] = "member" | |
| if "枚数分" in content: | |
| params["multiplier"] = "count" | |
| effects.append(Effect(EffectType.DRAW, 1, params=params)) | |
| # Replacement Effects (代わりに - Cluster 4) | |
| if "代わりに" in content: | |
| # Find the replacement value (e.g., 'スコアを+2' -> 2) | |
| match = re.search(r"代わりに.*?[++](\d+)", content) | |
| if match: | |
| effects.append( | |
| Effect(EffectType.REPLACE_EFFECT, int(match.group(1)), params={"replaces": "score_boost"}) | |
| ) | |
| if (content.startswith("(") and content.endswith(")")) or ( | |
| content.startswith("(") and content.endswith(")") | |
| ): | |
| # If the whole sentence is in parens, it's often reminder text | |
| if not effects: | |
| params = {} | |
| if "対戦相手" in content and any(kw in content for kw in ["発動", "能力"]): | |
| params["type"] = "opponent_trigger_allowed" | |
| effects.append(Effect(EffectType.META_RULE, 1, params=params)) | |
| trigger = TriggerType.CONSTANT | |
| else: | |
| # Ensure reminder text within a block doesn't add accidental effects | |
| pass | |
| # Explicit check for Opponent Interaction text (robust against splitting) | |
| if "対戦相手のカードの効果でも発動する" in content: | |
| effects.append(Effect(EffectType.META_RULE, 1, params={"type": "opponent_trigger_allowed"})) | |
| # Also propagate flag to the last ability if this is a continuation | |
| if last_ability: | |
| # Add condition to mark it can trigger from opponent effects | |
| last_ability.conditions.append( | |
| Condition(ConditionType.OPPONENT_HAS, {"opponent_trigger_allowed": True}) | |
| ) | |
| # If we just added this effect and no other trigger, set constant | |
| if trigger == TriggerType.NONE: | |
| trigger = TriggerType.CONSTANT | |
| # Final touches | |
| is_opt = ( | |
| "てよい" in content or "てもよい" in content or "。その後。" in content | |
| ) # Heuristic for optional continuations | |
| is_glob = "すべての" in content | |
| is_opp_hand = "相手の手札" in content | |
| for eff in effects: | |
| eff.is_optional = is_opt | |
| if is_glob: | |
| eff.params["all"] = True | |
| if is_opp_hand or "自分と相手" in content: | |
| eff.target = TargetType.OPPONENT_HAND if is_opp_hand else TargetType.OPPONENT | |
| if "自分と相手" in content: | |
| eff.params["both_players"] = True | |
| if eff.effect_type in ( | |
| EffectType.RECOVER_MEMBER, | |
| EffectType.RECOVER_LIVE, | |
| EffectType.ADD_TO_HAND, | |
| EffectType.DRAW, | |
| EffectType.SWAP_CARDS, | |
| ): | |
| if eff.target == TargetType.SELF: | |
| eff.target = TargetType.PLAYER | |
| if current_multiplier and "multiplier" not in eff.params: | |
| eff.params["multiplier"] = current_multiplier | |
| # Group Propagation: If sentence has a group in 『』, apply it to effects that might need it | |
| if sentence_groups: | |
| # Apply to effects that don't already have a more specific group | |
| if eff.effect_type in [ | |
| EffectType.ADD_BLADES, | |
| EffectType.ADD_HEARTS, | |
| EffectType.BUFF_POWER, | |
| EffectType.RECOVER_MEMBER, | |
| EffectType.RECOVER_LIVE, | |
| EffectType.ADD_TO_HAND, | |
| EffectType.SEARCH_DECK, | |
| EffectType.LOOK_AND_CHOOSE, | |
| ]: | |
| if "group" not in eff.params: | |
| eff.params["group"] = sentence_groups[0] | |
| # Zone Propagation | |
| if context_zone: | |
| if "from" not in eff.params: | |
| eff.params["from"] = context_zone.lower() | |
| # Marker for master_validator to see this zone is accounted for | |
| eff.params["zone_accounted"] = True | |
| if context_zone == "Discard": | |
| eff.params["discard_interaction"] = True | |
| if context_zone == "SUCCESS_LIVE": | |
| eff.params["live_interaction"] = True | |
| # Target selection filter migration: | |
| # If this effect is a choice or play, and we have conditions that look like filters, move them. | |
| if eff.effect_type in ( | |
| EffectType.PLAY_MEMBER_FROM_HAND, | |
| EffectType.RECOVER_MEMBER, | |
| EffectType.RECOVER_LIVE, | |
| EffectType.ADD_TO_HAND, | |
| EffectType.SEARCH_DECK, | |
| EffectType.LOOK_AND_CHOOSE, | |
| EffectType.TAP_OPPONENT, | |
| EffectType.TAP_MEMBER, | |
| ): | |
| for cond in list(conditions): | |
| if cond.type == ConditionType.COST_CHECK: | |
| eff.params["cost_max" if cond.params.get("comparison") == "LE" else "cost_min"] = ( | |
| cond.params.get("value") | |
| ) | |
| conditions.remove(cond) | |
| elif cond.type == ConditionType.COUNT_GROUP: | |
| eff.params["group"] = cond.params.get("group") | |
| # Don't remove COUNT_GROUP as it might also be a global requirement | |
| if ( | |
| any(kw in content for kw in ["ライブカードがある場合", "ライブカードが含まれる場合"]) | |
| and "その中に" in content | |
| ): | |
| for eff in effects: | |
| if eff.effect_type == EffectType.DRAW: | |
| eff.params["condition"] = "has_live_in_looked" | |
| # Also mark costs as optional when pattern detected | |
| for cost in costs: | |
| if is_opt: | |
| cost.is_optional = True | |
| # --- Construct Ability --- | |
| if trigger != TriggerType.NONE: | |
| # Handle multiple triggers (lazy way: create one ability, caller might need to dup if we want perfection, | |
| # but actually we can just return a list of abilities from this function, so we can append multiple) | |
| # For now, let's keep it simple: if we detected the slash, we append multiple | |
| base_ability = Ability( | |
| raw_text=line.strip(), | |
| trigger=trigger, | |
| effects=effects, | |
| conditions=conditions, | |
| costs=costs, | |
| is_once_per_turn=is_once_per_turn, | |
| ) | |
| # print(f"DEBUG: Created Ability: Trigger={trigger}, Effects={[e.effect_type for e in effects]}") | |
| abilities.append(base_ability) | |
| last_ability = base_ability | |
| # Dual trigger hack for PL!-PR-009-PR | |
| if "toujyou" in line and (("live_start" in line) or ("live_success" in line)) and "/" in line: | |
| second_trigger = ( | |
| TriggerType.ON_LIVE_START if "live_start" in line else TriggerType.ON_LIVE_SUCCESS | |
| ) | |
| # Clone it effectively | |
| abilities.append( | |
| Ability( | |
| raw_text=content.strip(), | |
| trigger=second_trigger, | |
| effects=[ | |
| Effect(e.effect_type, e.value, e.target, e.params.copy(), e.is_optional) | |
| for e in effects | |
| ], | |
| conditions=[Condition(c.type, c.params.copy(), c.is_negated) for c in conditions], | |
| costs=[Cost(c.type, c.value, c.params.copy(), c.is_optional) for c in costs], | |
| is_once_per_turn=base_ability.is_once_per_turn, | |
| ) | |
| ) | |
| elif effects or conditions or costs: | |
| if last_ability and is_continuation: | |
| # Check for Modal Answer Branching first | |
| if is_modal_answer_branch: | |
| # Find or create SELECT_MODE effect for modal answers | |
| select_eff = None | |
| if last_ability.effects and last_ability.effects[-1].effect_type == EffectType.SELECT_MODE: | |
| if last_ability.effects[-1].params.get("type") == "modal_answer": | |
| select_eff = last_ability.effects[-1] | |
| if not select_eff: | |
| # Create new SELECT_MODE effect | |
| # Assuming 3 options usually, but value doesn't strictly matter for VM if modal_options list is used | |
| select_eff = Effect( | |
| EffectType.SELECT_MODE, 1, TargetType.PLAYER, params={"type": "modal_answer"} | |
| ) | |
| select_eff.modal_options = [] | |
| last_ability.effects.append(select_eff) | |
| # Append current branch effects | |
| select_eff.modal_options.append(effects) | |
| # Do NOT extend main effects/conditions | |
| elif ( | |
| line.startswith("・") | |
| or line.startswith("-") | |
| or line.startswith("-") | |
| or re.match(r"^[\(\(]\d+[\)\)]", line) | |
| ) and any(e.effect_type == EffectType.SELECT_MODE for e in last_ability.effects): | |
| last_ability.modal_options.append(effects) | |
| else: | |
| last_ability.effects.extend(effects) | |
| # Only add conditions if they are not already present to avoid duplicates | |
| for cond in conditions: | |
| if cond not in last_ability.conditions: | |
| last_ability.conditions.append(cond) | |
| last_ability.costs.extend(costs) | |
| last_ability.raw_text += " " + line.strip() | |
| if is_once_per_turn: | |
| last_ability.is_once_per_turn = True | |
| elif not is_continuation: | |
| # 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 line | |
| for kw in [ | |
| "引", | |
| "スコア", | |
| "プラス", | |
| "+", | |
| "ブレード", | |
| "ハート", | |
| "控", | |
| "戻", | |
| "エネ", | |
| "デッキ", | |
| "山札", | |
| "見る", | |
| "公開", | |
| "選ぶ", | |
| "扱", | |
| "得る", | |
| "移動", | |
| ] | |
| ) | |
| if has_ability_indicators: | |
| last_ability = Ability( | |
| raw_text=content.strip(), | |
| trigger=TriggerType.CONSTANT, | |
| effects=effects, | |
| conditions=conditions, | |
| costs=costs, | |
| is_once_per_turn=is_once_per_turn, | |
| ) | |
| abilities.append(last_ability) | |
| else: | |
| # If no indicators and not a continuation, maybe it's an error? | |
| # For robustness, we'll log it if we were in a logger-enabled mode | |
| # but here we just avoid creating a bogus Constant ability. | |
| pass | |
| return abilities | |
| def parse(self, text: str) -> List[Ability]: | |
| """Alias for parse_ability_text for consistency.""" | |
| return self.parse_ability_text(text) | |