LovecaSim / engine /models /ability.py
trioskosmos's picture
Upload folder using huggingface_hub
bb3fbf9 verified
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)