Spaces:
Running
Running
| 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: "【離脱時】", | |
| } | |
| class Condition: | |
| type: ConditionType | |
| params: Dict[str, Any] = field(default_factory=dict) | |
| is_negated: bool = False # "If NOT X" / "Except X" | |
| 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 | |
| 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 | |
| class Cost: | |
| type: AbilityCostType | |
| value: int = 0 | |
| params: Dict[str, Any] = field(default_factory=dict) | |
| is_optional: bool = False | |
| def cost_type(self) -> AbilityCostType: | |
| return self.type | |
| 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) | |