from dataclasses import dataclass, field from enum import IntEnum from typing import Any, Dict, List from engine.models.opcodes import Opcode class TriggerType(IntEnum): NONE = 0 ON_PLAY = 1 # 登場時 ON_LIVE_START = 2 # ライブ開始時 ON_LIVE_SUCCESS = 3 # ライブ成功時 TURN_START = 4 TURN_END = 5 CONSTANT = 6 # 常時 ACTIVATED = 7 # 起動 ON_LEAVES = 8 # 自動 - when member leaves stage/is discarded ON_REVEAL = 9 # エールにより公開、公開されたとき ON_POSITION_CHANGE = 10 # エリアを移動するたび ON_ACTIVATE = 7 # Alias for ACTIVATED class TargetType(IntEnum): SELF = 0 PLAYER = 1 OPPONENT = 2 ALL_PLAYERS = 3 MEMBER_SELF = 4 MEMBER_OTHER = 5 CARD_HAND = 6 CARD_DISCARD = 7 CARD_DECK_TOP = 8 OPPONENT_HAND = 9 # 相手の手札 MEMBER_SELECT = 10 # Select manual target MEMBER_NAMED = 11 # Specific named member implementation OPPONENT_MEMBER = 12 # Specific opponent member target PLAYER_SELECT = 13 # 自分か相手を選ぶ class EffectType(IntEnum): DRAW = 0 ADD_BLADES = 1 ADD_HEARTS = 2 REDUCE_COST = 3 LOOK_DECK = 4 RECOVER_LIVE = 5 # Recover Live from discard BOOST_SCORE = 6 RECOVER_MEMBER = 7 # Recover Member from discard BUFF_POWER = 8 # Generic power/heart buff IMMUNITY = 9 # Cannot be targeted/chosen MOVE_MEMBER = 10 # Move member to different area SWAP_CARDS = 11 # Swap cards between zones SEARCH_DECK = 12 # Search deck for specific card ENERGY_CHARGE = 13 # Add cards to energy zone SET_BLADES = 31 # Layer 4: Set blades to fixed value SET_HEARTS = 32 # Layer 4: Set hearts to fixed value FORMATION_CHANGE = 33 # Rule 11.10: Rearrange all members NEGATE_EFFECT = 14 # Cancel/negate an effect ORDER_DECK = 15 # Reorder cards in deck META_RULE = 16 # Rule clarification text (no effect) SELECT_MODE = 17 # Choose one of the following effects MOVE_TO_DECK = 18 # Move card to top/bottom of deck TAP_OPPONENT = 19 # Tap opponent's member PLACE_UNDER = 20 # Place card under member FLAVOR_ACTION = 99 # "Ask opponent what they like", etc. RESTRICTION = 21 # Restriction on actions (Cannot Live, etc) BATON_TOUCH_MOD = 22 # Modify baton touch rules (e.g. 2 members) SET_SCORE = 23 # Set score to fixed value SWAP_ZONE = 24 # Swap between zones (e.g. Hand <-> Live) TRANSFORM_COLOR = 25 # Change all colors of type X to Y REVEAL_CARDS = 26 # 公開 - reveal cards from zone LOOK_AND_CHOOSE = 27 # 見る、その中から - look at cards, choose from them CHEER_REVEAL = 28 # エールにより公開 - cards revealed via cheer mechanic ACTIVATE_MEMBER = 29 # アクティブにする - untap/make active a member ADD_TO_HAND = 30 # 手札に加える - add card to hand (from any zone) COLOR_SELECT = 37 # Specify a heart color REPLACE_EFFECT = 34 # Replacement effect (代わりに) TRIGGER_REMOTE = 35 # Trigger ability from another zone (Cluster 5) REDUCE_HEART_REQ = 36 # Need hearts reduced MODIFY_SCORE_RULE = 38 # Modify how score is calculated (e.g. +1 per yell score) PLAY_MEMBER_FROM_HAND = 39 # Play member from hand (e.g. Keke) TAP_MEMBER = 40 # Tap a member (usually self or other on stage) MOVE_TO_DISCARD = 41 # 控え室に置く # --- Added to fix META_RULE fallback --- GRANT_ABILITY = 42 # Grant an ability to a member INCREASE_HEART_COST = 43 # Increase heart requirement REDUCE_YELL_COUNT = 44 # Reduce yell count PLAY_MEMBER_FROM_DISCARD = 45 # Play member from discard PAY_ENERGY = 46 # Pay/tap energy as cost SELECT_MEMBER = 47 # Select target member DRAW_UNTIL = 48 # Draw until hand size = X SELECT_PLAYER = 49 # Select target player SELECT_LIVE = 50 # Select target live REVEAL_UNTIL = 51 # Reveal until condition met INCREASE_COST = 52 # Increase cost (e.g., play cost) PREVENT_PLAY_TO_SLOT = 53 # Prevent play to specific slot SWAP_AREA = 54 # Swap members between areas TRANSFORM_HEART = 55 # Transform heart color/count SELECT_CARDS = 56 # Select specific cards OPPONENT_CHOOSE = 57 # Opponent must make a choice PLAY_LIVE_FROM_DISCARD = 58 # Play Live card from discard REDUCE_LIVE_SET_LIMIT = 59 # Modify limit of live cards set per turn ACTIVATE_ENERGY = 81 # エネルギーをアクティブにする PREVENT_ACTIVATE = 72 # Prevent activating abilities/effects class ConditionType(IntEnum): NONE = 0 TURN_1 = 1 # Turn == 1 HAS_MEMBER = 2 # Specific member on stage HAS_COLOR = 3 # Specific color on stage COUNT_STAGE = 4 # Count members >= X COUNT_HAND = 5 COUNT_DISCARD = 6 IS_CENTER = 7 LIFE_LEAD = 8 COUNT_GROUP = 9 # "3+ Aqours members" GROUP_FILTER = 10 # Filter by group name OPPONENT_HAS = 11 # Opponent has X SELF_IS_GROUP = 12 # This card is from group X MODAL_ANSWER = 13 # Choice/Answer branch (e.g. LL-PR-004-PR) COUNT_ENERGY = 14 # エネルギーがX枚以上 HAS_LIVE_CARD = 15 # ライブカードがある場合 COST_CHECK = 16 # コストがX以下/以上 RARITY_CHECK = 17 # Rarity filter HAND_HAS_NO_LIVE = 18 # Hand contains no live cards (usually paired with reveal cost) COUNT_SUCCESS_LIVE = 19 # 成功ライブカード置き場にX枚以上 OPPONENT_HAND_DIFF = 20 # Opponent has more/less/diff cards in hand SCORE_COMPARE = 21 # Compare scores (e.g. higher than opponent) HAS_CHOICE = 22 # Ability involves a choice OPPONENT_CHOICE = 23 # Opponent makes a choice (相手は~選ぶ) COUNT_HEARTS = 24 # Heart count condition (ハートがX個以上) COUNT_BLADES = 25 # Blade count condition (ブレードがX以上) OPPONENT_ENERGY_DIFF = 26 # Opponent energy comparison HAS_KEYWORD = 27 # Has specific keyword/property (e.g. Blade Heart) DECK_REFRESHED = 28 # Deck was refreshed (reshuffled) this turn HAS_MOVED = 29 # Member has moved this turn HAND_INCREASED = 30 # Hand size increased by X cards this turn COUNT_LIVE_ZONE = 31 # Number of cards in live zone BATON = 32 # Baton Pass check TYPE_CHECK = 33 # Card type check (member/live) IS_IN_DISCARD = 34 # Check if card is in discard pile # --- DESCRIPTIONS --- EFFECT_DESCRIPTIONS = { EffectType.DRAW: "Draw {value} card(s)", EffectType.LOOK_DECK: "Look at top {value} card(s) of deck", EffectType.ADD_BLADES: "Gain {value} Blade(s)", EffectType.ADD_HEARTS: "Gain {value} Heart(s)", EffectType.REDUCE_COST: "Reduce cost by {value}", EffectType.BOOST_SCORE: "Boost live score by {value}", EffectType.RECOVER_LIVE: "Recover {value} Live card(s) from discard", EffectType.RECOVER_MEMBER: "Recover {value} Member card(s) from discard", EffectType.BUFF_POWER: "Power Up {value} (Blade/Heart)", EffectType.IMMUNITY: "Gain Immunity", EffectType.MOVE_MEMBER: "Move Member to another zone", EffectType.SWAP_CARDS: "Discard {value} card(s) then Draw {value}", EffectType.SEARCH_DECK: "Search Deck", EffectType.ENERGY_CHARGE: "Charge {value} Energy", EffectType.SET_BLADES: "Set Blade(s) to {value}", EffectType.SET_HEARTS: "Set Heart(s) to {value}", EffectType.FORMATION_CHANGE: "Rearrange members on stage", EffectType.NEGATE_EFFECT: "Negate effect", EffectType.ORDER_DECK: "Reorder top {value} cards of deck", EffectType.META_RULE: "[Rule modifier]", EffectType.SELECT_MODE: "Choose One:", EffectType.MOVE_TO_DECK: "Return {value} card(s) to Deck", EffectType.TAP_OPPONENT: "Tap {value} Opponent Member(s)", EffectType.PLACE_UNDER: "Place card under Member", EffectType.RESTRICTION: "Apply Restriction", EffectType.BATON_TOUCH_MOD: "Modify Baton Touch rules", EffectType.SET_SCORE: "Set Live Score to {value}", EffectType.REVEAL_CARDS: "Reveal {value} card(s)", EffectType.LOOK_AND_CHOOSE: "Look at {value} card(s) from deck and choose", EffectType.ACTIVATE_MEMBER: "Active {value} Member(s)/Energy", EffectType.ADD_TO_HAND: "Add {value} card(s) to Hand", EffectType.TRIGGER_REMOTE: "Trigger Remote Ability", EffectType.CHEER_REVEAL: "Reveal via Cheer", EffectType.REDUCE_HEART_REQ: "Modify Heart Requirement", EffectType.SWAP_ZONE: "Swap card zones", EffectType.FLAVOR_ACTION: "Flavor Action", EffectType.MOVE_TO_DISCARD: "Move {value} card(s) to Discard", EffectType.PLAY_MEMBER_FROM_HAND: "Play member from hand", EffectType.TAP_MEMBER: "Tap {value} Member(s)", } EFFECT_DESCRIPTIONS_JP = { EffectType.DRAW: "{value}枚ドロー", EffectType.LOOK_DECK: "デッキの上から{value}枚見る", EffectType.ADD_BLADES: "ブレード+{value}", EffectType.ADD_HEARTS: "ハート+{value}", EffectType.REDUCE_COST: "コスト-{value}", EffectType.BOOST_SCORE: "スコア+{value}", EffectType.RECOVER_LIVE: "控えライブ{value}枚回収", EffectType.RECOVER_MEMBER: "控えメンバー{value}枚回収", EffectType.BUFF_POWER: "パワー+{value}", EffectType.IMMUNITY: "効果無効", EffectType.MOVE_MEMBER: "メンバー移動", EffectType.SWAP_CARDS: "手札交換({value}枚捨て{value}枚引く)", EffectType.SEARCH_DECK: "デッキ検索", EffectType.ENERGY_CHARGE: "エネルギーチャージ+{value}", EffectType.SET_BLADES: "ブレードを{value}にセット", EffectType.SET_HEARTS: "ハートを{value}にセット", EffectType.FORMATION_CHANGE: "配置変更", EffectType.NEGATE_EFFECT: "効果打ち消し", EffectType.ORDER_DECK: "デッキトップ{value}枚並べ替え", EffectType.META_RULE: "[ルール変更]", EffectType.SELECT_MODE: "モード選択:", EffectType.MOVE_TO_DECK: "{value}枚をデッキに戻す", EffectType.TAP_OPPONENT: "相手メンバー{value}人をウェイトにする", EffectType.PLACE_UNDER: "メンバーの下に置く", EffectType.RESTRICTION: "プレイ制限適用", EffectType.BATON_TOUCH_MOD: "バトンタッチルール変更", EffectType.SET_SCORE: "ライブスコアを{value}にセット", EffectType.REVEAL_CARDS: "{value}枚公開", EffectType.LOOK_AND_CHOOSE: "デッキから{value}枚見て選ぶ", EffectType.ACTIVATE_MEMBER: "{value}人/エネをアクティブにする", EffectType.ADD_TO_HAND: "手札に{value}枚加える", EffectType.TRIGGER_REMOTE: "リモート能力誘発", EffectType.CHEER_REVEAL: "応援で公開", EffectType.REDUCE_HEART_REQ: "ハート条件変更", EffectType.SWAP_ZONE: "カード移動(ゾーン間)", EffectType.FLAVOR_ACTION: "フレーバーアクション", EffectType.MOVE_TO_DISCARD: "控え室に{value}枚置く", EffectType.PLAY_MEMBER_FROM_HAND: "手札からメンバーを登場させる", EffectType.TAP_MEMBER: "{value}人をウェイトにする", EffectType.ACTIVATE_ENERGY: "エネルギーを{value}枚アクティブにする", } TRIGGER_DESCRIPTIONS = { TriggerType.ON_PLAY: "[On Play]", TriggerType.ON_LIVE_START: "[Live Start]", TriggerType.ON_LIVE_SUCCESS: "[Live Success]", TriggerType.TURN_START: "[Turn Start]", TriggerType.TURN_END: "[Turn End - live]", TriggerType.CONSTANT: "[Constant - live]", TriggerType.ACTIVATED: "[Activated]", TriggerType.ON_LEAVES: "[When Leaves]", } TRIGGER_DESCRIPTIONS_JP = { TriggerType.ON_PLAY: "【登場時】", TriggerType.ON_LIVE_START: "【ライブ開始時】", TriggerType.ON_LIVE_SUCCESS: "【ライブ成功時】", TriggerType.TURN_START: "【ターン開始時】", TriggerType.TURN_END: "【ターン終了時】", TriggerType.CONSTANT: "【常時】", TriggerType.ACTIVATED: "【起動】", TriggerType.ON_LEAVES: "【離脱時】", } @dataclass(slots=True) class Condition: type: ConditionType params: Dict[str, Any] = field(default_factory=dict) is_negated: bool = False # "If NOT X" / "Except X" @dataclass(slots=True) class Effect: effect_type: EffectType value: int = 0 value_cond: ConditionType = ConditionType.NONE target: TargetType = TargetType.SELF params: Dict[str, Any] = field(default_factory=dict) is_optional: bool = False # ~てもよい modal_options: List[List["Effect"]] = field(default_factory=list) # For SELECT_MODE @dataclass(slots=True) class ResolvingEffect: """Wrapper for an effect currently being resolved to track its source and progress.""" effect: Effect source_card_id: int step_index: int total_steps: int class AbilityCostType(IntEnum): NONE = 0 ENERGY = 1 TAP_SELF = 2 # ウェイトにする DISCARD_HAND = 3 # 手札を捨てる RETURN_HAND = 4 # 手札に戻す (Self bounce) SACRIFICE_SELF = 5 # このメンバーを控え室に置く REVEAL_HAND_ALL = 6 # 手札をすべて公開する SACRIFICE_UNDER = 7 # 下に置かれているカードを控え室に置く DISCARD_ENERGY = 8 # エネルギーを控え室に置く REVEAL_HAND = 9 # 手札を公開する TAP_PLAYER = 2 # Alias for TAP_SELF (ウェイトにする) # Missing aliases/members inferred from usage TAP_MEMBER = 20 TAP_ENERGY = 21 PAY_ENERGY = 1 # Alias for ENERGY REST_MEMBER = 22 RETURN_MEMBER_TO_HAND = 23 DISCARD_MEMBER = 24 DISCARD_LIVE = 25 REMOVE_LIVE = 26 REMOVE_MEMBER = 27 RETURN_LIVE_TO_HAND = 28 RETURN_LIVE_TO_DECK = 29 RETURN_MEMBER_TO_DECK = 30 PLACE_MEMBER_FROM_HAND = 31 PLACE_LIVE_FROM_HAND = 32 PLACE_ENERGY_FROM_HAND = 33 PLACE_MEMBER_FROM_DISCARD = 34 PLACE_LIVE_FROM_DISCARD = 35 PLACE_ENERGY_FROM_DISCARD = 36 PLACE_MEMBER_FROM_DECK = 37 PLACE_LIVE_FROM_DECK = 38 PLACE_ENERGY_FROM_DECK = 39 # REVEAL_HAND = 40 # Moved to 9 SHUFFLE_DECK = 41 DRAW_CARD = 42 DISCARD_TOP_DECK = 43 REMOVE_TOP_DECK = 44 RETURN_DISCARD_TO_DECK = 45 RETURN_REMOVED_TO_DECK = 46 RETURN_REMOVED_TO_HAND = 47 RETURN_REMOVED_TO_DISCARD = 48 PLACE_ENERGY_FROM_SUCCESS = 49 DISCARD_SUCCESS_LIVE = 50 REMOVE_SUCCESS_LIVE = 51 RETURN_SUCCESS_LIVE_TO_HAND = 52 RETURN_SUCCESS_LIVE_TO_DECK = 53 RETURN_SUCCESS_LIVE_TO_DISCARD = 54 PLACE_MEMBER_FROM_SUCCESS = 55 PLACE_LIVE_FROM_SUCCESS = 56 PLACE_ENERGY_FROM_REMOVED = 57 PLACE_MEMBER_FROM_REMOVED = 58 PLACE_LIVE_FROM_REMOVED = 59 RETURN_ENERGY_TO_DECK = 60 RETURN_ENERGY_TO_HAND = 61 REMOVE_ENERGY = 62 RETURN_STAGE_ENERGY_TO_HAND = 64 DISCARD_STAGE_ENERGY = 65 REMOVE_STAGE_ENERGY = 66 DISCARD_STAGE = 65 # Alias for DISCARD_STAGE_ENERGY (often used for members/energy) MOVE_TO_DISCARD = 5 # Common alias for sacrifice/discard PLACE_ENERGY_FROM_STAGE_ENERGY = 67 PLACE_MEMBER_FROM_STAGE_ENERGY = 68 PLACE_LIVE_FROM_STAGE_ENERGY = 69 PLACE_ENERGY_FROM_HAND_TO_STAGE_ENERGY = 70 PLACE_MEMBER_FROM_HAND_TO_STAGE_ENERGY = 71 PLACE_LIVE_FROM_HAND_TO_STAGE_ENERGY = 72 PLACE_ENERGY_FROM_DISCARD_TO_STAGE_ENERGY = 73 PLACE_MEMBER_FROM_DISCARD_TO_STAGE_ENERGY = 74 PLACE_LIVE_FROM_DISCARD_TO_STAGE_ENERGY = 75 PLACE_ENERGY_FROM_DECK_TO_STAGE_ENERGY = 76 PLACE_MEMBER_FROM_DECK_TO_STAGE_ENERGY = 77 PLACE_LIVE_FROM_DECK_TO_STAGE_ENERGY = 78 PLACE_ENERGY_FROM_SUCCESS_TO_STAGE_ENERGY = 79 PLACE_MEMBER_FROM_SUCCESS_TO_STAGE_ENERGY = 80 PLACE_LIVE_FROM_SUCCESS_TO_STAGE_ENERGY = 81 PLACE_ENERGY_FROM_REMOVED_TO_STAGE_ENERGY = 82 PLACE_MEMBER_FROM_REMOVED_TO_STAGE_ENERGY = 83 PLACE_LIVE_FROM_REMOVED_TO_STAGE_ENERGY = 84 RETURN_LIVE_TO_DISCARD = 85 RETURN_LIVE_TO_REMOVED = 86 RETURN_LIVE_TO_SUCCESS = 87 RETURN_MEMBER_TO_DISCARD = 88 RETURN_MEMBER_TO_REMOVED = 89 RETURN_MEMBER_TO_SUCCESS = 90 RETURN_ENERGY_TO_DISCARD = 91 RETURN_ENERGY_TO_REMOVED = 92 RETURN_ENERGY_TO_SUCCESS = 93 RETURN_SUCCESS_LIVE_TO_REMOVED = 94 RETURN_REMOVED_TO_SUCCESS = 95 RETURN_STAGE_ENERGY_TO_DISCARD = 96 RETURN_STAGE_ENERGY_TO_REMOVED = 97 RETURN_STAGE_ENERGY_TO_SUCCESS = 98 RETURN_DISCARD_TO_HAND = 99 RETURN_DISCARD_TO_REMOVED = 100 RETURN_DISCARD_TO_SUCCESS = 101 RETURN_DECK_TO_DISCARD = 102 RETURN_DECK_TO_HAND = 103 RETURN_DECK_TO_REMOVED = 104 RETURN_DECK_TO_SUCCESS = 105 RETURN_ENERGY_DECK_TO_DISCARD = 106 RETURN_ENERGY_DECK_TO_HAND = 107 RETURN_ENERGY_DECK_TO_REMOVED = 108 RETURN_ENERGY_DECK_TO_SUCCESS = 109 # Auto-generated missing members for effect_mixin.py compatibility PLACE_ENERGY_FROM_DECK_TO_DISCARD = 110 PLACE_ENERGY_FROM_DECK_TO_HAND = 111 PLACE_ENERGY_FROM_DECK_TO_REMOVED = 112 PLACE_ENERGY_FROM_DECK_TO_SUCCESS = 113 PLACE_ENERGY_FROM_DISCARD_TO_HAND = 114 PLACE_ENERGY_FROM_DISCARD_TO_REMOVED = 115 PLACE_ENERGY_FROM_DISCARD_TO_SUCCESS = 116 PLACE_ENERGY_FROM_ENERGY_DECK = 117 PLACE_ENERGY_FROM_ENERGY_DECK_TO_DISCARD = 118 PLACE_ENERGY_FROM_ENERGY_DECK_TO_HAND = 119 PLACE_ENERGY_FROM_ENERGY_DECK_TO_REMOVED = 120 PLACE_ENERGY_FROM_ENERGY_DECK_TO_STAGE_ENERGY = 121 PLACE_ENERGY_FROM_ENERGY_DECK_TO_SUCCESS = 122 PLACE_ENERGY_FROM_ENERGY_ZONE_TO_DISCARD = 123 PLACE_ENERGY_FROM_ENERGY_ZONE_TO_HAND = 124 PLACE_ENERGY_FROM_ENERGY_ZONE_TO_REMOVED = 125 PLACE_ENERGY_FROM_ENERGY_ZONE_TO_SUCCESS = 126 PLACE_ENERGY_FROM_HAND_TO_DISCARD = 127 PLACE_ENERGY_FROM_HAND_TO_REMOVED = 128 PLACE_ENERGY_FROM_HAND_TO_SUCCESS = 129 PLACE_ENERGY_FROM_REMOVED_TO_DISCARD = 130 PLACE_ENERGY_FROM_REMOVED_TO_HAND = 131 PLACE_ENERGY_FROM_REMOVED_TO_SUCCESS = 132 PLACE_ENERGY_FROM_STAGE_ENERGY_TO_DISCARD = 133 PLACE_ENERGY_FROM_STAGE_ENERGY_TO_HAND = 134 PLACE_ENERGY_FROM_STAGE_ENERGY_TO_REMOVED = 135 PLACE_ENERGY_FROM_STAGE_ENERGY_TO_SUCCESS = 136 PLACE_ENERGY_FROM_SUCCESS_TO_DISCARD = 137 PLACE_ENERGY_FROM_SUCCESS_TO_HAND = 138 PLACE_ENERGY_FROM_SUCCESS_TO_REMOVED = 139 PLACE_LIVE_FROM_DECK_TO_DISCARD = 140 PLACE_LIVE_FROM_DECK_TO_HAND = 141 PLACE_LIVE_FROM_DECK_TO_REMOVED = 142 PLACE_LIVE_FROM_DECK_TO_SUCCESS = 143 PLACE_LIVE_FROM_DISCARD_TO_HAND = 144 PLACE_LIVE_FROM_DISCARD_TO_REMOVED = 145 PLACE_LIVE_FROM_DISCARD_TO_SUCCESS = 146 PLACE_LIVE_FROM_ENERGY_DECK = 147 PLACE_LIVE_FROM_ENERGY_DECK_TO_DISCARD = 148 PLACE_LIVE_FROM_ENERGY_DECK_TO_HAND = 149 PLACE_LIVE_FROM_ENERGY_DECK_TO_REMOVED = 150 PLACE_LIVE_FROM_ENERGY_DECK_TO_STAGE_ENERGY = 151 PLACE_LIVE_FROM_ENERGY_DECK_TO_SUCCESS = 152 PLACE_LIVE_FROM_ENERGY_ZONE_TO_DISCARD = 153 PLACE_LIVE_FROM_ENERGY_ZONE_TO_HAND = 154 PLACE_LIVE_FROM_ENERGY_ZONE_TO_REMOVED = 155 PLACE_LIVE_FROM_ENERGY_ZONE_TO_SUCCESS = 156 PLACE_LIVE_FROM_HAND_TO_DISCARD = 157 PLACE_LIVE_FROM_HAND_TO_REMOVED = 158 PLACE_LIVE_FROM_HAND_TO_SUCCESS = 159 PLACE_LIVE_FROM_REMOVED_TO_DISCARD = 160 PLACE_LIVE_FROM_REMOVED_TO_HAND = 161 PLACE_LIVE_FROM_REMOVED_TO_SUCCESS = 162 PLACE_LIVE_FROM_STAGE_ENERGY_TO_DISCARD = 163 PLACE_LIVE_FROM_STAGE_ENERGY_TO_HAND = 164 PLACE_LIVE_FROM_STAGE_ENERGY_TO_REMOVED = 165 PLACE_LIVE_FROM_STAGE_ENERGY_TO_SUCCESS = 166 PLACE_LIVE_FROM_SUCCESS_TO_DISCARD = 167 PLACE_LIVE_FROM_SUCCESS_TO_HAND = 168 PLACE_LIVE_FROM_SUCCESS_TO_REMOVED = 169 PLACE_MEMBER_FROM_DECK_TO_DISCARD = 170 PLACE_MEMBER_FROM_DECK_TO_HAND = 171 PLACE_MEMBER_FROM_DECK_TO_REMOVED = 172 PLACE_MEMBER_FROM_DECK_TO_SUCCESS = 173 PLACE_MEMBER_FROM_DISCARD_TO_HAND = 174 PLACE_MEMBER_FROM_DISCARD_TO_REMOVED = 175 PLACE_MEMBER_FROM_DISCARD_TO_SUCCESS = 176 PLACE_MEMBER_FROM_ENERGY_DECK = 177 PLACE_MEMBER_FROM_ENERGY_DECK_TO_DISCARD = 178 PLACE_MEMBER_FROM_ENERGY_DECK_TO_HAND = 179 PLACE_MEMBER_FROM_ENERGY_DECK_TO_REMOVED = 180 PLACE_MEMBER_FROM_ENERGY_DECK_TO_STAGE_ENERGY = 181 PLACE_MEMBER_FROM_ENERGY_DECK_TO_SUCCESS = 182 PLACE_MEMBER_FROM_ENERGY_ZONE_TO_DISCARD = 183 PLACE_MEMBER_FROM_ENERGY_ZONE_TO_HAND = 184 PLACE_MEMBER_FROM_ENERGY_ZONE_TO_REMOVED = 185 PLACE_MEMBER_FROM_ENERGY_ZONE_TO_SUCCESS = 186 PLACE_MEMBER_FROM_HAND_TO_DISCARD = 187 PLACE_MEMBER_FROM_HAND_TO_REMOVED = 188 PLACE_MEMBER_FROM_HAND_TO_SUCCESS = 189 PLACE_MEMBER_FROM_REMOVED_TO_DISCARD = 190 PLACE_MEMBER_FROM_REMOVED_TO_HAND = 191 PLACE_MEMBER_FROM_REMOVED_TO_SUCCESS = 192 PLACE_MEMBER_FROM_STAGE_ENERGY_TO_DISCARD = 193 PLACE_MEMBER_FROM_STAGE_ENERGY_TO_HAND = 194 PLACE_MEMBER_FROM_STAGE_ENERGY_TO_REMOVED = 195 PLACE_MEMBER_FROM_STAGE_ENERGY_TO_SUCCESS = 196 PLACE_MEMBER_FROM_SUCCESS_TO_DISCARD = 197 PLACE_MEMBER_FROM_SUCCESS_TO_HAND = 198 PLACE_MEMBER_FROM_SUCCESS_TO_REMOVED = 199 @dataclass class Cost: type: AbilityCostType value: int = 0 params: Dict[str, Any] = field(default_factory=dict) is_optional: bool = False @property def cost_type(self) -> AbilityCostType: return self.type @dataclass class Ability: raw_text: str trigger: TriggerType effects: List[Effect] conditions: List[Condition] = field(default_factory=list) costs: List[Cost] = field(default_factory=list) modal_options: List[List[Effect]] = field(default_factory=list) # For SELECT_MODE is_once_per_turn: bool = False bytecode: List[int] = field(default_factory=list) requires_selection: bool = False # Ordered list of operations (Union[Effect, Condition]) for precise execution order instructions: List[Any] = field(default_factory=list) def compile(self) -> List[int]: """Compile ability into fixed-width bytecode sequence (groups of 4 ints).""" bytecode = [] # 0. Compile Ordered Instructions (If present - New Parser V2.1) if self.instructions: for instr in self.instructions: if isinstance(instr, Condition): self._compile_single_condition(instr, bytecode) elif isinstance(instr, Effect): self._compile_effect_wrapper(instr, bytecode) # Terminator bytecode.extend([int(Opcode.RETURN), 0, 0, 0]) return bytecode # 1. Compile Conditions (Legacy/Split Mode) for cond in self.conditions: self._compile_single_condition(cond, bytecode) # 1.5. Compile Costs (Note: Modern engine handles costs via pay_cost shell) # We don't compile costs into bytecode unless they are meant for mid-ability execution. # 2. Compile Effects for eff in self.effects: self._compile_effect_wrapper(eff, bytecode) # Terminator bytecode.extend([int(Opcode.RETURN), 0, 0, 0]) return bytecode def _compile_single_condition(self, cond: Condition, bytecode: List[int]): op_name = f"CHECK_{cond.type.name}" if hasattr(Opcode, op_name): op = getattr(Opcode, op_name) # Fixed width: [Opcode, Value, Attr, TargetSlot] # Check multiple potential keys for the value (min, count, value, diff) v_raw = cond.params.get("value", cond.params.get("min", cond.params.get("count", cond.params.get("diff", 0)))) try: val = int(v_raw) if v_raw is not None else 0 except (ValueError, TypeError): val = 0 # Resolve attr (color, group, or unit) to integer attr_raw = cond.params.get("color", cond.params.get("group", cond.params.get("unit", 0))) if isinstance(attr_raw, str): # Resolve using enums if "group" in cond.params: from engine.models.enums import Group attr = int(Group.from_japanese_name(attr_raw)) elif "unit" in cond.params: from engine.models.enums import Unit attr = int(Unit.from_japanese_name(attr_raw)) elif cond.type == ConditionType.SCORE_COMPARE: # Map score/cost/heart types to int stype = cond.params.get("type", "score") type_map = {"score": 0, "cost": 1, "heart": 2, "heart_count": 2, "cheer_count": 3} attr = type_map.get(stype, 0) else: attr = 0 else: attr = int(attr_raw) if attr_raw is not None else 0 # Comparison mapping (GE=0, LE=1, GT=2, LT=3, EQ=4) comp_str = cond.params.get("comparison", "GE") comp_map = {"GE": 0, "LE": 1, "GT": 2, "LT": 3, "EQ": 4} comp_val = comp_map.get(comp_str, 0) # Zone mapping: STAGE=0, LIVE_ZONE=1, LIVE_RESULT/EXCESS=2 slot = 0 zone = cond.params.get("zone", "") context = cond.params.get("context", "") if zone == "LIVE_ZONE": slot = 1 elif zone == "STAGE": slot = 0 elif context == "excess": slot = 2 else: slot = cond.params.get("TargetSlot", 0) # Pack comparison into higher bits of slot (bits 4-7) # Slot is usually 0-3, so shift 4 is safe. packed_slot = (slot & 0x0F) | ((comp_val & 0x0F) << 4) op_val = int(op) if cond.is_negated: op_val += 1000 bytecode.extend( [ op_val, val, attr, packed_slot, ] ) elif cond.type == ConditionType.BATON: # Special handling for BATON condition if hasattr(Opcode, "CHECK_BATON"): unit_id = 0 if "unit" in cond.params: from engine.models.enums import Unit try: # Handle string unit names u_val = cond.params["unit"] if isinstance(u_val, str): unit_id = int(Unit.from_japanese_name(u_val)) else: unit_id = int(u_val) except: unit_id = 0 filter_type = 0 if "filter" in cond.params: f_str = cond.params["filter"] if f_str == "COST_LT_SELF": filter_type = 1 # 1 = Cost Check Less Than Self bytecode.extend([int(Opcode.CHECK_BATON), unit_id, filter_type, 0]) elif cond.type == ConditionType.TYPE_CHECK: if hasattr(Opcode, "CHECK_TYPE_CHECK"): # card_type: "live" = 1, "member" = 0 ctype = 1 if cond.params.get("card_type") == "live" else 0 bytecode.extend([int(Opcode.CHECK_TYPE_CHECK), ctype, 0, 0]) def _compile_effect_wrapper(self, eff: Effect, bytecode: List[int]): # Fix: Use name comparison to avoid Enum identity issues from reloading/imports if eff.effect_type.name == "ORDER_DECK": # O_ORDER_DECK requires looking at cards first. # Emit: [O_LOOK_DECK, val, 0, 0] -> [O_ORDER_DECK, val, attr, 0] # attr: 0=Discard, 1=DeckTop, 2=DeckBottom rem = eff.params.get("remainder", "discard").lower() attr = 0 if rem == "deck_top": attr = 1 elif rem == "deck_bottom": attr = 2 bytecode.extend([int(Opcode.LOOK_DECK), eff.value, 0, 0]) bytecode.extend([int(Opcode.ORDER_DECK), eff.value, attr, 0]) return # Check for modal options on Effect OR Ability (fallback) modal_opts = eff.modal_options if eff.modal_options else self.modal_options if eff.effect_type == EffectType.SELECT_MODE and modal_opts: # Handle SELECT_MODE with jump table num_options = len(modal_opts) # Emit header: [SELECT_MODE, NumOptions, 0, 0] if hasattr(Opcode, "SELECT_MODE"): bytecode.extend([int(Opcode.SELECT_MODE), num_options, 0, 0]) # Placeholders for Jump Table jump_table_start_idx = len(bytecode) for _ in range(num_options): bytecode.extend([int(Opcode.JUMP), 0, 0, 0]) # Compile each option and track start/end option_start_offsets = [] end_jumps_locations = [] for opt_effects in modal_opts: # Record start offset (relative to current instruction pointer) current_idx = len(bytecode) // 4 option_start_offsets.append(current_idx) # Compile option effects for opt_eff in opt_effects: self._compile_single_effect(opt_eff, bytecode) # Add Jump to End (placeholder) end_jumps_locations.append(len(bytecode)) bytecode.extend([int(Opcode.JUMP), 0, 0, 0]) # Determine End Index end_idx = len(bytecode) // 4 # Patch Jump Table (Start Jumps) for i in range(num_options): jump_instr_idx = (jump_table_start_idx // 4) + i target_idx = option_start_offsets[i] offset = target_idx - jump_instr_idx bytecode[jump_instr_idx * 4 + 1] = offset # Patch End Jumps for loc in end_jumps_locations: jump_instr_idx = loc // 4 offset = end_idx - jump_instr_idx bytecode[loc + 1] = offset else: self._compile_single_effect(eff, bytecode) def _compile_single_effect(self, eff: Effect, bytecode: List[int]): print(f"DEBUG: Compiling Single Effect: {eff.effect_type.name} (Val={eff.value})") if hasattr(Opcode, eff.effect_type.name): op = getattr(Opcode, eff.effect_type.name) try: val = int(eff.value) except (ValueError, TypeError): val = 1 attr = eff.params.get("color", 0) if isinstance(eff.params.get("color"), int) else 0 slot = eff.target.value if hasattr(eff.target, "value") else int(eff.target) # Check for interactive target selection requirement # Use Bit 5 (0x20) in attr to flag "Requires Selection" if eff.effect_type == EffectType.TAP_OPPONENT: attr |= 1 << 5 # Special handling for PLACE_UNDER params if eff.effect_type == EffectType.PLACE_UNDER: if eff.params.get("from") == "energy": attr = 1 # Source: Energy elif eff.params.get("from") == "discard": attr = 2 # Source: Discard (for future proofing) # Keep existing type logic if any, but currently PLACE_UNDER uses attr for source # Special handling for SEARCH_DECK params if eff.effect_type == EffectType.SEARCH_DECK: if "group" in eff.params: slot = 1 # Filter Type: Group try: attr = int(eff.params["group"]) except: pass elif "unit" in eff.params: slot = 2 # Filter Type: Unit try: attr = int(eff.params["unit"]) except: pass # Special handling for PLAY_MEMBER_FROM_HAND params if eff.effect_type == EffectType.PLAY_MEMBER_FROM_HAND: if "group" in eff.params: from engine.models.enums import Group try: attr = int(Group.from_japanese_name(eff.params["group"])) except: pass # Special handling for LOOK_AND_CHOOSE destination if eff.effect_type == EffectType.LOOK_AND_CHOOSE: if eff.params.get("source") == "HAND": slot = int(TargetType.CARD_HAND) attr = 0 if eff.params.get("destination") == "discard": attr |= 0x01 # Bit 0: Destination Discard if eff.is_optional or eff.params.get("is_optional"): attr |= 0x02 # Bit 1: Optional (May) # Parse 'filter' string if present (e.g. "GROUP_ID=0, TYPE_LIVE") if "filter" in eff.params: filter_str = str(eff.params["filter"]) parts = [p.strip() for p in filter_str.split(",")] for part in parts: if "=" in part: k, v = part.split("=", 1) k, v = k.strip().upper(), v.strip() if k == "GROUP_ID": eff.params["group"] = v if k == "TYPE": eff.params["type"] = v else: if part.upper() == "TYPE_LIVE": eff.params["type"] = "live" if part.upper() == "TYPE_MEMBER": eff.params["type"] = "member" # Source Zone: Bits 12-15 src_zone = eff.params.get("source", "DECK") src_val = 8 # Default DECK if src_zone == "HAND": src_val = 6 elif src_zone == "DISCARD": src_val = 7 attr |= src_val << 12 # Filter Mode (Type): Bit 2-3 ctype = str(eff.params.get("type", "")).lower() if ctype == "live": attr |= 0x02 << 2 # Value 2 in bits 2-3 elif ctype == "member": attr |= 0x01 << 2 # Value 1 in bits 2-3 # Group Filter: Bit 4 (Enable) and Bits 5-11 (ID) group_val = eff.params.get("group") if group_val is not None: try: if str(group_val).isdigit(): g_id = int(str(group_val)) else: from engine.models.enums import Group g_id = int(Group.from_japanese_name(str(group_val))) attr |= 0x10 # Bit 4: Has Group Filter attr |= g_id << 5 # Bits 5-11: Group ID except: pass # Special handling for TAP filters if eff.effect_type in (EffectType.TAP_OPPONENT, EffectType.TAP_MEMBER): # Use Value for cost_max filter (99 = dynamic/none) # Use Attr bits 0-6 for blades_max filter (99 = none) try: val = int(eff.params.get("cost_max", 99)) except (ValueError, TypeError): val = 99 try: blades_max = int(eff.params.get("blades_max", 99)) except (ValueError, TypeError): blades_max = 99 attr = (attr & 0x80) | (blades_max & 0x7F) # Special handling for MOVE_TO_DISCARD params if eff.effect_type == EffectType.MOVE_TO_DISCARD: if eff.params.get("from") == "deck_top": attr = 1 # Source: Deck Top elif eff.params.get("from") == "hand": attr = 2 # Source: Hand elif eff.params.get("from") == "energy": attr = 3 # Source: Energy if eff.value_cond != ConditionType.NONE: val = int(eff.value_cond) attr |= 0x40 # Bit 6 for Dynamic # Special encoding for REVEAL_UNTIL if eff.effect_type == EffectType.REVEAL_UNTIL: if eff.value_cond == ConditionType.TYPE_CHECK: if eff.params.get("card_type") == "live": attr |= 0x01 elif eff.value_cond == ConditionType.COST_CHECK: cost = int(eff.params.get("min", 0)) attr |= (cost & 0x1F) << 1 # Default to Choice (slot 4) for PLAY opcodes if target is generic (SELF/PLAYER) if eff.effect_type in ( EffectType.PLAY_MEMBER_FROM_HAND, EffectType.PLAY_MEMBER_FROM_DISCARD, EffectType.PLAY_LIVE_FROM_DISCARD, ): if eff.target in (TargetType.SELF, TargetType.PLAYER): slot = 4 bytecode.extend( [ int(op), int(val), attr if not eff.params.get("all") else (attr | 0x80), # Bit 7 for ALL slot, ] ) def reconstruct_text(self, lang: str = "en") -> str: """Generate standardized text description.""" parts = [] is_jp = lang == "jp" t_desc_map = TRIGGER_DESCRIPTIONS_JP if is_jp else TRIGGER_DESCRIPTIONS e_desc_map = EFFECT_DESCRIPTIONS_JP if is_jp else EFFECT_DESCRIPTIONS t_name = getattr(self.trigger, "name", str(self.trigger)) trigger_desc = t_desc_map.get(self.trigger, f"[{t_name}]") if self.trigger == TriggerType.ON_LEAVES: if "discard" not in trigger_desc.lower() and "控え室" not in trigger_desc: suffix = " (to discard)" if not is_jp else "(控え室へ)" trigger_desc += suffix parts.append(trigger_desc) for cost in self.costs: if is_jp: if cost.type == AbilityCostType.ENERGY: parts.append(f"(コスト: エネ{cost.value}消費)") elif cost.type == AbilityCostType.TAP_SELF: parts.append("(コスト: 自身ウェイト)") elif cost.type == AbilityCostType.DISCARD_HAND: parts.append(f"(コスト: 手札{cost.value}枚捨て)") elif cost.type == AbilityCostType.SACRIFICE_SELF: parts.append("(コスト: 自身退場)") else: parts.append(f"(コスト: {cost.type.name} {cost.value})") else: if cost.type == AbilityCostType.ENERGY: parts.append(f"(Cost: Pay {cost.value} Energy)") elif cost.type == AbilityCostType.TAP_SELF: parts.append("(Cost: Rest Self)") elif cost.type == AbilityCostType.DISCARD_HAND: parts.append(f"(Cost: Discard {cost.value} from hand)") elif cost.type == AbilityCostType.SACRIFICE_SELF: parts.append("(Cost: Sacrifice Self)") else: parts.append(f"(Cost: {cost.type.name} {cost.value})") for cond in self.conditions: if is_jp: neg = "NOT " if cond.is_negated else "" # JP negation usually handles via suffix, but keeping simple cond_desc = f"{neg}{cond.type.name}" if cond.type == ConditionType.BATON: cond_desc = "条件: バトンタッチ" if "unit" in cond.params: cond_desc += f" ({cond.params['unit']})" # ... (add more JP specific cond descs if needed, but for now fallback) else: neg = "NOT " if cond.is_negated else "" cond_desc = f"{neg}{cond.type.name}" # Add basic params if cond.params.get("type") == "score": cond_desc += " (Score)" if cond.type == ConditionType.SCORE_COMPARE: target_str = " (Opponent)" if cond.params.get("target") == "opponent" else "" cond_desc += f" (Score check{target_str})" if cond.type == ConditionType.OPPONENT_HAS: cond_desc += " (Opponent has)" if cond.type == ConditionType.OPPONENT_CHOICE: cond_desc += " (Opponent chooses)" if cond.type == ConditionType.OPPONENT_HAND_DIFF: cond_desc += " (Opponent hand check)" if cond.params.get("group"): cond_desc += f"({cond.params['group']})" if cond.params.get("zone"): cond_desc += f" (in {cond.params['zone']})" if cond.params.get("zone") == "SUCCESS_LIVE": cond_desc += " (in Live Area)" if cond.type == ConditionType.HAS_CHOICE: cond_desc = "Condition: Choose One" if cond.type == ConditionType.HAS_KEYWORD: cond_desc += f" (Has {cond.params.get('keyword', '?')})" if cond.params.get("context") == "heart_inclusion": cond_desc += " (Heart check)" if cond.type == ConditionType.COUNT_BLADES: cond_desc += " (Blade count)" if cond.type == ConditionType.COUNT_HEARTS: cond_desc += " (Heart count)" if cond.params.get("context") == "excess": cond_desc += " (Excess)" if cond.type == ConditionType.COUNT_ENERGY: cond_desc += " (Energy count)" if cond.type == ConditionType.COUNT_SUCCESS_LIVE: cond_desc += " (Success Live count)" if cond.type == ConditionType.HAS_LIVE_CARD: cond_desc += " (Live card check)" type_str = ( "Heart comparison" if cond.params.get("type") == "heart" else "Cheer comparison" if cond.params.get("type") == "cheer_count" else "Score check" ) cond_desc += f" ({type_str}{target_str})" if cond.type == ConditionType.BATON: cond_desc = "Condition: Baton Pass" if "unit" in cond.params: cond_desc += f" ({cond.params['unit']})" parts.append(cond_desc) for eff in self.effects: # Special handling for META_RULE which relies heavily on params desc = None if eff.effect_type == EffectType.META_RULE: if eff.params.get("type") == "opponent_trigger_allowed": desc = "[Meta: Opponent effects trigger this]" elif eff.params.get("type") == "shuffle": desc = "Shuffle Deck" elif eff.params.get("type") == "heart_rule": src = eff.params.get("source", "") src_text = "ALL Blades" if src == "all_blade" else "Blade" if src == "blade" else "" desc = f"[Meta: Treat {src_text} as Heart]" if src_text else "[Meta: Treat as Heart]" elif eff.params.get("type") == "live": desc = "[Meta: Live Rule]" elif eff.params.get("type") == "lose_blade_heart": desc = "[Meta: Lose Blade Heart]" elif eff.params.get("type") == "re_cheer": desc = "[Meta: Cheer Again]" elif eff.params.get("type") == "cheer_mod": val = eff.value desc = f"[Meta: Cheer Reveal Count {'+' if val > 0 else ''}{val}]" elif eff.effect_type == getattr(EffectType, "TAP_OPPONENT", -1): desc = "Tap Opponent Member(s)" if desc is None: # Custom overrides for standard effects with params if eff.effect_type == EffectType.DRAW and eff.params.get("multiplier") == "energy": req = eff.params.get("req_per_unit", 1) desc = f"Draw {eff.value} card(s) per {req} Energy" elif eff.effect_type == EffectType.REDUCE_HEART_REQ and eff.value < 0: # e.g. value -1 means reduce requirement. value +1 means increase requirement (opp). pass if desc is None: template = e_desc_map.get(eff.effect_type, getattr(eff.effect_type, "name", str(eff.effect_type))) context = eff.params.copy() context["value"] = eff.value # Refine REDUCE_HEART_REQ if eff.effect_type == EffectType.REDUCE_HEART_REQ: if eff.params.get("mode") == "select_requirement": desc = "Choose Heart Requirement (hearts) (choice)" if not is_jp else "ハート条件選択" elif eff.value < 0: desc = ( f"Reduce Heart Requirement by {abs(eff.value)} (Live)" if not is_jp else f"ハート条件-{abs(eff.value)}" ) else: desc = ( f"Increase Heart Requirement by {eff.value} (Live)" if not is_jp else f"ハート条件+{eff.value}" ) elif eff.effect_type == EffectType.TRANSFORM_COLOR: target_s = eff.params.get("target", "Color") if target_s == "heart": target_s = "Heart" desc = f"Transform {target_s} Color" if not is_jp else f"{target_s}の色を変換" elif eff.effect_type == EffectType.PLACE_UNDER: type_s = f" {eff.params.get('type', '')}" if "type" in eff.params else "" desc = f"Place{type_s} card under member" if not is_jp else f"メンバーの下に{type_s}置く" if eff.params.get("type") == "energy": desc = "Place Energy under member" if not is_jp else "メンバーの下にエネルギーを置く" else: try: desc = template.format(**context) except KeyError: desc = template # Clean up descriptions if eff.params.get("live") and "live" not in desc.lower() and "meta" not in desc.lower(): desc = f"{desc} (Live Rule)" # Contextual refinements without spamming "Interaction" tags if eff.params.get("per_energy"): desc += " per Energy" if eff.params.get("per_member"): desc += " per Member" if eff.params.get("per_live"): desc += " per Live" # Target Context if eff.target == TargetType.MEMBER_SELECT: desc += " (Choose member)" if eff.target == TargetType.OPPONENT or eff.target == TargetType.OPPONENT_HAND: if "opponent" not in desc.lower(): desc += " (Opponent)" # Trigger Remote Context if eff.effect_type == EffectType.TRIGGER_REMOTE: zone = eff.params.get("from", "unknown") desc += f" from {zone}" # Reveal Context if eff.effect_type == EffectType.REVEAL_CARDS: if "from" in eff.params and eff.params["from"] == "deck": desc += " from Deck" if eff.effect_type == EffectType.MOVE_TO_DECK: if eff.params.get("to_energy_deck"): desc = "Return to Energy Deck" elif eff.params.get("from") == "discard": desc += " from Discard" if eff.params.get("rest") == "discard" or eff.params.get("on_fail") == "discard": if "discard" not in desc.lower(): desc += " (else Discard)" if eff.params.get("both_players"): desc += " (Both Players)" if not is_jp else " (両プレイヤー)" if eff.params.get("filter") == "live" and "live" not in desc.lower() and "ライブ" not in desc: desc += " (Live Card)" if not is_jp else " (ライブカード)" if eff.params.get("filter") == "energy" and "energy" not in desc.lower() and "エネ" not in desc: desc += " (Energy)" if not is_jp else " (エネルギー)" parts.append(f"→ {desc}") # Check for Effect-level modal options (e.g. from parser fix) if eff.modal_options: for i, option in enumerate(eff.modal_options): opt_descs = [] for sub_eff in option: template = e_desc_map.get(sub_eff.effect_type, sub_eff.effect_type.name) context = sub_eff.params.copy() context["value"] = sub_eff.value try: opt_descs.append(template.format(**context)) except KeyError: opt_descs.append(template) parts.append( f"[Option {i + 1}: {' + '.join(opt_descs)}]" if not is_jp else f"[選択肢 {i + 1}: {' + '.join(opt_descs)}]" ) # Include modal options (Ability level - legacy/bullet points) if self.modal_options: for i, option in enumerate(self.modal_options): opt_descs = [] for eff in option: template = e_desc_map.get(eff.effect_type, eff.effect_type.name) context = eff.params.copy() context["value"] = eff.value try: opt_descs.append(template.format(**context)) except KeyError: opt_descs.append(template) parts.append( f"[Option {i + 1}: {' + '.join(opt_descs)}]" if not is_jp else f"[選択肢 {i + 1}: {' + '.join(opt_descs)}]" ) return " ".join(parts)