trioskosmos's picture
Upload folder using huggingface_hub
2113a6a verified
import re
from typing import List
from engine.models.ability import (
Ability,
AbilityCostType,
Condition,
ConditionType,
Cost,
Effect,
EffectType,
TargetType,
TriggerType,
)
class AbilityParser:
@staticmethod
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)