Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- compiler/__init__.py +0 -0
- compiler/__pycache__/__init__.cpython-312.pyc +0 -0
- compiler/__pycache__/main.cpython-312.pyc +0 -0
- compiler/__pycache__/parser.cpython-312.pyc +0 -0
- compiler/__pycache__/parser_v2.cpython-312.pyc +0 -0
- compiler/_legacy_parsers/parser.py +0 -0
- compiler/id_converter.py +75 -0
- compiler/main.py +338 -0
- compiler/parser.py +8 -0
- compiler/parser_v2.py +1702 -0
- compiler/patterns/__init__.py +1 -0
- compiler/patterns/__pycache__/__init__.cpython-312.pyc +0 -0
- compiler/patterns/__pycache__/base.cpython-312.pyc +0 -0
- compiler/patterns/__pycache__/conditions.cpython-312.pyc +0 -0
- compiler/patterns/__pycache__/effects.cpython-312.pyc +0 -0
- compiler/patterns/__pycache__/modifiers.cpython-312.pyc +0 -0
- compiler/patterns/__pycache__/registry.cpython-312.pyc +0 -0
- compiler/patterns/__pycache__/triggers.cpython-312.pyc +0 -0
- compiler/patterns/base.py +158 -0
- compiler/patterns/conditions.py +257 -0
- compiler/patterns/effects.py +644 -0
- compiler/patterns/modifiers.py +176 -0
- compiler/patterns/registry.py +130 -0
- compiler/patterns/triggers.py +158 -0
- compiler/search_cards_improved.py +17 -0
- compiler/tests/debug_clean.py +42 -0
- compiler/tests/debug_sd1_parsing.py +30 -0
- compiler/tests/reproduce_bp2_008_p.py +30 -0
- compiler/tests/reproduce_failures.py +156 -0
- compiler/tests/reproduce_sd1_006.py +30 -0
- compiler/tests/test_card_parsing.py +85 -0
- compiler/tests/test_pseudocode_parsing.py +153 -0
- compiler/tests/test_regex_direct.py +11 -0
- compiler/tests/test_robustness.py +23 -0
- compiler/tests/verify_parser_fixes.py +49 -0
compiler/__init__.py
ADDED
|
File without changes
|
compiler/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (171 Bytes). View file
|
|
|
compiler/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (16.6 kB). View file
|
|
|
compiler/__pycache__/parser.cpython-312.pyc
ADDED
|
Binary file (684 Bytes). View file
|
|
|
compiler/__pycache__/parser_v2.cpython-312.pyc
ADDED
|
Binary file (55.5 kB). View file
|
|
|
compiler/_legacy_parsers/parser.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
compiler/id_converter.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def load_db():
|
| 7 |
+
compiled_path = "engine/data/cards_compiled.json"
|
| 8 |
+
if not os.path.exists(compiled_path):
|
| 9 |
+
print(f"Error: {compiled_path} not found.")
|
| 10 |
+
return None
|
| 11 |
+
try:
|
| 12 |
+
with open(compiled_path, "r", encoding="utf-8-sig") as f:
|
| 13 |
+
return json.load(f)
|
| 14 |
+
except Exception as e:
|
| 15 |
+
print(f"Load Error: {e}")
|
| 16 |
+
return None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def convert(query):
|
| 20 |
+
full_db = load_db()
|
| 21 |
+
if not full_db:
|
| 22 |
+
return
|
| 23 |
+
|
| 24 |
+
# Handle member_db and live_db
|
| 25 |
+
member_db = full_db.get("member_db", {})
|
| 26 |
+
live_db = full_db.get("live_db", {})
|
| 27 |
+
dbs = [member_db, live_db]
|
| 28 |
+
|
| 29 |
+
print(f"Total cards loaded: Members {len(member_db)}, Lives {len(live_db)}")
|
| 30 |
+
|
| 31 |
+
# Try numeric ID
|
| 32 |
+
if query.isdigit():
|
| 33 |
+
for db in dbs:
|
| 34 |
+
if query in db:
|
| 35 |
+
card = db[query]
|
| 36 |
+
print(f"ID {query} -> {card.get('card_no')} ({card.get('name')})")
|
| 37 |
+
abis = card.get("abilities", [])
|
| 38 |
+
print(f"Parsed {len(abis)} abilities.")
|
| 39 |
+
for i, a in enumerate(abis):
|
| 40 |
+
print(f" [{i}] Trig:{a.get('trigger')} | {a.get('raw_text')}")
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
# Try Card No or Search
|
| 44 |
+
matches = []
|
| 45 |
+
for db in dbs:
|
| 46 |
+
for cid, card in db.items():
|
| 47 |
+
card_no = str(card.get("card_no", ""))
|
| 48 |
+
name = str(card.get("name", ""))
|
| 49 |
+
|
| 50 |
+
if card_no.lower() == query.lower():
|
| 51 |
+
print(f"Card No {card_no} -> ID {cid} ({name})")
|
| 52 |
+
abis = card.get("abilities", [])
|
| 53 |
+
print(f"Parsed {len(abis)} abilities.")
|
| 54 |
+
for i, a in enumerate(abis):
|
| 55 |
+
print(f" [{i}] Trig:{a.get('trigger')} | {a.get('raw_text')}")
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
if query.lower() in card_no.lower() or query in name:
|
| 59 |
+
matches.append((cid, card_no, name))
|
| 60 |
+
|
| 61 |
+
if matches:
|
| 62 |
+
print(f"Found {len(matches)} matches:")
|
| 63 |
+
for m in matches[:15]:
|
| 64 |
+
print(f" ID {m[0]} | {m[1]} | {m[2]}")
|
| 65 |
+
if len(matches) > 15:
|
| 66 |
+
print(" ...")
|
| 67 |
+
else:
|
| 68 |
+
print(f"No matches found for '{query}'.")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
if len(sys.argv) < 2:
|
| 73 |
+
print("Usage: uv run python compiler/id_converter.py <ID or CardNo>")
|
| 74 |
+
else:
|
| 75 |
+
convert(sys.argv[1])
|
compiler/main.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
# Add project root to path to allow imports if running as script
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from pydantic import TypeAdapter
|
| 13 |
+
|
| 14 |
+
# from compiler.parser import AbilityParser
|
| 15 |
+
from engine.models.card import EnergyCard, LiveCard, MemberCard
|
| 16 |
+
|
| 17 |
+
CHAR_MAP = {
|
| 18 |
+
"高坂 穂乃果": 1,
|
| 19 |
+
"絢瀬 絵里": 2,
|
| 20 |
+
"南 ことり": 3,
|
| 21 |
+
"園田 海未": 4,
|
| 22 |
+
"星空 凛": 5,
|
| 23 |
+
"西木野 真姫": 6,
|
| 24 |
+
"東條 希": 7,
|
| 25 |
+
"小泉 花陽": 8,
|
| 26 |
+
"矢澤 にこ": 9,
|
| 27 |
+
"高海 千歌": 11,
|
| 28 |
+
"桜内 梨子": 12,
|
| 29 |
+
"松浦 果南": 13,
|
| 30 |
+
"黒澤 ダイヤ": 14,
|
| 31 |
+
"渡辺 曜": 15,
|
| 32 |
+
"津島 善子": 16,
|
| 33 |
+
"国木田 花丸": 17,
|
| 34 |
+
"小原 鞠莉": 18,
|
| 35 |
+
"黒澤 ルビィ": 19,
|
| 36 |
+
"上原 歩夢": 21,
|
| 37 |
+
"中須 かすみ": 22,
|
| 38 |
+
"桜坂 しずく": 23,
|
| 39 |
+
"朝香 果林": 24,
|
| 40 |
+
"宮下 愛": 25,
|
| 41 |
+
"近江 彼方": 26,
|
| 42 |
+
"優木 せつ菜": 27,
|
| 43 |
+
"エマ・ヴェルデ": 28,
|
| 44 |
+
"天王寺 璃奈": 29,
|
| 45 |
+
"三船 栞子": 30,
|
| 46 |
+
"ミア・テイラー": 31,
|
| 47 |
+
"鐘 嵐珠": 32,
|
| 48 |
+
"高咲 侑": 33,
|
| 49 |
+
"澁谷 かのん": 41,
|
| 50 |
+
"唐 可可": 42,
|
| 51 |
+
"嵐 千砂都": 43,
|
| 52 |
+
"平安名 すみれ": 44,
|
| 53 |
+
"葉月 恋": 45,
|
| 54 |
+
"桜小路 きな子": 46,
|
| 55 |
+
"米女 メイ": 47,
|
| 56 |
+
"若菜 四季": 48,
|
| 57 |
+
"鬼塚 夏美": 49,
|
| 58 |
+
"ウィーン・マルガレーテ": 50,
|
| 59 |
+
"鬼塚 冬毬": 51,
|
| 60 |
+
"日野下 花帆": 61,
|
| 61 |
+
"村野 さやか": 62,
|
| 62 |
+
"乙宗 梢": 63,
|
| 63 |
+
"夕霧 綴理": 64,
|
| 64 |
+
"大沢 瑠璃乃": 65,
|
| 65 |
+
"藤島 慈": 66,
|
| 66 |
+
"百生 吟子": 67,
|
| 67 |
+
"徒町 小鈴": 68,
|
| 68 |
+
"安養寺 姫芽": 69,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def compile_cards(input_path: str, output_path: str):
|
| 73 |
+
print(f"Loading raw cards from {input_path}...")
|
| 74 |
+
with open(input_path, "r", encoding="utf-8") as f:
|
| 75 |
+
raw_data = json.load(f)
|
| 76 |
+
|
| 77 |
+
compiled_data = {"member_db": {}, "live_db": {}, "energy_db": {}, "meta": {"version": "1.0", "source": input_path}}
|
| 78 |
+
|
| 79 |
+
sorted_keys = sorted(raw_data.keys())
|
| 80 |
+
m_idx = 0
|
| 81 |
+
l_idx = 30000
|
| 82 |
+
e_idx = 40000
|
| 83 |
+
|
| 84 |
+
success_count = 0
|
| 85 |
+
errors = []
|
| 86 |
+
|
| 87 |
+
# Pre-create adapters
|
| 88 |
+
member_adapter = TypeAdapter(MemberCard)
|
| 89 |
+
live_adapter = TypeAdapter(LiveCard)
|
| 90 |
+
energy_adapter = TypeAdapter(EnergyCard)
|
| 91 |
+
|
| 92 |
+
for key in sorted_keys:
|
| 93 |
+
item = raw_data[key]
|
| 94 |
+
ctype = item.get("type", "")
|
| 95 |
+
# print(f"DEBUG: Processing {key} Type: '{ctype}'")
|
| 96 |
+
if "ライブ" in ctype or "Live" in ctype:
|
| 97 |
+
print(f"FOUND LIVE: {key} Type: '{ctype}'")
|
| 98 |
+
elif "Member" not in ctype and "メンバー" not in ctype and "Energy" not in ctype and "エネルギー" not in ctype:
|
| 99 |
+
print(f"UNKNOWN TYPE: {key} Type: '{ctype}'")
|
| 100 |
+
|
| 101 |
+
# Collect variants from rare_list
|
| 102 |
+
variants = [{"card_no": key, "name": item.get("name", ""), "data": item}]
|
| 103 |
+
if "rare_list" in item and isinstance(item["rare_list"], list):
|
| 104 |
+
for r in item["rare_list"]:
|
| 105 |
+
v_no = r.get("card_no")
|
| 106 |
+
if v_no and v_no != key:
|
| 107 |
+
print(f"DEBUG: Found variant {v_no} in rare_list of {key}")
|
| 108 |
+
# Create a variant that inherits base data but overrides metadata
|
| 109 |
+
v_item = item.copy()
|
| 110 |
+
v_item.update(r)
|
| 111 |
+
variants.append({"card_no": v_no, "name": r.get("name", item.get("name", "")), "data": v_item})
|
| 112 |
+
|
| 113 |
+
for v in variants:
|
| 114 |
+
v_key = v["card_no"]
|
| 115 |
+
v_data = v["data"]
|
| 116 |
+
try:
|
| 117 |
+
if ctype == "メンバー":
|
| 118 |
+
m_card = parse_member(m_idx, v_key, v_data)
|
| 119 |
+
compiled_item = member_adapter.dump_python(m_card, mode="json")
|
| 120 |
+
compiled_data["member_db"][str(m_idx)] = compiled_item
|
| 121 |
+
m_idx += 1
|
| 122 |
+
elif ctype == "ライブ":
|
| 123 |
+
l_card = parse_live(l_idx, v_key, v_data)
|
| 124 |
+
compiled_data["live_db"][str(l_idx)] = live_adapter.dump_python(l_card, mode="json")
|
| 125 |
+
l_idx += 1
|
| 126 |
+
else:
|
| 127 |
+
# Treat everything else (Energy, etc.) as basic cards to preserve IDs for decks
|
| 128 |
+
e_card = parse_energy(e_idx, v_key, v_data)
|
| 129 |
+
compiled_data["energy_db"][str(e_idx)] = energy_adapter.dump_python(e_card, mode="json")
|
| 130 |
+
e_idx += 1
|
| 131 |
+
success_count += 1
|
| 132 |
+
except Exception as e:
|
| 133 |
+
errors.append(f"Error parsing card {v_key}: {e}")
|
| 134 |
+
|
| 135 |
+
print(f"Compilation complete. Processed {success_count} cards.")
|
| 136 |
+
if errors:
|
| 137 |
+
print(f"Encountered {len(errors)} errors. See compiler_errors.log for details.")
|
| 138 |
+
with open("compiler_errors.log", "w", encoding="utf-8") as f_err:
|
| 139 |
+
for err_msg in errors:
|
| 140 |
+
f_err.write(f"- {err_msg}\n")
|
| 141 |
+
|
| 142 |
+
# Write output
|
| 143 |
+
print(f"Writing compiled data to {output_path}...")
|
| 144 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 145 |
+
json.dump(compiled_data, f, ensure_ascii=False, indent=2)
|
| 146 |
+
print("Done.")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _resolve_img_path(data: dict) -> str:
|
| 150 |
+
# Use cards_webp as the flattened source
|
| 151 |
+
img_path = str(data.get("_img", ""))
|
| 152 |
+
if img_path:
|
| 153 |
+
filename = os.path.basename(img_path)
|
| 154 |
+
if filename.lower().endswith(".png"):
|
| 155 |
+
filename = filename[:-4] + ".webp"
|
| 156 |
+
return f"cards_webp/{filename}"
|
| 157 |
+
|
| 158 |
+
raw_url = str(data.get("img", ""))
|
| 159 |
+
if raw_url:
|
| 160 |
+
filename = os.path.basename(raw_url)
|
| 161 |
+
if filename.lower().endswith(".png"):
|
| 162 |
+
filename = filename[:-4] + ".webp"
|
| 163 |
+
return f"cards_webp/{filename}"
|
| 164 |
+
|
| 165 |
+
return raw_url
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
from compiler.parser_v2 import AbilityParserV2
|
| 169 |
+
|
| 170 |
+
# Initialize parser globally
|
| 171 |
+
_v2_parser = AbilityParserV2()
|
| 172 |
+
|
| 173 |
+
# Load manual overrides
|
| 174 |
+
MANUAL_OVERRIDES_PATH = "data/manual_pseudocode.json"
|
| 175 |
+
_manual_overrides = {}
|
| 176 |
+
if os.path.exists(MANUAL_OVERRIDES_PATH):
|
| 177 |
+
print(f"Loading manual overrides from {MANUAL_OVERRIDES_PATH}")
|
| 178 |
+
with open(MANUAL_OVERRIDES_PATH, "r", encoding="utf-8") as f:
|
| 179 |
+
_manual_overrides = json.load(f)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def parse_member(card_id: int, card_no: str, data: dict) -> MemberCard:
|
| 183 |
+
spec = data.get("special_heart", {})
|
| 184 |
+
# Use manual override if present
|
| 185 |
+
override_data = _manual_overrides.get(card_no, {})
|
| 186 |
+
|
| 187 |
+
# Use manual pseudo/text if available, else raw data
|
| 188 |
+
if "pseudocode" in override_data:
|
| 189 |
+
raw_ability = str(override_data["pseudocode"])
|
| 190 |
+
else:
|
| 191 |
+
raw_ability = str(data.get("pseudocode", data.get("ability", "")))
|
| 192 |
+
|
| 193 |
+
abilities = _v2_parser.parse(raw_ability)
|
| 194 |
+
|
| 195 |
+
for ab in abilities:
|
| 196 |
+
try:
|
| 197 |
+
ab.bytecode = ab.compile()
|
| 198 |
+
except Exception as e:
|
| 199 |
+
print(f"Warning: Failed to compile bytecode for {card_no} ability: {e}")
|
| 200 |
+
|
| 201 |
+
return MemberCard(
|
| 202 |
+
card_id=card_id,
|
| 203 |
+
card_no=card_no,
|
| 204 |
+
name=str(data.get("name", "Unknown")),
|
| 205 |
+
cost=data.get("cost", 0),
|
| 206 |
+
hearts=parse_hearts(data.get("base_heart", {})),
|
| 207 |
+
blade_hearts=parse_blade_hearts(data.get("blade_heart", {})),
|
| 208 |
+
blades=data.get("blade", 0),
|
| 209 |
+
groups=data.get("series", ""), # Validator will handle string -> List[Group]
|
| 210 |
+
units=data.get("unit", ""), # Validator will handle string -> List[Unit]
|
| 211 |
+
abilities=abilities,
|
| 212 |
+
img_path=_resolve_img_path(data),
|
| 213 |
+
ability_text=raw_ability,
|
| 214 |
+
original_text=str(data.get("ability", "")),
|
| 215 |
+
volume_icons=spec.get("score", 0),
|
| 216 |
+
draw_icons=spec.get("draw", 0),
|
| 217 |
+
char_id=CHAR_MAP.get(str(data.get("name", "")), 0),
|
| 218 |
+
faq=data.get("faq", []),
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def parse_live(card_id: int, card_no: str, data: dict) -> LiveCard:
|
| 223 |
+
spec = data.get("special_heart", {})
|
| 224 |
+
# Use manual override if present
|
| 225 |
+
override_data = _manual_overrides.get(card_no, {})
|
| 226 |
+
# Prioritize 'pseudocode' over 'ability'
|
| 227 |
+
raw_ability = str(override_data.get("pseudocode", data.get("pseudocode", data.get("ability", ""))))
|
| 228 |
+
abilities = _v2_parser.parse(raw_ability)
|
| 229 |
+
for ab in abilities:
|
| 230 |
+
try:
|
| 231 |
+
ab.bytecode = ab.compile()
|
| 232 |
+
except Exception as e:
|
| 233 |
+
print(f"Warning: Failed to compile bytecode for {card_no} ability: {e}")
|
| 234 |
+
|
| 235 |
+
return LiveCard(
|
| 236 |
+
card_id=card_id,
|
| 237 |
+
card_no=card_no,
|
| 238 |
+
name=str(data.get("name", "Unknown")),
|
| 239 |
+
score=data.get("score", 0),
|
| 240 |
+
required_hearts=parse_live_reqs(data.get("need_heart", {})),
|
| 241 |
+
abilities=abilities,
|
| 242 |
+
groups=data.get("series", ""),
|
| 243 |
+
units=data.get("unit", ""),
|
| 244 |
+
img_path=_resolve_img_path(data),
|
| 245 |
+
ability_text=raw_ability,
|
| 246 |
+
original_text=str(data.get("ability", "")),
|
| 247 |
+
volume_icons=spec.get("score", 0),
|
| 248 |
+
draw_icons=spec.get("draw", 0),
|
| 249 |
+
blade_hearts=parse_blade_hearts(data.get("blade_heart", {})),
|
| 250 |
+
faq=data.get("faq", []),
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def parse_energy(card_id: int, card_no: str, data: dict) -> EnergyCard:
|
| 255 |
+
return EnergyCard(
|
| 256 |
+
card_id=card_id,
|
| 257 |
+
card_no=card_no,
|
| 258 |
+
name=str(data.get("name", "Energy")),
|
| 259 |
+
img_path=_resolve_img_path(data),
|
| 260 |
+
ability_text=str(data.get("ability", "")),
|
| 261 |
+
original_text=str(data.get("ability", "")),
|
| 262 |
+
rare=str(data.get("rare", "N")),
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def parse_hearts(heart_dict: dict) -> np.ndarray:
|
| 267 |
+
hearts = np.zeros(7, dtype=np.int32)
|
| 268 |
+
if not heart_dict:
|
| 269 |
+
return hearts
|
| 270 |
+
for k, v in heart_dict.items():
|
| 271 |
+
if k.startswith("heart"):
|
| 272 |
+
try:
|
| 273 |
+
num_str = k.replace("heart", "")
|
| 274 |
+
if num_str == "0": # Handle heart0 as ANY/STAR
|
| 275 |
+
hearts[6] = int(v)
|
| 276 |
+
continue
|
| 277 |
+
idx = int(num_str) - 1
|
| 278 |
+
if 0 <= idx < 6:
|
| 279 |
+
hearts[idx] = int(v)
|
| 280 |
+
except ValueError:
|
| 281 |
+
pass
|
| 282 |
+
elif k in ["common", "any", "star"]:
|
| 283 |
+
hearts[6] = int(v)
|
| 284 |
+
return hearts
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def parse_blade_hearts(heart_dict: dict) -> np.ndarray:
|
| 288 |
+
hearts = np.zeros(7, dtype=np.int32)
|
| 289 |
+
if not heart_dict:
|
| 290 |
+
return hearts
|
| 291 |
+
for k, v in heart_dict.items():
|
| 292 |
+
if k == "b_all":
|
| 293 |
+
hearts[6] = int(v)
|
| 294 |
+
elif k.startswith("b_heart"):
|
| 295 |
+
try:
|
| 296 |
+
idx = int(k.replace("b_heart", "")) - 1
|
| 297 |
+
if 0 <= idx < 6:
|
| 298 |
+
hearts[idx] = int(v)
|
| 299 |
+
except ValueError:
|
| 300 |
+
pass
|
| 301 |
+
return hearts
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def parse_live_reqs(req_dict: dict) -> np.ndarray:
|
| 305 |
+
# Use parse_hearts directly as it now handles 7 elements correctly
|
| 306 |
+
return parse_hearts(req_dict)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
if __name__ == "__main__":
|
| 310 |
+
parser = argparse.ArgumentParser()
|
| 311 |
+
parser.add_argument("--input", default="data/cards.json", help="Path to raw cards.json")
|
| 312 |
+
parser.add_argument("--output", default="data/cards_compiled.json", help="Output path")
|
| 313 |
+
args = parser.parse_args()
|
| 314 |
+
|
| 315 |
+
# Resolve paths relative to cwd if needed, or assume running from root
|
| 316 |
+
compile_cards(args.input, args.output)
|
| 317 |
+
|
| 318 |
+
# Copy to both data/ and engine/data/ for compatibility with all scripts
|
| 319 |
+
import shutil
|
| 320 |
+
|
| 321 |
+
root_data_path = os.path.join(os.getcwd(), "data", "cards_compiled.json")
|
| 322 |
+
engine_data_path = os.path.join(os.getcwd(), "engine", "data", "cards_compiled.json")
|
| 323 |
+
|
| 324 |
+
# Sync to root data/
|
| 325 |
+
if os.path.abspath(args.output) != os.path.abspath(root_data_path):
|
| 326 |
+
try:
|
| 327 |
+
shutil.copy(args.output, root_data_path)
|
| 328 |
+
print(f"Copied compiled data to {root_data_path}")
|
| 329 |
+
except Exception as e:
|
| 330 |
+
print(f"Warning: Failed to copy to root data directory: {e}")
|
| 331 |
+
|
| 332 |
+
# Sync to engine/data/ to keep paths consistent
|
| 333 |
+
try:
|
| 334 |
+
os.makedirs(os.path.dirname(engine_data_path), exist_ok=True)
|
| 335 |
+
shutil.copy(root_data_path, engine_data_path)
|
| 336 |
+
print(f"Synced compiled data to {engine_data_path}")
|
| 337 |
+
except Exception as e:
|
| 338 |
+
print(f"Warning: Failed to sync to engine/data directory: {e}")
|
compiler/parser.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .parser_v2 import AbilityParserV2
|
| 2 |
+
from .parser_v2 import parse_ability_text as _parse_ability_text
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class AbilityParser(AbilityParserV2):
|
| 6 |
+
@staticmethod
|
| 7 |
+
def parse_ability_text(text: str):
|
| 8 |
+
return _parse_ability_text(text)
|
compiler/parser_v2.py
ADDED
|
@@ -0,0 +1,1702 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""New multi-pass ability parser using the pattern registry system.
|
| 3 |
+
|
| 4 |
+
This parser replaces the legacy 3500-line spaghetti parser with a clean,
|
| 5 |
+
modular architecture based on:
|
| 6 |
+
1. Declarative patterns organized by phase
|
| 7 |
+
2. Multi-pass parsing: Trigger → Conditions → Effects → Modifiers
|
| 8 |
+
3. Proper optionality handling (fixes the is_optional bug)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import copy
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
from typing import Any, Dict, List, Match, Optional, Tuple
|
| 15 |
+
|
| 16 |
+
from engine.models.ability import (
|
| 17 |
+
Ability,
|
| 18 |
+
AbilityCostType,
|
| 19 |
+
Condition,
|
| 20 |
+
ConditionType,
|
| 21 |
+
Cost,
|
| 22 |
+
Effect,
|
| 23 |
+
EffectType,
|
| 24 |
+
TargetType,
|
| 25 |
+
TriggerType,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
from .patterns.base import PatternPhase
|
| 29 |
+
from .patterns.registry import PatternRegistry, get_registry
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class AbilityParserV2:
|
| 33 |
+
"""Multi-pass ability parser using pattern registry."""
|
| 34 |
+
|
| 35 |
+
def __init__(self, registry: Optional[PatternRegistry] = None):
|
| 36 |
+
self.registry = registry or get_registry()
|
| 37 |
+
|
| 38 |
+
def parse(self, text: str) -> List[Ability]:
|
| 39 |
+
"""Parse ability text into structured Ability objects."""
|
| 40 |
+
# Detect pseudocode format
|
| 41 |
+
triggers = ["TRIGGER:", "CONDITION:", "EFFECT:", "COST:"]
|
| 42 |
+
if any(text.strip().startswith(kw) for kw in triggers):
|
| 43 |
+
return self._parse_pseudocode_block(text)
|
| 44 |
+
|
| 45 |
+
# Preprocessing
|
| 46 |
+
text = self._preprocess(text)
|
| 47 |
+
|
| 48 |
+
# Split into sentences
|
| 49 |
+
sentences = self._split_sentences(text)
|
| 50 |
+
|
| 51 |
+
# Group sentences into ability blocks
|
| 52 |
+
blocks = []
|
| 53 |
+
current_block = []
|
| 54 |
+
for i, sentence in enumerate(sentences):
|
| 55 |
+
if i > 0 and self._is_continuation(sentence, i):
|
| 56 |
+
current_block.append(sentence)
|
| 57 |
+
else:
|
| 58 |
+
if current_block:
|
| 59 |
+
blocks.append(" ".join(current_block))
|
| 60 |
+
current_block = [sentence]
|
| 61 |
+
if current_block:
|
| 62 |
+
blocks.append(" ".join(current_block))
|
| 63 |
+
|
| 64 |
+
abilities = []
|
| 65 |
+
for block in blocks:
|
| 66 |
+
ability = self._parse_block(block)
|
| 67 |
+
if ability:
|
| 68 |
+
abilities.append(ability)
|
| 69 |
+
|
| 70 |
+
return abilities
|
| 71 |
+
|
| 72 |
+
def _parse_block(self, block: str) -> Optional[Ability]:
|
| 73 |
+
"""Parse a single combined ability block."""
|
| 74 |
+
# Split into cost and effect parts
|
| 75 |
+
colon_idx = block.find(":")
|
| 76 |
+
if colon_idx == -1:
|
| 77 |
+
colon_idx = block.find(":")
|
| 78 |
+
|
| 79 |
+
if colon_idx != -1:
|
| 80 |
+
cost_part = block[:colon_idx].strip()
|
| 81 |
+
effect_part = block[colon_idx + 1 :].strip()
|
| 82 |
+
else:
|
| 83 |
+
cost_part = ""
|
| 84 |
+
effect_part = block
|
| 85 |
+
|
| 86 |
+
# === PASS 1: Extract trigger ===
|
| 87 |
+
trigger, trigger_match = self._extract_trigger(block)
|
| 88 |
+
|
| 89 |
+
# Mask trigger text from effect part to avoid double-matching
|
| 90 |
+
# (e.g. "when placed in discard" shouldn't trigger "place in discard")
|
| 91 |
+
effective_effect_part = effect_part
|
| 92 |
+
if trigger_match:
|
| 93 |
+
# Standard Japanese card formatting: [Trigger/Condition]とき、[Effect]
|
| 94 |
+
# Or [Trigger/Condition]:[Effect]
|
| 95 |
+
# If we see "とき", everything before it is usually trigger/condition
|
| 96 |
+
toki_idx = effective_effect_part.find("とき")
|
| 97 |
+
if toki_idx == -1:
|
| 98 |
+
toki_idx = effective_effect_part.find("場合")
|
| 99 |
+
|
| 100 |
+
if toki_idx != -1:
|
| 101 |
+
# Mask everything up to "とき" or "場合" (plus the word itself)
|
| 102 |
+
# BUT ONLY if it's in the same sentence (no punctuation in between)
|
| 103 |
+
preceding = effective_effect_part[:toki_idx]
|
| 104 |
+
if "。" in preceding:
|
| 105 |
+
toki_idx = -1
|
| 106 |
+
|
| 107 |
+
if toki_idx != -1:
|
| 108 |
+
mask_end = toki_idx + 2 # Length of "とき" or "場合"
|
| 109 |
+
effective_effect_part = " " * mask_end + effective_effect_part[mask_end:]
|
| 110 |
+
else:
|
| 111 |
+
# Fallback: just mask the trigger match itself
|
| 112 |
+
start, end = trigger_match.span()
|
| 113 |
+
if start >= (len(block) - len(effect_part)):
|
| 114 |
+
rel_start = start - (len(block) - len(effect_part))
|
| 115 |
+
rel_end = end - (len(block) - len(effect_part))
|
| 116 |
+
if rel_start >= 0 and rel_end <= len(effect_part):
|
| 117 |
+
effective_effect_part = (
|
| 118 |
+
effect_part[:rel_start] + " " * (rel_end - rel_start) + effect_part[rel_end:]
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# === PASS 2: Extract conditions ===
|
| 122 |
+
# Scan the entire block for conditions as they can appear anywhere
|
| 123 |
+
conditions = self._extract_conditions(block)
|
| 124 |
+
|
| 125 |
+
# === PASS 3: Extract effects ===
|
| 126 |
+
# Only extract effects from the masked part to avoid trigger/cost confusion
|
| 127 |
+
effects = self._extract_effects(effective_effect_part)
|
| 128 |
+
|
| 129 |
+
# === PASS 5: Extract costs ===
|
| 130 |
+
costs = self._extract_costs(cost_part)
|
| 131 |
+
|
| 132 |
+
# Determine Trigger and construct Ability
|
| 133 |
+
if trigger == TriggerType.NONE and not (effects or conditions or costs):
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
final_trigger = trigger
|
| 137 |
+
if final_trigger == TriggerType.NONE:
|
| 138 |
+
# Only default to CONSTANT if we have some indicators of an ability
|
| 139 |
+
# (to avoid splitting errors defaulting to Constant)
|
| 140 |
+
has_ability_indicators = any(
|
| 141 |
+
kw in block
|
| 142 |
+
for kw in [
|
| 143 |
+
"引",
|
| 144 |
+
"スコア",
|
| 145 |
+
"プラス",
|
| 146 |
+
"+",
|
| 147 |
+
"ブレード",
|
| 148 |
+
"ハート",
|
| 149 |
+
"控",
|
| 150 |
+
"戻",
|
| 151 |
+
"エネ",
|
| 152 |
+
"デッキ",
|
| 153 |
+
"山札",
|
| 154 |
+
"見る",
|
| 155 |
+
"公開",
|
| 156 |
+
"選ぶ",
|
| 157 |
+
"扱",
|
| 158 |
+
"得る",
|
| 159 |
+
"移動",
|
| 160 |
+
]
|
| 161 |
+
)
|
| 162 |
+
if has_ability_indicators:
|
| 163 |
+
final_trigger = TriggerType.CONSTANT
|
| 164 |
+
else:
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
ability = Ability(raw_text=block, trigger=final_trigger, effects=effects, conditions=conditions, costs=costs)
|
| 168 |
+
|
| 169 |
+
# === PASS 4: Apply modifiers ===
|
| 170 |
+
# Scan the entire block for modifiers (OPT, optionality, etc.)
|
| 171 |
+
modifiers = self._extract_modifiers(block)
|
| 172 |
+
self._apply_modifiers(ability, modifiers)
|
| 173 |
+
|
| 174 |
+
# === PASS 6: Handle "Choose Player" transformation ===
|
| 175 |
+
# If the ability starts with "自分か相手を選ぶ", transform following effects into SELECT_MODE
|
| 176 |
+
if "自分か相手を選ぶ" in block and len(ability.effects) > 0:
|
| 177 |
+
original_effects = []
|
| 178 |
+
# Find the "choose player" dummy effect (META_RULE) if present and remove it
|
| 179 |
+
other_effects = []
|
| 180 |
+
for eff in ability.effects:
|
| 181 |
+
if eff.effect_type == EffectType.META_RULE and eff.params.get("target") == "PLAYER_SELECT":
|
| 182 |
+
continue
|
| 183 |
+
other_effects.append(eff)
|
| 184 |
+
|
| 185 |
+
if other_effects:
|
| 186 |
+
# Option 1: Yourself
|
| 187 |
+
self_effects = []
|
| 188 |
+
for eff in other_effects:
|
| 189 |
+
new_eff = copy.deepcopy(eff)
|
| 190 |
+
new_eff.target = TargetType.SELF
|
| 191 |
+
self_effects.append(new_eff)
|
| 192 |
+
|
| 193 |
+
# Option 2: Opponent
|
| 194 |
+
opp_effects = []
|
| 195 |
+
for eff in other_effects:
|
| 196 |
+
new_eff = copy.deepcopy(eff)
|
| 197 |
+
new_eff.target = TargetType.OPPONENT
|
| 198 |
+
opp_effects.append(new_eff)
|
| 199 |
+
|
| 200 |
+
# Replace effects with a single SELECT_MODE
|
| 201 |
+
ability.effects = [
|
| 202 |
+
Effect(
|
| 203 |
+
EffectType.SELECT_MODE,
|
| 204 |
+
value=1,
|
| 205 |
+
target=TargetType.SELF,
|
| 206 |
+
params={"options_text": ["自分", "相手"]},
|
| 207 |
+
modal_options=[self_effects, opp_effects],
|
| 208 |
+
)
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
return ability
|
| 212 |
+
|
| 213 |
+
# =========================================================================
|
| 214 |
+
# Preprocessing
|
| 215 |
+
# =========================================================================
|
| 216 |
+
|
| 217 |
+
def _preprocess(self, text: str) -> str:
|
| 218 |
+
"""Normalize text for parsing."""
|
| 219 |
+
text = text.replace("<br>", "\n")
|
| 220 |
+
return text
|
| 221 |
+
|
| 222 |
+
def _split_sentences(self, text: str) -> List[str]:
|
| 223 |
+
"""Split text into individual sentences."""
|
| 224 |
+
# Split by newlines first
|
| 225 |
+
blocks = re.split(r"\\n|\n", text)
|
| 226 |
+
|
| 227 |
+
sentences = []
|
| 228 |
+
for block in blocks:
|
| 229 |
+
block = block.strip()
|
| 230 |
+
if not block:
|
| 231 |
+
continue
|
| 232 |
+
# Split on Japanese period, keeping the period
|
| 233 |
+
parts = re.split(r"(。)\s*", block)
|
| 234 |
+
# Reconstruct sentences with periods
|
| 235 |
+
current = ""
|
| 236 |
+
for part in parts:
|
| 237 |
+
if part == "。":
|
| 238 |
+
current += part
|
| 239 |
+
if current.strip():
|
| 240 |
+
sentences.append(current.strip())
|
| 241 |
+
current = ""
|
| 242 |
+
else:
|
| 243 |
+
current = part
|
| 244 |
+
if current.strip():
|
| 245 |
+
sentences.append(current.strip())
|
| 246 |
+
|
| 247 |
+
return sentences
|
| 248 |
+
|
| 249 |
+
def _is_continuation(self, sentence: str, index: int) -> bool:
|
| 250 |
+
"""Check if sentence is a continuation of previous ability."""
|
| 251 |
+
# First sentence can't be a continuation
|
| 252 |
+
if index == 0:
|
| 253 |
+
return False
|
| 254 |
+
|
| 255 |
+
# Explicit trigger icons should NEVER be continuations
|
| 256 |
+
if any(
|
| 257 |
+
icon in sentence
|
| 258 |
+
for icon in ["{{live_success", "{{live_start", "{{toujyou", "{{kidou", "{{jyouji", "{{jidou"]
|
| 259 |
+
):
|
| 260 |
+
return False
|
| 261 |
+
|
| 262 |
+
# Check for continuation markers
|
| 263 |
+
continuation_markers = [
|
| 264 |
+
"・",
|
| 265 |
+
"-",
|
| 266 |
+
"-",
|
| 267 |
+
"回答が",
|
| 268 |
+
"選んだ場合",
|
| 269 |
+
"条件が",
|
| 270 |
+
"それ以外",
|
| 271 |
+
"その",
|
| 272 |
+
"それら",
|
| 273 |
+
"残り",
|
| 274 |
+
"そし",
|
| 275 |
+
"その後",
|
| 276 |
+
"そこから",
|
| 277 |
+
"山札",
|
| 278 |
+
"デッキ",
|
| 279 |
+
"もよい",
|
| 280 |
+
"を自分",
|
| 281 |
+
"ライブ終了時まで",
|
| 282 |
+
"この能力",
|
| 283 |
+
"この効果",
|
| 284 |
+
"(",
|
| 285 |
+
"(",
|
| 286 |
+
"そうした場合",
|
| 287 |
+
"」",
|
| 288 |
+
"』",
|
| 289 |
+
")」",
|
| 290 |
+
")",
|
| 291 |
+
")",
|
| 292 |
+
"ただし",
|
| 293 |
+
"かつ",
|
| 294 |
+
"または",
|
| 295 |
+
"もしくは",
|
| 296 |
+
"および",
|
| 297 |
+
"代わりに",
|
| 298 |
+
"このメンバー",
|
| 299 |
+
"そのメンバー",
|
| 300 |
+
"選んだ",
|
| 301 |
+
"選んだエリア",
|
| 302 |
+
"自分は",
|
| 303 |
+
"相手は",
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
# Check if it starts with any common phrase that usually continues an ability
|
| 307 |
+
for marker in continuation_markers:
|
| 308 |
+
if sentence.startswith(marker):
|
| 309 |
+
return True
|
| 310 |
+
|
| 311 |
+
# Special case: "その" or "プレイヤー" often appears slightly after "自分は"
|
| 312 |
+
if "その" in sentence[:10] or "プレイヤー" in sentence[:10]:
|
| 313 |
+
return True
|
| 314 |
+
|
| 315 |
+
return False
|
| 316 |
+
|
| 317 |
+
def _extend_ability(self, ability: Ability, sentence: str):
|
| 318 |
+
"""Extend an existing ability with content from a continuation sentence."""
|
| 319 |
+
# Extract additional effects
|
| 320 |
+
effects = self._extract_effects(sentence)
|
| 321 |
+
ability.effects.extend(effects)
|
| 322 |
+
|
| 323 |
+
# Extract additional conditions
|
| 324 |
+
conditions = self._extract_conditions(sentence)
|
| 325 |
+
for cond in conditions:
|
| 326 |
+
if cond not in ability.conditions:
|
| 327 |
+
ability.conditions.append(cond)
|
| 328 |
+
|
| 329 |
+
# Apply modifiers
|
| 330 |
+
modifiers = self._extract_modifiers(sentence)
|
| 331 |
+
self._apply_modifiers(ability, modifiers)
|
| 332 |
+
|
| 333 |
+
# Update raw text
|
| 334 |
+
ability.raw_text += " " + sentence
|
| 335 |
+
|
| 336 |
+
# =========================================================================
|
| 337 |
+
# Pass 1: Trigger Extraction
|
| 338 |
+
# =========================================================================
|
| 339 |
+
|
| 340 |
+
def _extract_trigger(self, sentence: str) -> Tuple[TriggerType, Optional[Match]]:
|
| 341 |
+
"""Extract trigger type and match object from sentence."""
|
| 342 |
+
result = self.registry.match_first(sentence, PatternPhase.TRIGGER)
|
| 343 |
+
if result:
|
| 344 |
+
pattern, match, data = result
|
| 345 |
+
type_str = data.get("type", "")
|
| 346 |
+
return self._resolve_trigger_type(type_str), match
|
| 347 |
+
return TriggerType.NONE, None
|
| 348 |
+
|
| 349 |
+
def _resolve_trigger_type(self, type_str: str) -> TriggerType:
|
| 350 |
+
"""Convert type string to TriggerType enum."""
|
| 351 |
+
mapping = {
|
| 352 |
+
"TriggerType.ON_PLAY": TriggerType.ON_PLAY,
|
| 353 |
+
"TriggerType.ON_LIVE_START": TriggerType.ON_LIVE_START,
|
| 354 |
+
"TriggerType.ON_LIVE_SUCCESS": TriggerType.ON_LIVE_SUCCESS,
|
| 355 |
+
"TriggerType.ACTIVATED": TriggerType.ACTIVATED,
|
| 356 |
+
"TriggerType.CONSTANT": TriggerType.CONSTANT,
|
| 357 |
+
"TriggerType.ON_LEAVES": TriggerType.ON_LEAVES,
|
| 358 |
+
"TriggerType.ON_REVEAL": TriggerType.ON_REVEAL,
|
| 359 |
+
"TriggerType.TURN_START": TriggerType.TURN_START,
|
| 360 |
+
"TriggerType.TURN_END": TriggerType.TURN_END,
|
| 361 |
+
}
|
| 362 |
+
return mapping.get(type_str, TriggerType.NONE)
|
| 363 |
+
|
| 364 |
+
# =========================================================================
|
| 365 |
+
# Pass 2: Condition Extraction
|
| 366 |
+
# =========================================================================
|
| 367 |
+
|
| 368 |
+
def _extract_conditions(self, sentence: str) -> List[Condition]:
|
| 369 |
+
"""Extract all conditions from sentence."""
|
| 370 |
+
conditions = []
|
| 371 |
+
results = self.registry.match_all(sentence, PatternPhase.CONDITION)
|
| 372 |
+
|
| 373 |
+
for pattern, match, data in results:
|
| 374 |
+
cond_type = self._resolve_condition_type(data.get("type", ""))
|
| 375 |
+
if cond_type is not None:
|
| 376 |
+
params = data.get("params", {}).copy()
|
| 377 |
+
|
| 378 |
+
# Use extracted value if not already in params
|
| 379 |
+
if "value" in data and "min" not in params:
|
| 380 |
+
params["min"] = data["value"]
|
| 381 |
+
elif "min" not in params and match.lastindex:
|
| 382 |
+
try:
|
| 383 |
+
# Fallback for simple numeric patterns with one group
|
| 384 |
+
params["min"] = int(match.group(1))
|
| 385 |
+
except (ValueError, IndexError):
|
| 386 |
+
pass
|
| 387 |
+
|
| 388 |
+
conditions.append(Condition(cond_type, params))
|
| 389 |
+
|
| 390 |
+
return conditions
|
| 391 |
+
|
| 392 |
+
def _resolve_condition_type(self, type_str: str) -> Optional[ConditionType]:
|
| 393 |
+
"""Convert type string to ConditionType enum."""
|
| 394 |
+
if not type_str:
|
| 395 |
+
return None
|
| 396 |
+
name = type_str.replace("ConditionType.", "")
|
| 397 |
+
print(f"DEBUG_LOUD: Resolving '{type_str}' -> '{name}'")
|
| 398 |
+
|
| 399 |
+
# Debug members
|
| 400 |
+
# if name == "COUNT_STAGE":
|
| 401 |
+
# print(f"DEBUG_MEMBERS: {[m.name for m in ConditionType]}")
|
| 402 |
+
|
| 403 |
+
try:
|
| 404 |
+
val = ConditionType[name]
|
| 405 |
+
print(f"DEBUG_LOUD: SUCCESS {name} -> {val}")
|
| 406 |
+
return val
|
| 407 |
+
except KeyError:
|
| 408 |
+
print(f"DEBUG_LOUD: FAILED {name}")
|
| 409 |
+
return None
|
| 410 |
+
|
| 411 |
+
# =========================================================================
|
| 412 |
+
# Pass 3: Effect Extraction
|
| 413 |
+
# =========================================================================
|
| 414 |
+
|
| 415 |
+
def _extract_effects(self, sentence: str) -> List[Effect]:
|
| 416 |
+
"""Extract all effects from sentence."""
|
| 417 |
+
effects = []
|
| 418 |
+
results = self.registry.match_all(sentence, PatternPhase.EFFECT)
|
| 419 |
+
|
| 420 |
+
# Debug: Show what's being parsed
|
| 421 |
+
if "DRAW(" in sentence:
|
| 422 |
+
print(f"DEBUG_EFFECTS: Parsing sentence with DRAW: '{sentence[:50]}'")
|
| 423 |
+
print(f"DEBUG_EFFECTS: Got {len(results)} pattern matches")
|
| 424 |
+
for pattern, match, data in results:
|
| 425 |
+
print(f"DEBUG_EFFECTS: Pattern={pattern.name}, Data={data}")
|
| 426 |
+
|
| 427 |
+
for pattern, match, data in results:
|
| 428 |
+
eff_type = self._resolve_effect_type(data.get("type", ""))
|
| 429 |
+
if eff_type is not None: # Use 'is not None' because EffectType.DRAW = 0 is falsy
|
| 430 |
+
value = data.get("value", 1)
|
| 431 |
+
params = data.get("params", {}).copy()
|
| 432 |
+
|
| 433 |
+
# Check for dynamic value condition
|
| 434 |
+
value_cond = ConditionType.NONE
|
| 435 |
+
if "value_cond" in data:
|
| 436 |
+
vc_str = data["value_cond"]
|
| 437 |
+
# If it's a string, try to resolve it
|
| 438 |
+
if isinstance(vc_str, str):
|
| 439 |
+
resolved_vc = self._resolve_condition_type(vc_str)
|
| 440 |
+
if resolved_vc:
|
| 441 |
+
value_cond = resolved_vc
|
| 442 |
+
elif isinstance(vc_str, int):
|
| 443 |
+
value_cond = ConditionType(vc_str)
|
| 444 |
+
|
| 445 |
+
# Special case for "一番上" (top of deck) which means 1 card
|
| 446 |
+
if "一番上" in sentence and value == 1:
|
| 447 |
+
pass # Value 1 is already default
|
| 448 |
+
|
| 449 |
+
# Determine target
|
| 450 |
+
target = self._determine_target(sentence, params)
|
| 451 |
+
|
| 452 |
+
effects.append(Effect(eff_type, value, value_cond, target, params))
|
| 453 |
+
|
| 454 |
+
return effects
|
| 455 |
+
|
| 456 |
+
def _resolve_effect_type(self, type_str: str) -> Optional[EffectType]:
|
| 457 |
+
"""Convert type string to EffectType enum."""
|
| 458 |
+
if not type_str:
|
| 459 |
+
return None
|
| 460 |
+
name = type_str.replace("EffectType.", "")
|
| 461 |
+
try:
|
| 462 |
+
return EffectType[name]
|
| 463 |
+
except KeyError:
|
| 464 |
+
return None
|
| 465 |
+
|
| 466 |
+
def _determine_target(self, sentence: str, params: Dict[str, Any]) -> TargetType:
|
| 467 |
+
"""Determine target type from sentence context."""
|
| 468 |
+
if "相手" in sentence:
|
| 469 |
+
return TargetType.OPPONENT
|
| 470 |
+
if "自分と相手" in sentence:
|
| 471 |
+
return TargetType.ALL_PLAYERS
|
| 472 |
+
if "控え室" in sentence:
|
| 473 |
+
return TargetType.CARD_DISCARD
|
| 474 |
+
if "手札" in sentence:
|
| 475 |
+
return TargetType.CARD_HAND
|
| 476 |
+
return TargetType.PLAYER
|
| 477 |
+
|
| 478 |
+
# =========================================================================
|
| 479 |
+
# Pass 4: Modifier Extraction & Application
|
| 480 |
+
# =========================================================================
|
| 481 |
+
|
| 482 |
+
def _extract_modifiers(self, sentence: str) -> Dict[str, Any]:
|
| 483 |
+
"""Extract all modifiers from sentence."""
|
| 484 |
+
modifiers = {}
|
| 485 |
+
results = self.registry.match_all(sentence, PatternPhase.MODIFIER)
|
| 486 |
+
|
| 487 |
+
for pattern, match, data in results:
|
| 488 |
+
params = data.get("params", {})
|
| 489 |
+
|
| 490 |
+
# Special handling for target_name accumulation
|
| 491 |
+
if "target_name" in params:
|
| 492 |
+
if "target_names" not in modifiers:
|
| 493 |
+
modifiers["target_names"] = []
|
| 494 |
+
modifiers["target_names"].append(params["target_name"])
|
| 495 |
+
# Remove target_name from params to avoid overwriting invalid data
|
| 496 |
+
params = {k: v for k, v in params.items() if k != "target_name"}
|
| 497 |
+
|
| 498 |
+
# Special handling for group accumulation
|
| 499 |
+
if "group" in params:
|
| 500 |
+
if "groups" not in modifiers:
|
| 501 |
+
modifiers["groups"] = []
|
| 502 |
+
modifiers["groups"].append(params["group"])
|
| 503 |
+
# Note: We do NOT remove "group" from params here because we want the last one
|
| 504 |
+
# to persist in modifiers["group"] for singular backward compatibility,
|
| 505 |
+
# which modifiers.update(params) below will handle.
|
| 506 |
+
|
| 507 |
+
modifiers.update(params)
|
| 508 |
+
|
| 509 |
+
# Extract numeric values if present
|
| 510 |
+
if match.lastindex:
|
| 511 |
+
try:
|
| 512 |
+
if "cost_max" not in modifiers and "コスト" in pattern.name:
|
| 513 |
+
modifiers["cost_max"] = int(match.group(1))
|
| 514 |
+
if "multiplier" not in modifiers and "multiplier" in pattern.name:
|
| 515 |
+
modifiers["multiplier_value"] = int(match.group(1))
|
| 516 |
+
except (ValueError, IndexError):
|
| 517 |
+
pass
|
| 518 |
+
|
| 519 |
+
return modifiers
|
| 520 |
+
|
| 521 |
+
def _apply_modifiers(self, ability: Ability, modifiers: Dict[str, Any]):
|
| 522 |
+
"""Apply extracted modifiers to effects and conditions."""
|
| 523 |
+
target_str = None
|
| 524 |
+
# Apply optionality
|
| 525 |
+
is_optional = modifiers.get("is_optional", False) or modifiers.get("cost_is_optional", False)
|
| 526 |
+
if is_optional:
|
| 527 |
+
# Apply to all costs if they exist
|
| 528 |
+
for cost in ability.costs:
|
| 529 |
+
cost.is_optional = True
|
| 530 |
+
|
| 531 |
+
for effect in ability.effects:
|
| 532 |
+
# Primary effects that are usually optional
|
| 533 |
+
primary_optional_types = [
|
| 534 |
+
EffectType.ADD_TO_HAND,
|
| 535 |
+
EffectType.RECOVER_MEMBER,
|
| 536 |
+
EffectType.RECOVER_LIVE,
|
| 537 |
+
EffectType.PLAY_MEMBER_FROM_HAND,
|
| 538 |
+
EffectType.SEARCH_DECK,
|
| 539 |
+
EffectType.LOOK_AND_CHOOSE,
|
| 540 |
+
EffectType.DRAW,
|
| 541 |
+
EffectType.ENERGY_CHARGE,
|
| 542 |
+
]
|
| 543 |
+
|
| 544 |
+
# Housekeeping effects that are usually NOT optional even if primary is
|
| 545 |
+
# (unless they contain their own "may" keyword, which _extract_modifiers would catch)
|
| 546 |
+
housekeeping_types = [
|
| 547 |
+
EffectType.SWAP_CARDS, # Often "discard remainder"
|
| 548 |
+
EffectType.MOVE_TO_DECK,
|
| 549 |
+
EffectType.ORDER_DECK,
|
| 550 |
+
]
|
| 551 |
+
|
| 552 |
+
if effect.effect_type in primary_optional_types:
|
| 553 |
+
effect.is_optional = True
|
| 554 |
+
# If it's housekeeping, we check if the SPECIFIC text for this effect has "てもよい"
|
| 555 |
+
# But since we don't have per-effect text easily here without more refactoring,
|
| 556 |
+
# we'll stick to the heuristic.
|
| 557 |
+
|
| 558 |
+
# Apply usage limits
|
| 559 |
+
if modifiers.get("is_once_per_turn"):
|
| 560 |
+
ability.is_once_per_turn = True
|
| 561 |
+
|
| 562 |
+
# Apply duration
|
| 563 |
+
duration = modifiers.get("duration")
|
| 564 |
+
if duration:
|
| 565 |
+
for effect in ability.effects:
|
| 566 |
+
effect.params["until"] = duration
|
| 567 |
+
|
| 568 |
+
# Apply target overrides
|
| 569 |
+
if modifiers.get("target"):
|
| 570 |
+
target_str = modifiers["target"]
|
| 571 |
+
target_map = {
|
| 572 |
+
"OPPONENT": TargetType.OPPONENT,
|
| 573 |
+
"ALL_PLAYERS": TargetType.ALL_PLAYERS,
|
| 574 |
+
"OPPONENT_HAND": TargetType.OPPONENT_HAND,
|
| 575 |
+
}
|
| 576 |
+
if target_str in target_map:
|
| 577 |
+
for effect in ability.effects:
|
| 578 |
+
effect.target = target_map[target_str]
|
| 579 |
+
|
| 580 |
+
# Apply both_players flag
|
| 581 |
+
if modifiers.get("both_players"):
|
| 582 |
+
for effect in ability.effects:
|
| 583 |
+
effect.params["both_players"] = True
|
| 584 |
+
|
| 585 |
+
# Apply "all" scope
|
| 586 |
+
if modifiers.get("all"):
|
| 587 |
+
for effect in ability.effects:
|
| 588 |
+
effect.params["all"] = True
|
| 589 |
+
|
| 590 |
+
# Apply multiplier flags
|
| 591 |
+
for key in ["per_member", "per_live", "per_energy", "has_multiplier"]:
|
| 592 |
+
if modifiers.get(key):
|
| 593 |
+
for effect in ability.effects:
|
| 594 |
+
effect.params[key] = True
|
| 595 |
+
|
| 596 |
+
# Apply filters
|
| 597 |
+
if modifiers.get("cost_max"):
|
| 598 |
+
for effect in ability.effects:
|
| 599 |
+
effect.params["cost_max"] = modifiers["cost_max"]
|
| 600 |
+
|
| 601 |
+
if modifiers.get("has_ability"):
|
| 602 |
+
for effect in ability.effects:
|
| 603 |
+
effect.params["has_ability"] = modifiers["has_ability"]
|
| 604 |
+
|
| 605 |
+
# Apply group filter
|
| 606 |
+
if modifiers.get("group") or modifiers.get("groups"):
|
| 607 |
+
for effect in ability.effects:
|
| 608 |
+
# Apply to effects that might need a group filter
|
| 609 |
+
if effect.effect_type in [
|
| 610 |
+
EffectType.ADD_TO_HAND,
|
| 611 |
+
EffectType.RECOVER_MEMBER,
|
| 612 |
+
EffectType.RECOVER_LIVE,
|
| 613 |
+
EffectType.SEARCH_DECK,
|
| 614 |
+
EffectType.LOOK_AND_CHOOSE,
|
| 615 |
+
EffectType.PLAY_MEMBER_FROM_HAND,
|
| 616 |
+
EffectType.ADD_BLADES,
|
| 617 |
+
EffectType.ADD_HEARTS,
|
| 618 |
+
EffectType.BUFF_POWER,
|
| 619 |
+
]:
|
| 620 |
+
if "group" not in effect.params and modifiers.get("group"):
|
| 621 |
+
effect.params["group"] = modifiers["group"]
|
| 622 |
+
|
| 623 |
+
if "groups" not in effect.params and modifiers.get("groups"):
|
| 624 |
+
effect.params["groups"] = modifiers["groups"]
|
| 625 |
+
|
| 626 |
+
# Apply name filter
|
| 627 |
+
if modifiers.get("target_names"):
|
| 628 |
+
for effect in ability.effects:
|
| 629 |
+
# Apply to effects that might need a name filter
|
| 630 |
+
if effect.effect_type in [
|
| 631 |
+
EffectType.ADD_TO_HAND,
|
| 632 |
+
EffectType.RECOVER_MEMBER,
|
| 633 |
+
EffectType.RECOVER_LIVE,
|
| 634 |
+
EffectType.SEARCH_DECK,
|
| 635 |
+
EffectType.LOOK_AND_CHOOSE,
|
| 636 |
+
EffectType.PLAY_MEMBER_FROM_HAND,
|
| 637 |
+
]:
|
| 638 |
+
if "names" not in effect.params:
|
| 639 |
+
effect.params["names"] = modifiers["target_names"]
|
| 640 |
+
|
| 641 |
+
# Apply opponent trigger flag to conditions
|
| 642 |
+
if modifiers.get("opponent_trigger_allowed"):
|
| 643 |
+
ability.conditions.append(Condition(ConditionType.OPPONENT_HAS, {"opponent_trigger_allowed": True}))
|
| 644 |
+
|
| 645 |
+
# =========================================================================
|
| 646 |
+
# Pass 5: Cost Extraction
|
| 647 |
+
# =========================================================================
|
| 648 |
+
|
| 649 |
+
def _extract_costs(self, cost_part: str) -> List[Cost]:
|
| 650 |
+
"""Extract ability costs from cost text."""
|
| 651 |
+
costs = []
|
| 652 |
+
if not cost_part:
|
| 653 |
+
return costs
|
| 654 |
+
|
| 655 |
+
# Extract names if present (e.g. discard specific members)
|
| 656 |
+
cost_names = re.findall(r"「(?!\{\{)(.*?)」", cost_part)
|
| 657 |
+
|
| 658 |
+
# Check for tap self cost
|
| 659 |
+
if "このメンバーをウェイトにし" in cost_part:
|
| 660 |
+
costs.append(Cost(AbilityCostType.TAP_SELF))
|
| 661 |
+
|
| 662 |
+
# Check for discard cost
|
| 663 |
+
if "控え室に置" in cost_part and "手札" in cost_part:
|
| 664 |
+
count = 1
|
| 665 |
+
if m := re.search(r"(\d+)枚", cost_part):
|
| 666 |
+
count = int(m.group(1))
|
| 667 |
+
|
| 668 |
+
params = {}
|
| 669 |
+
if cost_names:
|
| 670 |
+
params["names"] = cost_names
|
| 671 |
+
|
| 672 |
+
costs.append(Cost(AbilityCostType.DISCARD_HAND, count, params=params))
|
| 673 |
+
|
| 674 |
+
# Check for sacrifice self cost
|
| 675 |
+
if "このメンバーを" in cost_part and "控え室に置" in cost_part:
|
| 676 |
+
costs.append(Cost(AbilityCostType.SACRIFICE_SELF))
|
| 677 |
+
|
| 678 |
+
# Check for energy cost
|
| 679 |
+
# Strip potential separators like '、' or '。' that might be between icons
|
| 680 |
+
clean_cost_part = cost_part.replace("、", "").replace("。", "")
|
| 681 |
+
energy_icons = len(re.findall(r"\{\{icon_energy.*?\}\}", clean_cost_part))
|
| 682 |
+
if energy_icons:
|
| 683 |
+
costs.append(Cost(AbilityCostType.ENERGY, energy_icons))
|
| 684 |
+
|
| 685 |
+
# Check for reveal hand cost
|
| 686 |
+
if "手札" in cost_part and "公開" in cost_part:
|
| 687 |
+
count = 1
|
| 688 |
+
if m := re.search(r"(\d+)枚", cost_part):
|
| 689 |
+
count = int(m.group(1))
|
| 690 |
+
params = {}
|
| 691 |
+
if "ライブカード" in cost_part:
|
| 692 |
+
params["filter"] = "live"
|
| 693 |
+
elif "メンバー" in cost_part:
|
| 694 |
+
params["filter"] = "member"
|
| 695 |
+
costs.append(Cost(AbilityCostType.REVEAL_HAND, count, params))
|
| 696 |
+
|
| 697 |
+
return costs
|
| 698 |
+
|
| 699 |
+
# =========================================================================
|
| 700 |
+
# Pseudocode Parsing (Inverse of tools/simplify_cards.py)
|
| 701 |
+
# =========================================================================
|
| 702 |
+
|
| 703 |
+
def _parse_pseudocode_block(self, text: str) -> List[Ability]:
|
| 704 |
+
"""Parse one or more abilities from pseudocode format."""
|
| 705 |
+
# Split by "TRIGGER:" but respect quotes to support GRANT_ABILITY
|
| 706 |
+
blocks = []
|
| 707 |
+
current_block = ""
|
| 708 |
+
in_quote = False
|
| 709 |
+
|
| 710 |
+
i = 0
|
| 711 |
+
while i < len(text):
|
| 712 |
+
if text[i] == '"':
|
| 713 |
+
in_quote = not in_quote
|
| 714 |
+
|
| 715 |
+
# Check for TRIGGER: start
|
| 716 |
+
# Ensure we are at start or newline-ish boundary to avoid false positives,
|
| 717 |
+
# but main requirement is not in quote.
|
| 718 |
+
if not in_quote and text[i:].startswith("TRIGGER:"):
|
| 719 |
+
if current_block.strip():
|
| 720 |
+
blocks.append(current_block)
|
| 721 |
+
current_block = ""
|
| 722 |
+
# Append TRIGGER: and Move forward
|
| 723 |
+
current_block += "TRIGGER:"
|
| 724 |
+
i += 8
|
| 725 |
+
continue
|
| 726 |
+
|
| 727 |
+
current_block += text[i]
|
| 728 |
+
i += 1
|
| 729 |
+
|
| 730 |
+
if current_block.strip():
|
| 731 |
+
blocks.append(current_block)
|
| 732 |
+
|
| 733 |
+
abilities = []
|
| 734 |
+
for block in blocks:
|
| 735 |
+
if not block.strip():
|
| 736 |
+
continue
|
| 737 |
+
ability = self._parse_single_pseudocode(block)
|
| 738 |
+
# Default trigger to ACTIVATED if missing but has content
|
| 739 |
+
if ability.trigger == TriggerType.NONE and (ability.costs or ability.effects):
|
| 740 |
+
ability.trigger = TriggerType.ACTIVATED
|
| 741 |
+
abilities.append(ability)
|
| 742 |
+
return abilities
|
| 743 |
+
|
| 744 |
+
def _parse_single_pseudocode(self, text: str) -> Ability:
|
| 745 |
+
"""Parse a single ability from pseudocode format."""
|
| 746 |
+
# Clean up lines but preserve structure for Options: parsing
|
| 747 |
+
lines = [line.strip() for line in text.split("\n") if line.strip()]
|
| 748 |
+
|
| 749 |
+
trigger = TriggerType.NONE
|
| 750 |
+
costs = []
|
| 751 |
+
conditions = []
|
| 752 |
+
effects = []
|
| 753 |
+
instructions = []
|
| 754 |
+
is_once_per_turn = False
|
| 755 |
+
|
| 756 |
+
# New: Track nested options for SELECT_MODE
|
| 757 |
+
# If we see "Options:", the next lines until the next keyword belong to it
|
| 758 |
+
i = 0
|
| 759 |
+
last_target = TargetType.PLAYER
|
| 760 |
+
while i < len(lines):
|
| 761 |
+
line = lines[i]
|
| 762 |
+
|
| 763 |
+
if line.startswith("TRIGGER:"):
|
| 764 |
+
t_name = line.replace("TRIGGER:", "").strip()
|
| 765 |
+
if "(Once per turn)" in t_name:
|
| 766 |
+
is_once_per_turn = True
|
| 767 |
+
|
| 768 |
+
# Strip all content in parentheses
|
| 769 |
+
t_name = re.sub(r"\(.*?\)", "", t_name).strip()
|
| 770 |
+
|
| 771 |
+
# Aliases for triggers
|
| 772 |
+
alias_map = {
|
| 773 |
+
"ON_YELL": "ON_REVEAL",
|
| 774 |
+
"ON_YELL_SUCCESS": "ON_REVEAL",
|
| 775 |
+
"ON_ACTIVATE": "ACTIVATED",
|
| 776 |
+
"JIDOU": "ON_REVEAL", # JIDOU often means automatic trigger on reveal
|
| 777 |
+
"ON_MEMBER_DISCARD": "ON_LEAVES",
|
| 778 |
+
"ON_DISCARDED": "ON_LEAVES",
|
| 779 |
+
"ON_REMOVE": "ON_LEAVES",
|
| 780 |
+
"ON_SET": "ON_PLAY",
|
| 781 |
+
"ON_STAGE_ENTRY": "ON_PLAY",
|
| 782 |
+
"ON_PLAY_OTHER": "ON_PLAY",
|
| 783 |
+
"ON_REVEAL_OTHER": "ON_REVEAL",
|
| 784 |
+
"ON_LIVE_SUCCESS_OTHER": "ON_LIVE_SUCCESS",
|
| 785 |
+
"ON_TURN_START": "TURN_START",
|
| 786 |
+
"ON_TURN_END": "TURN_END",
|
| 787 |
+
"ON_TAP": "ACTIVATED",
|
| 788 |
+
"ON_OPPONENT_TAP": "ON_LEAVES", # Approximation
|
| 789 |
+
"ON_REVEAL_SELF": "ON_REVEAL",
|
| 790 |
+
"ON_LIVE_SUCCESS_SELF": "ON_LIVE_SUCCESS",
|
| 791 |
+
"ACTIVATED_FROM_DISCARD": "ACTIVATED",
|
| 792 |
+
"ON_ENERGY_CHARGE": "ACTIVATED",
|
| 793 |
+
"ON_DRAW": "ACTIVATED", # Approx
|
| 794 |
+
}
|
| 795 |
+
t_name = alias_map.get(t_name, t_name)
|
| 796 |
+
|
| 797 |
+
try:
|
| 798 |
+
trigger = TriggerType[t_name]
|
| 799 |
+
except (KeyError, ValueError):
|
| 800 |
+
trigger = getattr(TriggerType, t_name, TriggerType.NONE)
|
| 801 |
+
|
| 802 |
+
elif "(Once per turn)" in line:
|
| 803 |
+
is_once_per_turn = True
|
| 804 |
+
|
| 805 |
+
elif line.startswith("COST:"):
|
| 806 |
+
cost_str = line.replace("COST:", "").strip()
|
| 807 |
+
costs = self._parse_pseudocode_costs(cost_str)
|
| 808 |
+
|
| 809 |
+
elif line.startswith("CONDITION:"):
|
| 810 |
+
cond_str = line.replace("CONDITION:", "").strip()
|
| 811 |
+
new_conditions = self._parse_pseudocode_conditions(cond_str)
|
| 812 |
+
conditions.extend(new_conditions)
|
| 813 |
+
instructions.extend(new_conditions)
|
| 814 |
+
|
| 815 |
+
elif line.startswith("EFFECT:"):
|
| 816 |
+
eff_str = line.replace("EFFECT:", "").strip()
|
| 817 |
+
new_effects = self._parse_pseudocode_effects(eff_str, last_target=last_target)
|
| 818 |
+
if new_effects:
|
| 819 |
+
last_target = new_effects[-1].target
|
| 820 |
+
effects.extend(new_effects)
|
| 821 |
+
instructions.extend(new_effects)
|
| 822 |
+
|
| 823 |
+
elif line.startswith("Options:"):
|
| 824 |
+
# The most recently added effect should be SELECT_MODE
|
| 825 |
+
if effects and effects[-1].effect_type == EffectType.SELECT_MODE:
|
| 826 |
+
# Parse subsequent lines until next major keyword
|
| 827 |
+
modal_options = []
|
| 828 |
+
i += 1
|
| 829 |
+
while i < len(lines) and not any(
|
| 830 |
+
lines[i].startswith(kw) for kw in ["TRIGGER:", "COST:", "CONDITION:", "EFFECT:"]
|
| 831 |
+
):
|
| 832 |
+
# Format: N: EFFECT1, EFFECT2
|
| 833 |
+
option_match = re.match(r"\d+:\s*(.*)", lines[i])
|
| 834 |
+
if option_match:
|
| 835 |
+
option_text = option_match.group(1)
|
| 836 |
+
sub_effects = self._parse_pseudocode_effects_compact(option_text)
|
| 837 |
+
modal_options.append(sub_effects)
|
| 838 |
+
i += 1
|
| 839 |
+
effects[-1].modal_options = modal_options
|
| 840 |
+
continue # Already incremented i
|
| 841 |
+
|
| 842 |
+
elif line.startswith("OPTION:"):
|
| 843 |
+
# Format: OPTION: Description | EFFECT: Effect1; Effect2
|
| 844 |
+
if effects and effects[-1].effect_type == EffectType.SELECT_MODE:
|
| 845 |
+
# Parse the option line
|
| 846 |
+
parts = line.replace("OPTION:", "").split("|")
|
| 847 |
+
opt_desc = parts[0].strip()
|
| 848 |
+
|
| 849 |
+
# Store description in select_mode effect params
|
| 850 |
+
if "options" not in effects[-1].params:
|
| 851 |
+
effects[-1].params["options"] = []
|
| 852 |
+
effects[-1].params["options"].append(opt_desc)
|
| 853 |
+
|
| 854 |
+
eff_part = next((p.strip() for p in parts if p.strip().startswith("EFFECT:")), None)
|
| 855 |
+
if eff_part:
|
| 856 |
+
eff_str = eff_part.replace("EFFECT:", "").strip()
|
| 857 |
+
# Use standard effect parser as these can be complex
|
| 858 |
+
sub_effects = self._parse_pseudocode_effects(eff_str)
|
| 859 |
+
|
| 860 |
+
# Initialize modal_options if needed
|
| 861 |
+
if not hasattr(effects[-1], "modal_options") or effects[-1].modal_options is None:
|
| 862 |
+
effects[-1].modal_options = []
|
| 863 |
+
|
| 864 |
+
effects[-1].modal_options.append(sub_effects)
|
| 865 |
+
|
| 866 |
+
i += 1
|
| 867 |
+
|
| 868 |
+
return Ability(
|
| 869 |
+
raw_text=text,
|
| 870 |
+
trigger=trigger,
|
| 871 |
+
costs=costs,
|
| 872 |
+
conditions=conditions,
|
| 873 |
+
effects=effects,
|
| 874 |
+
is_once_per_turn=is_once_per_turn,
|
| 875 |
+
instructions=instructions,
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
def _parse_pseudocode_effects_compact(self, text: str) -> List[Effect]:
|
| 879 |
+
"""Special parser for compact effects in Options list (comma separated)."""
|
| 880 |
+
# Format example: DRAW(1)->SELF {PARAMS}, MOVE_TO_DECK(1)->SELF {PARAMS}
|
| 881 |
+
# Split by comma but not inside {}
|
| 882 |
+
parts = []
|
| 883 |
+
current = ""
|
| 884 |
+
depth = 0
|
| 885 |
+
for char in text:
|
| 886 |
+
if char == "{":
|
| 887 |
+
depth += 1
|
| 888 |
+
elif char == "}":
|
| 889 |
+
depth -= 1
|
| 890 |
+
elif char == "," and depth == 0:
|
| 891 |
+
parts.append(current.strip())
|
| 892 |
+
current = ""
|
| 893 |
+
continue
|
| 894 |
+
current += char
|
| 895 |
+
if current:
|
| 896 |
+
parts.append(current.strip())
|
| 897 |
+
|
| 898 |
+
effects = []
|
| 899 |
+
for p in parts:
|
| 900 |
+
# Format: NAME(VAL)->TARGET {PARAMS}
|
| 901 |
+
m = re.match(r"(\w+)\((.*?)\)\s*->\s*(\w+)(.*)", p)
|
| 902 |
+
if m:
|
| 903 |
+
name, val, target_name, rest = m.groups()
|
| 904 |
+
etype = getattr(EffectType, name, EffectType.DRAW)
|
| 905 |
+
target = getattr(TargetType, target_name, TargetType.PLAYER)
|
| 906 |
+
params = self._parse_pseudocode_params(rest)
|
| 907 |
+
|
| 908 |
+
val_int = 0
|
| 909 |
+
val_cond = ConditionType.NONE
|
| 910 |
+
|
| 911 |
+
# Check if val is a condition type
|
| 912 |
+
if hasattr(ConditionType, val):
|
| 913 |
+
val_cond = getattr(ConditionType, val)
|
| 914 |
+
else:
|
| 915 |
+
try:
|
| 916 |
+
val_int = int(val)
|
| 917 |
+
except ValueError:
|
| 918 |
+
val_int = 1
|
| 919 |
+
|
| 920 |
+
effects.append(Effect(etype, val_int, val_cond, target, params))
|
| 921 |
+
return effects
|
| 922 |
+
|
| 923 |
+
def _parse_pseudocode_params(self, param_str: str) -> Dict[str, Any]:
|
| 924 |
+
"""Parse parameters in {KEY=VAL, ...} format."""
|
| 925 |
+
if not param_str or "{" not in param_str:
|
| 926 |
+
return {}
|
| 927 |
+
|
| 928 |
+
# Extract content between { and }
|
| 929 |
+
match = re.search(r"\{(.*)\}", param_str)
|
| 930 |
+
if not match:
|
| 931 |
+
return {}
|
| 932 |
+
|
| 933 |
+
content = match.group(1)
|
| 934 |
+
params = {}
|
| 935 |
+
|
| 936 |
+
# Simple parser for KEY=VAL or KEY=["a", "b"] or FLAG
|
| 937 |
+
parts = []
|
| 938 |
+
current = ""
|
| 939 |
+
depth = 0
|
| 940 |
+
in_quotes = False
|
| 941 |
+
for char in content:
|
| 942 |
+
if char == '"' and (not current or (current and current[-1] != "\\")):
|
| 943 |
+
in_quotes = not in_quotes
|
| 944 |
+
if not in_quotes:
|
| 945 |
+
if char in "[{":
|
| 946 |
+
depth += 1
|
| 947 |
+
elif char in "}]":
|
| 948 |
+
depth -= 1
|
| 949 |
+
elif char == "," and depth == 0:
|
| 950 |
+
parts.append(current.strip())
|
| 951 |
+
current = ""
|
| 952 |
+
continue
|
| 953 |
+
current += char
|
| 954 |
+
if current:
|
| 955 |
+
parts.append(current.strip())
|
| 956 |
+
|
| 957 |
+
for part in parts:
|
| 958 |
+
if "=" in part:
|
| 959 |
+
k, v = part.split("=", 1)
|
| 960 |
+
k = k.strip().lower()
|
| 961 |
+
v = v.strip()
|
| 962 |
+
# Try to parse as JSON for lists/objects
|
| 963 |
+
try:
|
| 964 |
+
val = json.loads(v)
|
| 965 |
+
# Normalize common ENUM string values
|
| 966 |
+
if k in ["until", "from", "to", "target", "type"]:
|
| 967 |
+
if isinstance(val, str):
|
| 968 |
+
params[k] = val.lower()
|
| 969 |
+
elif isinstance(val, list):
|
| 970 |
+
params[k] = [x.lower() if isinstance(x, str) else x for x in val]
|
| 971 |
+
else:
|
| 972 |
+
params[k] = val
|
| 973 |
+
elif k == "cost_max" or k == "cost_min":
|
| 974 |
+
params[k] = val
|
| 975 |
+
elif k == "cost<= ": # Support legacy/alternative
|
| 976 |
+
params["cost_max"] = val
|
| 977 |
+
elif k == "cost>= ":
|
| 978 |
+
params["cost_min"] = val
|
| 979 |
+
else:
|
| 980 |
+
params[k] = val
|
| 981 |
+
except:
|
| 982 |
+
# Fallback
|
| 983 |
+
if v.startswith('"') and v.endswith('"'):
|
| 984 |
+
v = v[1:-1]
|
| 985 |
+
|
| 986 |
+
if v.isdigit():
|
| 987 |
+
params[k] = int(v)
|
| 988 |
+
elif k in ["until", "from", "to", "target", "type"]:
|
| 989 |
+
params[k] = v.lower()
|
| 990 |
+
else:
|
| 991 |
+
params[k] = v
|
| 992 |
+
elif part.startswith("{") and part.endswith("}"):
|
| 993 |
+
# Merge embedded JSON
|
| 994 |
+
try:
|
| 995 |
+
embedded = json.loads(part)
|
| 996 |
+
if isinstance(embedded, dict):
|
| 997 |
+
params.update(embedded)
|
| 998 |
+
except:
|
| 999 |
+
pass
|
| 1000 |
+
elif part.startswith("(") and part.endswith(")"):
|
| 1001 |
+
# Handle (VAL) shorthand
|
| 1002 |
+
params["val"] = part[1:-1]
|
| 1003 |
+
else:
|
| 1004 |
+
# Flag
|
| 1005 |
+
params[part.lower()] = True
|
| 1006 |
+
|
| 1007 |
+
return params
|
| 1008 |
+
|
| 1009 |
+
def _parse_pseudocode_costs(self, text: str) -> List[Cost]:
|
| 1010 |
+
costs = []
|
| 1011 |
+
# Split by ' OR ' first, but for now we might just take the first one or treat as optional?
|
| 1012 |
+
# Actually, let's treat 'OR' as splitting into separate options if needed,
|
| 1013 |
+
# but the Cost model is AND-only.
|
| 1014 |
+
# We'll split by comma AND ' OR ' for now and mark them all.
|
| 1015 |
+
parts = []
|
| 1016 |
+
current = ""
|
| 1017 |
+
depth = 0
|
| 1018 |
+
i = 0
|
| 1019 |
+
while i < len(text):
|
| 1020 |
+
char = text[i]
|
| 1021 |
+
if char == "{":
|
| 1022 |
+
depth += 1
|
| 1023 |
+
elif char == "}":
|
| 1024 |
+
depth -= 1
|
| 1025 |
+
elif depth == 0:
|
| 1026 |
+
if text[i : i + 4] == " OR ":
|
| 1027 |
+
parts.append(current.strip())
|
| 1028 |
+
current = ""
|
| 1029 |
+
i += 4
|
| 1030 |
+
continue
|
| 1031 |
+
elif char == "," or char == ";":
|
| 1032 |
+
parts.append(current.strip())
|
| 1033 |
+
current = ""
|
| 1034 |
+
i += 1
|
| 1035 |
+
continue
|
| 1036 |
+
current += char
|
| 1037 |
+
i += 1
|
| 1038 |
+
if current:
|
| 1039 |
+
parts.append(current.strip())
|
| 1040 |
+
|
| 1041 |
+
for p in parts:
|
| 1042 |
+
if not p:
|
| 1043 |
+
continue
|
| 1044 |
+
# Format: NAME(VAL) {PARAMS} (Optional)
|
| 1045 |
+
m = re.match(r"(\w+)(?:\((.*?)\))?(.*)", p)
|
| 1046 |
+
if m:
|
| 1047 |
+
name, val_str, rest = m.groups()
|
| 1048 |
+
|
| 1049 |
+
# Manual Mapping for specific cost names
|
| 1050 |
+
if name == "MOVE_TO_DECK":
|
| 1051 |
+
if 'from="discard"' in rest.lower() or "from='discard'" in rest.lower():
|
| 1052 |
+
name = "RETURN_DISCARD_TO_DECK"
|
| 1053 |
+
else:
|
| 1054 |
+
name = "RETURN_MEMBER_TO_DECK"
|
| 1055 |
+
|
| 1056 |
+
cost_name = name.upper()
|
| 1057 |
+
if cost_name == "REMOVE_SELF":
|
| 1058 |
+
cost_name = "SACRIFICE_SELF"
|
| 1059 |
+
ctype = getattr(AbilityCostType, cost_name, AbilityCostType.NONE)
|
| 1060 |
+
try:
|
| 1061 |
+
val = int(val_str) if val_str else 0
|
| 1062 |
+
except ValueError:
|
| 1063 |
+
val = 0
|
| 1064 |
+
is_opt = "(Optional)" in rest or " OR " in text # OR implies selectivity
|
| 1065 |
+
params = self._parse_pseudocode_params(rest)
|
| 1066 |
+
costs.append(Cost(ctype, val, is_optional=is_opt, params=params))
|
| 1067 |
+
return costs
|
| 1068 |
+
|
| 1069 |
+
def _parse_pseudocode_conditions(self, text: str) -> List[Condition]:
|
| 1070 |
+
conditions = []
|
| 1071 |
+
parts = []
|
| 1072 |
+
current = ""
|
| 1073 |
+
depth = 0
|
| 1074 |
+
i = 0
|
| 1075 |
+
while i < len(text):
|
| 1076 |
+
char = text[i]
|
| 1077 |
+
if char == "{":
|
| 1078 |
+
depth += 1
|
| 1079 |
+
elif char == "}":
|
| 1080 |
+
depth -= 1
|
| 1081 |
+
elif depth == 0:
|
| 1082 |
+
if text[i : i + 4] == " OR ":
|
| 1083 |
+
parts.append(current.strip())
|
| 1084 |
+
current = ""
|
| 1085 |
+
i += 4
|
| 1086 |
+
continue
|
| 1087 |
+
elif char == "," or char == ";":
|
| 1088 |
+
parts.append(current.strip())
|
| 1089 |
+
current = ""
|
| 1090 |
+
i += 1
|
| 1091 |
+
continue
|
| 1092 |
+
current += char
|
| 1093 |
+
i += 1
|
| 1094 |
+
if current:
|
| 1095 |
+
parts.append(current.strip())
|
| 1096 |
+
|
| 1097 |
+
for p in parts:
|
| 1098 |
+
if not p:
|
| 1099 |
+
continue
|
| 1100 |
+
negated = p.startswith("NOT ")
|
| 1101 |
+
name_part = p[4:] if negated else p
|
| 1102 |
+
|
| 1103 |
+
# Support ! as prefix for negation
|
| 1104 |
+
if not negated and name_part.startswith("!"):
|
| 1105 |
+
negated = True
|
| 1106 |
+
name_part = name_part[1:]
|
| 1107 |
+
|
| 1108 |
+
# Match name and params
|
| 1109 |
+
m = re.match(r"(\w+)(.*)", name_part)
|
| 1110 |
+
if m:
|
| 1111 |
+
name, rest = m.groups()
|
| 1112 |
+
ctype = getattr(ConditionType, name.upper(), ConditionType.NONE)
|
| 1113 |
+
|
| 1114 |
+
# Robust parameter parsing
|
| 1115 |
+
params = self._parse_pseudocode_params(rest)
|
| 1116 |
+
if not params or "val" not in params:
|
| 1117 |
+
# Check for (VAL)
|
| 1118 |
+
p_m = re.search(r"\((.*?)\)", rest)
|
| 1119 |
+
if p_m:
|
| 1120 |
+
params["val"] = p_m.group(1)
|
| 1121 |
+
else:
|
| 1122 |
+
# Check for =VAL
|
| 1123 |
+
e_m = re.search(r"=\s*[\"']?(.*?)[\"']?$", rest.strip())
|
| 1124 |
+
if e_m and "{" not in rest:
|
| 1125 |
+
params["val"] = e_m.group(1)
|
| 1126 |
+
|
| 1127 |
+
params["raw_cond"] = name
|
| 1128 |
+
if name == "COST_LEAD":
|
| 1129 |
+
ctype = ConditionType.SCORE_COMPARE
|
| 1130 |
+
params["type"] = "cost"
|
| 1131 |
+
params["target"] = "opponent"
|
| 1132 |
+
params["comparison"] = "GT"
|
| 1133 |
+
if params.get("area") == "CENTER":
|
| 1134 |
+
params["zone"] = "CENTER_STAGE"
|
| 1135 |
+
del params["area"]
|
| 1136 |
+
|
| 1137 |
+
# Fix for SCORE_LEAD -> SCORE_COMPARE
|
| 1138 |
+
if name == "SCORE_LEAD":
|
| 1139 |
+
ctype = ConditionType.SCORE_COMPARE
|
| 1140 |
+
params["type"] = "score"
|
| 1141 |
+
# Default comparison GT (Lead)
|
| 1142 |
+
if "comparison" not in params:
|
| 1143 |
+
params["comparison"] = "GT"
|
| 1144 |
+
# If target is opponent, it implies checking relative to opponent
|
| 1145 |
+
if "target" not in params:
|
| 1146 |
+
params["target"] = "opponent"
|
| 1147 |
+
|
| 1148 |
+
# TYPE_MEMBER/TYPE_LIVE -> TYPE_CHECK
|
| 1149 |
+
if name == "TYPE_MEMBER":
|
| 1150 |
+
ctype = ConditionType.TYPE_CHECK
|
| 1151 |
+
params["card_type"] = "member"
|
| 1152 |
+
if name == "TYPE_LIVE":
|
| 1153 |
+
ctype = ConditionType.TYPE_CHECK
|
| 1154 |
+
params["card_type"] = "live"
|
| 1155 |
+
|
| 1156 |
+
# Fix for COUNT_LIVE -> COUNT_LIVE_ZONE
|
| 1157 |
+
if name == "COUNT_LIVE":
|
| 1158 |
+
ctype = ConditionType.COUNT_LIVE_ZONE
|
| 1159 |
+
|
| 1160 |
+
# ENERGY_LAGGING / ENERGY_LEAD -> OPPONENT_ENERGY_DIFF
|
| 1161 |
+
if name == "ENERGY_LAGGING":
|
| 1162 |
+
ctype = ConditionType.OPPONENT_ENERGY_DIFF
|
| 1163 |
+
params["comparison"] = "GE"
|
| 1164 |
+
if "diff" not in params:
|
| 1165 |
+
params["diff"] = 1
|
| 1166 |
+
if name == "ENERGY_LEAD":
|
| 1167 |
+
ctype = ConditionType.OPPONENT_ENERGY_DIFF
|
| 1168 |
+
params["comparison"] = "LE"
|
| 1169 |
+
if "diff" not in params:
|
| 1170 |
+
params["diff"] = 0
|
| 1171 |
+
|
| 1172 |
+
# Aliases
|
| 1173 |
+
if name == "SUM_SCORE":
|
| 1174 |
+
ctype = ConditionType.SCORE_COMPARE
|
| 1175 |
+
params["type"] = "score"
|
| 1176 |
+
if "comparison" not in params:
|
| 1177 |
+
params["comparison"] = "GE"
|
| 1178 |
+
if "min" in params and "value" not in params:
|
| 1179 |
+
# Map min to value for SCORE_COMPARE absolute check?
|
| 1180 |
+
# Assuming SCORE_COMPARE supports absolute value if target is set?
|
| 1181 |
+
# Actually logic.rs might compare vs opponent score if no value is set?
|
| 1182 |
+
# If value IS set, it might compare vs value?
|
| 1183 |
+
# I'll rely on value mapping logic.
|
| 1184 |
+
pass
|
| 1185 |
+
|
| 1186 |
+
if name == "COUNT_PLAYED_THIS_TURN":
|
| 1187 |
+
# Pending engine support, use HAS_KEYWORD to silence linter
|
| 1188 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1189 |
+
params["keyword"] = "PLAYED_THIS_TURN"
|
| 1190 |
+
|
| 1191 |
+
if name == "SUM_COST":
|
| 1192 |
+
ctype = ConditionType.SCORE_COMPARE
|
| 1193 |
+
params["type"] = "cost"
|
| 1194 |
+
if "comparison" not in params:
|
| 1195 |
+
params["comparison"] = "GE"
|
| 1196 |
+
# Default target to ME if not specified?
|
| 1197 |
+
# If params has TARGET="OPPONENT", it will be parsed.
|
| 1198 |
+
|
| 1199 |
+
if name == "REVEALED_CONTAINS":
|
| 1200 |
+
# No generic HAS_CARD_IN_ZONE condition yet
|
| 1201 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1202 |
+
params["keyword"] = "REVEALED_CONTAINS"
|
| 1203 |
+
if "TYPE_LIVE" in params:
|
| 1204 |
+
params["value"] = "live"
|
| 1205 |
+
if "TYPE_MEMBER" in params:
|
| 1206 |
+
params["value"] = "member"
|
| 1207 |
+
|
| 1208 |
+
if name == "ZONE":
|
| 1209 |
+
# Heuristic for ZONE condition (e.g. ZONE="YELL_REVEALED")
|
| 1210 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1211 |
+
params["keyword"] = "ZONE_CHECK"
|
| 1212 |
+
params["value"] = params.get("val", "Unknown") # Default param processing might put it in val?
|
| 1213 |
+
# The parser puts the value in params based on default logic?
|
| 1214 |
+
# Actually _parse_pseudocode_conditions logic puts keys in params.
|
| 1215 |
+
# params is passed in? No, params is dict.
|
| 1216 |
+
# We rely on default param parsing for the "YELL_REVEALED" value which should be in params?
|
| 1217 |
+
# Actually parsing of condition params happens AFTER this block usually?
|
| 1218 |
+
# No, this block converts Name to Params.
|
| 1219 |
+
# If ZONE="YELL_REVEALED", input `name` is "ZONE".
|
| 1220 |
+
# params is empty.
|
| 1221 |
+
pass
|
| 1222 |
+
|
| 1223 |
+
if name == "IS_MAIN_PHASE" or name == "MAIN_PHASE":
|
| 1224 |
+
# Implicit in activated abilities usually, map to NONE to ignore
|
| 1225 |
+
ctype = ConditionType.NONE
|
| 1226 |
+
|
| 1227 |
+
if name == "COUNT_SUCCESS_LIVES" or name == "COUNT_SUCCESS_LIVE":
|
| 1228 |
+
ctype = ConditionType.COUNT_SUCCESS_LIVE
|
| 1229 |
+
# Handle PLAYER=0/1 param mapping
|
| 1230 |
+
if "PLAYER" in params:
|
| 1231 |
+
pval = params["PLAYER"]
|
| 1232 |
+
if str(pval) == "1":
|
| 1233 |
+
params["target"] = "opponent"
|
| 1234 |
+
else:
|
| 1235 |
+
params["target"] = "self"
|
| 1236 |
+
del params["PLAYER"]
|
| 1237 |
+
if "COUNT" in params:
|
| 1238 |
+
params["value"] = params["COUNT"]
|
| 1239 |
+
params["comparison"] = "EQ"
|
| 1240 |
+
del params["COUNT"]
|
| 1241 |
+
|
| 1242 |
+
if name == "HAS_SUCCESS_LIVE":
|
| 1243 |
+
ctype = ConditionType.COUNT_SUCCESS_LIVE
|
| 1244 |
+
|
| 1245 |
+
if name == "SUM_ENERGY":
|
| 1246 |
+
ctype = ConditionType.COUNT_ENERGY
|
| 1247 |
+
|
| 1248 |
+
if name == "BATON_FROM_NAME":
|
| 1249 |
+
ctype = ConditionType.BATON
|
| 1250 |
+
|
| 1251 |
+
if name == "MOVED_THIS_TURN":
|
| 1252 |
+
ctype = ConditionType.HAS_MOVED
|
| 1253 |
+
|
| 1254 |
+
if name == "DECK_REFRESHED_THIS_TURN":
|
| 1255 |
+
ctype = ConditionType.DECK_REFRESHED
|
| 1256 |
+
|
| 1257 |
+
if name == "HAND_SIZE_DIFF":
|
| 1258 |
+
ctype = ConditionType.OPPONENT_HAND_DIFF
|
| 1259 |
+
|
| 1260 |
+
if name == "COST_LE_9":
|
| 1261 |
+
ctype = ConditionType.COST_CHECK
|
| 1262 |
+
params["comparison"] = "LE"
|
| 1263 |
+
params["value"] = 9
|
| 1264 |
+
|
| 1265 |
+
if name == "TARGET":
|
| 1266 |
+
# Data error where params separated by comma
|
| 1267 |
+
ctype = ConditionType.NONE
|
| 1268 |
+
|
| 1269 |
+
if name.startswith("MATCH_"):
|
| 1270 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1271 |
+
params["keyword"] = name
|
| 1272 |
+
|
| 1273 |
+
if name.startswith("DID_ACTIVATE_"):
|
| 1274 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1275 |
+
params["keyword"] = name
|
| 1276 |
+
|
| 1277 |
+
if name == "SUCCESS_LIVES_CONTAINS":
|
| 1278 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1279 |
+
params["keyword"] = "SUCCESS_LIVES_CONTAINS"
|
| 1280 |
+
|
| 1281 |
+
if name == "YELL_COUNT" or name == "COUNT_YELL_REVEALED":
|
| 1282 |
+
# Pending engine support for Yell Count
|
| 1283 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1284 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1285 |
+
params["keyword"] = "YELL_COUNT"
|
| 1286 |
+
|
| 1287 |
+
if name == "HAS_REMAINING_HEART":
|
| 1288 |
+
ctype = ConditionType.COUNT_HEARTS
|
| 1289 |
+
params["min"] = 1
|
| 1290 |
+
|
| 1291 |
+
if name == "COUNT_CHARGED_ENERGY":
|
| 1292 |
+
ctype = ConditionType.COUNT_ENERGY
|
| 1293 |
+
|
| 1294 |
+
if name == "SUM_SUCCESS_LIVE":
|
| 1295 |
+
ctype = ConditionType.COUNT_SUCCESS_LIVE # Approx
|
| 1296 |
+
|
| 1297 |
+
if name == "SUM_HEARTS":
|
| 1298 |
+
ctype = ConditionType.COUNT_HEARTS
|
| 1299 |
+
|
| 1300 |
+
if name == "SCORE_EQUAL_OPPONENT":
|
| 1301 |
+
ctype = ConditionType.SCORE_COMPARE
|
| 1302 |
+
params["comparison"] = "EQ"
|
| 1303 |
+
params["target"] = "opponent"
|
| 1304 |
+
|
| 1305 |
+
if name == "AREA":
|
| 1306 |
+
ctype = ConditionType.HAS_KEYWORD # Likely filtering by area
|
| 1307 |
+
params["keyword"] = "AREA_CHECK"
|
| 1308 |
+
|
| 1309 |
+
if name == "EFFECT_NEGATED_THIS_TURN":
|
| 1310 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1311 |
+
params["keyword"] = "EFFECT_NEGATED"
|
| 1312 |
+
|
| 1313 |
+
if name == "HIGHEST_COST_ON_STAGE":
|
| 1314 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1315 |
+
params["keyword"] = "HIGHEST_COST"
|
| 1316 |
+
|
| 1317 |
+
if name == "BATON_TOUCH":
|
| 1318 |
+
ctype = ConditionType.BATON
|
| 1319 |
+
|
| 1320 |
+
if name == "HAND_SIZE":
|
| 1321 |
+
ctype = ConditionType.COUNT_HAND
|
| 1322 |
+
|
| 1323 |
+
if name == "COUNT_UNIQUE_NAMES":
|
| 1324 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1325 |
+
params["keyword"] = "UNIQUE_NAMES"
|
| 1326 |
+
|
| 1327 |
+
if name == "HAS_TYPE_LIVE":
|
| 1328 |
+
ctype = ConditionType.TYPE_CHECK
|
| 1329 |
+
params["card_type"] = "live"
|
| 1330 |
+
|
| 1331 |
+
if name == "OPPONENT_EXTRA_HEARTS":
|
| 1332 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1333 |
+
params["keyword"] = "OPPONENT_EXTRA_HEARTS"
|
| 1334 |
+
|
| 1335 |
+
if name == "EXTRA_HEARTS":
|
| 1336 |
+
ctype = ConditionType.COUNT_HEARTS
|
| 1337 |
+
# Typically means checking if we have extra hearts
|
| 1338 |
+
if "min" not in params:
|
| 1339 |
+
params["min"] = 1
|
| 1340 |
+
|
| 1341 |
+
if name == "BLADES":
|
| 1342 |
+
ctype = ConditionType.COUNT_BLADES
|
| 1343 |
+
|
| 1344 |
+
if name == "AREA_IN" or name == "AREA":
|
| 1345 |
+
val = params.get("val", "").upper().strip('"')
|
| 1346 |
+
if val == "CENTER" or params.get("zone") == "CENTER" or params.get("area") == "CENTER":
|
| 1347 |
+
ctype = ConditionType.IS_CENTER
|
| 1348 |
+
else:
|
| 1349 |
+
ctype = ConditionType.GROUP_FILTER
|
| 1350 |
+
params["keyword"] = "AREA_CHECK"
|
| 1351 |
+
|
| 1352 |
+
if name == "BATON_COUNT" or name == "BATON" or name == "BATON_TOUCH":
|
| 1353 |
+
ctype = ConditionType.BATON
|
| 1354 |
+
|
| 1355 |
+
if name == "HAS_ACTIVE_ENERGY":
|
| 1356 |
+
ctype = ConditionType.COUNT_ENERGY
|
| 1357 |
+
params["filter"] = "active"
|
| 1358 |
+
if "min" not in params:
|
| 1359 |
+
params["min"] = 1
|
| 1360 |
+
|
| 1361 |
+
if name == "HAS_LIVE_SET":
|
| 1362 |
+
ctype = ConditionType.HAS_KEYWORD
|
| 1363 |
+
params["keyword"] = "HAS_LIVE_SET"
|
| 1364 |
+
|
| 1365 |
+
if name == "ALL_ENERGY_ACTIVE":
|
| 1366 |
+
ctype = ConditionType.COUNT_ENERGY
|
| 1367 |
+
params["filter"] = "active"
|
| 1368 |
+
params["comparison"] = "ALL" # Custom logic in engine likely
|
| 1369 |
+
|
| 1370 |
+
if name == "ENERGY":
|
| 1371 |
+
ctype = ConditionType.COUNT_ENERGY
|
| 1372 |
+
|
| 1373 |
+
# Aliases
|
| 1374 |
+
if name == "ON_YELL" or name == "ON_YELL_SUCCESS":
|
| 1375 |
+
ctype = ConditionType.NONE # Triggers handled separately, but avoid ERROR
|
| 1376 |
+
|
| 1377 |
+
if name == "CHECK_GROUP_FILTER":
|
| 1378 |
+
ctype = ConditionType.GROUP_FILTER
|
| 1379 |
+
|
| 1380 |
+
if name == "FILTER":
|
| 1381 |
+
ctype = ConditionType.GROUP_FILTER
|
| 1382 |
+
|
| 1383 |
+
if name == "TOTAL_BLADES":
|
| 1384 |
+
ctype = ConditionType.COUNT_BLADES
|
| 1385 |
+
|
| 1386 |
+
if name == "HEART_LEAD":
|
| 1387 |
+
ctype = ConditionType.COUNT_HEARTS
|
| 1388 |
+
if "comparison" not in params:
|
| 1389 |
+
params["comparison"] = "GE"
|
| 1390 |
+
if "min" not in params and "value" not in params:
|
| 1391 |
+
params["min"] = 1
|
| 1392 |
+
|
| 1393 |
+
if name == "SCORE_TOTAL":
|
| 1394 |
+
ctype = ConditionType.SCORE_COMPARE
|
| 1395 |
+
params["type"] = "score"
|
| 1396 |
+
if "comparison" not in params:
|
| 1397 |
+
params["comparison"] = "GE"
|
| 1398 |
+
|
| 1399 |
+
if name == "COUNT_ACTIVATED":
|
| 1400 |
+
ctype = ConditionType.COUNT_STAGE
|
| 1401 |
+
params["filter"] = "ACTIVATED"
|
| 1402 |
+
|
| 1403 |
+
if name == "OPPONENT_HAS_WAIT":
|
| 1404 |
+
ctype = ConditionType.COUNT_STAGE
|
| 1405 |
+
params["target"] = "opponent"
|
| 1406 |
+
params["filter"] = "tapped"
|
| 1407 |
+
if "min" not in params:
|
| 1408 |
+
params["min"] = 1
|
| 1409 |
+
|
| 1410 |
+
if name == "CHECK_IS_IN_DISCARD":
|
| 1411 |
+
ctype = ConditionType.IS_IN_DISCARD
|
| 1412 |
+
|
| 1413 |
+
if name == "HAS_EXCESS_HEART":
|
| 1414 |
+
ctype = ConditionType.COUNT_HEARTS
|
| 1415 |
+
params["context"] = "excess"
|
| 1416 |
+
if "min" not in params:
|
| 1417 |
+
params["min"] = 1
|
| 1418 |
+
|
| 1419 |
+
if name == "COUNT_MEMBER":
|
| 1420 |
+
ctype = ConditionType.COUNT_STAGE
|
| 1421 |
+
|
| 1422 |
+
if name == "TOTAL_HEARTS":
|
| 1423 |
+
ctype = ConditionType.COUNT_HEARTS
|
| 1424 |
+
|
| 1425 |
+
if name == "ALL_MEMBER":
|
| 1426 |
+
ctype = ConditionType.GROUP_FILTER
|
| 1427 |
+
|
| 1428 |
+
if name == "MEMBER_AT_SLOT":
|
| 1429 |
+
ctype = ConditionType.GROUP_FILTER
|
| 1430 |
+
|
| 1431 |
+
if name == "SUCCESS":
|
| 1432 |
+
ctype = ConditionType.MODAL_ANSWER
|
| 1433 |
+
|
| 1434 |
+
if name == "HAS_LIVE_HEART_COLORS":
|
| 1435 |
+
ctype = ConditionType.HAS_COLOR
|
| 1436 |
+
|
| 1437 |
+
if name == "COUNT_REVEALED":
|
| 1438 |
+
ctype = ConditionType.COUNT_HAND # Approximate or META_RULE
|
| 1439 |
+
|
| 1440 |
+
if name == "COUNT_DISCARDED_THIS_TURN":
|
| 1441 |
+
ctype = ConditionType.COUNT_DISCARD
|
| 1442 |
+
|
| 1443 |
+
if name == "IS_MAIN_PHASE":
|
| 1444 |
+
ctype = ConditionType.NONE
|
| 1445 |
+
|
| 1446 |
+
if name == "MATCH_PREVIOUS":
|
| 1447 |
+
ctype = ConditionType.MODAL_ANSWER # Heuristic
|
| 1448 |
+
|
| 1449 |
+
if name == "NOT_MOVED_THIS_TURN":
|
| 1450 |
+
ctype = ConditionType.HAS_MOVED
|
| 1451 |
+
negated = True
|
| 1452 |
+
|
| 1453 |
+
if name == "NAME_MATCH":
|
| 1454 |
+
ctype = ConditionType.GROUP_FILTER
|
| 1455 |
+
params["filter"] = "NAME_MATCH"
|
| 1456 |
+
|
| 1457 |
+
conditions.append(Condition(ctype, params, is_negated=negated))
|
| 1458 |
+
return conditions
|
| 1459 |
+
|
| 1460 |
+
def _parse_pseudocode_effects(self, text: str, last_target: TargetType = TargetType.PLAYER) -> List[Effect]:
|
| 1461 |
+
effects = []
|
| 1462 |
+
# Split by semicolon but not inside {}
|
| 1463 |
+
parts = []
|
| 1464 |
+
current = ""
|
| 1465 |
+
depth = 0
|
| 1466 |
+
for char in text:
|
| 1467 |
+
if char == "{":
|
| 1468 |
+
depth += 1
|
| 1469 |
+
elif char == "}":
|
| 1470 |
+
depth -= 1
|
| 1471 |
+
elif char == ";" and depth == 0:
|
| 1472 |
+
parts.append(current.strip())
|
| 1473 |
+
current = ""
|
| 1474 |
+
continue
|
| 1475 |
+
current += char
|
| 1476 |
+
if current:
|
| 1477 |
+
parts.append(current.strip())
|
| 1478 |
+
|
| 1479 |
+
for p in parts:
|
| 1480 |
+
if not p:
|
| 1481 |
+
continue
|
| 1482 |
+
# Format: NAME(VAL) -> TARGET {PARAMS} (Optional)
|
| 1483 |
+
# Support optional -> TARGET
|
| 1484 |
+
# Improved regex to handle optional {PARAMS} before -> TARGET
|
| 1485 |
+
m = re.match(r"(\w+)(?:\((.*?)\))?(?:\s*\{.*?\}\s*)?(?:\s*->\s*([\w, ]+))?(.*)", p)
|
| 1486 |
+
if m:
|
| 1487 |
+
name, val, target_name, rest = m.groups()
|
| 1488 |
+
|
| 1489 |
+
# Extract params from the whole string 'p' since {} might be anywhere
|
| 1490 |
+
params = self._parse_pseudocode_params(p)
|
| 1491 |
+
|
| 1492 |
+
# Aliases from parser_pseudocode
|
| 1493 |
+
if name == "TAP_PLAYER":
|
| 1494 |
+
name = "TAP_MEMBER"
|
| 1495 |
+
if name == "CHARGE_SELF":
|
| 1496 |
+
name = "ENERGY_CHARGE"
|
| 1497 |
+
target_name = "MEMBER_SELF"
|
| 1498 |
+
if name == "CHARGE_ENERGY":
|
| 1499 |
+
name = "ENERGY_CHARGE"
|
| 1500 |
+
if name == "MOVE_DISCARD":
|
| 1501 |
+
name = "MOVE_TO_DISCARD"
|
| 1502 |
+
if name == "REMOVE_SELF":
|
| 1503 |
+
name = "MOVE_TO_DISCARD"
|
| 1504 |
+
target_name = "MEMBER_SELF"
|
| 1505 |
+
if name == "SWAP_SELF":
|
| 1506 |
+
name = "SWAP_ZONE"
|
| 1507 |
+
target_name = "MEMBER_SELF"
|
| 1508 |
+
if name == "MOVE_HAND" or name == "MOVE_TO_HAND":
|
| 1509 |
+
name = "ADD_TO_HAND"
|
| 1510 |
+
if name == "ADD_HAND":
|
| 1511 |
+
name = "ADD_TO_HAND"
|
| 1512 |
+
if name == "TRIGGER_YELL_AGAIN":
|
| 1513 |
+
name = "META_RULE"
|
| 1514 |
+
params["meta_type"] = "TRIGGER_YELL_AGAIN"
|
| 1515 |
+
if name == "DISCARD_HAND":
|
| 1516 |
+
name = "LOOK_AND_CHOOSE"
|
| 1517 |
+
params["source"] = "HAND"
|
| 1518 |
+
params["destination"] = "discard"
|
| 1519 |
+
if name == "RECOVER_LIVE":
|
| 1520 |
+
# Usually means from discard
|
| 1521 |
+
params["source"] = "discard"
|
| 1522 |
+
if name == "RECOVER_MEMBER":
|
| 1523 |
+
# Usually means from discard
|
| 1524 |
+
params["source"] = "discard"
|
| 1525 |
+
if name == "SELECT_LIMIT":
|
| 1526 |
+
name = "REDUCE_LIVE_SET_LIMIT"
|
| 1527 |
+
if name == "POWER_UP":
|
| 1528 |
+
name = "BUFF_POWER"
|
| 1529 |
+
if name == "REDUCE_SET_LIMIT":
|
| 1530 |
+
name = "REDUCE_LIVE_SET_LIMIT"
|
| 1531 |
+
if name == "REDUCE_LIMIT":
|
| 1532 |
+
name = "REDUCE_LIVE_SET_LIMIT"
|
| 1533 |
+
if name == "REDUCE_HEART":
|
| 1534 |
+
name = "REDUCE_HEART_REQ"
|
| 1535 |
+
if name == "ADD_TAG":
|
| 1536 |
+
name = "META_RULE"
|
| 1537 |
+
params["tag"] = val
|
| 1538 |
+
if name == "PREVENT_LIVE":
|
| 1539 |
+
name = "RESTRICTION"
|
| 1540 |
+
params["type"] = "no_live"
|
| 1541 |
+
if name == "PREVENT_SET_TO_SUCCESS_PILE":
|
| 1542 |
+
name = "META_RULE"
|
| 1543 |
+
params["meta_type"] = "PREVENT_SET_TO_SUCCESS_PILE"
|
| 1544 |
+
if name == "MOVE_DECK":
|
| 1545 |
+
name = "MOVE_TO_DECK"
|
| 1546 |
+
if name == "OPPONENT_CHOICE":
|
| 1547 |
+
etype = EffectType.OPPONENT_CHOOSE
|
| 1548 |
+
# OPPONENT_CHOICE implies complex options which parse_pseudocode_block/effects handles?
|
| 1549 |
+
# Actually SELECT_MODE handles options. OPPONENT_CHOICE likely structured similarly.
|
| 1550 |
+
if name == "RESET_YELL_HEARTS":
|
| 1551 |
+
name = "META_RULE"
|
| 1552 |
+
params["meta_type"] = "RESET_YELL_HEARTS"
|
| 1553 |
+
if name == "TRIGGER_YELL_AGAIN":
|
| 1554 |
+
name = "META_RULE"
|
| 1555 |
+
params["meta_type"] = "TRIGGER_YELL_AGAIN"
|
| 1556 |
+
if name == "ADD_HAND":
|
| 1557 |
+
name = "ADD_TO_HAND"
|
| 1558 |
+
if name == "ACTION_YELL_MULLIGAN":
|
| 1559 |
+
name = "META_RULE"
|
| 1560 |
+
params["meta_type"] = "ACTION_YELL_MULLIGAN"
|
| 1561 |
+
if name == "OPPONENT_CHOICE":
|
| 1562 |
+
name = "OPPONENT_CHOOSE"
|
| 1563 |
+
if name == "SET_BASE_BLADES":
|
| 1564 |
+
name = "SET_BLADES"
|
| 1565 |
+
if name == "GRANT_HEARTS" or name == "GRANT_HEART":
|
| 1566 |
+
name = "ADD_HEARTS"
|
| 1567 |
+
if name == "SELECT_REVEALED":
|
| 1568 |
+
name = "LOOK_AND_CHOOSE"
|
| 1569 |
+
params["source"] = "revealed"
|
| 1570 |
+
if name == "LOOK_AND_CHOOSE_REVEALED":
|
| 1571 |
+
name = "LOOK_AND_CHOOSE"
|
| 1572 |
+
params["source"] = "revealed"
|
| 1573 |
+
if name == "TAP_SELF":
|
| 1574 |
+
name = "TAP_MEMBER"
|
| 1575 |
+
target_name = "SELF"
|
| 1576 |
+
if name == "CHANGE_BASE_HEART":
|
| 1577 |
+
name = "TRANSFORM_HEART"
|
| 1578 |
+
if name == "SELECT_LIVE_CARD":
|
| 1579 |
+
name = "SELECT_LIVE"
|
| 1580 |
+
if name == "MOVE_TO_HAND":
|
| 1581 |
+
name = "ADD_TO_HAND"
|
| 1582 |
+
if name == "POSITION_CHANGE":
|
| 1583 |
+
name = "MOVE_MEMBER"
|
| 1584 |
+
if name == "INCREASE_HEART":
|
| 1585 |
+
name = "INCREASE_HEART_COST"
|
| 1586 |
+
if name == "CHANGE_YELL_BLADE_COLOR":
|
| 1587 |
+
name = "TRANSFORM_COLOR"
|
| 1588 |
+
if name == "MOVE_SUCCESS":
|
| 1589 |
+
name = "META_RULE"
|
| 1590 |
+
params["meta_type"] = "MOVE_SUCCESS" # Use meta_type to silence linter
|
| 1591 |
+
if name.startswith("PLAY_MEMBER"):
|
| 1592 |
+
# Heuristic: if params has 'discard', use PLAY_MEMBER_FROM_DISCARD
|
| 1593 |
+
if params.get("zone") == "DISCARD" or "DISCARD" in p.upper():
|
| 1594 |
+
name = "PLAY_MEMBER_FROM_DISCARD"
|
| 1595 |
+
else:
|
| 1596 |
+
name = "PLAY_MEMBER_FROM_HAND"
|
| 1597 |
+
if name == "PREVENT_ACTIVATE":
|
| 1598 |
+
name = "META_RULE" # No opcode yet
|
| 1599 |
+
|
| 1600 |
+
etype = getattr(EffectType, name.upper(), None)
|
| 1601 |
+
if name.upper() == "LOOK_AND_CHOOSE_ORDER":
|
| 1602 |
+
etype = EffectType.ORDER_DECK
|
| 1603 |
+
if name.upper() == "LOOK_AND_CHOOSE_REVEAL":
|
| 1604 |
+
etype = EffectType.LOOK_AND_CHOOSE
|
| 1605 |
+
|
| 1606 |
+
if name.upper() == "DISCARD_HAND":
|
| 1607 |
+
etype = EffectType.LOOK_AND_CHOOSE
|
| 1608 |
+
params["source"] = "HAND"
|
| 1609 |
+
params["destination"] = "discard"
|
| 1610 |
+
|
| 1611 |
+
if target_name:
|
| 1612 |
+
target_name_up = target_name.upper()
|
| 1613 |
+
if "CARD_HAND" in target_name_up:
|
| 1614 |
+
target = TargetType.CARD_HAND
|
| 1615 |
+
elif "CARD_DISCARD" in target_name_up:
|
| 1616 |
+
target = TargetType.CARD_DISCARD
|
| 1617 |
+
else:
|
| 1618 |
+
t_part = target_name.split(",")[0].strip()
|
| 1619 |
+
target = getattr(TargetType, t_part.upper(), TargetType.PLAYER)
|
| 1620 |
+
|
| 1621 |
+
if "DISCARD_REMAINDER" in target_name_up:
|
| 1622 |
+
params["destination"] = "discard"
|
| 1623 |
+
|
| 1624 |
+
# Variable targeting support: if target is "TARGET" or "TARGET_MEMBER", use last_target
|
| 1625 |
+
if target_name_up in ["TARGET", "TARGET_MEMBER"]:
|
| 1626 |
+
target = last_target
|
| 1627 |
+
elif target_name_up == "ACTIVATE_AND_SELF":
|
| 1628 |
+
# Special case for "activate and self" -> targets player but implied multi-target
|
| 1629 |
+
# For now default to player or member self
|
| 1630 |
+
target = TargetType.PLAYER
|
| 1631 |
+
else:
|
| 1632 |
+
target = TargetType.PLAYER
|
| 1633 |
+
|
| 1634 |
+
if name.upper() == "LOOK_AND_CHOOSE_REVEAL" and "DISCARD_REMAINDER" in p.upper():
|
| 1635 |
+
params["destination"] = "discard"
|
| 1636 |
+
|
| 1637 |
+
if etype is None:
|
| 1638 |
+
etype = EffectType.META_RULE
|
| 1639 |
+
params["raw_effect"] = name.upper()
|
| 1640 |
+
|
| 1641 |
+
if target_name and target_name.upper() == "SLOT" and params.get("self"):
|
| 1642 |
+
target = TargetType.MEMBER_SELF
|
| 1643 |
+
is_opt = "(Optional)" in rest or "(Optional)" in p
|
| 1644 |
+
|
| 1645 |
+
val_int = 0
|
| 1646 |
+
val_cond = ConditionType.NONE
|
| 1647 |
+
|
| 1648 |
+
# Check if val is a condition type (e.g. COUNT_STAGE)
|
| 1649 |
+
if val and hasattr(ConditionType, val):
|
| 1650 |
+
val_cond = getattr(ConditionType, val)
|
| 1651 |
+
elif etype == EffectType.REVEAL_UNTIL and val:
|
| 1652 |
+
# Special parsing for REVEAL_UNTIL(CONDITION)
|
| 1653 |
+
if "TYPE_LIVE" in val:
|
| 1654 |
+
val_cond = ConditionType.TYPE_CHECK
|
| 1655 |
+
params["card_type"] = "live"
|
| 1656 |
+
elif "TYPE_MEMBER" in val:
|
| 1657 |
+
val_cond = ConditionType.TYPE_CHECK
|
| 1658 |
+
params["card_type"] = "member"
|
| 1659 |
+
|
| 1660 |
+
# Handle COST_GE/LE in REVEAL_UNTIL
|
| 1661 |
+
if "COST_" in val:
|
| 1662 |
+
# Extract COST_GE=10 or COST_LE=X
|
| 1663 |
+
cost_match = re.search(r"COST_(GE|LE|GT|LT|EQ)=(\d+)", val)
|
| 1664 |
+
if cost_match:
|
| 1665 |
+
comp, cval = cost_match.groups()
|
| 1666 |
+
# If we also have TYPE check, we need to combine them?
|
| 1667 |
+
# Bytecode only supports one condition on REVEAL_UNTIL.
|
| 1668 |
+
# We'll prioritize COST check if present, or maybe the engine supports compound?
|
| 1669 |
+
# For now, map to COST_CHECK condition.
|
| 1670 |
+
val_cond = ConditionType.COST_CHECK
|
| 1671 |
+
params["comparison"] = comp
|
| 1672 |
+
params["value"] = int(cval)
|
| 1673 |
+
|
| 1674 |
+
if "COST_GE" in val:
|
| 1675 |
+
val_cond = ConditionType.COST_CHECK
|
| 1676 |
+
m_cost = re.search(r"COST_GE=(\d+)", val)
|
| 1677 |
+
if m_cost:
|
| 1678 |
+
params["min"] = int(m_cost.group(1))
|
| 1679 |
+
|
| 1680 |
+
if val_cond == ConditionType.NONE:
|
| 1681 |
+
try:
|
| 1682 |
+
val_int = int(val)
|
| 1683 |
+
except ValueError:
|
| 1684 |
+
val_int = 1
|
| 1685 |
+
else:
|
| 1686 |
+
try:
|
| 1687 |
+
val_int = int(val) if val else 1
|
| 1688 |
+
except ValueError:
|
| 1689 |
+
val_int = 1 # Fallback for non-numeric val (e.g. "ALL")
|
| 1690 |
+
if val == "ALL":
|
| 1691 |
+
val_int = 99
|
| 1692 |
+
|
| 1693 |
+
effects.append(Effect(etype, val_int, val_cond, target, params, is_optional=is_opt))
|
| 1694 |
+
last_target = target
|
| 1695 |
+
return effects
|
| 1696 |
+
|
| 1697 |
+
|
| 1698 |
+
# Convenience function
|
| 1699 |
+
def parse_ability_text(text: str) -> List[Ability]:
|
| 1700 |
+
"""Parse ability text using the V2 parser."""
|
| 1701 |
+
parser = AbilityParserV2()
|
| 1702 |
+
return parser.parse(text)
|
compiler/patterns/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Pattern Registry System for Ability Parser
|
compiler/patterns/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (180 Bytes). View file
|
|
|
compiler/patterns/__pycache__/base.cpython-312.pyc
ADDED
|
Binary file (7.71 kB). View file
|
|
|
compiler/patterns/__pycache__/conditions.cpython-312.pyc
ADDED
|
Binary file (6.87 kB). View file
|
|
|
compiler/patterns/__pycache__/effects.cpython-312.pyc
ADDED
|
Binary file (17.6 kB). View file
|
|
|
compiler/patterns/__pycache__/modifiers.cpython-312.pyc
ADDED
|
Binary file (4.19 kB). View file
|
|
|
compiler/patterns/__pycache__/registry.cpython-312.pyc
ADDED
|
Binary file (6.6 kB). View file
|
|
|
compiler/patterns/__pycache__/triggers.cpython-312.pyc
ADDED
|
Binary file (3.26 kB). View file
|
|
|
compiler/patterns/base.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base pattern definitions for the ability parser."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Any, Callable, Dict, List, Match, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class PatternPhase(Enum):
|
| 10 |
+
"""Phases of parsing, executed in order."""
|
| 11 |
+
|
| 12 |
+
TRIGGER = 1 # Determine when ability activates
|
| 13 |
+
CONDITION = 2 # Extract gating conditions
|
| 14 |
+
EFFECT = 3 # Extract effects/actions
|
| 15 |
+
MODIFIER = 4 # Apply flags (optional, once per turn, duration)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class Pattern:
|
| 20 |
+
"""A declarative pattern definition for ability text parsing.
|
| 21 |
+
|
| 22 |
+
Patterns are matched in priority order within each phase.
|
| 23 |
+
Lower priority number = higher precedence.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
name: str
|
| 27 |
+
phase: PatternPhase
|
| 28 |
+
priority: int = 100
|
| 29 |
+
|
| 30 |
+
# Matching criteria (at least one must be specified)
|
| 31 |
+
regex: Optional[str] = None # Regex pattern to search for
|
| 32 |
+
keywords: List[str] = field(default_factory=list) # Must contain ALL keywords
|
| 33 |
+
any_keywords: List[str] = field(default_factory=list) # Must contain ANY keyword
|
| 34 |
+
|
| 35 |
+
# Filter conditions
|
| 36 |
+
requires: List[str] = field(default_factory=list) # Context must contain ALL
|
| 37 |
+
excludes: List[str] = field(default_factory=list) # Context must NOT contain ANY
|
| 38 |
+
look_ahead_excludes: List[str] = field(default_factory=list) # Text after match must NOT contain
|
| 39 |
+
|
| 40 |
+
# Behavior
|
| 41 |
+
exclusive: bool = False # If True, stops further matching in this phase
|
| 42 |
+
consumes: bool = False # If True, removes matched text from further processing
|
| 43 |
+
|
| 44 |
+
# Output specification
|
| 45 |
+
output_type: Optional[str] = None # e.g., "TriggerType.ON_PLAY", "EffectType.DRAW"
|
| 46 |
+
output_value: Optional[Any] = None # Default value for effect
|
| 47 |
+
output_params: Dict[str, Any] = field(default_factory=dict) # Additional parameters
|
| 48 |
+
|
| 49 |
+
# Custom extraction (for complex patterns)
|
| 50 |
+
extractor: Optional[Callable[[str, Match], Dict[str, Any]]] = None
|
| 51 |
+
|
| 52 |
+
def __post_init__(self):
|
| 53 |
+
"""Compile regex if provided."""
|
| 54 |
+
self._compiled_regex = re.compile(self.regex) if self.regex else None
|
| 55 |
+
|
| 56 |
+
def matches(self, text: str, context: Optional[str] = None) -> Optional[Match]:
|
| 57 |
+
"""Check if pattern matches the text.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
text: The text to match against
|
| 61 |
+
context: Full sentence context for requires/excludes checks
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
Match object if pattern matches, None otherwise
|
| 65 |
+
"""
|
| 66 |
+
ctx = context or text
|
| 67 |
+
|
| 68 |
+
# Check requires
|
| 69 |
+
if self.requires and not all(kw in ctx for kw in self.requires):
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
# Check excludes
|
| 73 |
+
if self.excludes and any(kw in ctx for kw in self.excludes):
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
# Check keywords (must contain ALL)
|
| 77 |
+
if self.keywords and not all(kw in text for kw in self.keywords):
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
# Check any_keywords (must contain ANY)
|
| 81 |
+
if self.any_keywords and not any(kw in text for kw in self.any_keywords):
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
# Check regex
|
| 85 |
+
if self._compiled_regex:
|
| 86 |
+
m = self._compiled_regex.search(text)
|
| 87 |
+
if m:
|
| 88 |
+
# Check look-ahead excludes
|
| 89 |
+
if self.look_ahead_excludes:
|
| 90 |
+
look_ahead = text[m.start() : m.start() + 20]
|
| 91 |
+
if any(kw in look_ahead for kw in self.look_ahead_excludes):
|
| 92 |
+
return None
|
| 93 |
+
return m
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
# If no regex but keywords matched, return a pseudo-match
|
| 97 |
+
if self.keywords or self.any_keywords:
|
| 98 |
+
# Find first keyword position
|
| 99 |
+
for kw in self.keywords or self.any_keywords:
|
| 100 |
+
if kw in text:
|
| 101 |
+
idx = text.find(kw)
|
| 102 |
+
# Create a fake match-like object
|
| 103 |
+
return _KeywordMatch(kw, idx)
|
| 104 |
+
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
def extract(self, text: str, match: Match) -> Dict[str, Any]:
|
| 108 |
+
"""Extract structured data from a match.
|
| 109 |
+
|
| 110 |
+
Returns dict with:
|
| 111 |
+
- 'type': output_type if specified
|
| 112 |
+
- 'value': extracted value or output_value
|
| 113 |
+
- 'params': output_params merged with any extracted params
|
| 114 |
+
"""
|
| 115 |
+
if self.extractor:
|
| 116 |
+
return self.extractor(text, match)
|
| 117 |
+
|
| 118 |
+
result = {}
|
| 119 |
+
if self.output_type:
|
| 120 |
+
result["type"] = self.output_type
|
| 121 |
+
if self.output_value is not None:
|
| 122 |
+
result["value"] = self.output_value
|
| 123 |
+
if self.output_params:
|
| 124 |
+
result["params"] = self.output_params.copy()
|
| 125 |
+
|
| 126 |
+
# Try to extract numeric value from match groups
|
| 127 |
+
if match.lastindex and match.lastindex >= 1:
|
| 128 |
+
try:
|
| 129 |
+
result["value"] = int(match.group(1))
|
| 130 |
+
except (ValueError, TypeError):
|
| 131 |
+
# Don't assign non-numeric strings to 'value' as it breaks bytecode compilation
|
| 132 |
+
pass
|
| 133 |
+
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class _KeywordMatch:
|
| 138 |
+
"""Fake match object for keyword-based patterns."""
|
| 139 |
+
|
| 140 |
+
def __init__(self, keyword: str, start: int):
|
| 141 |
+
self._keyword = keyword
|
| 142 |
+
self._start = start
|
| 143 |
+
self.lastindex = None
|
| 144 |
+
|
| 145 |
+
def start(self) -> int:
|
| 146 |
+
return self._start
|
| 147 |
+
|
| 148 |
+
def end(self) -> int:
|
| 149 |
+
return self._start + len(self._keyword)
|
| 150 |
+
|
| 151 |
+
def span(self) -> Tuple[int, int]:
|
| 152 |
+
return (self.start(), self.end())
|
| 153 |
+
|
| 154 |
+
def group(self, n: int = 0) -> str:
|
| 155 |
+
return self._keyword if n == 0 else ""
|
| 156 |
+
|
| 157 |
+
def groups(self) -> Tuple:
|
| 158 |
+
return ()
|
compiler/patterns/conditions.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Condition patterns.
|
| 2 |
+
|
| 3 |
+
Conditions are gating requirements that must be met for an ability to activate.
|
| 4 |
+
Examples: COUNT_GROUP, SCORE_COMPARE, HAS_MEMBER, etc.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .base import Pattern, PatternPhase
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Helper function for extracting Japanese numbers
|
| 11 |
+
def _extract_number(text: str, match) -> int:
|
| 12 |
+
"""Extract a number from match, handling full-width and kanji numerals."""
|
| 13 |
+
val_map = {
|
| 14 |
+
"1": 1,
|
| 15 |
+
"2": 2,
|
| 16 |
+
"3": 3,
|
| 17 |
+
"4": 4,
|
| 18 |
+
"5": 5,
|
| 19 |
+
"6": 6,
|
| 20 |
+
"7": 7,
|
| 21 |
+
"8": 8,
|
| 22 |
+
"9": 9,
|
| 23 |
+
"0": 0,
|
| 24 |
+
"一": 1,
|
| 25 |
+
"二": 2,
|
| 26 |
+
"三": 3,
|
| 27 |
+
"四": 4,
|
| 28 |
+
"五": 5,
|
| 29 |
+
"六": 6,
|
| 30 |
+
"七": 7,
|
| 31 |
+
"八": 8,
|
| 32 |
+
"九": 9,
|
| 33 |
+
"〇": 0,
|
| 34 |
+
}
|
| 35 |
+
if match.lastindex and match.lastindex >= 1:
|
| 36 |
+
val = match.group(1)
|
| 37 |
+
return int(val_map.get(val, val)) if not str(val).isdigit() else int(val)
|
| 38 |
+
return 1
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
CONDITION_PATTERNS = [
|
| 42 |
+
# ==========================================================================
|
| 43 |
+
# Count conditions
|
| 44 |
+
# ==========================================================================
|
| 45 |
+
Pattern(
|
| 46 |
+
name="count_group",
|
| 47 |
+
phase=PatternPhase.CONDITION,
|
| 48 |
+
regex=r"『(.*?)』.*?(\d+)(枚|人)以上",
|
| 49 |
+
priority=20,
|
| 50 |
+
output_type="ConditionType.COUNT_GROUP",
|
| 51 |
+
extractor=lambda text, match: {
|
| 52 |
+
"type": "ConditionType.COUNT_GROUP",
|
| 53 |
+
"value": int(match.group(2)),
|
| 54 |
+
"params": {"group": match.group(1), "min": int(match.group(2))},
|
| 55 |
+
},
|
| 56 |
+
),
|
| 57 |
+
Pattern(
|
| 58 |
+
name="count_stage",
|
| 59 |
+
phase=PatternPhase.CONDITION,
|
| 60 |
+
regex=r"(\d+)(枚|人)以上",
|
| 61 |
+
priority=50,
|
| 62 |
+
requires=["ステージ"],
|
| 63 |
+
output_type="ConditionType.COUNT_STAGE",
|
| 64 |
+
),
|
| 65 |
+
Pattern(
|
| 66 |
+
name="count_energy",
|
| 67 |
+
phase=PatternPhase.CONDITION,
|
| 68 |
+
regex=r"エネルギーが(\d+)枚以上",
|
| 69 |
+
priority=30,
|
| 70 |
+
output_type="ConditionType.COUNT_ENERGY",
|
| 71 |
+
),
|
| 72 |
+
Pattern(
|
| 73 |
+
name="count_success_live",
|
| 74 |
+
phase=PatternPhase.CONDITION,
|
| 75 |
+
regex=r"成功ライブカード置き場.*?(\d+)枚以上",
|
| 76 |
+
priority=20,
|
| 77 |
+
output_type="ConditionType.COUNT_SUCCESS_LIVE",
|
| 78 |
+
),
|
| 79 |
+
Pattern(
|
| 80 |
+
name="count_live_zone",
|
| 81 |
+
phase=PatternPhase.CONDITION,
|
| 82 |
+
regex=r"ライブ中のカード.*?(\d+)枚以上",
|
| 83 |
+
priority=20,
|
| 84 |
+
output_type="ConditionType.COUNT_LIVE_ZONE",
|
| 85 |
+
),
|
| 86 |
+
Pattern(
|
| 87 |
+
name="count_hearts",
|
| 88 |
+
phase=PatternPhase.CONDITION,
|
| 89 |
+
regex=r"(?:ハート|heart).*?(\d+)(つ|個)以上",
|
| 90 |
+
priority=30,
|
| 91 |
+
output_type="ConditionType.COUNT_HEARTS",
|
| 92 |
+
),
|
| 93 |
+
Pattern(
|
| 94 |
+
name="count_blades",
|
| 95 |
+
phase=PatternPhase.CONDITION,
|
| 96 |
+
regex=r"ブレード.*?(\d+)(つ|個)以上",
|
| 97 |
+
priority=30,
|
| 98 |
+
output_type="ConditionType.COUNT_BLADES",
|
| 99 |
+
),
|
| 100 |
+
# ==========================================================================
|
| 101 |
+
# Comparison conditions
|
| 102 |
+
# ==========================================================================
|
| 103 |
+
Pattern(
|
| 104 |
+
name="score_compare_gt",
|
| 105 |
+
phase=PatternPhase.CONDITION,
|
| 106 |
+
regex=r"(?:スコア|コスト).*?相手.*?より(?:高い|多い)",
|
| 107 |
+
priority=25,
|
| 108 |
+
output_type="ConditionType.SCORE_COMPARE",
|
| 109 |
+
output_params={"comparison": "GT", "target": "opponent"},
|
| 110 |
+
),
|
| 111 |
+
Pattern(
|
| 112 |
+
name="score_compare_ge",
|
| 113 |
+
phase=PatternPhase.CONDITION,
|
| 114 |
+
regex=r"(?:スコア|コスト).*?同じか(?:高い|多い)",
|
| 115 |
+
priority=25,
|
| 116 |
+
output_type="ConditionType.SCORE_COMPARE",
|
| 117 |
+
output_params={"comparison": "GE", "target": "opponent"},
|
| 118 |
+
),
|
| 119 |
+
Pattern(
|
| 120 |
+
name="score_compare_lt",
|
| 121 |
+
phase=PatternPhase.CONDITION,
|
| 122 |
+
regex=r"(?:スコア|コスト).*?相手.*?より(?:低い|少ない)",
|
| 123 |
+
priority=25,
|
| 124 |
+
output_type="ConditionType.SCORE_COMPARE",
|
| 125 |
+
output_params={"comparison": "LT", "target": "opponent"},
|
| 126 |
+
),
|
| 127 |
+
Pattern(
|
| 128 |
+
name="score_compare_eq",
|
| 129 |
+
phase=PatternPhase.CONDITION,
|
| 130 |
+
regex=r"(?:スコア|コスト).*?同じ(?:場合|なら|とき)",
|
| 131 |
+
priority=25,
|
| 132 |
+
output_type="ConditionType.SCORE_COMPARE",
|
| 133 |
+
output_params={"comparison": "EQ", "target": "opponent"},
|
| 134 |
+
),
|
| 135 |
+
Pattern(
|
| 136 |
+
name="opponent_hand_diff",
|
| 137 |
+
phase=PatternPhase.CONDITION,
|
| 138 |
+
regex=r"相手の手札(?:の枚数)?が自分より(\d+)?枚?以上?多い",
|
| 139 |
+
priority=25,
|
| 140 |
+
output_type="ConditionType.OPPONENT_HAND_DIFF",
|
| 141 |
+
),
|
| 142 |
+
Pattern(
|
| 143 |
+
name="opponent_energy_diff",
|
| 144 |
+
phase=PatternPhase.CONDITION,
|
| 145 |
+
regex=r"相手のエネルギーが自分より(?:(\d+)枚以上)?多い",
|
| 146 |
+
priority=25,
|
| 147 |
+
output_type="ConditionType.OPPONENT_ENERGY_DIFF",
|
| 148 |
+
),
|
| 149 |
+
Pattern(
|
| 150 |
+
name="life_lead_gt",
|
| 151 |
+
phase=PatternPhase.CONDITION,
|
| 152 |
+
regex=r"ライフが相手より多い",
|
| 153 |
+
priority=25,
|
| 154 |
+
output_type="ConditionType.LIFE_LEAD",
|
| 155 |
+
output_params={"comparison": "GT", "target": "opponent"},
|
| 156 |
+
),
|
| 157 |
+
Pattern(
|
| 158 |
+
name="life_lead_lt",
|
| 159 |
+
phase=PatternPhase.CONDITION,
|
| 160 |
+
regex=r"ライフが相手より少ない",
|
| 161 |
+
priority=25,
|
| 162 |
+
output_type="ConditionType.LIFE_LEAD",
|
| 163 |
+
output_params={"comparison": "LT", "target": "opponent"},
|
| 164 |
+
),
|
| 165 |
+
Pattern(
|
| 166 |
+
name="opponent_choice_select",
|
| 167 |
+
phase=PatternPhase.CONDITION,
|
| 168 |
+
keywords=["相手", "選ぶ"],
|
| 169 |
+
excludes=["自分か相手"],
|
| 170 |
+
priority=30,
|
| 171 |
+
output_type="ConditionType.OPPONENT_CHOICE",
|
| 172 |
+
output_params={"type": "select"},
|
| 173 |
+
),
|
| 174 |
+
Pattern(
|
| 175 |
+
name="opponent_choice_discard",
|
| 176 |
+
phase=PatternPhase.CONDITION,
|
| 177 |
+
keywords=["相手", "手札", "捨て"],
|
| 178 |
+
priority=30,
|
| 179 |
+
output_type="ConditionType.OPPONENT_CHOICE",
|
| 180 |
+
output_params={"type": "discard"},
|
| 181 |
+
),
|
| 182 |
+
# ==========================================================================
|
| 183 |
+
# State conditions
|
| 184 |
+
# ==========================================================================
|
| 185 |
+
Pattern(
|
| 186 |
+
name="is_center",
|
| 187 |
+
phase=PatternPhase.CONDITION,
|
| 188 |
+
keywords=["センターエリア", "場合"],
|
| 189 |
+
priority=40,
|
| 190 |
+
output_type="ConditionType.IS_CENTER",
|
| 191 |
+
),
|
| 192 |
+
Pattern(
|
| 193 |
+
name="has_moved",
|
| 194 |
+
phase=PatternPhase.CONDITION,
|
| 195 |
+
keywords=["移動している場合"],
|
| 196 |
+
priority=30,
|
| 197 |
+
output_type="ConditionType.HAS_MOVED",
|
| 198 |
+
),
|
| 199 |
+
Pattern(
|
| 200 |
+
name="has_live_card",
|
| 201 |
+
phase=PatternPhase.CONDITION,
|
| 202 |
+
keywords=["ライブカードがある場合"],
|
| 203 |
+
priority=30,
|
| 204 |
+
output_type="ConditionType.HAS_LIVE_CARD",
|
| 205 |
+
),
|
| 206 |
+
Pattern(
|
| 207 |
+
name="has_choice",
|
| 208 |
+
phase=PatternPhase.CONDITION,
|
| 209 |
+
regex=r"(?:1つを選ぶ|どちらか.*?選ぶ|選んでもよい|のうち.*?選ぶ)",
|
| 210 |
+
priority=40,
|
| 211 |
+
output_type="ConditionType.HAS_CHOICE",
|
| 212 |
+
),
|
| 213 |
+
Pattern(
|
| 214 |
+
name="group_filter",
|
| 215 |
+
phase=PatternPhase.CONDITION,
|
| 216 |
+
regex=r"『(.*?)』",
|
| 217 |
+
priority=60,
|
| 218 |
+
requires=["場合", "なら", "がいる"],
|
| 219 |
+
output_type="ConditionType.GROUP_FILTER",
|
| 220 |
+
),
|
| 221 |
+
Pattern(
|
| 222 |
+
name="has_member",
|
| 223 |
+
phase=PatternPhase.CONDITION,
|
| 224 |
+
regex=r"「(.*?)」.*?(?:がある|がいる|登場している)場合",
|
| 225 |
+
priority=30,
|
| 226 |
+
output_type="ConditionType.HAS_MEMBER",
|
| 227 |
+
),
|
| 228 |
+
# ==========================================================================
|
| 229 |
+
# Once per turn / Turn restrictions
|
| 230 |
+
# ==========================================================================
|
| 231 |
+
Pattern(
|
| 232 |
+
name="turn_1",
|
| 233 |
+
phase=PatternPhase.CONDITION,
|
| 234 |
+
regex=r"\[Turn 1\]|1ターン目|ターン1(?!回)",
|
| 235 |
+
priority=20,
|
| 236 |
+
output_type="ConditionType.TURN_1",
|
| 237 |
+
output_params={"turn": 1},
|
| 238 |
+
),
|
| 239 |
+
# ==========================================================================
|
| 240 |
+
# Revealed/Milled card conditions
|
| 241 |
+
# ==========================================================================
|
| 242 |
+
Pattern(
|
| 243 |
+
name="all_revealed_are_members",
|
| 244 |
+
phase=PatternPhase.CONDITION,
|
| 245 |
+
regex=r"それらがすべてメンバーカード.*?場合",
|
| 246 |
+
priority=15,
|
| 247 |
+
output_type="ConditionType.GROUP_FILTER",
|
| 248 |
+
output_params={"filter_type": "all_revealed", "card_type": "member"},
|
| 249 |
+
),
|
| 250 |
+
Pattern(
|
| 251 |
+
name="is_in_discard",
|
| 252 |
+
phase=PatternPhase.CONDITION,
|
| 253 |
+
regex=r"この(カード|メンバー)が控え室にある場合のみ起動できる",
|
| 254 |
+
priority=10,
|
| 255 |
+
output_type="ConditionType.IS_IN_DISCARD",
|
| 256 |
+
),
|
| 257 |
+
]
|
compiler/patterns/effects.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Effect patterns.
|
| 3 |
+
|
| 4 |
+
Effects are the actions that occur when an ability activates.
|
| 5 |
+
Examples: DRAW, ADD_BLADES, RECOVER_MEMBER, etc.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .base import Pattern, PatternPhase
|
| 9 |
+
|
| 10 |
+
EFFECT_PATTERNS = [
|
| 11 |
+
# ==========================================================================
|
| 12 |
+
# Card manipulation effects
|
| 13 |
+
# ==========================================================================
|
| 14 |
+
Pattern(
|
| 15 |
+
name="draw_cards",
|
| 16 |
+
phase=PatternPhase.EFFECT,
|
| 17 |
+
regex=r"カード.*?(\d+)枚.*?引",
|
| 18 |
+
priority=20,
|
| 19 |
+
excludes=["引き入れ"], # "bring under" is not draw
|
| 20 |
+
consumes=True,
|
| 21 |
+
output_type="EffectType.DRAW",
|
| 22 |
+
),
|
| 23 |
+
Pattern(
|
| 24 |
+
name="draw_pseudocode",
|
| 25 |
+
phase=PatternPhase.EFFECT,
|
| 26 |
+
regex=r"DRAW\((.*?)\)",
|
| 27 |
+
priority=5,
|
| 28 |
+
consumes=True,
|
| 29 |
+
output_type="EffectType.DRAW",
|
| 30 |
+
extractor=lambda text, m: {
|
| 31 |
+
"type": "EffectType.DRAW",
|
| 32 |
+
"value": int(m.group(1)) if m.group(1).isdigit() else 0,
|
| 33 |
+
"value_cond": m.group(1) if not m.group(1).isdigit() else None,
|
| 34 |
+
},
|
| 35 |
+
),
|
| 36 |
+
Pattern(
|
| 37 |
+
name="draw_one",
|
| 38 |
+
phase=PatternPhase.EFFECT,
|
| 39 |
+
regex=r"引く",
|
| 40 |
+
priority=50,
|
| 41 |
+
excludes=["引き入れ", "置いた枚数分"],
|
| 42 |
+
consumes=True,
|
| 43 |
+
output_type="EffectType.DRAW",
|
| 44 |
+
output_value=1,
|
| 45 |
+
),
|
| 46 |
+
Pattern(
|
| 47 |
+
name="look_deck_top",
|
| 48 |
+
phase=PatternPhase.EFFECT,
|
| 49 |
+
regex=r"(?:デッキ|山札)の一番上.*?見る",
|
| 50 |
+
priority=19,
|
| 51 |
+
output_type="EffectType.LOOK_DECK",
|
| 52 |
+
output_value=1,
|
| 53 |
+
),
|
| 54 |
+
Pattern(
|
| 55 |
+
name="look_deck",
|
| 56 |
+
phase=PatternPhase.EFFECT,
|
| 57 |
+
regex=r"(?:デッキ|山札).*?(\d+)枚.*?(?:見る|見て)",
|
| 58 |
+
priority=20,
|
| 59 |
+
output_type="EffectType.LOOK_DECK",
|
| 60 |
+
),
|
| 61 |
+
Pattern(
|
| 62 |
+
name="search_deck",
|
| 63 |
+
phase=PatternPhase.EFFECT,
|
| 64 |
+
any_keywords=["探", "サーチ"],
|
| 65 |
+
requires=["デッキ"],
|
| 66 |
+
priority=30,
|
| 67 |
+
output_type="EffectType.SEARCH_DECK",
|
| 68 |
+
output_value=1,
|
| 69 |
+
),
|
| 70 |
+
Pattern(
|
| 71 |
+
name="search_deck_from",
|
| 72 |
+
phase=PatternPhase.EFFECT,
|
| 73 |
+
# "デッキから...手札に加える" (search deck for card, add to hand)
|
| 74 |
+
regex=r"(?:デッキ|山札)から.*?手札に加",
|
| 75 |
+
priority=5, # Very high priority to catch before ADD_TO_HAND
|
| 76 |
+
consumes=True,
|
| 77 |
+
output_type="EffectType.SEARCH_DECK",
|
| 78 |
+
output_value=1,
|
| 79 |
+
output_params={"to": "hand"},
|
| 80 |
+
),
|
| 81 |
+
Pattern(
|
| 82 |
+
name="reveal_cards",
|
| 83 |
+
phase=PatternPhase.EFFECT,
|
| 84 |
+
regex=r"(\d+)枚.*?公開",
|
| 85 |
+
priority=30,
|
| 86 |
+
excludes=["エール"], # Not cheer-based reveal
|
| 87 |
+
output_type="EffectType.REVEAL_CARDS",
|
| 88 |
+
),
|
| 89 |
+
Pattern(
|
| 90 |
+
name="reveal_top",
|
| 91 |
+
phase=PatternPhase.EFFECT,
|
| 92 |
+
regex=r"デッキの一番上.*?公開",
|
| 93 |
+
priority=20,
|
| 94 |
+
output_type="EffectType.REVEAL_CARDS",
|
| 95 |
+
output_value=1,
|
| 96 |
+
),
|
| 97 |
+
Pattern(
|
| 98 |
+
name="choose_heart_icons",
|
| 99 |
+
phase=PatternPhase.EFFECT,
|
| 100 |
+
regex=r"({{heart_.*?}}か{{heart_.*?}}).*?選ぶ",
|
| 101 |
+
priority=15,
|
| 102 |
+
output_type="EffectType.COLOR_SELECT",
|
| 103 |
+
),
|
| 104 |
+
Pattern(
|
| 105 |
+
name="return_discard_to_deck_bottom",
|
| 106 |
+
phase=PatternPhase.EFFECT,
|
| 107 |
+
regex=r"デッキの一番下に置く",
|
| 108 |
+
priority=15,
|
| 109 |
+
output_type="EffectType.MOVE_TO_DECK",
|
| 110 |
+
output_params={"to": "deck_bottom"},
|
| 111 |
+
),
|
| 112 |
+
Pattern(
|
| 113 |
+
name="add_hearts_color_text",
|
| 114 |
+
phase=PatternPhase.EFFECT,
|
| 115 |
+
regex=r"[(赤|青|黄|緑|紫|桃)ハート].*?得る",
|
| 116 |
+
priority=15,
|
| 117 |
+
output_type="EffectType.ADD_HEARTS",
|
| 118 |
+
extractor=lambda text, m: {"type": "EffectType.ADD_HEARTS", "value": 1, "params": {"color_text": m.group(1)}},
|
| 119 |
+
),
|
| 120 |
+
Pattern(
|
| 121 |
+
name="look_and_choose_order",
|
| 122 |
+
phase=PatternPhase.EFFECT,
|
| 123 |
+
regex=r"LOOK_AND_CHOOSE_ORDER\((\d+)\)",
|
| 124 |
+
priority=20,
|
| 125 |
+
output_type="EffectType.ORDER_DECK",
|
| 126 |
+
extractor=lambda text, m: {
|
| 127 |
+
"type": "EffectType.ORDER_DECK",
|
| 128 |
+
"value": int(m.group(1)),
|
| 129 |
+
},
|
| 130 |
+
),
|
| 131 |
+
Pattern(
|
| 132 |
+
name="select_from_pool",
|
| 133 |
+
phase=PatternPhase.EFFECT,
|
| 134 |
+
regex=r"の中から.*?(\d+)?(枚|人).*?選ぶ",
|
| 135 |
+
priority=20,
|
| 136 |
+
output_type="EffectType.LOOK_AND_CHOOSE",
|
| 137 |
+
extractor=lambda text, m: {
|
| 138 |
+
"type": "EffectType.LOOK_AND_CHOOSE",
|
| 139 |
+
"value": int(m.group(1)) if m.group(1) else 1,
|
| 140 |
+
},
|
| 141 |
+
),
|
| 142 |
+
# ==========================================================================
|
| 143 |
+
# Recovery effects
|
| 144 |
+
# ==========================================================================
|
| 145 |
+
Pattern(
|
| 146 |
+
name="add_self_to_hand",
|
| 147 |
+
phase=PatternPhase.EFFECT,
|
| 148 |
+
regex=r"この(カード|メンバー)を手札に加",
|
| 149 |
+
priority=15,
|
| 150 |
+
output_type="EffectType.ADD_TO_HAND",
|
| 151 |
+
output_params={"target": "self", "to": "hand"},
|
| 152 |
+
),
|
| 153 |
+
Pattern(
|
| 154 |
+
name="place_member_to_hand",
|
| 155 |
+
phase=PatternPhase.EFFECT,
|
| 156 |
+
regex=r"メンバーを?.*?手札に加",
|
| 157 |
+
priority=30, # Lower precedence
|
| 158 |
+
consumes=True,
|
| 159 |
+
output_type="EffectType.ADD_TO_HAND",
|
| 160 |
+
output_params={"to": "hand"},
|
| 161 |
+
),
|
| 162 |
+
Pattern(
|
| 163 |
+
name="recover_member",
|
| 164 |
+
phase=PatternPhase.EFFECT,
|
| 165 |
+
regex=r"控え室から.*?メンバーを?.*?手札に加",
|
| 166 |
+
priority=10, # High precedence to consume early
|
| 167 |
+
consumes=True,
|
| 168 |
+
output_type="EffectType.RECOVER_MEMBER",
|
| 169 |
+
output_value=1,
|
| 170 |
+
output_params={"from": "discard", "to": "hand"},
|
| 171 |
+
),
|
| 172 |
+
Pattern(
|
| 173 |
+
name="recover_from_success",
|
| 174 |
+
phase=PatternPhase.EFFECT,
|
| 175 |
+
regex=r"成功ライブカード(?:置き場)?[\s\S]*?手札に加",
|
| 176 |
+
priority=20,
|
| 177 |
+
output_type="EffectType.RECOVER_LIVE",
|
| 178 |
+
output_value=1,
|
| 179 |
+
output_params={"from": "success_zone", "to": "hand"},
|
| 180 |
+
),
|
| 181 |
+
Pattern(
|
| 182 |
+
name="recover_live",
|
| 183 |
+
phase=PatternPhase.EFFECT,
|
| 184 |
+
regex=r"控え室から.*?ライブカードを?.*?手札に加",
|
| 185 |
+
priority=10,
|
| 186 |
+
consumes=True,
|
| 187 |
+
output_type="EffectType.RECOVER_LIVE",
|
| 188 |
+
output_value=1,
|
| 189 |
+
output_params={"from": "discard", "to": "hand"},
|
| 190 |
+
),
|
| 191 |
+
Pattern(
|
| 192 |
+
name="add_to_hand_from_deck",
|
| 193 |
+
phase=PatternPhase.EFFECT,
|
| 194 |
+
keywords=["デッキ", "手札に加え"],
|
| 195 |
+
excludes=["見る", "選"], # Not look and choose
|
| 196 |
+
priority=25,
|
| 197 |
+
consumes=True,
|
| 198 |
+
output_type="EffectType.ADD_TO_HAND",
|
| 199 |
+
output_params={"from": "deck"},
|
| 200 |
+
),
|
| 201 |
+
Pattern(
|
| 202 |
+
name="look_and_choose",
|
| 203 |
+
phase=PatternPhase.EFFECT,
|
| 204 |
+
# Relaxed regex to allow filters between "look" and "choose"
|
| 205 |
+
regex=r"(?:カードを?|[\{\[].*?[\}\]])?(\d+)枚(見て|見る).*?(?:その中から|その中にある|公開された).*?(?:カードを?)?(\d+)?(?:枚|つ)?.*?手札に加",
|
| 206 |
+
priority=10, # High priority to catch before LOOK_DECK
|
| 207 |
+
consumes=True,
|
| 208 |
+
output_type="EffectType.LOOK_AND_CHOOSE",
|
| 209 |
+
extractor=lambda text, m: {
|
| 210 |
+
"type": "EffectType.LOOK_AND_CHOOSE",
|
| 211 |
+
"value": int(m.group(3)) if m.group(3) else 1,
|
| 212 |
+
"params": {"look_count": int(m.group(1))},
|
| 213 |
+
},
|
| 214 |
+
),
|
| 215 |
+
Pattern(
|
| 216 |
+
name="discard_looked",
|
| 217 |
+
phase=PatternPhase.EFFECT,
|
| 218 |
+
# "そのカードを控え室に置く" (referring to looked card)
|
| 219 |
+
regex=r"(?:その|公開した|見た)カードを?.*?控え室に置",
|
| 220 |
+
priority=25,
|
| 221 |
+
consumes=True,
|
| 222 |
+
output_type="EffectType.LOOK_AND_CHOOSE",
|
| 223 |
+
output_value=1,
|
| 224 |
+
output_params={"look_count": 1, "destination": "discard"},
|
| 225 |
+
),
|
| 226 |
+
Pattern(
|
| 227 |
+
name="mill_to_discard",
|
| 228 |
+
phase=PatternPhase.EFFECT,
|
| 229 |
+
# "デッキの上からカードをX枚控え室に置く"
|
| 230 |
+
regex=r"(?:デッキ|山札).*?(\d+)枚.*?控え室に置",
|
| 231 |
+
priority=15, # Higher priority than generic swap_to_discard
|
| 232 |
+
consumes=True,
|
| 233 |
+
output_type="EffectType.SWAP_CARDS",
|
| 234 |
+
extractor=lambda text, m: {
|
| 235 |
+
"type": "EffectType.SWAP_CARDS",
|
| 236 |
+
"value": int(m.group(1)),
|
| 237 |
+
"params": {"from": "deck", "target": "discard"},
|
| 238 |
+
},
|
| 239 |
+
),
|
| 240 |
+
Pattern(
|
| 241 |
+
name="discard_remainder",
|
| 242 |
+
phase=PatternPhase.EFFECT,
|
| 243 |
+
regex=r"残りを?控え室に",
|
| 244 |
+
priority=20,
|
| 245 |
+
consumes=True,
|
| 246 |
+
output_type="EffectType.SWAP_CARDS",
|
| 247 |
+
output_params={"target": "discard"},
|
| 248 |
+
),
|
| 249 |
+
Pattern(
|
| 250 |
+
name="choose_player",
|
| 251 |
+
phase=PatternPhase.EFFECT,
|
| 252 |
+
regex=r"自分か相手を選",
|
| 253 |
+
priority=5, # Very high priority
|
| 254 |
+
output_type="EffectType.META_RULE",
|
| 255 |
+
output_params={"target": "PLAYER_SELECT"},
|
| 256 |
+
),
|
| 257 |
+
# ==========================================================================
|
| 258 |
+
# Stat modification effects
|
| 259 |
+
# ==========================================================================
|
| 260 |
+
Pattern(
|
| 261 |
+
name="add_blades",
|
| 262 |
+
phase=PatternPhase.EFFECT,
|
| 263 |
+
regex=r"(?:{{icon_blade.*?}})?ブレード[^スコア場合]*?[++](\d+|1|2|3)",
|
| 264 |
+
priority=20,
|
| 265 |
+
output_type="EffectType.ADD_BLADES",
|
| 266 |
+
),
|
| 267 |
+
Pattern(
|
| 268 |
+
name="add_blades_gain",
|
| 269 |
+
phase=PatternPhase.EFFECT,
|
| 270 |
+
regex=r"(?:{{icon_blade.*?}})?ブレード.*?を得る",
|
| 271 |
+
priority=30,
|
| 272 |
+
output_type="EffectType.ADD_BLADES",
|
| 273 |
+
output_value=1,
|
| 274 |
+
),
|
| 275 |
+
Pattern(
|
| 276 |
+
name="add_hearts",
|
| 277 |
+
phase=PatternPhase.EFFECT,
|
| 278 |
+
regex=r"ハート[^スコア場合]*?[++](\d+|1|2|3)",
|
| 279 |
+
priority=20,
|
| 280 |
+
output_type="EffectType.ADD_HEARTS",
|
| 281 |
+
),
|
| 282 |
+
Pattern(
|
| 283 |
+
name="add_hearts_gain",
|
| 284 |
+
phase=PatternPhase.EFFECT,
|
| 285 |
+
regex=r"(?:{{(?:heart_\d+|icon_heart).*?}})?ハートを?(\d+|1|2|3)?(つ|個|枚)?(を)?得る",
|
| 286 |
+
priority=20,
|
| 287 |
+
output_type="EffectType.ADD_HEARTS",
|
| 288 |
+
extractor=lambda text, m: {
|
| 289 |
+
"type": "EffectType.ADD_HEARTS",
|
| 290 |
+
"value": int(m.group(1)) if m.group(1) else 1,
|
| 291 |
+
},
|
| 292 |
+
),
|
| 293 |
+
Pattern(
|
| 294 |
+
name="add_hearts_icon",
|
| 295 |
+
phase=PatternPhase.EFFECT,
|
| 296 |
+
# Heart icons: {{heart_XX.png|heartXX}}を得る
|
| 297 |
+
regex=r"({{heart_\d+\.png\|heart\d+}})+(を)?得る",
|
| 298 |
+
priority=10, # Very high priority
|
| 299 |
+
consumes=True,
|
| 300 |
+
output_type="EffectType.ADD_HEARTS",
|
| 301 |
+
extractor=lambda text, m: {
|
| 302 |
+
"type": "EffectType.ADD_HEARTS",
|
| 303 |
+
"value": text[: m.end()].count("{{heart_"), # Count heart icons
|
| 304 |
+
"params": {},
|
| 305 |
+
},
|
| 306 |
+
),
|
| 307 |
+
Pattern(
|
| 308 |
+
name="boost_score",
|
| 309 |
+
phase=PatternPhase.EFFECT,
|
| 310 |
+
regex=r"スコア.*?[++](\d+|1|2|3)",
|
| 311 |
+
priority=20,
|
| 312 |
+
output_type="EffectType.BOOST_SCORE",
|
| 313 |
+
),
|
| 314 |
+
Pattern(
|
| 315 |
+
name="reduce_heart_req",
|
| 316 |
+
phase=PatternPhase.EFFECT,
|
| 317 |
+
any_keywords=["必要ハート", "ハート条件"],
|
| 318 |
+
requires=["減", "少なく"],
|
| 319 |
+
priority=25,
|
| 320 |
+
output_type="EffectType.REDUCE_HEART_REQ",
|
| 321 |
+
),
|
| 322 |
+
# ==========================================================================
|
| 323 |
+
# Energy effects
|
| 324 |
+
# ==========================================================================
|
| 325 |
+
Pattern(
|
| 326 |
+
name="energy_charge",
|
| 327 |
+
phase=PatternPhase.EFFECT,
|
| 328 |
+
regex=r"エネルギー(?:カード)?を?(\d+)?枚.*?(?:置く|加える|チャージ)",
|
| 329 |
+
priority=25,
|
| 330 |
+
excludes=["控え室", "の上から", "を公開", "公開された"],
|
| 331 |
+
output_type="EffectType.ENERGY_CHARGE",
|
| 332 |
+
),
|
| 333 |
+
# ==========================================================================
|
| 334 |
+
# Movement/Position effects
|
| 335 |
+
# ==========================================================================
|
| 336 |
+
Pattern(
|
| 337 |
+
name="move_member",
|
| 338 |
+
phase=PatternPhase.EFFECT,
|
| 339 |
+
any_keywords=["ポジションチェンジ", "移動させ", "移動する", "場所を入れ替える"],
|
| 340 |
+
priority=25,
|
| 341 |
+
output_type="EffectType.MOVE_MEMBER",
|
| 342 |
+
output_value=1,
|
| 343 |
+
),
|
| 344 |
+
Pattern(
|
| 345 |
+
name="tap_opponent",
|
| 346 |
+
phase=PatternPhase.EFFECT,
|
| 347 |
+
regex=r"相手.*?(\d+)?人?.*?(?:ウェイト|休み)",
|
| 348 |
+
priority=25,
|
| 349 |
+
output_type="EffectType.TAP_OPPONENT",
|
| 350 |
+
),
|
| 351 |
+
Pattern(
|
| 352 |
+
name="activate_member",
|
| 353 |
+
phase=PatternPhase.EFFECT,
|
| 354 |
+
keywords=["アクティブに"],
|
| 355 |
+
excludes=["手札", "加え"], # Not "add card with active ability"
|
| 356 |
+
priority=25,
|
| 357 |
+
output_type="EffectType.ACTIVATE_MEMBER",
|
| 358 |
+
output_value=1,
|
| 359 |
+
),
|
| 360 |
+
# ==========================================================================
|
| 361 |
+
# Zone transfer effects
|
| 362 |
+
# ==========================================================================
|
| 363 |
+
Pattern(
|
| 364 |
+
name="swap_to_discard",
|
| 365 |
+
phase=PatternPhase.EFFECT,
|
| 366 |
+
any_keywords=["控え室に置", "控え室に送"],
|
| 367 |
+
priority=30,
|
| 368 |
+
output_type="EffectType.SWAP_CARDS",
|
| 369 |
+
output_params={"target": "discard"},
|
| 370 |
+
extractor=lambda text, m: {
|
| 371 |
+
"type": "EffectType.SWAP_CARDS",
|
| 372 |
+
"params": {"target": "discard", "from": "deck" if "デッキ" in text or "山札" in text else "field"},
|
| 373 |
+
},
|
| 374 |
+
),
|
| 375 |
+
Pattern(
|
| 376 |
+
name="move_to_deck",
|
| 377 |
+
phase=PatternPhase.EFFECT,
|
| 378 |
+
any_keywords=["デッキに戻す", "山札に置く"],
|
| 379 |
+
priority=30,
|
| 380 |
+
output_type="EffectType.MOVE_TO_DECK",
|
| 381 |
+
),
|
| 382 |
+
Pattern(
|
| 383 |
+
name="return_discard_to_deck",
|
| 384 |
+
phase=PatternPhase.EFFECT,
|
| 385 |
+
# "控え室にある...デッキの一番上に置く" (place from discard to top of deck)
|
| 386 |
+
regex=r"控え室.*?(\d+)枚.*?(?:デッキ|山札).*?一番上に置",
|
| 387 |
+
priority=15,
|
| 388 |
+
consumes=True,
|
| 389 |
+
output_type="EffectType.MOVE_TO_DECK",
|
| 390 |
+
extractor=lambda text, m: {
|
| 391 |
+
"type": "EffectType.MOVE_TO_DECK",
|
| 392 |
+
"value": int(m.group(1)),
|
| 393 |
+
"params": {"from": "discard", "to": "deck_top"},
|
| 394 |
+
},
|
| 395 |
+
),
|
| 396 |
+
Pattern(
|
| 397 |
+
name="place_under",
|
| 398 |
+
phase=PatternPhase.EFFECT,
|
| 399 |
+
keywords=["の下に置"],
|
| 400 |
+
excludes=["コスト", "払"], # Not cost
|
| 401 |
+
priority=30,
|
| 402 |
+
output_type="EffectType.PLACE_UNDER",
|
| 403 |
+
),
|
| 404 |
+
# ==========================================================================
|
| 405 |
+
# Meta/Rule effects
|
| 406 |
+
# ==========================================================================
|
| 407 |
+
Pattern(
|
| 408 |
+
name="select_mode",
|
| 409 |
+
phase=PatternPhase.EFFECT,
|
| 410 |
+
regex=r"(?:以下から|のうち、)(\d+|1|2|3)(つ|枚|回)?を選ぶ",
|
| 411 |
+
priority=20,
|
| 412 |
+
output_type="EffectType.SELECT_MODE",
|
| 413 |
+
),
|
| 414 |
+
Pattern(
|
| 415 |
+
name="color_select",
|
| 416 |
+
phase=PatternPhase.EFFECT,
|
| 417 |
+
any_keywords=["ハートの色を1つ指定", "好きなハートの色を"],
|
| 418 |
+
priority=25,
|
| 419 |
+
output_type="EffectType.COLOR_SELECT",
|
| 420 |
+
output_value=1,
|
| 421 |
+
),
|
| 422 |
+
Pattern(
|
| 423 |
+
name="negate_effect",
|
| 424 |
+
phase=PatternPhase.EFFECT,
|
| 425 |
+
any_keywords=["無効", "キャンセル"],
|
| 426 |
+
priority=25,
|
| 427 |
+
output_type="EffectType.NEGATE_EFFECT",
|
| 428 |
+
output_value=1,
|
| 429 |
+
),
|
| 430 |
+
Pattern(
|
| 431 |
+
name="shuffle_deck",
|
| 432 |
+
phase=PatternPhase.EFFECT,
|
| 433 |
+
keywords=["シャッフル"],
|
| 434 |
+
priority=40,
|
| 435 |
+
output_type="EffectType.META_RULE",
|
| 436 |
+
output_params={"type": "shuffle", "deck": True},
|
| 437 |
+
),
|
| 438 |
+
Pattern(
|
| 439 |
+
name="play_member_from_hand",
|
| 440 |
+
phase=PatternPhase.EFFECT,
|
| 441 |
+
regex=r"手札から.*?登場させ",
|
| 442 |
+
priority=15,
|
| 443 |
+
output_type="EffectType.PLAY_MEMBER_FROM_HAND",
|
| 444 |
+
output_value=1,
|
| 445 |
+
),
|
| 446 |
+
Pattern(
|
| 447 |
+
name="play_member_from_discard",
|
| 448 |
+
phase=PatternPhase.EFFECT,
|
| 449 |
+
regex=r"控え室から.*?登場させ",
|
| 450 |
+
priority=15,
|
| 451 |
+
output_type="EffectType.PLAY_MEMBER_FROM_DISCARD",
|
| 452 |
+
output_value=1,
|
| 453 |
+
),
|
| 454 |
+
Pattern(
|
| 455 |
+
name="tap_self",
|
| 456 |
+
phase=PatternPhase.EFFECT,
|
| 457 |
+
regex=r"このメンバーをウェイトにする",
|
| 458 |
+
priority=20,
|
| 459 |
+
output_type="EffectType.TAP_MEMBER",
|
| 460 |
+
output_params={"target": "self"},
|
| 461 |
+
),
|
| 462 |
+
Pattern(
|
| 463 |
+
name="card_selection",
|
| 464 |
+
phase=PatternPhase.EFFECT,
|
| 465 |
+
regex=r"(?:控え室にある|デッキにある|ステージにいる)?.*?(\d+)枚選ぶ",
|
| 466 |
+
priority=50, # Low priority
|
| 467 |
+
output_type="EffectType.LOOK_AND_CHOOSE",
|
| 468 |
+
),
|
| 469 |
+
# ==========================================================================
|
| 470 |
+
# Cost/Constant modifiers parsed as effects
|
| 471 |
+
# ==========================================================================
|
| 472 |
+
Pattern(
|
| 473 |
+
name="reduce_cost_self",
|
| 474 |
+
phase=PatternPhase.EFFECT,
|
| 475 |
+
# "コストは...X少なくなる" OR "コストはX減る" (cost is reduced by X)
|
| 476 |
+
regex=r"コストは.*?(\d+)(?:少なく|減る|減)",
|
| 477 |
+
priority=15,
|
| 478 |
+
consumes=True,
|
| 479 |
+
output_type="EffectType.REDUCE_COST",
|
| 480 |
+
),
|
| 481 |
+
Pattern(
|
| 482 |
+
name="reduce_cost_per_card",
|
| 483 |
+
phase=PatternPhase.EFFECT,
|
| 484 |
+
# "手札1枚につき、1少なくなる" (reduced by 1 per card in hand)
|
| 485 |
+
regex=r"手札.*?(\d+)枚につき.*?(\d+)少なく",
|
| 486 |
+
priority=10, # Higher than reduce_cost_self
|
| 487 |
+
consumes=True,
|
| 488 |
+
output_type="EffectType.REDUCE_COST",
|
| 489 |
+
extractor=lambda text, m: {
|
| 490 |
+
"type": "EffectType.REDUCE_COST",
|
| 491 |
+
"value": int(m.group(2)),
|
| 492 |
+
"params": {"per_card": int(m.group(1)), "zone": "hand"},
|
| 493 |
+
},
|
| 494 |
+
),
|
| 495 |
+
Pattern(
|
| 496 |
+
name="grant_ability",
|
| 497 |
+
phase=PatternPhase.EFFECT,
|
| 498 |
+
# Ability granting: "能力を得る" / "」を得る"
|
| 499 |
+
regex=r"(?:」|{{.*?}}).*?を得る",
|
| 500 |
+
priority=25,
|
| 501 |
+
consumes=False, # Allow inner effects to be parsed
|
| 502 |
+
output_type="EffectType.BUFF_POWER",
|
| 503 |
+
output_value=1,
|
| 504 |
+
),
|
| 505 |
+
Pattern(
|
| 506 |
+
name="grant_stat_buff",
|
| 507 |
+
phase=PatternPhase.EFFECT,
|
| 508 |
+
# "ブレード+X」を得る" / "ハート+X」を得る" (gain Blade+X / Heart+X)
|
| 509 |
+
regex=r"(ブレード|ハート)[++](\d+)[」」].*?得る",
|
| 510 |
+
priority=15,
|
| 511 |
+
consumes=True,
|
| 512 |
+
output_type="EffectType.BUFF_POWER",
|
| 513 |
+
extractor=lambda text, m: {
|
| 514 |
+
"type": "EffectType.BUFF_POWER" if "ブレード" in m.group(1) else "EffectType.ADD_HEARTS",
|
| 515 |
+
"value": int(m.group(2)),
|
| 516 |
+
"params": {"stat": "blade" if "ブレード" in m.group(1) else "heart"},
|
| 517 |
+
},
|
| 518 |
+
),
|
| 519 |
+
Pattern(
|
| 520 |
+
name="tap_member_cost",
|
| 521 |
+
phase=PatternPhase.EFFECT,
|
| 522 |
+
# Tap member as cost/effect: "メンバーXをウェイトにしてもよい"
|
| 523 |
+
regex=r"メンバー.*?(\d+)人?.*?ウェイトにして",
|
| 524 |
+
priority=25,
|
| 525 |
+
consumes=True,
|
| 526 |
+
output_type="EffectType.TAP_MEMBER",
|
| 527 |
+
extractor=lambda text, m: {"type": "EffectType.TAP_MEMBER", "value": int(m.group(1)), "params": {"cost": True}},
|
| 528 |
+
),
|
| 529 |
+
Pattern(
|
| 530 |
+
name="draw_equal_to_discarded",
|
| 531 |
+
phase=PatternPhase.EFFECT,
|
| 532 |
+
regex=r"置いた枚数分カードを引く",
|
| 533 |
+
priority=15,
|
| 534 |
+
consumes=True,
|
| 535 |
+
output_type="EffectType.DRAW",
|
| 536 |
+
output_params={"multiplier": "discarded_count"},
|
| 537 |
+
),
|
| 538 |
+
Pattern(
|
| 539 |
+
name="trigger_ability",
|
| 540 |
+
phase=PatternPhase.EFFECT,
|
| 541 |
+
regex=r"(能力1つ)?を?発動させる",
|
| 542 |
+
priority=20,
|
| 543 |
+
consumes=True,
|
| 544 |
+
output_type="EffectType.TRIGGER_REMOTE",
|
| 545 |
+
),
|
| 546 |
+
Pattern(
|
| 547 |
+
name="treat_as_all_colors",
|
| 548 |
+
phase=PatternPhase.EFFECT,
|
| 549 |
+
regex=r"属性を全ての属性として扱う",
|
| 550 |
+
priority=20,
|
| 551 |
+
output_type="EffectType.META_RULE",
|
| 552 |
+
output_params={"type": "all_colors"},
|
| 553 |
+
),
|
| 554 |
+
Pattern(
|
| 555 |
+
name="transform_base_hearts",
|
| 556 |
+
phase=PatternPhase.EFFECT,
|
| 557 |
+
regex=r"元々持つハートはすべて.*?({{heart_.*?}})?になる",
|
| 558 |
+
priority=20,
|
| 559 |
+
output_type="EffectType.TRANSFORM_COLOR",
|
| 560 |
+
output_params={"target": "base_hearts"},
|
| 561 |
+
),
|
| 562 |
+
Pattern(
|
| 563 |
+
name="add_from_reveal_to_hand",
|
| 564 |
+
phase=PatternPhase.EFFECT,
|
| 565 |
+
regex=r"(?:公開された|公開される).*?手札に加",
|
| 566 |
+
priority=20,
|
| 567 |
+
output_type="EffectType.ADD_TO_HAND",
|
| 568 |
+
output_params={"from": "reveal_zone", "to": "hand"},
|
| 569 |
+
),
|
| 570 |
+
Pattern(
|
| 571 |
+
name="recover_live_to_zone",
|
| 572 |
+
phase=PatternPhase.EFFECT,
|
| 573 |
+
regex=r"控え室からライブカードを.*?ライブカード置き場に置",
|
| 574 |
+
priority=20,
|
| 575 |
+
output_type="EffectType.PLAY_LIVE_FROM_DISCARD",
|
| 576 |
+
output_value=1,
|
| 577 |
+
),
|
| 578 |
+
Pattern(
|
| 579 |
+
name="increase_cost",
|
| 580 |
+
phase=PatternPhase.EFFECT,
|
| 581 |
+
regex=r"コストを?[++](\d+)する",
|
| 582 |
+
priority=20,
|
| 583 |
+
output_type="EffectType.REDUCE_COST", # Use negative for increase in engine or separate Opcode
|
| 584 |
+
extractor=lambda text, m: {
|
| 585 |
+
"type": "EffectType.REDUCE_COST",
|
| 586 |
+
"value": -int(m.group(1)),
|
| 587 |
+
},
|
| 588 |
+
),
|
| 589 |
+
Pattern(
|
| 590 |
+
name="increase_heart_req",
|
| 591 |
+
phase=PatternPhase.EFFECT,
|
| 592 |
+
regex=r"必要ハートが.*?多くなる",
|
| 593 |
+
priority=20,
|
| 594 |
+
output_type="EffectType.REDUCE_HEART_REQ",
|
| 595 |
+
output_value=1, # Default increase by 1 if value not specified
|
| 596 |
+
),
|
| 597 |
+
Pattern(
|
| 598 |
+
name="modify_cheer_count",
|
| 599 |
+
phase=PatternPhase.EFFECT,
|
| 600 |
+
regex=r"エールによって公開される.*?枚数が.*?(\d+)枚(減る|増える)",
|
| 601 |
+
priority=20,
|
| 602 |
+
output_type="EffectType.META_RULE",
|
| 603 |
+
extractor=lambda text, m: {
|
| 604 |
+
"type": "EffectType.META_RULE",
|
| 605 |
+
"params": {"type": "cheer_mod"},
|
| 606 |
+
"value": -int(m.group(1)) if m.group(2) == "減る" else int(m.group(1)),
|
| 607 |
+
},
|
| 608 |
+
),
|
| 609 |
+
Pattern(
|
| 610 |
+
name="play_member_from_discard",
|
| 611 |
+
phase=PatternPhase.EFFECT,
|
| 612 |
+
regex=r"控え室(?:にある|から).*?登場させ",
|
| 613 |
+
priority=15,
|
| 614 |
+
output_type="EffectType.PLAY_MEMBER_FROM_DISCARD",
|
| 615 |
+
output_value=1,
|
| 616 |
+
),
|
| 617 |
+
Pattern(
|
| 618 |
+
name="baton_touch_mod",
|
| 619 |
+
phase=PatternPhase.EFFECT,
|
| 620 |
+
regex=r"(\d+)人のメンバーとバトンタッチ",
|
| 621 |
+
priority=20,
|
| 622 |
+
output_type="EffectType.BATON_TOUCH_MOD",
|
| 623 |
+
extractor=lambda text, m: {
|
| 624 |
+
"type": "EffectType.BATON_TOUCH_MOD",
|
| 625 |
+
"value": int(m.group(1)),
|
| 626 |
+
},
|
| 627 |
+
),
|
| 628 |
+
Pattern(
|
| 629 |
+
name="rule_equivalence",
|
| 630 |
+
phase=PatternPhase.EFFECT,
|
| 631 |
+
regex=r"についても同じこと(として扱う|を行う)",
|
| 632 |
+
priority=20,
|
| 633 |
+
output_type="EffectType.META_RULE",
|
| 634 |
+
output_params={"type": "rule_equivalence"},
|
| 635 |
+
),
|
| 636 |
+
Pattern(
|
| 637 |
+
name="restriction_no_live",
|
| 638 |
+
phase=PatternPhase.EFFECT,
|
| 639 |
+
regex=r"自分はライブ(できない|出来ません)",
|
| 640 |
+
priority=20,
|
| 641 |
+
output_type="EffectType.RESTRICTION",
|
| 642 |
+
output_params={"type": "no_live"},
|
| 643 |
+
),
|
| 644 |
+
]
|
compiler/patterns/modifiers.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modifier patterns.
|
| 2 |
+
|
| 3 |
+
Modifiers apply flags and adjustments to parsed abilities:
|
| 4 |
+
- is_optional: Whether the effect can be declined
|
| 5 |
+
- is_once_per_turn: Usage limit
|
| 6 |
+
- duration: How long effects last
|
| 7 |
+
- target: Who is affected
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from .base import Pattern, PatternPhase
|
| 11 |
+
|
| 12 |
+
MODIFIER_PATTERNS = [
|
| 13 |
+
# ==========================================================================
|
| 14 |
+
# Optionality (key fix for the bug in legacy parser)
|
| 15 |
+
# ==========================================================================
|
| 16 |
+
Pattern(
|
| 17 |
+
name="optional_may",
|
| 18 |
+
phase=PatternPhase.MODIFIER,
|
| 19 |
+
regex=r"てもよい",
|
| 20 |
+
priority=10,
|
| 21 |
+
output_params={"is_optional": True},
|
| 22 |
+
),
|
| 23 |
+
Pattern(
|
| 24 |
+
name="optional_can",
|
| 25 |
+
phase=PatternPhase.MODIFIER,
|
| 26 |
+
regex=r"てよい",
|
| 27 |
+
priority=10,
|
| 28 |
+
output_params={"is_optional": True},
|
| 29 |
+
),
|
| 30 |
+
Pattern(
|
| 31 |
+
name="optional_cost",
|
| 32 |
+
phase=PatternPhase.MODIFIER,
|
| 33 |
+
regex=r"(?:支払うことで|支払えば)",
|
| 34 |
+
priority=15,
|
| 35 |
+
output_params={"cost_is_optional": True},
|
| 36 |
+
),
|
| 37 |
+
# ==========================================================================
|
| 38 |
+
# Usage limits
|
| 39 |
+
# ==========================================================================
|
| 40 |
+
Pattern(
|
| 41 |
+
name="once_per_turn",
|
| 42 |
+
phase=PatternPhase.MODIFIER,
|
| 43 |
+
regex=r"1ターンに1回|ターン終了時まで1回|に限る|ターン1回|[ターン1回]|【ターン1回】",
|
| 44 |
+
priority=10,
|
| 45 |
+
output_params={"is_once_per_turn": True},
|
| 46 |
+
),
|
| 47 |
+
# ==========================================================================
|
| 48 |
+
# Duration modifiers
|
| 49 |
+
# ==========================================================================
|
| 50 |
+
Pattern(
|
| 51 |
+
name="until_live_end",
|
| 52 |
+
phase=PatternPhase.MODIFIER,
|
| 53 |
+
keywords=["ライブ終了時まで"],
|
| 54 |
+
priority=20,
|
| 55 |
+
output_params={"duration": "live_end"},
|
| 56 |
+
),
|
| 57 |
+
Pattern(
|
| 58 |
+
name="until_turn_end",
|
| 59 |
+
phase=PatternPhase.MODIFIER,
|
| 60 |
+
regex=r"ターン終了まで|終了時まで",
|
| 61 |
+
priority=20,
|
| 62 |
+
excludes=["ライブ終了時まで"], # More specific pattern takes precedence
|
| 63 |
+
output_params={"duration": "turn_end"},
|
| 64 |
+
),
|
| 65 |
+
# ==========================================================================
|
| 66 |
+
# Target modifiers
|
| 67 |
+
# ==========================================================================
|
| 68 |
+
Pattern(
|
| 69 |
+
name="target_all_players",
|
| 70 |
+
phase=PatternPhase.MODIFIER,
|
| 71 |
+
any_keywords=["自分と相手", "自分も相手も", "全員", "自分および相手"],
|
| 72 |
+
priority=20,
|
| 73 |
+
output_params={"target": "ALL_PLAYERS", "both_players": True},
|
| 74 |
+
),
|
| 75 |
+
Pattern(
|
| 76 |
+
name="target_opponent",
|
| 77 |
+
phase=PatternPhase.MODIFIER,
|
| 78 |
+
regex=r"相手は.*?(?:する|引く|置く)",
|
| 79 |
+
priority=25,
|
| 80 |
+
excludes=["自分は", "自分を"],
|
| 81 |
+
output_params={"target": "OPPONENT"},
|
| 82 |
+
),
|
| 83 |
+
Pattern(
|
| 84 |
+
name="target_opponent_hand",
|
| 85 |
+
phase=PatternPhase.MODIFIER,
|
| 86 |
+
keywords=["相手の手札"],
|
| 87 |
+
priority=20,
|
| 88 |
+
output_params={"target": "OPPONENT_HAND"},
|
| 89 |
+
),
|
| 90 |
+
# ==========================================================================
|
| 91 |
+
# Scope modifiers
|
| 92 |
+
# ==========================================================================
|
| 93 |
+
Pattern(
|
| 94 |
+
name="scope_all",
|
| 95 |
+
phase=PatternPhase.MODIFIER,
|
| 96 |
+
keywords=["すべての"],
|
| 97 |
+
priority=30,
|
| 98 |
+
output_params={"all": True},
|
| 99 |
+
),
|
| 100 |
+
# ==========================================================================
|
| 101 |
+
# Multiplier modifiers
|
| 102 |
+
# ==========================================================================
|
| 103 |
+
Pattern(
|
| 104 |
+
name="multiplier_per_unit",
|
| 105 |
+
phase=PatternPhase.MODIFIER,
|
| 106 |
+
regex=r"(\d+)(枚|人)につき",
|
| 107 |
+
priority=20,
|
| 108 |
+
output_params={"has_multiplier": True},
|
| 109 |
+
),
|
| 110 |
+
Pattern(
|
| 111 |
+
name="multiplier_per_member",
|
| 112 |
+
phase=PatternPhase.MODIFIER,
|
| 113 |
+
keywords=["人につき"],
|
| 114 |
+
priority=25,
|
| 115 |
+
output_params={"per_member": True},
|
| 116 |
+
),
|
| 117 |
+
Pattern(
|
| 118 |
+
name="multiplier_per_live",
|
| 119 |
+
phase=PatternPhase.MODIFIER,
|
| 120 |
+
any_keywords=["成功ライブカード", "ライブカード"],
|
| 121 |
+
requires=["につき", "枚数"],
|
| 122 |
+
priority=25,
|
| 123 |
+
output_params={"per_live": True},
|
| 124 |
+
),
|
| 125 |
+
Pattern(
|
| 126 |
+
name="multiplier_per_energy",
|
| 127 |
+
phase=PatternPhase.MODIFIER,
|
| 128 |
+
keywords=["エネルギー"],
|
| 129 |
+
requires=["につき"],
|
| 130 |
+
priority=25,
|
| 131 |
+
output_params={"per_energy": True},
|
| 132 |
+
),
|
| 133 |
+
# ==========================================================================
|
| 134 |
+
# Filter modifiers (for effect targets)
|
| 135 |
+
# ==========================================================================
|
| 136 |
+
Pattern(
|
| 137 |
+
name="filter_cost_max",
|
| 138 |
+
phase=PatternPhase.MODIFIER,
|
| 139 |
+
regex=r"コスト(\d+)以下",
|
| 140 |
+
priority=25,
|
| 141 |
+
output_params={"has_cost_filter": True},
|
| 142 |
+
),
|
| 143 |
+
Pattern(
|
| 144 |
+
name="filter_group",
|
| 145 |
+
phase=PatternPhase.MODIFIER,
|
| 146 |
+
regex=r"『(.*?)』",
|
| 147 |
+
priority=30,
|
| 148 |
+
consumes=True,
|
| 149 |
+
extractor=lambda text, m: {"params": {"group": m.group(1)}},
|
| 150 |
+
),
|
| 151 |
+
Pattern(
|
| 152 |
+
name="filter_names",
|
| 153 |
+
phase=PatternPhase.MODIFIER,
|
| 154 |
+
regex=r"「(?!\{\{)(.*?)」",
|
| 155 |
+
priority=30,
|
| 156 |
+
consumes=True,
|
| 157 |
+
extractor=lambda text, m: {"params": {"target_name": m.group(1)}},
|
| 158 |
+
),
|
| 159 |
+
Pattern(
|
| 160 |
+
name="filter_has_ability",
|
| 161 |
+
phase=PatternPhase.MODIFIER,
|
| 162 |
+
any_keywords=["アクティブにする」を持つ", "【起動】"],
|
| 163 |
+
priority=25,
|
| 164 |
+
output_params={"has_ability": "active"},
|
| 165 |
+
),
|
| 166 |
+
# ==========================================================================
|
| 167 |
+
# Meta modifiers
|
| 168 |
+
# ==========================================================================
|
| 169 |
+
Pattern(
|
| 170 |
+
name="opponent_trigger_allowed",
|
| 171 |
+
phase=PatternPhase.MODIFIER,
|
| 172 |
+
keywords=["対戦相手のカードの効果でも発動する"],
|
| 173 |
+
priority=10,
|
| 174 |
+
output_params={"opponent_trigger_allowed": True},
|
| 175 |
+
),
|
| 176 |
+
]
|
compiler/patterns/registry.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pattern registry for managing and matching patterns."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Match, Optional, Tuple
|
| 4 |
+
|
| 5 |
+
from .base import Pattern, PatternPhase
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class PatternRegistry:
|
| 9 |
+
"""Central registry for all parsing patterns.
|
| 10 |
+
|
| 11 |
+
Patterns are organized by phase and sorted by priority.
|
| 12 |
+
Lower priority number = higher precedence.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self._patterns: Dict[PatternPhase, List[Pattern]] = {phase: [] for phase in PatternPhase}
|
| 17 |
+
self._frozen = False
|
| 18 |
+
|
| 19 |
+
def register(self, pattern: Pattern) -> "PatternRegistry":
|
| 20 |
+
"""Register a pattern. Returns self for chaining."""
|
| 21 |
+
if self._frozen:
|
| 22 |
+
raise RuntimeError("Cannot register patterns after registry is frozen")
|
| 23 |
+
self._patterns[pattern.phase].append(pattern)
|
| 24 |
+
return self
|
| 25 |
+
|
| 26 |
+
def register_all(self, patterns: List[Pattern]) -> "PatternRegistry":
|
| 27 |
+
"""Register multiple patterns. Returns self for chaining."""
|
| 28 |
+
for p in patterns:
|
| 29 |
+
self.register(p)
|
| 30 |
+
return self
|
| 31 |
+
|
| 32 |
+
def freeze(self) -> "PatternRegistry":
|
| 33 |
+
"""Freeze the registry and sort patterns by priority."""
|
| 34 |
+
for phase in PatternPhase:
|
| 35 |
+
self._patterns[phase].sort(key=lambda p: p.priority)
|
| 36 |
+
self._frozen = True
|
| 37 |
+
return self
|
| 38 |
+
|
| 39 |
+
def match_all(
|
| 40 |
+
self, text: str, phase: PatternPhase, context: Optional[str] = None
|
| 41 |
+
) -> List[Tuple[Pattern, Match, Dict[str, Any]]]:
|
| 42 |
+
"""Find all matching patterns for a phase.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
text: Text to match against
|
| 46 |
+
phase: Which parsing phase to match
|
| 47 |
+
context: Full sentence context for requires/excludes
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
List of (pattern, match, extracted_data) tuples
|
| 51 |
+
"""
|
| 52 |
+
results = []
|
| 53 |
+
working_text = text
|
| 54 |
+
|
| 55 |
+
for pattern in self._patterns[phase]:
|
| 56 |
+
# Loop to find all matches for this pattern
|
| 57 |
+
while True:
|
| 58 |
+
# Always check against original context but match against working_text
|
| 59 |
+
m = pattern.matches(working_text, context or text)
|
| 60 |
+
if not m:
|
| 61 |
+
break
|
| 62 |
+
|
| 63 |
+
data = pattern.extract(working_text, m)
|
| 64 |
+
results.append((pattern, m, data))
|
| 65 |
+
|
| 66 |
+
# If pattern consumes, mask out the matched text
|
| 67 |
+
if pattern.consumes:
|
| 68 |
+
start, end = m.start(), m.end()
|
| 69 |
+
working_text = working_text[:start] + " " * (end - start) + working_text[end:]
|
| 70 |
+
# Continue looking for more matches of this pattern
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
# If pattern doesn't consume, we stop after first match to prevent infinite loop
|
| 74 |
+
break
|
| 75 |
+
|
| 76 |
+
if pattern.exclusive and any(r[0] == pattern for r in results):
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
# Sort results by match position to maintain text order
|
| 80 |
+
results.sort(key=lambda x: x[1].start())
|
| 81 |
+
return results
|
| 82 |
+
|
| 83 |
+
def match_first(
|
| 84 |
+
self, text: str, phase: PatternPhase, context: Optional[str] = None
|
| 85 |
+
) -> Optional[Tuple[Pattern, Match, Dict[str, Any]]]:
|
| 86 |
+
"""Find the first (highest priority) matching pattern.
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
(pattern, match, extracted_data) tuple or None
|
| 90 |
+
"""
|
| 91 |
+
for pattern in self._patterns[phase]:
|
| 92 |
+
if m := pattern.matches(text, context):
|
| 93 |
+
data = pattern.extract(text, m)
|
| 94 |
+
return (pattern, m, data)
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
def get_patterns(self, phase: PatternPhase) -> List[Pattern]:
|
| 98 |
+
"""Get all patterns for a phase (for debugging/testing)."""
|
| 99 |
+
return list(self._patterns[phase])
|
| 100 |
+
|
| 101 |
+
def stats(self) -> Dict[str, int]:
|
| 102 |
+
"""Get pattern counts per phase."""
|
| 103 |
+
return {phase.name: len(patterns) for phase, patterns in self._patterns.items()}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# Global registry instance
|
| 107 |
+
_global_registry: Optional[PatternRegistry] = None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def get_registry() -> PatternRegistry:
|
| 111 |
+
"""Get the global pattern registry, creating if needed."""
|
| 112 |
+
global _global_registry
|
| 113 |
+
if _global_registry is None:
|
| 114 |
+
_global_registry = PatternRegistry()
|
| 115 |
+
_load_all_patterns(_global_registry)
|
| 116 |
+
_global_registry.freeze()
|
| 117 |
+
return _global_registry
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _load_all_patterns(registry: PatternRegistry):
|
| 121 |
+
"""Load all patterns from pattern definition modules."""
|
| 122 |
+
from .conditions import CONDITION_PATTERNS
|
| 123 |
+
from .effects import EFFECT_PATTERNS
|
| 124 |
+
from .modifiers import MODIFIER_PATTERNS
|
| 125 |
+
from .triggers import TRIGGER_PATTERNS
|
| 126 |
+
|
| 127 |
+
registry.register_all(TRIGGER_PATTERNS)
|
| 128 |
+
registry.register_all(CONDITION_PATTERNS)
|
| 129 |
+
registry.register_all(EFFECT_PATTERNS)
|
| 130 |
+
registry.register_all(MODIFIER_PATTERNS)
|
compiler/patterns/triggers.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trigger detection patterns.
|
| 2 |
+
|
| 3 |
+
Triggers determine WHEN an ability activates:
|
| 4 |
+
- ON_PLAY: When member enters the stage
|
| 5 |
+
- ON_LIVE_START: When a live begins
|
| 6 |
+
- ON_LIVE_SUCCESS: When a live succeeds
|
| 7 |
+
- ACTIVATED: Manual activation
|
| 8 |
+
- CONSTANT: Always active
|
| 9 |
+
- etc.
|
| 10 |
+
|
| 11 |
+
Patterns are organized in tiers:
|
| 12 |
+
- Tier 1 (priority 10-19): Icon filenames (most reliable)
|
| 13 |
+
- Tier 2 (priority 20-29): Specific phrases
|
| 14 |
+
- Tier 3 (priority 30-39): Generic kanji keywords
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from .base import Pattern, PatternPhase
|
| 18 |
+
|
| 19 |
+
TRIGGER_PATTERNS = [
|
| 20 |
+
# ==========================================================================
|
| 21 |
+
# TIER 1: Icon-based triggers (highest priority)
|
| 22 |
+
# ==========================================================================
|
| 23 |
+
Pattern(
|
| 24 |
+
name="on_play_icon",
|
| 25 |
+
phase=PatternPhase.TRIGGER,
|
| 26 |
+
regex=r"toujyou",
|
| 27 |
+
priority=10,
|
| 28 |
+
output_type="TriggerType.ON_PLAY",
|
| 29 |
+
),
|
| 30 |
+
Pattern(
|
| 31 |
+
name="on_live_start_icon",
|
| 32 |
+
phase=PatternPhase.TRIGGER,
|
| 33 |
+
regex=r"live_start",
|
| 34 |
+
priority=10,
|
| 35 |
+
output_type="TriggerType.ON_LIVE_START",
|
| 36 |
+
),
|
| 37 |
+
Pattern(
|
| 38 |
+
name="on_live_success_icon",
|
| 39 |
+
phase=PatternPhase.TRIGGER,
|
| 40 |
+
regex=r"live_success",
|
| 41 |
+
priority=10,
|
| 42 |
+
# Skip if "この能力は...のみ発動する" (activation restriction, not trigger)
|
| 43 |
+
excludes=["この能力は"],
|
| 44 |
+
output_type="TriggerType.ON_LIVE_SUCCESS",
|
| 45 |
+
),
|
| 46 |
+
Pattern(
|
| 47 |
+
name="activated_icon",
|
| 48 |
+
phase=PatternPhase.TRIGGER,
|
| 49 |
+
regex=r"kidou",
|
| 50 |
+
priority=10,
|
| 51 |
+
output_type="TriggerType.ACTIVATED",
|
| 52 |
+
),
|
| 53 |
+
Pattern(
|
| 54 |
+
name="constant_icon",
|
| 55 |
+
phase=PatternPhase.TRIGGER,
|
| 56 |
+
regex=r"jyouji",
|
| 57 |
+
priority=10,
|
| 58 |
+
output_type="TriggerType.CONSTANT",
|
| 59 |
+
),
|
| 60 |
+
Pattern(
|
| 61 |
+
name="on_leaves_icon",
|
| 62 |
+
phase=PatternPhase.TRIGGER,
|
| 63 |
+
regex=r"jidou",
|
| 64 |
+
priority=10,
|
| 65 |
+
output_type="TriggerType.ON_LEAVES",
|
| 66 |
+
),
|
| 67 |
+
Pattern(
|
| 68 |
+
name="live_end_icon",
|
| 69 |
+
phase=PatternPhase.TRIGGER,
|
| 70 |
+
regex=r"live_end",
|
| 71 |
+
priority=10,
|
| 72 |
+
output_type="TriggerType.TURN_END",
|
| 73 |
+
),
|
| 74 |
+
# ==========================================================================
|
| 75 |
+
# TIER 2: Specific phrase triggers
|
| 76 |
+
# ==========================================================================
|
| 77 |
+
Pattern(
|
| 78 |
+
name="on_reveal_cheer",
|
| 79 |
+
phase=PatternPhase.TRIGGER,
|
| 80 |
+
regex=r"エールにより公開|エールで公開",
|
| 81 |
+
priority=20,
|
| 82 |
+
output_type="TriggerType.ON_REVEAL",
|
| 83 |
+
),
|
| 84 |
+
Pattern(
|
| 85 |
+
name="constant_yell_reveal",
|
| 86 |
+
phase=PatternPhase.TRIGGER,
|
| 87 |
+
keywords=["エールで出た"],
|
| 88 |
+
priority=20,
|
| 89 |
+
output_type="TriggerType.CONSTANT",
|
| 90 |
+
),
|
| 91 |
+
# ==========================================================================
|
| 92 |
+
# TIER 3: Kanji keyword triggers
|
| 93 |
+
# ==========================================================================
|
| 94 |
+
Pattern(
|
| 95 |
+
name="on_play_kanji",
|
| 96 |
+
phase=PatternPhase.TRIGGER,
|
| 97 |
+
regex=r"登場",
|
| 98 |
+
priority=30,
|
| 99 |
+
# Filter: Not when describing "has [Play] ability" etc
|
| 100 |
+
look_ahead_excludes=["能力", "スキル", "を持つ", "を持たない", "がない"],
|
| 101 |
+
output_type="TriggerType.ON_PLAY",
|
| 102 |
+
),
|
| 103 |
+
Pattern(
|
| 104 |
+
name="on_live_start_kanji",
|
| 105 |
+
phase=PatternPhase.TRIGGER,
|
| 106 |
+
regex=r"ライブ開始|ライブの開始",
|
| 107 |
+
priority=30,
|
| 108 |
+
look_ahead_excludes=["能力", "スキル", "を持つ"],
|
| 109 |
+
output_type="TriggerType.ON_LIVE_START",
|
| 110 |
+
),
|
| 111 |
+
Pattern(
|
| 112 |
+
name="on_live_success_kanji",
|
| 113 |
+
phase=PatternPhase.TRIGGER,
|
| 114 |
+
regex=r"ライブ成功",
|
| 115 |
+
priority=30,
|
| 116 |
+
look_ahead_excludes=["能力", "スキル", "を持つ"],
|
| 117 |
+
output_type="TriggerType.ON_LIVE_SUCCESS",
|
| 118 |
+
),
|
| 119 |
+
Pattern(
|
| 120 |
+
name="activated_kanji",
|
| 121 |
+
phase=PatternPhase.TRIGGER,
|
| 122 |
+
keywords=["起動"],
|
| 123 |
+
priority=30,
|
| 124 |
+
look_ahead_excludes=["能力", "スキル", "を持つ"],
|
| 125 |
+
output_type="TriggerType.ACTIVATED",
|
| 126 |
+
),
|
| 127 |
+
Pattern(
|
| 128 |
+
name="constant_kanji",
|
| 129 |
+
phase=PatternPhase.TRIGGER,
|
| 130 |
+
keywords=["常時"],
|
| 131 |
+
priority=30,
|
| 132 |
+
look_ahead_excludes=["能力", "スキル", "を持つ"],
|
| 133 |
+
output_type="TriggerType.CONSTANT",
|
| 134 |
+
),
|
| 135 |
+
Pattern(
|
| 136 |
+
name="on_leaves_kanji",
|
| 137 |
+
phase=PatternPhase.TRIGGER,
|
| 138 |
+
keywords=["自動"],
|
| 139 |
+
priority=30,
|
| 140 |
+
look_ahead_excludes=["能力", "スキル", "を持つ"],
|
| 141 |
+
output_type="TriggerType.ON_LEAVES",
|
| 142 |
+
),
|
| 143 |
+
Pattern(
|
| 144 |
+
name="turn_start",
|
| 145 |
+
phase=PatternPhase.TRIGGER,
|
| 146 |
+
keywords=["ターン開始"],
|
| 147 |
+
priority=30,
|
| 148 |
+
output_type="TriggerType.TURN_START",
|
| 149 |
+
),
|
| 150 |
+
Pattern(
|
| 151 |
+
name="turn_end_kanji",
|
| 152 |
+
phase=PatternPhase.TRIGGER,
|
| 153 |
+
regex=r"ターン終了|ライブ終了",
|
| 154 |
+
priority=30,
|
| 155 |
+
look_ahead_excludes=["まで"], # "Until end of turn" is duration, not trigger
|
| 156 |
+
output_type="TriggerType.TURN_END",
|
| 157 |
+
),
|
| 158 |
+
]
|
compiler/search_cards_improved.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def search_cards():
|
| 5 |
+
cards_path = "engine/data/cards.json"
|
| 6 |
+
with open(cards_path, "r", encoding="utf-8") as f:
|
| 7 |
+
cards = json.load(f)
|
| 8 |
+
|
| 9 |
+
print("Searching for Life Lead patterns...")
|
| 10 |
+
for cid, card in cards.items():
|
| 11 |
+
text = card.get("ability", "")
|
| 12 |
+
if "ライフ" in text or "Life" in text:
|
| 13 |
+
print(f"[{card.get('card_no')}] {text[:50]}...")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
if __name__ == "__main__":
|
| 17 |
+
search_cards()
|
compiler/tests/debug_clean.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from compiler.parser_v2 import AbilityParserV2
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def run_debug():
|
| 5 |
+
with open("debug_output.txt", "w", encoding="utf-8") as f:
|
| 6 |
+
parser = AbilityParserV2()
|
| 7 |
+
text = "自分の成功ライブカード置き場にあるカードを1枚手札に加える。"
|
| 8 |
+
|
| 9 |
+
f.write(f"Input text repr: {repr(text)}\n")
|
| 10 |
+
|
| 11 |
+
# Manually invoke extraction to test registry
|
| 12 |
+
results = parser.registry.match_all(
|
| 13 |
+
text,
|
| 14 |
+
parser.registry.get_patterns(parser.registry._patterns[list(parser.registry._patterns.keys())[2]].phase)[
|
| 15 |
+
0
|
| 16 |
+
].phase,
|
| 17 |
+
)
|
| 18 |
+
# Accessing PatternPhase.EFFECT safely
|
| 19 |
+
|
| 20 |
+
from compiler.patterns.base import PatternPhase
|
| 21 |
+
|
| 22 |
+
results = parser.registry.match_all(text, PatternPhase.EFFECT)
|
| 23 |
+
|
| 24 |
+
f.write(f"Matches found: {len(results)}\n")
|
| 25 |
+
for p, m, d in results:
|
| 26 |
+
f.write(f"Matched: {p.name}\n")
|
| 27 |
+
|
| 28 |
+
# Check specific pattern
|
| 29 |
+
patterns = parser.registry.get_patterns(PatternPhase.EFFECT)
|
| 30 |
+
target = next((p for p in patterns if p.name == "recover_from_success"), None)
|
| 31 |
+
if target:
|
| 32 |
+
f.write(f"Target pattern found: {target.name}\n")
|
| 33 |
+
f.write(f"Target keywords: {target.keywords}\n")
|
| 34 |
+
f.write(f"Target regex: {target.regex}\n")
|
| 35 |
+
m = target.matches(text)
|
| 36 |
+
f.write(f"Target match result: {m}\n")
|
| 37 |
+
else:
|
| 38 |
+
f.write("Target pattern NOT found in registry.\n")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
if __name__ == "__main__":
|
| 42 |
+
run_debug()
|
compiler/tests/debug_sd1_parsing.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from compiler.parser_v2 import AbilityParserV2
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def debug_sd1_006():
|
| 5 |
+
parser = AbilityParserV2()
|
| 6 |
+
text = "{{toujyou.png|登場}}手札のライブカードを1枚公開してもよい:自分の成功ライブカード置き場にあるカードを1枚手札に加える。そうした場合、これにより公開したカードを自分の成功ライブカード置き場に置く。"
|
| 7 |
+
|
| 8 |
+
print(f"Parsing: {text}")
|
| 9 |
+
print("-" * 50)
|
| 10 |
+
|
| 11 |
+
# Manually split to see what parser_v2 sees
|
| 12 |
+
sentences = parser._split_sentences(parser._preprocess(text))
|
| 13 |
+
print(f"Sentences: {sentences}")
|
| 14 |
+
|
| 15 |
+
parsed = parser.parse(text)
|
| 16 |
+
|
| 17 |
+
print("\nParsed Abilities:")
|
| 18 |
+
for i, ab in enumerate(parsed):
|
| 19 |
+
print(f"Ability {i}:")
|
| 20 |
+
print(f" Raw: {ab.raw_text}")
|
| 21 |
+
print(f" Trigger: {ab.trigger}")
|
| 22 |
+
print(f" Effects: {len(ab.effects)}")
|
| 23 |
+
for eff in ab.effects:
|
| 24 |
+
print(f" - Type: {eff.effect_type}")
|
| 25 |
+
print(f" Val: {eff.value}")
|
| 26 |
+
print(f" Params: {eff.params}")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
debug_sd1_006()
|
compiler/tests/reproduce_bp2_008_p.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from compiler.parser_v2 import AbilityParserV2
|
| 2 |
+
from engine.models.ability import EffectType, TriggerType
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def test_bp2_008_p_repro_v2():
|
| 6 |
+
text = "{{kidou.png|起動}}{{turn1.png|ターン1回}}{{icon_energy.png|E}}:このメンバーがいるエリアとは別の自分のエリア1つを選ぶ。このメンバーをそのエリアに移動する。選んだエリアにメンバーがいる場合、そのメンバーは、このメンバーがいたエリアに移動させる。"
|
| 7 |
+
parser = AbilityParserV2()
|
| 8 |
+
abilities = parser.parse(text)
|
| 9 |
+
|
| 10 |
+
print(f"Parsed {len(abilities)} abilities with V2:")
|
| 11 |
+
for i, abi in enumerate(abilities):
|
| 12 |
+
print(f" Ability {i}: Trigger={abi.trigger.name}, Text='{abi.raw_text}'")
|
| 13 |
+
for j, eff in enumerate(abi.effects):
|
| 14 |
+
print(f" Effect {j}: {eff.effect_type.name}")
|
| 15 |
+
|
| 16 |
+
# The goal is to have only 1 ability (ACTIVATED)
|
| 17 |
+
assert len(abilities) == 1, f"Expected 1 ability, got {len(abilities)}"
|
| 18 |
+
assert abilities[0].trigger == TriggerType.ACTIVATED
|
| 19 |
+
|
| 20 |
+
# It should have the MOVE_MEMBER effects
|
| 21 |
+
has_move = any(e.effect_type == EffectType.MOVE_MEMBER for e in abilities[0].effects)
|
| 22 |
+
assert has_move, "Should have MOVE_MEMBER effect"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == "__main__":
|
| 26 |
+
try:
|
| 27 |
+
test_bp2_008_p_repro_v2()
|
| 28 |
+
print("Test PASSED!")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
print(f"Test FAILED: {e}")
|
compiler/tests/reproduce_failures.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
# Add project root to path
|
| 7 |
+
if __name__ == "__main__":
|
| 8 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
|
| 9 |
+
|
| 10 |
+
from compiler.parser_v2 import AbilityParserV2
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class ExpectedAbility:
|
| 15 |
+
trigger: str
|
| 16 |
+
effects: List[str] # Just types for simple check, or dicts for detailed
|
| 17 |
+
costs: List[str] = None
|
| 18 |
+
conditions: List[str] = None
|
| 19 |
+
effect_values: List[int] = None # Optional: check values of effects in order
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def check_parser_strict(text: str, expected: ExpectedAbility, name: str = "") -> bool:
|
| 23 |
+
parser = AbilityParserV2()
|
| 24 |
+
print(f"Test: {name}")
|
| 25 |
+
try:
|
| 26 |
+
abilities = parser.parse(text)
|
| 27 |
+
if not abilities:
|
| 28 |
+
print(" FAILURE: No abilities parsed")
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
ab = abilities[0] # Assuming single ability
|
| 32 |
+
|
| 33 |
+
# Verify Trigger
|
| 34 |
+
if ab.trigger.name != expected.trigger:
|
| 35 |
+
print(f" FAILURE: Trigger mismatch. Expected {expected.trigger}, got {ab.trigger.name}")
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
# Verify Effects
|
| 39 |
+
actual_effects = [e.effect_type.name for e in ab.effects]
|
| 40 |
+
missing_effects = [e for e in expected.effects if e not in actual_effects]
|
| 41 |
+
if missing_effects:
|
| 42 |
+
print(f" FAILURE: Missing effects. Expected {expected.effects}, got {actual_effects}")
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
# Verify Effect Values
|
| 46 |
+
if expected.effect_values:
|
| 47 |
+
actual_values = [e.value for e in ab.effects]
|
| 48 |
+
# Check length first
|
| 49 |
+
if len(actual_values) < len(expected.effect_values):
|
| 50 |
+
print(
|
| 51 |
+
f" FAILURE: Effect value count mismatch. Expected {len(expected.effect_values)}, got {len(actual_values)}"
|
| 52 |
+
)
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
for i, val in enumerate(expected.effect_values):
|
| 56 |
+
if actual_values[i] != val:
|
| 57 |
+
print(f" FAILURE: Effect value mismatch at index {i}. Expected {val}, got {actual_values[i]}")
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
# Verify Costs
|
| 61 |
+
if expected.costs:
|
| 62 |
+
actual_costs = [c.type.name for c in ab.costs]
|
| 63 |
+
missing_costs = [c for c in expected.costs if c not in actual_costs]
|
| 64 |
+
if missing_costs:
|
| 65 |
+
print(f" FAILURE: Missing costs. Expected {expected.costs}, got {actual_costs}")
|
| 66 |
+
return False
|
| 67 |
+
|
| 68 |
+
print(" SUCCESS")
|
| 69 |
+
return True
|
| 70 |
+
|
| 71 |
+
except Exception as e:
|
| 72 |
+
import traceback
|
| 73 |
+
|
| 74 |
+
traceback.print_exc()
|
| 75 |
+
print(f" ERROR: {e}")
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def run_tests():
|
| 80 |
+
tests = [
|
| 81 |
+
(
|
| 82 |
+
"Mill Effect (PL!-sd1-007-SD)",
|
| 83 |
+
"{{toujyou.png|登場}}自分のデッキの上からカードを5枚控え室に置く。",
|
| 84 |
+
ExpectedAbility(
|
| 85 |
+
trigger="ON_PLAY",
|
| 86 |
+
effects=["SWAP_CARDS"],
|
| 87 |
+
effect_values=[5], # Should be 5
|
| 88 |
+
),
|
| 89 |
+
),
|
| 90 |
+
(
|
| 91 |
+
"Reveal Cost (PL!-sd1-006-SD)",
|
| 92 |
+
"{{toujyou.png|登場}}手札のライブカードを1枚公開してもよい:自分の成功ライブカード置き場にあるカードを1枚手札に加える。",
|
| 93 |
+
ExpectedAbility(trigger="ON_PLAY", effects=["RECOVER_LIVE"], costs=["REVEAL_HAND"]),
|
| 94 |
+
),
|
| 95 |
+
(
|
| 96 |
+
"Optional Cost (PL!-sd1-003-SD)",
|
| 97 |
+
"{{live_start.png|ライブ開始時}}手札を1枚控え室に置いてもよい:{{heart_01.png|heart01}}か{{heart_03.png|heart03}}か{{heart_06.png|heart06}}のうち、1つを選ぶ。",
|
| 98 |
+
ExpectedAbility(trigger="ON_LIVE_START", effects=["SELECT_MODE"], costs=["DISCARD_HAND"]),
|
| 99 |
+
),
|
| 100 |
+
(
|
| 101 |
+
"Swap 10 Cards (PL!-sd1-008-SD)",
|
| 102 |
+
"{{kidou.png|起動}}{{turn1.png|ターン1回}}{{icon_energy.png|E}}{{icon_energy.png|E}}:自分のデッキの上からカードを10枚控え室に置く。",
|
| 103 |
+
ExpectedAbility(trigger="ACTIVATED", effects=["SWAP_CARDS"], costs=["ENERGY"], effect_values=[10]),
|
| 104 |
+
),
|
| 105 |
+
(
|
| 106 |
+
"Search Deck (Generic)",
|
| 107 |
+
"{{toujyou.png|登場}}自分のデッキから『μ's』のメンバーカードを1枚まで手札に加える。デッキをシャッフルする。",
|
| 108 |
+
ExpectedAbility(
|
| 109 |
+
trigger="ON_PLAY",
|
| 110 |
+
effects=["SEARCH_DECK", "META_RULE"],
|
| 111 |
+
),
|
| 112 |
+
),
|
| 113 |
+
(
|
| 114 |
+
"Score Boost",
|
| 115 |
+
"{{jyouji.png|常時}}ライブの合計スコアを+1する。",
|
| 116 |
+
ExpectedAbility(trigger="CONSTANT", effects=["BOOST_SCORE"], effect_values=[1]),
|
| 117 |
+
),
|
| 118 |
+
(
|
| 119 |
+
"Add Blades",
|
| 120 |
+
"{{live_start.png|ライブ開始時}}{{icon_blade.png|ブレード}}を1つ得る。",
|
| 121 |
+
ExpectedAbility(trigger="ON_LIVE_START", effects=["ADD_BLADES"], effect_values=[1]),
|
| 122 |
+
),
|
| 123 |
+
(
|
| 124 |
+
"Recover Member",
|
| 125 |
+
"{{toujyou.png|登場}}自分の控え室からメンバーカードを1枚まで手札に加える。",
|
| 126 |
+
ExpectedAbility(trigger="ON_PLAY", effects=["RECOVER_MEMBER"], effect_values=[1]),
|
| 127 |
+
),
|
| 128 |
+
(
|
| 129 |
+
"Recover Live",
|
| 130 |
+
"{{toujyou.png|登場}}自分の控え室からライブカードを1枚まで手札に加える。",
|
| 131 |
+
ExpectedAbility(trigger="ON_PLAY", effects=["RECOVER_LIVE"], effect_values=[1]),
|
| 132 |
+
),
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
print(f"Running {len(tests)} parser tests...")
|
| 136 |
+
print("=" * 60)
|
| 137 |
+
|
| 138 |
+
passed = 0
|
| 139 |
+
failed = 0
|
| 140 |
+
|
| 141 |
+
for name, text, expected in tests:
|
| 142 |
+
if check_parser_strict(text, expected, name):
|
| 143 |
+
passed += 1
|
| 144 |
+
else:
|
| 145 |
+
failed += 1
|
| 146 |
+
|
| 147 |
+
print("=" * 60)
|
| 148 |
+
print(f"Passed: {passed}")
|
| 149 |
+
print(f"Failed: {failed}")
|
| 150 |
+
|
| 151 |
+
if failed > 0:
|
| 152 |
+
sys.exit(1)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
run_tests()
|
compiler/tests/reproduce_sd1_006.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from compiler.parser_v2 import AbilityParserV2
|
| 2 |
+
from engine.models.ability import EffectType
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def test_sd1_006():
|
| 6 |
+
parser = AbilityParserV2()
|
| 7 |
+
text = "{{toujyou.png|登場}}手札のライブカードを1枚公開してもよい:自分の成功ライブカード置き場にあるカードを1枚手札に加える。そうした場合、これにより公開したカードを自分の成功ライブカード置き場に置く。"
|
| 8 |
+
|
| 9 |
+
print(f"Parsing: {text}")
|
| 10 |
+
print("-" * 50)
|
| 11 |
+
|
| 12 |
+
parsed = parser.parse(text)
|
| 13 |
+
|
| 14 |
+
print("\nParsed Effects:")
|
| 15 |
+
for ability in parsed:
|
| 16 |
+
for effect in ability.effects:
|
| 17 |
+
print(f"Effect: {effect.effect_type}")
|
| 18 |
+
print(f"Params: {effect.params}")
|
| 19 |
+
print("-" * 30)
|
| 20 |
+
|
| 21 |
+
# Check if RECOVER_LIVE is present
|
| 22 |
+
has_recover = any(e.effect_type == EffectType.RECOVER_LIVE for ab in parsed for e in ab.effects)
|
| 23 |
+
if has_recover:
|
| 24 |
+
print("\nSUCCESS: RECOVER_LIVE found.")
|
| 25 |
+
else:
|
| 26 |
+
print("\nFAILURE: RECOVER_LIVE not found.")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
test_sd1_006()
|
compiler/tests/test_card_parsing.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from compiler.parser import AbilityParser
|
| 2 |
+
from engine.models.ability import (
|
| 3 |
+
AbilityCostType,
|
| 4 |
+
ConditionType,
|
| 5 |
+
EffectType,
|
| 6 |
+
TriggerType,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_nico_dual_trigger():
|
| 11 |
+
"""Test PL!-PR-009-PR: Dual trigger ON_PLAY / ON_LIVE_START"""
|
| 12 |
+
text = "{{toujyou.png|登場}}/{{live_start.png|ライブ開始時}}このメンバーをウェイトにしてもよい:相手のステージにいるコスト4以下のメンバー1人をウェイトにする。"
|
| 13 |
+
abilities = AbilityParser.parse_ability_text(text)
|
| 14 |
+
|
| 15 |
+
assert len(abilities) == 2, "Should parse into 2 abilities"
|
| 16 |
+
assert abilities[0].trigger == TriggerType.ON_PLAY
|
| 17 |
+
assert abilities[1].trigger == TriggerType.ON_LIVE_START
|
| 18 |
+
|
| 19 |
+
for abi in abilities:
|
| 20 |
+
assert len(abi.costs) == 1
|
| 21 |
+
assert abi.costs[0].type == AbilityCostType.TAP_SELF
|
| 22 |
+
assert len(abi.effects) == 1
|
| 23 |
+
assert abi.effects[0].effect_type == EffectType.TAP_OPPONENT
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_kanata_reveal_hand():
|
| 27 |
+
"""Test PL!N-PR-008-PR: Reveal hand cost + condition"""
|
| 28 |
+
text = "{{kidou.png|起動}}{{turn1.png|ターン1回}}手札をすべて公開する:自分のステージにほかのメンバーがおり、かつこれにより公開した手札の中にライブカードがない場合、自分のデッキの上からカードを5枚見る。その中からライブカードを1枚公開して手札に加えてもよい。残りを控え室に置く。"
|
| 29 |
+
abilities = AbilityParser.parse_ability_text(text)
|
| 30 |
+
|
| 31 |
+
assert len(abilities) == 1
|
| 32 |
+
abi = abilities[0]
|
| 33 |
+
|
| 34 |
+
# Check Cost
|
| 35 |
+
assert any(c.type == AbilityCostType.REVEAL_HAND_ALL for c in abi.costs), "Should have REVEAL_HAND_ALL cost"
|
| 36 |
+
|
| 37 |
+
# Check Condition
|
| 38 |
+
assert any(c.type == ConditionType.HAND_HAS_NO_LIVE for c in abi.conditions), (
|
| 39 |
+
"Should have HAND_HAS_NO_LIVE condition"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Check Effect
|
| 43 |
+
assert abi.effects[0].effect_type == EffectType.LOOK_DECK
|
| 44 |
+
assert abi.effects[0].value == 5
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_ginko_parsing():
|
| 48 |
+
"""Test PL!N-PR-012-PR: Look 5, Choose 1 Member"""
|
| 49 |
+
text = "【登場時】手札を1枚捨ててもよい:そうしたら、デッキの上から5枚見る。その中からメンバーを1枚まで公開し、手札に加える。残りを山札の一番下に望む順番で置く。"
|
| 50 |
+
abilities = AbilityParser.parse_ability_text(text)
|
| 51 |
+
assert len(abilities) == 1
|
| 52 |
+
abi = abilities[0]
|
| 53 |
+
|
| 54 |
+
# Check Effects chain
|
| 55 |
+
# 1. Look Deck 5
|
| 56 |
+
assert abi.effects[0].effect_type == EffectType.LOOK_DECK
|
| 57 |
+
assert abi.effects[0].value == 5
|
| 58 |
+
# 2. Look and Choose (Member)
|
| 59 |
+
assert abi.effects[1].effect_type == EffectType.LOOK_AND_CHOOSE
|
| 60 |
+
assert abi.effects[1].params.get("filter") == "member", "Should capture member filter"
|
| 61 |
+
# 3. Order Deck (Remainder to bottom)
|
| 62 |
+
assert any(e.effect_type in (EffectType.ORDER_DECK, EffectType.MOVE_TO_DECK) for e in abi.effects), (
|
| 63 |
+
"Should handle remainder"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_honoka_parsing():
|
| 68 |
+
"""Test PL!-sd1-001-SD: Count Success Live Condition & Recover Live Effect"""
|
| 69 |
+
text = "【登場時】自分の成功ライブカード置き場にカードが2枚以上ある場合、自分の控え室からライブカードを1枚手札に加える。"
|
| 70 |
+
abilities = AbilityParser.parse_ability_text(text)
|
| 71 |
+
print(f"DEBUG: Honoka abilities: {abilities}")
|
| 72 |
+
assert len(abilities) == 1
|
| 73 |
+
abi = abilities[0]
|
| 74 |
+
|
| 75 |
+
# Verify Trigger
|
| 76 |
+
assert abi.trigger == TriggerType.ON_PLAY
|
| 77 |
+
|
| 78 |
+
# Verify Condition
|
| 79 |
+
assert len(abi.conditions) == 1, f"Expected 1 condition, found {len(abi.conditions)}: {abi.conditions}"
|
| 80 |
+
assert abi.conditions[0].type == ConditionType.COUNT_SUCCESS_LIVE
|
| 81 |
+
assert abi.conditions[0].params.get("min") == 2
|
| 82 |
+
|
| 83 |
+
# Verify Effect
|
| 84 |
+
assert len(abi.effects) == 1, f"Expected 1 effect, found {len(abi.effects)}: {abi.effects}"
|
| 85 |
+
assert abi.effects[0].effect_type == EffectType.RECOVER_LIVE
|
compiler/tests/test_pseudocode_parsing.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from compiler.parser_v2 import AbilityParserV2
|
| 4 |
+
from engine.models.ability import AbilityCostType, ConditionType, EffectType, TargetType, TriggerType
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@pytest.fixture
|
| 8 |
+
def parser():
|
| 9 |
+
return AbilityParserV2()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_basic_trigger(parser):
|
| 13 |
+
text = "TRIGGER: ON_PLAY\nEFFECT: DRAW(1) -> PLAYER"
|
| 14 |
+
abilities = parser.parse(text)
|
| 15 |
+
assert len(abilities) == 1
|
| 16 |
+
assert abilities[0].trigger == TriggerType.ON_PLAY
|
| 17 |
+
assert abilities[0].effects[0].effect_type == EffectType.DRAW
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_once_per_turn(parser):
|
| 21 |
+
text = "TRIGGER: ACTIVATED\n(Once per turn)\nEFFECT: DRAW(1) -> PLAYER"
|
| 22 |
+
abilities = parser.parse(text)
|
| 23 |
+
assert abilities[0].is_once_per_turn == True
|
| 24 |
+
|
| 25 |
+
# Alternative format (same line)
|
| 26 |
+
text2 = "TRIGGER: ACTIVATED (Once per turn)\nEFFECT: DRAW(1) -> PLAYER"
|
| 27 |
+
abilities2 = parser.parse(text2)
|
| 28 |
+
assert abilities2[0].is_once_per_turn == True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_costs(parser):
|
| 32 |
+
text = "TRIGGER: ACTIVATED\nCOST: ENERGY(2), TAP_SELF(0) (Optional)\nEFFECT: DRAW(1) -> PLAYER"
|
| 33 |
+
abilities = parser.parse(text)
|
| 34 |
+
costs = abilities[0].costs
|
| 35 |
+
assert len(costs) == 2
|
| 36 |
+
assert costs[0].type == AbilityCostType.ENERGY
|
| 37 |
+
assert costs[0].value == 2
|
| 38 |
+
assert costs[1].type == AbilityCostType.TAP_SELF
|
| 39 |
+
assert costs[1].is_optional == True
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_conditions(parser):
|
| 43 |
+
text = "TRIGGER: ON_PLAY\nCONDITION: COUNT_STAGE {MIN=3}, NOT HAS_COLOR {COLOR=1}\nEFFECT: DRAW(1) -> PLAYER"
|
| 44 |
+
abilities = parser.parse(text)
|
| 45 |
+
conds = abilities[0].conditions
|
| 46 |
+
assert len(conds) == 2
|
| 47 |
+
assert conds[0].type == ConditionType.COUNT_STAGE
|
| 48 |
+
assert conds[0].params["min"] == 3
|
| 49 |
+
assert conds[1].type == ConditionType.HAS_COLOR
|
| 50 |
+
assert conds[1].params["color"] == 1
|
| 51 |
+
assert conds[1].is_negated == True
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_effects_with_params(parser):
|
| 55 |
+
text = 'TRIGGER: ON_PLAY\nEFFECT: RECOVER_MEMBER(1) -> CARD_DISCARD {GROUP="μ\'s", FROM=DISCARD, TO=HAND}'
|
| 56 |
+
abilities = parser.parse(text)
|
| 57 |
+
eff = abilities[0].effects[0]
|
| 58 |
+
assert eff.effect_type == EffectType.RECOVER_MEMBER
|
| 59 |
+
assert eff.target == TargetType.CARD_DISCARD
|
| 60 |
+
assert eff.params["group"] == "μ's"
|
| 61 |
+
assert eff.params["from"] == "discard"
|
| 62 |
+
assert eff.params["to"] == "hand"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_case_normalization(parser):
|
| 66 |
+
# Enums should be case-insensitive in parameters where it makes sense
|
| 67 |
+
text = "TRIGGER: ON_LIVE_START\nEFFECT: BOOST_SCORE(3) -> PLAYER {UNTIL=LIVE_END}"
|
| 68 |
+
abilities = parser.parse(text)
|
| 69 |
+
assert abilities[0].effects[0].params["until"] == "live_end"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_embedded_json_params(parser):
|
| 73 |
+
# Some legacy/complex params might be stored as raw JSON in pseudocode
|
| 74 |
+
text = 'TRIGGER: ON_PLAY\nEFFECT: LOOK_AND_CHOOSE(1) -> CARD_DISCARD {GROUP="Liella!", {"look_count": 5}}'
|
| 75 |
+
abilities = parser.parse(text)
|
| 76 |
+
params = abilities[0].effects[0].params
|
| 77 |
+
assert params["group"] == "Liella!"
|
| 78 |
+
assert params["look_count"] == 5
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_multiple_abilities(parser):
|
| 82 |
+
text = """TRIGGER: ON_PLAY
|
| 83 |
+
EFFECT: DRAW(1) -> PLAYER
|
| 84 |
+
|
| 85 |
+
TRIGGER: TURN_START
|
| 86 |
+
EFFECT: ADD_BLADES(1) -> PLAYER"""
|
| 87 |
+
abilities = parser.parse(text)
|
| 88 |
+
assert len(abilities) == 2
|
| 89 |
+
assert abilities[0].trigger == TriggerType.ON_PLAY
|
| 90 |
+
assert abilities[1].trigger == TriggerType.TURN_START
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_numeric_parsing(parser):
|
| 94 |
+
# Ensure values and params handle numbers correctly
|
| 95 |
+
text = "TRIGGER: ACTIVATED\nCOST: DISCARD_HAND(3)\nCONDITION: COUNT_ENERGY {MIN=5}\nEFFECT: ADD_HEARTS(2) -> PLAYER"
|
| 96 |
+
abilities = parser.parse(text)
|
| 97 |
+
ab = abilities[0]
|
| 98 |
+
assert ab.costs[0].value == 3
|
| 99 |
+
assert ab.conditions[0].params["min"] == 5
|
| 100 |
+
assert ab.effects[0].value == 2
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_optional_effect(parser):
|
| 104 |
+
text = "TRIGGER: ON_PLAY\nEFFECT: SEARCH_DECK(1) -> CARD_HAND {FROM=DECK} (Optional)"
|
| 105 |
+
abilities = parser.parse(text)
|
| 106 |
+
assert abilities[0].effects[0].is_optional == True
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_robust_param_splitting(parser):
|
| 110 |
+
# Mixed spaces and commas
|
| 111 |
+
text = (
|
| 112 |
+
'TRIGGER: ON_PLAY\nEFFECT: RECOVER_LIVE(1) -> CARD_DISCARD {GROUP="BiBi",GROUPS=["BiBi","BiBi"], FROM=DISCARD }'
|
| 113 |
+
)
|
| 114 |
+
abilities = parser.parse(text)
|
| 115 |
+
params = abilities[0].effects[0].params
|
| 116 |
+
assert params["group"] == "BiBi"
|
| 117 |
+
assert params["groups"] == ["BiBi", "BiBi"]
|
| 118 |
+
assert params["from"] == "discard"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_bytecode_compilation(parser):
|
| 122 |
+
# This is the ultimate proof that the code "works" in the engine
|
| 123 |
+
text = """TRIGGER: ON_LIVE_START
|
| 124 |
+
COST: DISCARD_HAND(3) {NAMES=["上原歩夢", "澁谷かのん", "日野下花帆"]} (Optional)
|
| 125 |
+
EFFECT: BOOST_SCORE(3) -> PLAYER {UNTIL=LIVE_END}"""
|
| 126 |
+
|
| 127 |
+
abilities = parser.parse(text)
|
| 128 |
+
ab = abilities[0]
|
| 129 |
+
|
| 130 |
+
# Compile to bytecode
|
| 131 |
+
bytecode = ab.compile()
|
| 132 |
+
|
| 133 |
+
# Bytecode should:
|
| 134 |
+
# 1. Be a list of integers
|
| 135 |
+
# 2. Have length that is multiple of 4
|
| 136 |
+
# 3. End with the RETURN opcode (usually 0 in some engines, but let's check length)
|
| 137 |
+
assert isinstance(bytecode, list)
|
| 138 |
+
assert len(bytecode) > 0
|
| 139 |
+
assert len(bytecode) % 4 == 0
|
| 140 |
+
|
| 141 |
+
print(f"Compiled Bytecode: {bytecode}")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_complex_condition_compilation(parser):
|
| 145 |
+
text = 'TRIGGER: ON_PLAY\nCONDITION: COUNT_GROUP {GROUP="μ\'s", MIN=2}\nEFFECT: DRAW(1) -> PLAYER'
|
| 146 |
+
abilities = parser.parse(text)
|
| 147 |
+
ab = abilities[0]
|
| 148 |
+
bytecode = ab.compile()
|
| 149 |
+
|
| 150 |
+
assert len(bytecode) % 4 == 0
|
| 151 |
+
# First chunk should be a check for group count
|
| 152 |
+
# Opcode.CHECK_COUNT_GROUP is 1000 + enum value or similar
|
| 153 |
+
assert bytecode[0] > 0
|
compiler/tests/test_regex_direct.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
text = "自分の成功ライブカード置き場にあるカードを1枚手札に加える"
|
| 4 |
+
regex = r"成功ライブカード.*?手札に加"
|
| 5 |
+
|
| 6 |
+
match = re.search(regex, text)
|
| 7 |
+
print(f"Text: {text}")
|
| 8 |
+
print(f"Regex: {regex}")
|
| 9 |
+
print(f"Match: {match}")
|
| 10 |
+
if match:
|
| 11 |
+
print(f"Matched: {match.group(0)}")
|
compiler/tests/test_robustness.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from compiler.parser import AbilityParser
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_debug_parser():
|
| 5 |
+
parser = AbilityParser()
|
| 6 |
+
|
| 7 |
+
cases = [
|
| 8 |
+
("Slash", "{{toujyou.png|登場}}/{{live_start.png|ライブ開始時}} カードを1枚引く。"),
|
| 9 |
+
("Parens", "{{toujyou.png|登場}} カードを1枚引く。(これは説明文です。)"),
|
| 10 |
+
("Modal-", "以下から1回を選ぶ。\\n- カードを1枚引く。\\n- スコア+1。"),
|
| 11 |
+
("Choose2", "以下から2つを選ぶ。\\n・カードを1枚引く。\\n・スコア+1。\\n・エネチャージ。"),
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
# Just verify that parsing these strings produces valid non-empty ability lists
|
| 15 |
+
# without crashing.
|
| 16 |
+
for name, text in cases:
|
| 17 |
+
print(f"Testing case: {name}")
|
| 18 |
+
abs_list = parser.parse_ability_text(text)
|
| 19 |
+
assert len(abs_list) > 0, f"Failed to parse {name}"
|
| 20 |
+
|
| 21 |
+
# Additional sanity checks depending on expected logic
|
| 22 |
+
for a in abs_list:
|
| 23 |
+
assert a.trigger is not None, f"Trigger is None for {name}"
|
compiler/tests/verify_parser_fixes.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
# Add project root to sys.path
|
| 5 |
+
sys.path.append(os.getcwd())
|
| 6 |
+
|
| 7 |
+
from compiler.parser_v2 import AbilityParserV2
|
| 8 |
+
from engine.models.ability import TriggerType, EffectType, TargetType, ConditionType
|
| 9 |
+
|
| 10 |
+
def test_negation():
|
| 11 |
+
parser = AbilityParserV2()
|
| 12 |
+
text = "TRIGGER: ON_PLAY\nCONDITION: !MOVED_THIS_TURN\nEFFECT: DRAW(1)"
|
| 13 |
+
abilities = parser.parse(text)
|
| 14 |
+
ability = abilities[0]
|
| 15 |
+
condition = ability.conditions[0]
|
| 16 |
+
print(f"Condition: {condition.type.name}, Negated: {condition.is_negated}")
|
| 17 |
+
assert condition.is_negated == True
|
| 18 |
+
|
| 19 |
+
def test_variable_targeting():
|
| 20 |
+
parser = AbilityParserV2()
|
| 21 |
+
# Sequence: Look Deck to Hand -> Draw for Target
|
| 22 |
+
text = "TRIGGER: ON_PLAY\nEFFECT: LOOK_DECK(3) -> CARD_HAND; DRAW(1) -> TARGET"
|
| 23 |
+
abilities = parser.parse(text)
|
| 24 |
+
effects = abilities[0].effects
|
| 25 |
+
print(f"Effect 0 Target: {effects[0].target.name}")
|
| 26 |
+
print(f"Effect 1 Target: {effects[1].target.name}")
|
| 27 |
+
assert effects[1].target == TargetType.CARD_HAND
|
| 28 |
+
|
| 29 |
+
def test_aliases():
|
| 30 |
+
parser = AbilityParserV2()
|
| 31 |
+
text = "TRIGGER: ON_YELL_SUCCESS\nEFFECT: CHARGE_SELF(1)"
|
| 32 |
+
abilities = parser.parse(text)
|
| 33 |
+
ability = abilities[0]
|
| 34 |
+
print(f"Trigger: {ability.trigger.name}")
|
| 35 |
+
print(f"Effect: {ability.effects[0].effect_type.name}, Target: {ability.effects[0].target.name}")
|
| 36 |
+
assert ability.trigger == TriggerType.ON_REVEAL
|
| 37 |
+
assert ability.effects[0].effect_type == EffectType.ENERGY_CHARGE
|
| 38 |
+
assert ability.effects[0].target == TargetType.MEMBER_SELF
|
| 39 |
+
|
| 40 |
+
if __name__ == "__main__":
|
| 41 |
+
try:
|
| 42 |
+
test_negation()
|
| 43 |
+
test_variable_targeting()
|
| 44 |
+
test_aliases()
|
| 45 |
+
print("All tests passed!")
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print(f"Test failed: {e}")
|
| 48 |
+
import traceback
|
| 49 |
+
traceback.print_exc()
|