rabukasim / engine /models /ability.py
trioskosmos's picture
Upload folder using huggingface_hub
463f868 verified
import copy
import re
from dataclasses import dataclass, field, fields
from typing import Any, Dict, List, Tuple, Union
from engine.models.enums import CHAR_MAP
from engine.models.opcodes import Opcode
from .ability_ir import SemanticAbility, SemanticCondition, SemanticCost, SemanticEffect
from .ability_descriptions import (
EFFECT_DESCRIPTIONS,
EFFECT_DESCRIPTIONS_JP,
TRIGGER_DESCRIPTIONS,
TRIGGER_DESCRIPTIONS_JP,
)
from .ability_filter import PackedFilterSpec, explain_filter_attr, format_filter_attr
from .structured_instruction_ir import build_structured_instruction_ir
from .generated_enums import AbilityCostType, ConditionType, EffectType, TargetType, TriggerType
from .generated_metadata import COMPARISONS, COUNT_SOURCES, EXTRA_CONSTANTS, HEART_COLOR_MAP, META_RULE_TYPES, ZONES
from .generated_packer import (
pack_a_heart_cost,
pack_a_standard,
pack_s_standard,
pack_v_heart_counts,
pack_v_look_choose,
pack_v_scalar_dynamic,
unpack_a_standard,
)
def to_signed_32(x):
"""Utility to convert an integer to a signed 32-bit integer."""
x = int(x) & 0xFFFFFFFF
return x - 0x100000000 if x >= 0x80000000 else x
# Original definitions removed, now using generated_enums.
@dataclass(slots=True)
class Condition:
type: ConditionType
params: Dict[str, Any] = field(default_factory=dict)
is_negated: bool = False # "If NOT X" / "Except X"
value: int = 0
attr: int = 0
@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 # Optional choice flag
modal_options: List[List[Any]] = field(default_factory=list) # For SELECT_MODE
runtime_opcode: int = 0
runtime_value: int = 0
runtime_attr: int = 0
runtime_slot: int = 0
@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
# Original AbilityCostType removed, now using generated_enums.
@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[Any]] = field(default_factory=list) # For SELECT_MODE
is_once_per_turn: bool = False
bytecode: List[int] = field(default_factory=list)
# Ordered list of operations (Union[Effect, Condition]) for precise execution order
instructions: List[Union[Effect, Condition, Cost]] = field(default_factory=list)
card_no: str = "" # Metadata for debugging/tracing
requires_selection: bool = False
choice_flags: int = 0
choice_count: int = 0
pseudocode: str = ""
filters: List[Dict[str, Any]] = field(default_factory=list)
option_names: List[str] = field(default_factory=list)
semantic_form: Dict[str, Any] = field(default_factory=dict) # Human-readable form
def build_structured_ir(self) -> Dict[str, Any]:
return build_structured_instruction_ir(self).to_dict()
def compile(self) -> List[int]:
"""Compile ability into fixed-width bytecode sequence (groups of 4 ints)."""
bytecode = []
self.filters = [] # Reset filters for this compilation
self._annotate_effect_runtime_metadata()
# CONSTANT abilities are enforced from serialized conditions rather than
# condition opcodes in bytecode, so keep their numeric fields hydrated.
if self.trigger == TriggerType.CONSTANT:
self._normalize_serialized_conditions()
# 0. Compile Ordered Instructions (If present - New Parser V2.1)
if self.instructions:
# Pass 1: Pre-calculate individual instruction sizes
instr_bytecodes = []
self._last_counted_zone = None
last_emitted_target = None
i = 0
while i < len(self.instructions):
instr = self.instructions[i]
if isinstance(instr, Effect) and instr.params.get("raw_effect") == "COUNT_CARDS":
self._last_counted_zone = (instr.params.get("zone") or instr.params.get("ZONE") or "").upper()
temp_bc = []
if isinstance(instr, Condition):
# CRITICAL FIX: For CONSTANT trigger abilities, do NOT compile conditions into bytecode.
# The Rust engine evaluates CONSTANT ability conditions from the 'conditions' array separately
# and expects bytecode to contain ONLY effect opcodes.
if self.trigger != TriggerType.CONSTANT:
self._compile_single_condition(instr, temp_bc)
elif isinstance(instr, Effect) and instr.target == TargetType.ALL_PLAYERS:
block = [instr]
j = i + 1
while (
j < len(self.instructions)
and isinstance(self.instructions[j], Effect)
and self.instructions[j].target == TargetType.ALL_PLAYERS
):
block.append(self.instructions[j])
j += 1
last_emitted_target = self._compile_all_players_block(temp_bc, block, last_emitted_target)
instr_bytecodes.append(temp_bc)
for _ in range(i + 1, j):
instr_bytecodes.append([])
i = j
continue
elif isinstance(instr, Effect):
last_emitted_target = self._compile_effect_with_target_persistence(
instr, temp_bc, last_emitted_target
)
elif isinstance(instr, Cost):
mapping = {
AbilityCostType.ENERGY: Opcode.PAY_ENERGY,
AbilityCostType.TAP_SELF: Opcode.SET_TAPPED,
AbilityCostType.TAP_MEMBER: Opcode.TAP_MEMBER,
AbilityCostType.DISCARD_HAND: Opcode.MOVE_TO_DISCARD,
AbilityCostType.PLACE_ENERGY_FROM_DECK: Opcode.PLACE_ENERGY_FROM_DECK,
AbilityCostType.RETURN_HAND: Opcode.MOVE_MEMBER,
AbilityCostType.SACRIFICE_SELF: Opcode.MOVE_TO_DISCARD,
AbilityCostType.RETURN_DISCARD_TO_DECK: Opcode.MOVE_TO_DECK,
AbilityCostType.NONE: Opcode.SELECT_CARDS, # Fallback for named costs
}
if (
instr.is_optional
or instr.type in mapping
or instr.params.get("cost_type_name") in ["CALC_SUM_COST", "SELECT_CARDS", "SELECT_MEMBER", "SELECT_ENERGY"]
):
self._compile_single_cost(instr, temp_bc)
if instr.is_optional:
last_emitted_target = None
instr_bytecodes.append(temp_bc)
i += 1
# Pass 2: Emit bytecode with accurate jump targets
for i, instr in enumerate(self.instructions):
bc_chunk = instr_bytecodes[i]
bytecode.extend(bc_chunk)
if isinstance(instr, Cost) and instr.is_optional:
# Calculate jump target based on BYTECODE size, not instruction count
# skip_size = sum of lengths of remaining bytecode chunks / 5
remaining_bc_sum = sum(len(c) for c in instr_bytecodes[i + 1 :])
# We jump to the instruction AFTER the remaining ones (the RETURN)
skip_count = remaining_bc_sum // 5
bytecode.extend([int(Opcode.JUMP_IF_FALSE), to_signed_32(skip_count), 0, 0, 0])
bytecode.extend([int(Opcode.RETURN), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)])
# Build semantic form for debugging/inspection (before returning)
# Note: Only build if explicitly called, not during normal compilation to avoid side effects
# self.build_semantic_form()
return bytecode
# 1. Compile Conditions (Legacy/Split Mode)
# CRITICAL FIX: For CONSTANT trigger abilities, do NOT compile conditions into bytecode.
# The Rust engine evaluates CONSTANT ability conditions from the 'conditions' array separately
# and expects bytecode to contain ONLY effect opcodes (ADD_BLADES, etc).
# If conditions are embedded in bytecode, they interfere with effect processing.
if self.trigger != TriggerType.CONSTANT:
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 (with ALL_PLAYERS grouping and target persistence optimization)
# TARGET PERSISTENCE: Only emit SET_TARGET if it differs from last_emitted_target.
# This reduces redundant target-switching opcodes by ~5 words (1 instruction) per target switch.
i = 0
last_emitted_target = None # Track the last SET_TARGET that was emitted
while i < len(self.effects):
eff = self.effects[i]
if eff.target == TargetType.ALL_PLAYERS:
# Group consecutive ALL_PLAYERS effects into a single block
block = [eff]
j = i + 1
while j < len(self.effects) and self.effects[j].target == TargetType.ALL_PLAYERS:
block.append(self.effects[j])
j += 1
# Emit block for SELF (only emit SET_TARGET_SELF if needed)
if last_emitted_target != TargetType.PLAYER:
bytecode.extend(
[int(Opcode.SET_TARGET_SELF), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)]
)
last_emitted_target = TargetType.PLAYER
for e in block:
# Create a copy with target=PLAYER (Self)
e_self = Effect(e.effect_type, e.value, e.value_cond, TargetType.PLAYER, e.params)
e_self.is_optional = e.is_optional
self._compile_effect_wrapper(e_self, bytecode)
# Emit block for OPPONENT (only emit SET_TARGET_OPPONENT if needed)
if last_emitted_target != TargetType.OPPONENT:
bytecode.extend(
[
int(Opcode.SET_TARGET_OPPONENT),
to_signed_32(0),
to_signed_32(0),
to_signed_32(0),
to_signed_32(0),
]
)
last_emitted_target = TargetType.OPPONENT
for e in block:
# Create a copy with target=OPPONENT
e_opp = Effect(e.effect_type, e.value, e.value_cond, TargetType.OPPONENT, e.params)
e_opp.is_optional = e.is_optional
self._compile_effect_wrapper(e_opp, bytecode)
# Reset context to SELF for consistency (required for state consistency)
# Only emit if the next effect isn't going to emit its own target immediately
if j < len(self.effects):
# Check if next effect will set its own target
next_eff = self.effects[j]
if next_eff.target != TargetType.PLAYER and next_eff.target != TargetType.ALL_PLAYERS:
# Next effect sets its own target, skip the reset
last_emitted_target = None
else:
# Next is PLAYER or ALL_PLAYERS, emit reset
if last_emitted_target != TargetType.PLAYER:
bytecode.extend(
[int(Opcode.SET_TARGET_SELF), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)]
)
last_emitted_target = TargetType.PLAYER
else:
# End of effects, reset to SELF for cleanliness
if last_emitted_target != TargetType.PLAYER:
bytecode.extend(
[int(Opcode.SET_TARGET_SELF), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)]
)
last_emitted_target = TargetType.PLAYER
i = j
else:
# Non-ALL_PLAYERS effect - it will set its own target internally if needed
# Reset tracking since this effect handles targeting
last_emitted_target = None
self._compile_effect_wrapper(eff, bytecode)
i += 1
# 3. Add Costs to bytecode if present (Fallback for non-instruction abilities)
# DISABLE: Modern engine handles costs via pay_costs_transactional in the trigger/activation shell.
# if not self.instructions:
# for cost in self.costs:
# self._compile_single_cost(cost, bytecode)
# Terminator
bytecode.extend([int(Opcode.RETURN), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)])
# Build semantic form for debugging/inspection
# Note: Only build if explicitly called, not during normal compilation to avoid side effects
# self.build_semantic_form()
return bytecode
def _emit_target_opcode_if_needed(self, bytecode: List[int], target: TargetType, last_emitted_target: TargetType | None):
desired_target = None
if target in (TargetType.SELF, TargetType.PLAYER):
desired_target = TargetType.PLAYER
elif target == TargetType.OPPONENT:
desired_target = TargetType.OPPONENT
if desired_target is None:
return None
if desired_target == last_emitted_target:
return last_emitted_target
opcode = Opcode.SET_TARGET_SELF if desired_target == TargetType.PLAYER else Opcode.SET_TARGET_OPPONENT
bytecode.extend([int(opcode), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)])
return desired_target
def _compile_effect_with_target_persistence(
self, eff: Effect, bytecode: List[int], last_emitted_target: TargetType | None
) -> TargetType | None:
if eff.target == TargetType.ALL_PLAYERS:
return self._compile_all_players_block(bytecode, [eff], last_emitted_target)
next_target = None
if last_emitted_target is not None and eff.target in (TargetType.SELF, TargetType.PLAYER, TargetType.OPPONENT):
next_target = self._emit_target_opcode_if_needed(bytecode, eff.target, last_emitted_target)
self._compile_effect_wrapper(eff, bytecode)
if last_emitted_target is None:
return None
if eff.target in (TargetType.SELF, TargetType.PLAYER, TargetType.OPPONENT):
return next_target
return None
def _compile_all_players_block(
self, bytecode: List[int], block: List[Effect], last_emitted_target: TargetType | None
) -> TargetType | None:
last_emitted_target = self._emit_target_opcode_if_needed(bytecode, TargetType.PLAYER, last_emitted_target)
for eff in block:
eff_self = Effect(eff.effect_type, eff.value, eff.value_cond, TargetType.PLAYER, eff.params.copy())
eff_self.is_optional = eff.is_optional
self._compile_effect_wrapper(eff_self, bytecode)
last_emitted_target = self._emit_target_opcode_if_needed(bytecode, TargetType.OPPONENT, last_emitted_target)
for eff in block:
eff_opp = Effect(eff.effect_type, eff.value, eff.value_cond, TargetType.OPPONENT, eff.params.copy())
eff_opp.is_optional = eff.is_optional
self._compile_effect_wrapper(eff_opp, bytecode)
return last_emitted_target
def _normalize_serialized_conditions(self):
for cond in self.conditions:
temp_bc: List[int] = []
self._compile_single_condition(cond, temp_bc)
def build_semantic_form(self) -> Dict[str, Any]:
from .ability_rendering import build_semantic_form
return build_semantic_form(self)
def _annotate_effect_runtime_metadata(self):
for eff in self.effects:
self._annotate_single_effect_runtime(eff)
def _annotate_single_effect_runtime(self, eff: Effect):
eff.runtime_opcode = 0
eff.runtime_value = 0
eff.runtime_attr = 0
eff.runtime_slot = 0
for option in eff.modal_options:
for option_item in option:
if isinstance(option_item, Effect):
self._annotate_single_effect_runtime(option_item)
temp_bc: List[int] = []
eff_copy = copy.deepcopy(eff)
try:
self._compile_single_effect(eff_copy, temp_bc)
except Exception:
return
if len(temp_bc) != 5:
return
eff.runtime_opcode = int(temp_bc[0])
eff.runtime_value = int(temp_bc[1])
eff.runtime_attr = ((int(temp_bc[3]) & 0xFFFFFFFF) << 32) | (int(temp_bc[2]) & 0xFFFFFFFF)
eff.runtime_slot = int(temp_bc[4])
def _compile_single_condition(self, cond: Condition, bytecode: List[int]):
# Special handling for BATON condition - must be first since it uses different param keys
if cond.type == ConditionType.BATON:
# Special handling for BATON condition
# Bytecode format: [CHECK_BATON, val, attr, attr_hi, slot]
# val: expected baton touch count (0 = any > 0, 2 = exactly 2)
# attr: GROUP_ID filter (lower 32 bits)
if hasattr(Opcode, "CHECK_BATON"):
params_upper = {k.upper(): v for k, v in cond.params.items() if isinstance(k, str)}
# Value: expected baton touch count
val = 0
count_eq = (
cond.params.get("count_eq")
or params_upper.get("COUNT_EQ")
or cond.params.get("val")
or cond.params.get("value")
or params_upper.get("VAL")
or params_upper.get("VALUE")
)
if count_eq:
try:
val = int(count_eq)
except (ValueError, TypeError):
val = 0
# Attr: Standardized packing for filters
attr = self._pack_filter_attr(cond)
bytecode.extend(
[
int(Opcode.CHECK_BATON),
to_signed_32(val),
to_signed_32(attr & 0xFFFFFFFF),
to_signed_32((attr >> 32) & 0xFFFFFFFF),
to_signed_32(0),
]
)
cond.value = val
cond.attr = attr
return
if cond.type == ConditionType.TYPE_CHECK:
if hasattr(Opcode, "CHECK_TYPE_CHECK"):
ctype = 1 if str(cond.params.get("card_type", "")).lower() == "live" else 0
bytecode.extend(
[
int(Opcode.CHECK_TYPE_CHECK),
to_signed_32(ctype),
to_signed_32(0),
to_signed_32(0),
to_signed_32(0),
]
)
cond.value = ctype
cond.attr = 0
return
op_name = f"CHECK_{cond.type.name}"
op = getattr(Opcode, op_name, None)
if op is None and cond.type == ConditionType.NONE and cond.params:
# Systemic Fix: Preserve unknown conditions as Opcode 0 (NOP/UNKNOWN)
# This allows the engine to see the params even if it doesn't have a specific opcode.
op = 0
if op is not None:
# Fixed width: [Opcode, Value, Attr, TargetSlot]
# Check multiple potential keys for the value (min, count, value, diff) - case insensitive
params_upper = {k.upper(): v for k, v in cond.params.items() if isinstance(k, str)}
v_raw = (
cond.params.get("min")
or cond.params.get("count")
or cond.params.get("value")
or cond.params.get("diff")
or cond.params.get("GE")
or cond.params.get("LE")
or cond.params.get("GT")
or cond.params.get("LT")
or cond.params.get("EQ")
or cond.params.get("COUNT_GE")
or cond.params.get("COUNT_LE")
or cond.params.get("COUNT_GT")
or cond.params.get("COUNT_LT")
or cond.params.get("COUNT_EQ")
or cond.params.get("val")
or params_upper.get("MIN")
or params_upper.get("COUNT")
or params_upper.get("VALUE")
or params_upper.get("DIFF")
or params_upper.get("GE")
or params_upper.get("LE")
or params_upper.get("GT")
or params_upper.get("LT")
or params_upper.get("EQ")
or 0
)
try:
val = int(v_raw) if v_raw is not None else 0
except (ValueError, TypeError):
val = 0
if cond.params.get("ALL") or params_upper.get("ALL"):
val |= 0x04
# Unified Filter Packing
if op == int(Opcode.CHECK_HAS_KEYWORD):
attr = 0
kw = str(cond.params.get("keyword") or "").upper()
if "PLAYED_THIS_TURN" in kw:
attr |= 1 << 44
elif "YELL_COUNT" in kw:
attr |= 1 << 45
elif "HAS_LIVE_SET" in kw:
attr |= 1 << 46
elif "ENERGY" in kw:
attr |= 1 << 62
attr |= self._pack_filter_attr(cond)
elif "MEMBER" in kw:
attr |= 1 << 63
attr |= self._pack_filter_attr(cond)
else:
# Fallback for implicit keywords
if cond.type == ConditionType.HAS_KEYWORD:
cond.params["keyword"] = "PLAYED_THIS_TURN"
attr |= 1 << 44
elif op == int(Opcode.CHECK_HEART_COMPARE):
# Heart compare uses raw color index in bits 0-6
from engine.models.enums import HeartColor
color_name = str(cond.params.get("color") or "").upper()
try:
attr = int(HeartColor[color_name])
except (KeyError, TypeError, ValueError):
# Fallback: try to extract from filter string if directly missing
f_str = str(cond.params.get("filter", "")).upper()
if "YELLOW" in f_str:
attr = 2
elif "RED" in f_str:
attr = 1
elif "PINK" in f_str:
attr = 0
elif "BLUE" in f_str:
attr = 4
elif "GREEN" in f_str:
attr = 3
elif "PURPLE" in f_str:
attr = 5
else:
attr = 7 # Total count fallback
else:
attr = self._pack_filter_attr(cond)
# Persist back to the Condition object for JSON serialization
cond.value = val
cond.attr = attr
# Comparison and Slot Mapping
comp_str = str(cond.params.get("comparison") or params_upper.get("COMPARISON") or "GE").upper()
comp_val = COMPARISONS.get(comp_str, 0)
slot = 0
zone = str(cond.params.get("zone") or params_upper.get("ZONE") or "").upper()
if zone == "LIVE_ZONE":
slot = 13 # LIVE_SET
elif zone == "STAGE":
slot = int(TargetType.MEMBER_SELF)
elif zone == "YELL" or zone == "YELL_REVEALED":
slot = ZONES.get("YELL", 17)
elif str(cond.params.get("context", "")).lower() == "excess":
slot = 2
else:
slot_raw = cond.params.get("TargetSlot") or params_upper.get("TARGETSLOT") or 0
slot = int(slot_raw)
area_val = cond.params.get("area") or params_upper.get("AREA")
if area_val:
a_str = str(area_val).upper()
if "LEFT" in a_str:
slot |= 1 << 29
elif "CENTER" in a_str:
slot |= 2 << 29
elif "RIGHT" in a_str:
slot |= 3 << 29
packed_slot = (slot & 0x0F) | ((comp_val & 0x0F) << 4) | (slot & 0xFFFFFF00)
bytecode.extend(
[
to_signed_32(int(op) + (1000 if cond.is_negated else 0)),
to_signed_32(val),
to_signed_32(attr & 0xFFFFFFFF),
to_signed_32((attr >> 32) & 0xFFFFFFFF),
to_signed_32(packed_slot),
]
)
elif cond.type == ConditionType.UNIQUE_NAMES_COUNT:
# Map UNIQUE_NAMES_COUNT to CHECK_COUNT_STAGE with FILTER_UNIQUE_NAMES bit (32768)
attr = self._pack_filter_attr(cond)
attr |= EXTRA_CONSTANTS.get("FILTER_UNIQUE_NAMES", 32768)
val = cond.value
if val == 0:
val = int(cond.params.get("min") or cond.params.get("count") or 0)
comp_str = str(cond.params.get("comparison") or "GE").upper()
comp_val = COMPARISONS.get(comp_str, 3) # Default GE
slot = (int(TargetType.MEMBER_SELF) & 0x0F) | ((comp_val & 0x0F) << 4)
# Use CONDITIONS directly to avoid dynamic attribute issues
op_code = CONDITIONS.get("COUNT_STAGE", 203)
bytecode.extend(
[
to_signed_32(op_code),
to_signed_32(val),
to_signed_32(attr & 0xFFFFFFFF),
to_signed_32((attr >> 32) & 0xFFFFFFFF),
to_signed_32(slot),
]
)
cond.value = val
cond.attr = attr
else:
if cond.type != ConditionType.NONE:
print(f"CRITICAL WARNING: No opcode mapping for condition type: {cond.type.name}")
def _compile_single_cost(self, cost: Cost, bytecode: List[int]):
"""Compile a cost into its corresponding opcode."""
mapping = {
AbilityCostType.ENERGY: Opcode.PAY_ENERGY,
AbilityCostType.TAP_SELF: Opcode.SET_TAPPED,
AbilityCostType.TAP_MEMBER: Opcode.TAP_MEMBER,
AbilityCostType.DISCARD_HAND: Opcode.MOVE_TO_DISCARD,
AbilityCostType.DISCARD_TOP_DECK: Opcode.MOVE_TO_DISCARD,
AbilityCostType.PLACE_ENERGY_FROM_DECK: Opcode.PLACE_ENERGY_FROM_DECK,
AbilityCostType.RETURN_HAND: Opcode.MOVE_MEMBER,
AbilityCostType.SACRIFICE_SELF: Opcode.MOVE_TO_DISCARD,
AbilityCostType.RETURN_MEMBER_TO_DECK: Opcode.MOVE_TO_DECK,
AbilityCostType.RETURN_LIVE_TO_DECK: Opcode.MOVE_TO_DECK,
AbilityCostType.RETURN_DISCARD_TO_DECK: Opcode.MOVE_TO_DECK,
AbilityCostType.DISCARD_MEMBER: Opcode.MOVE_TO_DISCARD,
AbilityCostType.DISCARD_LIVE: Opcode.MOVE_TO_DISCARD,
AbilityCostType.DISCARD_ENERGY: Opcode.MOVE_TO_DISCARD,
AbilityCostType.DISCARD_SUCCESS_LIVE: Opcode.MOVE_TO_DISCARD,
AbilityCostType.DISCARD_STAGE_ENERGY: Opcode.MOVE_TO_DISCARD,
AbilityCostType.REVEAL_HAND: Opcode.REVEAL_CARDS,
AbilityCostType.REVEAL_HAND_ALL: Opcode.REVEAL_CARDS,
}
# Handle Named Costs (e.g. metadata-only costs handled via code)
if cost.params.get("cost_type_name") == "CALC_SUM_COST":
op = Opcode.CALC_SUM_COST
elif cost.params.get("cost_type_name") == "SELECT_CARDS":
op = Opcode.SELECT_CARDS
elif cost.params.get("cost_type_name") == "SELECT_MEMBER":
op = Opcode.SELECT_MEMBER
elif cost.params.get("cost_type_name") == "SELECT_ENERGY":
op = Opcode.PLACE_ENERGY_UNDER_MEMBER
else:
op = mapping.get(cost.type)
if op is not None:
attr = 0
slot_params = {
"target_slot": 0,
"remainder_zone": 0,
"source_zone": 0,
"dest_zone": 0,
"is_opponent": False,
"is_reveal_until_live": False,
"is_empty_slot": False,
"is_wait": False,
"is_dynamic": False,
"area_idx": 0,
}
params_upper = {k.upper(): v for k, v in cost.params.items() if isinstance(k, str)}
filter_expr = cost.params.get("filter") or params_upper.get("FILTER")
source_name = str(cost.params.get("from") or params_upper.get("FROM") or "").lower()
uses_filtered_discard_selection = (
op == Opcode.MOVE_TO_DECK and source_name == "discard" and bool(filter_expr)
)
if uses_filtered_discard_selection:
select_attr = self._pack_filter_attr(cost)
if cost.is_optional:
select_attr |= EXTRA_CONSTANTS.get("FILTER_IS_OPTIONAL", 1 << 61)
select_slot = pack_s_standard(
target_slot=int(TargetType.CARD_DISCARD),
remainder_zone=0,
source_zone=int(ZONES.get("DISCARD", 7)),
dest_zone=int(ZONES.get("DECK", 8)),
is_opponent=False,
is_reveal_until_live=False,
is_empty_slot=False,
is_wait=False,
is_dynamic=False,
area_idx=0,
)
value = cost.value
count_raw = cost.params.get("count") or params_upper.get("COUNT")
if not value and count_raw is not None:
value = int(count_raw)
bytecode.extend(
[
int(Opcode.SELECT_CARDS),
to_signed_32(int(value)),
to_signed_32(select_attr & 0xFFFFFFFF),
to_signed_32((select_attr >> 32) & 0xFFFFFFFF),
to_signed_32(select_slot),
]
)
return
# --- Resolve Slot (Source) ---
if cost.type in [
AbilityCostType.DISCARD_HAND,
AbilityCostType.RETURN_HAND,
AbilityCostType.REVEAL_HAND,
AbilityCostType.REVEAL_HAND_ALL,
]:
slot_params["target_slot"] = int(TargetType.CARD_HAND)
elif cost.type in [AbilityCostType.TAP_SELF, AbilityCostType.TAP_MEMBER, AbilityCostType.SACRIFICE_SELF]:
slot_params["target_slot"] = int(TargetType.MEMBER_SELF)
elif cost.type == AbilityCostType.DISCARD_ENERGY:
slot_params["target_slot"] = int(TargetType.SELF)
elif cost.type in [AbilityCostType.RETURN_DISCARD_TO_DECK]:
slot_params["target_slot"] = int(TargetType.CARD_DISCARD)
elif cost.type == AbilityCostType.DISCARD_TOP_DECK:
slot_params["source_zone"] = ZONES.get("DECK_TOP", 1)
elif cost.type in [AbilityCostType.RETURN_SUCCESS_LIVE_TO_HAND, AbilityCostType.DISCARD_SUCCESS_LIVE]:
slot_params["source_zone"] = ZONES.get("SUCCESS_PILE", 14)
elif cost.params.get("cost_type_name") == "SELECT_ENERGY":
slot_params["target_slot"] = int(TargetType.SELF)
slot_params["source_zone"] = ZONES.get("ENERGY", 3)
else:
slot_params["target_slot"] = int(TargetType.SELF)
# --- Resolve Attr (Params/Destination) ---
if op == Opcode.MOVE_TO_DECK:
# 0=Discard, 1=Top, 2=Bottom
to = str(cost.params.get("to") or params_upper.get("TO") or "top").lower()
if to == "bottom":
slot_params["remainder_zone"] = int(EXTRA_CONSTANTS.get("DECK_POSITION_BOTTOM", 2))
elif to == "top":
slot_params["remainder_zone"] = int(EXTRA_CONSTANTS.get("DECK_POSITION_TOP", 1))
# O_SELECT_MEMBER / O_PLAY_MEMBER_FROM_HAND / MOVE_TO_DISCARD, encode filters into 'attr' (a)
# O_SELECT_MEMBER / O_PLAY_MEMBER_FROM_HAND / MOVE_TO_DISCARD, encode filters into 'attr' (a)
if op in [
Opcode.SELECT_CARDS,
Opcode.SELECT_MEMBER,
Opcode.TAP_MEMBER,
Opcode.PLAY_MEMBER_FROM_HAND,
Opcode.PLAY_MEMBER_FROM_DISCARD,
Opcode.MOVE_TO_DISCARD,
]:
attr = self._pack_filter_attr(cost)
# Value capture flag (Bit 25 of slot)
if cost.params.get("destination") == "target_val" or params_upper.get("DESTINATION") == "TARGET_VAL":
slot_params["is_reveal_until_live"] = True # Reuse bit 25 for capture
if cost.params.get("cost_type_name") == "SELECT_CARDS":
source_name = str(
cost.params.get("from")
or cost.params.get("source")
or params_upper.get("FROM")
or params_upper.get("SOURCE")
or "discard"
).lower()
if source_name == "hand":
slot_params["source_zone"] = ZONES.get("HAND", 6)
slot_params["target_slot"] = int(TargetType.CARD_HAND)
elif source_name == "stage":
slot_params["source_zone"] = ZONES.get("STAGE", 4)
slot_params["target_slot"] = int(TargetType.MEMBER_SELECT)
elif source_name in ["deck", "deck_top"]:
slot_params["source_zone"] = ZONES.get("DECK_TOP", 1)
slot_params["target_slot"] = int(TargetType.CARD_DECK_TOP)
else:
slot_params["source_zone"] = ZONES.get("DISCARD", 7)
slot_params["target_slot"] = int(TargetType.CARD_DISCARD)
if cost.is_optional:
attr |= EXTRA_CONSTANTS.get("FILTER_IS_OPTIONAL", 1 << 61)
# Use value from cost params if available (max/count)
value = cost.value
count_raw = cost.params.get("count") or params_upper.get("COUNT")
if not value and count_raw is not None:
value = int(count_raw)
# Fix: Default MOVE_TO_DISCARD, SET_TAPPED, and ACTIVATE_MEMBER for members to 1
cur_slot_val = slot_params["target_slot"]
if (
op in [Opcode.MOVE_TO_DISCARD, Opcode.SET_TAPPED, Opcode.ACTIVATE_MEMBER]
and value == 0
and cur_slot_val
in (int(TargetType.MEMBER_SELF), int(TargetType.MEMBER_OTHER), int(TargetType.MEMBER_SELECT))
):
value = 1
slot = pack_s_standard(**slot_params)
bytecode.extend(
[
int(op),
to_signed_32(int(value)),
to_signed_32(attr & 0xFFFFFFFF),
to_signed_32((attr >> 32) & 0xFFFFFFFF),
to_signed_32(slot),
]
)
if cost.params.get("cost_type_name") == "SELECT_CARDS":
destination = str(cost.params.get("destination") or params_upper.get("DESTINATION") or "").lower()
if destination in {"deck_bottom", "deck_top"}:
move_slot_params = {
"target_slot": 0,
"remainder_zone": int(
EXTRA_CONSTANTS.get(
"DECK_POSITION_BOTTOM" if destination == "deck_bottom" else "DECK_POSITION_TOP",
2 if destination == "deck_bottom" else 1,
)
),
"source_zone": 0,
"dest_zone": int(ZONES.get("DECK", 8)),
"is_opponent": False,
"is_reveal_until_live": False,
"is_empty_slot": False,
"is_wait": False,
"is_dynamic": False,
"area_idx": 0,
}
move_slot = pack_s_standard(**move_slot_params)
bytecode.extend(
[
int(Opcode.MOVE_TO_DECK),
to_signed_32(int(value)),
to_signed_32(0),
to_signed_32(0),
to_signed_32(move_slot),
]
)
else:
if cost.type != AbilityCostType.NONE:
# This ensures we don't silently drop costs like we did with TAP_MEMBER
print(f"CRITICAL WARNING: No opcode mapping for cost type: {cost.type.name}")
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), to_signed_32(eff.value), to_signed_32(0), to_signed_32(0), to_signed_32(0)]
)
bytecode.extend(
[
int(Opcode.ORDER_DECK),
to_signed_32(eff.value),
to_signed_32(attr & 0xFFFFFFFF),
to_signed_32((attr >> 32) & 0xFFFFFFFF),
to_signed_32(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)
slot = 0
if eff.target == TargetType.OPPONENT:
slot |= 1 << 24
# Emit header: [SELECT_MODE, NumOptions, 0, 0, slot]
if hasattr(Opcode, "SELECT_MODE"):
bytecode.extend(
[
int(Opcode.SELECT_MODE),
to_signed_32(num_options),
to_signed_32(0),
to_signed_32(0),
to_signed_32(slot),
]
)
# Populate descriptive option names for the UI
if "options" in eff.params:
self.option_names = [str(opt) for opt in eff.params["options"]]
# Placeholders for Jump Table
jump_table_start_idx = len(bytecode)
for _ in range(num_options):
bytecode.extend([int(Opcode.JUMP), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)])
# Compile each option and track start/end
option_start_offsets = []
end_jumps_locations = []
for opt_instructions in modal_opts:
# Record start offset (relative to current instruction pointer)
current_idx = len(bytecode) // 5
option_start_offsets.append(current_idx)
# Compile option instructions
for opt_instr in opt_instructions:
if isinstance(opt_instr, Cost):
self._compile_single_cost(opt_instr, bytecode)
elif isinstance(opt_instr, Condition):
self._compile_single_condition(opt_instr, bytecode)
else:
self._compile_single_effect(opt_instr, bytecode)
# Add Jump to End (placeholder)
end_jumps_locations.append(len(bytecode))
bytecode.extend([int(Opcode.JUMP), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)])
# Determine End Index
end_idx = len(bytecode) // 5
# Patch Jump Table (Start Jumps)
for i in range(num_options):
jump_instr_idx = (jump_table_start_idx // 5) + i
target_idx = option_start_offsets[i]
# Fix: Subtract 1 because SELECT_MODE handler in engine_rust adds 1 back unintentionally or
# simply because the jump table itself counts as an instruction block in some offsets.
# Specifically, the engine jump is handled as current_ip + 1 + offset.
offset = target_idx - jump_instr_idx - 1
bytecode[jump_instr_idx * 5 + 1] = offset
# Patch End Jumps
for loc in end_jumps_locations:
jump_instr_idx = loc // 5
offset = end_idx - jump_instr_idx
bytecode[loc + 1] = offset
else:
if eff.target == TargetType.ALL_PLAYERS:
# ALL_PLAYERS: Emit sequences for both SELF and OPPONENT
# 1. SET_TARGET_SELF
bytecode.extend(
[int(Opcode.SET_TARGET_SELF), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)]
)
# 2. Compile for SELF - COPY params to avoid shared mutations
eff_self = Effect(eff.effect_type, eff.value, eff.value_cond, TargetType.PLAYER, eff.params.copy())
eff_self.is_optional = eff.is_optional
self._compile_single_effect(eff_self, bytecode)
# 3. SET_TARGET_OPPONENT
bytecode.extend(
[
int(Opcode.SET_TARGET_OPPONENT),
to_signed_32(0),
to_signed_32(0),
to_signed_32(0),
to_signed_32(0),
]
)
# 4. Compile for OPPONENT - COPY params to avoid shared mutations
eff_opp = Effect(eff.effect_type, eff.value, eff.value_cond, TargetType.OPPONENT, eff.params.copy())
eff_opp.is_optional = eff.is_optional
self._compile_single_effect(eff_opp, bytecode)
# 5. Restore context to SELF (optional safety)
bytecode.extend(
[int(Opcode.SET_TARGET_SELF), to_signed_32(0), to_signed_32(0), to_signed_32(0), to_signed_32(0)]
)
else:
self._compile_single_effect(eff, bytecode)
def _compile_single_effect(self, eff: Effect, bytecode: List[int]):
# Normalize params to lowercase keys for consistent lookups
eff.params = {str(k).lower(): v for k, v in eff.params.items()}
source_val = 0
if hasattr(Opcode, eff.effect_type.name):
op = getattr(Opcode, eff.effect_type.name)
move_member_raw_value = str(eff.params.get("raw_val") or "").upper()
if eff.effect_type == EffectType.MOVE_MEMBER and (
str(eff.value).upper() in ["ALL", "TARGETS"] or move_member_raw_value in ["ALL", "TARGET", "TARGETS"]
):
val = 99
attr = 99
else:
try:
val = int(eff.value)
except (ValueError, TypeError):
val = 1
attr = eff.params.get("color", eff.params.get("heart_type", 0))
if not isinstance(attr, int):
attr = 0
slot_params = {
"target_slot": eff.target.value if hasattr(eff.target, "value") else int(eff.target),
"remainder_zone": 0,
"source_zone": 0,
"dest_zone": 0,
"is_opponent": False,
"is_reveal_until_live": False,
"is_empty_slot": False,
"is_wait": False,
"area_idx": 0,
"is_dynamic": False,
}
self._resolve_effect_target(eff, slot_params)
# --- Systemic Area Packing ---
area_raw = eff.params.get("area", "")
if not area_raw:
f_str = str(eff.params.get("filter", "")).upper()
if "AREA=CENTER" in f_str:
area_raw = "CENTER"
elif "AREA=LEFT" in f_str:
area_raw = "LEFT_SIDE"
elif "AREA=RIGHT" in f_str:
area_raw = "RIGHT_SIDE"
if area_raw:
a_str = str(area_raw).upper()
if "LEFT" in a_str:
slot_params["area_idx"] = 1
elif "CENTER" in a_str:
slot_params["area_idx"] = 2
elif "RIGHT" in a_str:
slot_params["area_idx"] = 3
self._resolve_effect_source_zone(eff, slot_params)
# TAP/Interactive selection
tap_raw_value = str(eff.params.get("raw_val") or "").upper()
if eff.effect_type in (EffectType.TAP_OPPONENT, EffectType.TAP_MEMBER):
if eff.effect_type == EffectType.TAP_MEMBER and tap_raw_value in ["TARGET", "TARGETS"]:
attr = 0
else:
attr = self._pack_filter_attr(eff)
if eff.effect_type == EffectType.TAP_MEMBER and tap_raw_value not in ["TARGET", "TARGETS"]:
attr |= 0x02 # Bit 1: Selection mode
# PLACE_UNDER params
if eff.effect_type == EffectType.PLACE_UNDER:
source = str(eff.params.get("from") or eff.params.get("source") or "").lower()
u_src_val = 0
if source == "energy":
u_src_val = ZONES.get("ENERGY", 3)
elif source == "discard":
u_src_val = ZONES.get("DISCARD", 7)
slot_params["source_zone"] = u_src_val
# ENERGY_CHARGE params
if eff.effect_type == EffectType.ENERGY_CHARGE:
if eff.params.get("wait") or eff.params.get("state") == "wait":
slot_params["is_wait"] = True
# Empty Slot flag
dest = str(eff.params.get("destination") or "").lower()
if eff.params.get("is_empty_slot") or dest == "stage_empty" or "EMPTY" in dest:
slot_params["is_empty_slot"] = True
# Specialized Opcode Packing
if eff.effect_type == EffectType.SELECT_MEMBER:
attr = self._pack_filter_attr(eff)
if eff.effect_type == EffectType.MOVE_MEMBER:
destination = str(eff.params.get("destination") or "").lower()
if destination == "target" or move_member_raw_value in ["TARGET", "TARGETS"]:
attr = 99
if eff.effect_type in (EffectType.PLAY_MEMBER_FROM_HAND, EffectType.PLAY_MEMBER_FROM_DISCARD):
attr = self._pack_filter_attr(eff)
dest_raw = str(eff.params.get("destination") or "").upper()
if dest_raw == "STAGE_EMPTY":
slot_params["target_slot"] = 4
elif "BATON" in dest_raw:
slot_params["is_baton_slot"] = True
if eff.effect_type == EffectType.PLAY_LIVE_FROM_DISCARD:
attr = self._pack_filter_attr(eff)
if eff.effect_type == EffectType.LOOK_AND_CHOOSE:
val = self._pack_effect_look_and_choose(eff, val, slot_params)
attr |= self._pack_filter_attr(eff)
if eff.effect_type in (
EffectType.SELECT_CARDS,
EffectType.SELECT_MEMBER,
EffectType.SELECT_LIVE,
EffectType.MOVE_TO_DISCARD,
EffectType.RECOVER_LIVE,
EffectType.RECOVER_MEMBER,
):
attr = self._pack_filter_attr(eff)
src_zone_str = str(eff.params.get("source") or eff.params.get("from") or eff.params.get("zone") or "DECK").upper()
if "," not in src_zone_str:
if src_zone_str == "HAND":
src_val = ZONES.get("HAND", 6)
elif src_zone_str == "DISCARD":
src_val = ZONES.get("DISCARD", 7)
elif src_zone_str in ("YELL", "REVEALED", "CHEER"):
src_val = ZONES.get("YELL", 15)
elif src_zone_str in ("STAGE", "TARGET_STAGE"):
src_val = ZONES.get("STAGE", 4)
elif (
src_zone_str == "DECK"
and eff.effect_type in (EffectType.SELECT_MEMBER, EffectType.MOVE_TO_DISCARD)
):
src_val = ZONES.get("STAGE", 4)
else:
src_val = ZONES.get("DECK_TOP", 1)
slot_params["source_zone"] = src_val
rem_val = eff.params.get("remainder_zone", 0)
if not rem_val and eff.params.get("raw_val") == "REMAINDER":
rem_val = "DISCARD"
slot_params["target_slot"] = 0
if isinstance(rem_val, str):
rem_map = {
"DISCARD": ZONES.get("DISCARD", 7),
"DECK": ZONES.get("DECK_TOP", 1),
"HAND": ZONES.get("HAND", 6),
"DECK_TOP": EXTRA_CONSTANTS.get("DECK_POSITION_TOP", 1),
"DECK_BOTTOM": EXTRA_CONSTANTS.get("DECK_POSITION_BOTTOM", 2),
}
rem_val = rem_map.get(rem_val.upper(), 0)
slot_params["remainder_zone"] = rem_val
if eff.effect_type == EffectType.MOVE_MEMBER and attr != 99:
attr = self._pack_filter_attr(eff)
src_zone_str = str(eff.params.get("source") or eff.params.get("from") or eff.params.get("zone") or "DECK").upper()
if "," not in src_zone_str:
if src_zone_str == "HAND":
src_val = ZONES.get("HAND", 6)
elif src_zone_str == "DISCARD":
src_val = ZONES.get("DISCARD", 7)
elif src_zone_str in ("YELL", "REVEALED", "CHEER"):
src_val = ZONES.get("YELL", 15)
elif src_zone_str in ("STAGE", "TARGET_STAGE"):
src_val = ZONES.get("STAGE", 4)
else:
src_val = ZONES.get("DECK_TOP", 1)
slot_params["source_zone"] = src_val
rem_val = eff.params.get("remainder_zone", 0)
if not rem_val and eff.params.get("raw_val") == "REMAINDER":
rem_val = "DISCARD"
slot_params["target_slot"] = 0
if isinstance(rem_val, str):
rem_map = {
"DISCARD": ZONES.get("DISCARD", 7),
"DECK": ZONES.get("DECK_TOP", 1),
"HAND": ZONES.get("HAND", 6),
"DECK_TOP": EXTRA_CONSTANTS.get("DECK_POSITION_TOP", 1),
"DECK_BOTTOM": EXTRA_CONSTANTS.get("DECK_POSITION_BOTTOM", 2),
}
rem_val = rem_map.get(rem_val.upper(), 0)
slot_params["remainder_zone"] = rem_val
if eff.effect_type == EffectType.MOVE_TO_DISCARD:
# REMAINDER handling for LOOK_AND_CHOOSE_ORDER
if not rem_val and eff.params.get("raw_val") == "REMAINDER":
rem_val = "DISCARD"
slot_params["target_slot"] = 0
# For UNTIL_SIZE operations, default source is HAND (not STAGE)
# as UNTIL_SIZE is used for hand size checks
default_source = "hand" if eff.params.get("operation") == "UNTIL_SIZE" else "stage"
src_val = eff.params.get("source", default_source).upper()
if src_val == "HAND":
src_val = ZONES.get("HAND", 6)
elif src_val == "DISCARD":
src_val = ZONES.get("DISCARD", 7)
elif any(k in src_val for k in ["DECK", "LOOKED"]):
src_val = ZONES.get("DECK_TOP", 1)
else:
src_val = ZONES.get("STAGE", 4)
slot_params["source_zone"] = src_val
if isinstance(rem_val, str):
rem_map = {
"DISCARD": ZONES.get("DISCARD", 7),
"DECK_TOP": ZONES.get("DECK_TOP", 1),
"DECK_BOTTOM": ZONES.get("DECK_BOTTOM", 2),
"HAND": ZONES.get("HAND", 6),
}
slot_params["remainder_zone"] = rem_map.get(rem_val.upper(), 0)
slot_params["dest_zone"] = ZONES.get("DISCARD", 7)
if eff.effect_type == EffectType.PLACE_UNDER:
source = str(eff.params.get("from") or eff.params.get("source") or "").lower()
u_src_val = 0
if source == "energy":
u_src_val = ZONES.get("ENERGY", 3)
elif source == "discard":
u_src_val = ZONES.get("DISCARD", 7)
slot_params["source_zone"] = u_src_val
# ENERGY_CHARGE params
if eff.effect_type == EffectType.ENERGY_CHARGE:
if eff.params.get("wait") or eff.params.get("state") == "wait":
slot_params["is_wait"] = True
# Empty Slot flag
dest = str(eff.params.get("destination") or "").lower()
if eff.params.get("is_empty_slot") or dest == "stage_empty" or "EMPTY" in dest:
slot_params["is_empty_slot"] = True
# Specialized Opcode Packing
if eff.effect_type == EffectType.SELECT_MEMBER:
attr = self._pack_filter_attr(eff)
if eff.effect_type == EffectType.MOVE_MEMBER:
destination = str(eff.params.get("destination") or "").lower()
if destination == "target" or move_member_raw_value in ["TARGET", "TARGETS"]:
attr = 99
if eff.effect_type in (EffectType.PLAY_MEMBER_FROM_HAND, EffectType.PLAY_MEMBER_FROM_DISCARD):
attr = self._pack_filter_attr(eff)
dest_raw = str(eff.params.get("destination") or "").upper()
if dest_raw == "STAGE_EMPTY":
slot_params["target_slot"] = 4
elif "BATON" in dest_raw:
slot_params["is_baton_slot"] = True
if eff.effect_type == EffectType.PLAY_LIVE_FROM_DISCARD:
attr = self._pack_filter_attr(eff)
if eff.effect_type == EffectType.LOOK_AND_CHOOSE:
val = self._pack_effect_look_and_choose(eff, val, slot_params)
attr |= self._pack_filter_attr(eff)
if eff.effect_type in (
EffectType.SELECT_CARDS,
EffectType.SELECT_MEMBER,
EffectType.SELECT_LIVE,
EffectType.MOVE_TO_DISCARD,
EffectType.RECOVER_LIVE,
EffectType.RECOVER_MEMBER,
):
attr = self._pack_filter_attr(eff)
src_zone_str = str(eff.params.get("source") or eff.params.get("from") or eff.params.get("zone") or "DECK").upper()
if "," not in src_zone_str:
if src_zone_str == "HAND":
src_val = ZONES.get("HAND", 6)
elif src_zone_str == "DISCARD":
src_val = ZONES.get("DISCARD", 7)
elif src_zone_str in ("YELL", "REVEALED", "CHEER"):
src_val = ZONES.get("YELL", 15)
elif src_zone_str in ("STAGE", "TARGET_STAGE"):
src_val = ZONES.get("STAGE", 4)
elif (
src_zone_str == "DECK"
and eff.effect_type in (EffectType.SELECT_MEMBER, EffectType.MOVE_TO_DISCARD)
):
src_val = ZONES.get("STAGE", 4)
else:
src_val = ZONES.get("DECK_TOP", 1)
slot_params["source_zone"] = src_val
rem_val = eff.params.get("remainder_zone", 0)
if not rem_val and eff.params.get("raw_val") == "REMAINDER":
rem_val = "DISCARD"
slot_params["target_slot"] = 0
if isinstance(rem_val, str):
rem_map = {
"DISCARD": ZONES.get("DISCARD", 7),
"DECK": ZONES.get("DECK_TOP", 1),
"HAND": ZONES.get("HAND", 6),
"DECK_TOP": EXTRA_CONSTANTS.get("DECK_POSITION_TOP", 1),
"DECK_BOTTOM": EXTRA_CONSTANTS.get("DECK_POSITION_BOTTOM", 2),
}
rem_val = rem_map.get(rem_val.upper(), 0)
slot_params["remainder_zone"] = rem_val
if eff.effect_type == EffectType.MOVE_MEMBER and attr != 99:
attr = self._pack_filter_attr(eff)
src_zone_str = str(eff.params.get("source") or eff.params.get("from") or eff.params.get("zone") or "DECK").upper()
if "," not in src_zone_str:
if src_zone_str == "HAND":
src_val = ZONES.get("HAND", 6)
elif src_zone_str == "DISCARD":
src_val = ZONES.get("DISCARD", 7)
elif src_zone_str in ("YELL", "REVEALED", "CHEER"):
src_val = ZONES.get("YELL", 15)
elif src_zone_str in ("STAGE", "TARGET_STAGE"):
src_val = ZONES.get("STAGE", 4)
else:
src_val = ZONES.get("DECK_TOP", 1)
slot_params["source_zone"] = src_val
rem_val = eff.params.get("remainder_zone", 0)
if not rem_val and eff.params.get("raw_val") == "REMAINDER":
rem_val = "DISCARD"
slot_params["target_slot"] = 0
if isinstance(rem_val, str):
rem_map = {
"DISCARD": ZONES.get("DISCARD", 7),
"DECK": ZONES.get("DECK_TOP", 1),
"HAND": ZONES.get("HAND", 6),
"DECK_TOP": EXTRA_CONSTANTS.get("DECK_POSITION_TOP", 1),
"DECK_BOTTOM": EXTRA_CONSTANTS.get("DECK_POSITION_BOTTOM", 2),
}
rem_val = rem_map.get(rem_val.upper(), 0)
slot_params["remainder_zone"] = rem_val
if eff.effect_type == EffectType.SET_HEART_COST:
val, attr = self._pack_effect_heart_cost(eff, val, attr)
if eff.effect_type == EffectType.REVEAL_UNTIL:
attr = self._pack_filter_attr(eff)
if (
eff.value_cond == ConditionType.TYPE_CHECK
and str(eff.params.get("card_type", "")).lower() == "live"
):
slot_params["is_reveal_until_live"] = True
elif eff.value_cond == ConditionType.COST_CHECK:
attr = int(eff.params.get("min", 0))
if eff.effect_type == EffectType.META_RULE:
m_type = str(eff.params.get("type", "") or eff.params.get("meta_type", "") or "CHEER_MOD").upper()
attr = META_RULE_TYPES.get(m_type, 0)
if attr == 1:
src = str(eff.params.get("source", "")).lower()
if src == "all_blade" or m_type == "ALL_BLADE_AS_ANY_HEART":
val = 1
elif src == "blade":
val = 2
if eff.effect_type == EffectType.RESTRICTION:
restriction_type = str(eff.params.get("type", "")).lower()
restriction_map = {
"live": 1,
"placement": 2,
}
attr = restriction_map.get(restriction_type, attr)
if eff.effect_type in (
EffectType.MOVE_TO_DISCARD,
EffectType.COLOR_SELECT,
EffectType.TRANSFORM_HEART,
EffectType.TRANSFORM_COLOR,
):
attr = self._pack_filter_attr(eff)
if eff.effect_type == EffectType.MOVE_TO_DISCARD and eff.params.get("operation") == "UNTIL_SIZE":
val = (int(val) & 0x7FFFFFFF) | (1 << 31)
val, attr = self._resolve_effect_dynamic_multiplier(eff, val, slot_params, attr)
# Default to Choice (slot 4) if target is generic
is_non_stage_discard = eff.effect_type == EffectType.MOVE_TO_DISCARD and slot_params["source_zone"] in (
ZONES.get("HAND", 6),
ZONES.get("DECK", 5),
)
if (
eff.target in (TargetType.SELF, TargetType.PLAYER)
and not is_non_stage_discard
and not slot_params.get("is_dynamic", False)
and not (eff.effect_type == EffectType.MOVE_MEMBER and attr == 99)
):
slot_params["target_slot"] = 4
# TARGETS usually means "the previously selected player-stage members".
# For sweep selectors like SELECT_MEMBER(ALL) {FILTER="PLAYER"} -> TARGETS,
# follow-up stage buffs should resolve as a player-wide stage effect.
if (
str(eff.params.get("destination") or "").lower() == "targets"
and eff.target == TargetType.PLAYER
and eff.effect_type in (EffectType.ADD_BLADES, EffectType.ADD_HEARTS, EffectType.BUFF_POWER, EffectType.GRANT_ABILITY)
):
slot_params["target_slot"] = int(TargetType.PLAYER)
# SYSTEMIC FIX: If this is REDUCE_COST with dynamic multiplier, base value should be 1
if eff.effect_type == EffectType.REDUCE_COST and slot_params.get("is_dynamic"):
val = 1
# SYSTEMIC FIX: Set is_wait if wait flow is detected
if eff.params.get("wait") or eff.params.get("wait_flow"):
slot_params["is_wait"] = True
slot = pack_s_standard(**slot_params)
# Redundant setting for PREVENT_PLAY_TO_SLOT (relies on slot bit 24)
if eff.effect_type == EffectType.PREVENT_PLAY_TO_SLOT:
pass # Target opponent bit 24 handles this generically
# ENSURE OPTIONAL BIT 61 SET FOR ALL OPCODES
if eff.is_optional or eff.params.get("is_optional"):
attr |= EXTRA_CONSTANTS.get("FILTER_IS_OPTIONAL", 1 << 61)
attr_val = attr if not eff.params.get("all") else (attr | 0x80)
# --- SYSTEMIC FIX: Ensure Opcode Mapping for all types ---
if eff.effect_type == EffectType.LOOK_REORDER_DISCARD:
op = Opcode.LOOK_REORDER_DISCARD
v = int(val)
bytecode.extend(
[
int(op),
to_signed_32(v),
to_signed_32(attr_val & 0xFFFFFFFF),
to_signed_32((attr_val >> 32) & 0xFFFFFFFF),
to_signed_32(slot),
]
)
elif eff.effect_type == EffectType.DIV_VALUE:
op = Opcode.DIV_VALUE
v = int(eff.params.get("divisor") or eff.value or 2)
bytecode.extend([int(op), to_signed_32(v), 0, 0, 0])
elif eff.effect_type == EffectType.REVEAL_UNTIL:
op = Opcode.REVEAL_UNTIL
v = int(val)
bytecode.extend(
[
int(op),
to_signed_32(v),
to_signed_32(attr_val & 0xFFFFFFFF),
to_signed_32((attr_val >> 32) & 0xFFFFFFFF),
to_signed_32(slot),
]
)
elif eff.effect_type == EffectType.CALC_SUM_COST:
op = Opcode.CALC_SUM_COST
bytecode.extend(
[
int(op),
to_signed_32(val),
to_signed_32(attr_val & 0xFFFFFFFF),
to_signed_32((attr_val >> 32) & 0xFFFFFFFF),
to_signed_32(slot),
]
)
else:
# SYSTEMIC FIX: Ensure filter is packed if present in params for generic effects
# This fixes data loss for ADD_BLADES, ADD_HEARTS, BOOST_SCORE etc when they carry a filter.
if "filter" in eff.params and attr == 0:
attr_val = self._pack_filter_attr(eff)
if eff.is_optional or eff.params.get("is_optional"):
attr_val |= EXTRA_CONSTANTS.get("FILTER_IS_OPTIONAL", 1 << 61)
# Fix: Default MOVE_TO_DISCARD for members to 1
if (
op == Opcode.MOVE_TO_DISCARD
and val == 0
and slot
in (int(TargetType.MEMBER_SELF), int(TargetType.MEMBER_OTHER), int(TargetType.MEMBER_SELECT))
):
val = 1
bytecode.extend(
[
int(op),
to_signed_32(val),
to_signed_32(attr_val & 0xFFFFFFFF), # a_low
to_signed_32((attr_val >> 32) & 0xFFFFFFFF), # a_high
to_signed_32(slot),
]
)
def _resolve_effect_target(self, eff: Effect, slot_params: Dict[str, Any]):
"""--- Target Resolution from Params ---"""
target_raw = eff.params.get("target") or eff.params.get("to")
if target_raw:
target_str = str(target_raw).upper()
target_map = {
"HAND": TargetType.CARD_HAND,
"CARD_HAND": TargetType.CARD_HAND,
"DISCARD": TargetType.CARD_DISCARD,
"CARD_DISCARD": TargetType.CARD_DISCARD,
"DECK": TargetType.CARD_DECK_TOP,
"CARD_DECK_TOP": TargetType.CARD_DECK_TOP,
"PLAYER": TargetType.PLAYER,
"SELF": TargetType.SELF,
"OPPONENT": TargetType.OPPONENT,
"MEMBER_SELF": TargetType.MEMBER_SELF,
"MEMBER_SELECT": TargetType.MEMBER_SELECT,
}
if target_str in target_map:
eff.target = target_map[target_str]
elif "MEMBER" in target_str:
if "OTHER" in target_str:
eff.target = TargetType.MEMBER_OTHER
else:
eff.target = TargetType.MEMBER_SELECT
elif eff.effect_type == EffectType.TAP_OPPONENT:
eff.target = TargetType.OPPONENT
# Update slot_params with the new target
slot_params["target_slot"] = eff.target.value if hasattr(eff.target, "value") else int(eff.target)
def _resolve_effect_source_zone(self, eff: Effect, slot_params: Dict[str, Any]):
"""--- Zone Relocation ---"""
src_val = 0
if eff.effect_type in (
EffectType.RECOVER_MEMBER,
EffectType.RECOVER_LIVE,
EffectType.PLAY_MEMBER_FROM_DISCARD,
EffectType.PLAY_LIVE_FROM_DISCARD,
):
source = str(eff.params.get("source") or eff.params.get("zone") or "discard").lower()
src_val = ZONES.get("DISCARD", 7) if source == "discard" else 0
if source == "yell":
src_val = ZONES.get("YELL", 15)
elif source in ("deck", "deck_top"):
src_val = ZONES.get("DECK_TOP", 1)
slot_params["source_zone"] = src_val
# TAP/Interactive selection also uses source zone
if eff.effect_type in (EffectType.TAP_OPPONENT, EffectType.TAP_MEMBER):
slot_params["source_zone"] = src_val
def _pack_effect_look_and_choose(self, eff: Effect, val: int, slot_params: Dict[str, Any]) -> int:
"""Special encoding for LOOK_AND_CHOOSE: val = look_count | (pick_count << 8) | (color_mask << 23)"""
char_ids = []
# Simple extraction of up to 3 character IDs
raw_names = str(eff.params.get("group") or eff.params.get("target_name") or eff.params.get("character") or "")
if raw_names:
parts = raw_names.replace(",", "/").split("/")
for p in parts[:3]:
p = p.strip()
if p in CHAR_MAP:
char_ids.append(CHAR_MAP[p])
look_v = {
"count": val,
"char_id_1": char_ids[0] if char_ids else 0,
"char_id_2": char_ids[1] if len(char_ids) > 1 else 0,
"char_id_3": char_ids[2] if len(char_ids) > 2 else 0,
"reveal": 1 if eff.params.get("reveal") else 0,
"dest_discard": 1 if eff.params.get("destination") == "discard" or eff.params.get("dest_discard") else 0,
}
val = pack_v_look_choose(**look_v)
src = str(eff.params.get("source") or eff.params.get("zone") or "DECK").upper()
src_val = ZONES.get("DECK_TOP", 1)
if src == "HAND":
src_val = ZONES.get("HAND", 6)
elif src == "DISCARD":
src_val = ZONES.get("DISCARD", 7)
elif src in ("YELL", "REVEALED", "CHEER"):
src_val = ZONES.get("YELL", 15)
elif src == "ENERGY":
src_val = ZONES.get("ENERGY", 3)
slot_params["source_zone"] = src_val
rem_dest_str = str(eff.params.get("remainder") or eff.params.get("destination") or "").upper()
rem_val = 0
if rem_dest_str == "DISCARD":
rem_val = ZONES.get("DISCARD", 7)
elif rem_dest_str == "DECK":
rem_val = ZONES.get("DECK_TOP", 1)
elif rem_dest_str == "HAND":
rem_val = ZONES.get("HAND", 6)
elif rem_dest_str == "DECK_TOP":
rem_val = EXTRA_CONSTANTS.get("DECK_POSITION_TOP", 1)
elif rem_dest_str == "DECK_BOTTOM":
rem_val = EXTRA_CONSTANTS.get("DECK_POSITION_BOTTOM", 2)
slot_params["remainder_zone"] = rem_val
# Parity Fix: Set is_wait if 'wait' parameter is present
if eff.params.get("wait") or eff.params.get("wait_flow"):
slot_params["is_wait"] = True
return val
def _pack_effect_heart_cost(self, eff: Effect, val: int, attr: int) -> Tuple[int, int]:
"""Specialized packing for SET_HEART_COST."""
colors = ["pink", "red", "yellow", "green", "blue", "purple"]
v_params = {c: int(eff.params.get(c, 0)) for c in colors}
val = pack_v_heart_counts(**v_params)
color_map = HEART_COLOR_MAP
req_list = []
add_val = eff.params.get("add")
if isinstance(add_val, (str, list)):
parts = add_val.replace(",", "/").split("/") if isinstance(add_val, str) else add_val
req_list = [color_map.get(str(p).strip().upper(), 0) for p in parts[:8]]
any_count = int(eff.params.get("any", 0))
for _ in range(any_count):
if len(req_list) < 8:
req_list.append(HEART_COLOR_MAP.get("ANY", 7))
a_params = {f"req_{i + 1}": req_list[i] if i < len(req_list) else 0 for i in range(8)}
unit_val = eff.params.get("unit")
if unit_val:
try:
from engine.models.enums import Unit
u_id = int(str(unit_val)) if str(unit_val).isdigit() else int(Unit.from_japanese_name(str(unit_val)))
a_params["unit_enabled"] = True
a_params["unit_id"] = u_id & 0x7F
except (AttributeError, TypeError, ValueError):
pass
attr = pack_a_heart_cost(**a_params)
return val, attr
def _resolve_effect_dynamic_multiplier(self, eff: Effect, val: int, slot_params: Dict[str, Any], attr: int) -> Tuple[int, int]:
"""Resolve dynamic multiplier logic for effects."""
divisor = 1
params = eff.params
if not (params.get("per_card") or params.get("per_member") or params.get("has_multiplier") or params.get("per_energy") or params.get("per_energy_paid")):
return val, attr
# Extract scaling divisor if present
divisor = int(params.get("per_energy") or params.get("per_energy_paid") or params.get("per_card_divisor") or 1)
count_src = str(params.get("per_card", "")).upper()
if count_src == "COUNT" and hasattr(self, "_last_counted_zone") and self._last_counted_zone:
count_src = self._last_counted_zone
count_op = COUNT_SOURCES.get(count_src, int(ConditionType.COUNT_STAGE))
slot_params["remainder_zone"] = count_op
slot_params["is_dynamic"] = True
# Ensure multiplier threshold is 1 if not specified
if not params.get("value_enabled") and not params.get("cost_ge") and not params.get("cost_le"):
params["value_enabled"] = True
params["value_threshold"] = 1
# SYSTEMIC FIX: If this is REDUCE_COST, base value should be 1
if eff.effect_type == EffectType.REDUCE_COST:
# We can't easily change 'val' here because it's passed by value
# But we can update the effect value in place if needed, or caller handles it.
pass
# Packed value with divisor in high bits
packed_val = pack_v_scalar_dynamic(base_value=val, divisor=divisor)
# Ensure the DYNAMIC_VALUE bit (bit 60) is set in the attribute for the engine to recognize scaling
dynamic_bit = EXTRA_CONSTANTS.get("DYNAMIC_VALUE", 1 << 60)
return packed_val, self._pack_filter_attr(eff) | dynamic_bit
def _pack_filter_slot(self, area_str: str) -> int:
"""Helper to pack area strings into slot bits (29-31)."""
slot = 0
a_str = area_str.upper()
if "LEFT" in a_str:
slot |= 1 << 29
elif "CENTER" in a_str:
slot |= 2 << 29
elif "RIGHT" in a_str:
slot |= 3 << 29
return slot
def _pack_filter_attr(self, source: Any) -> int:
"""
Standardized packing for all filter parameters (Revision 5, 64-bit).
'source' can be an Effect, Condition, or direct Dict params.
"""
attr = 0
from engine.models.enums import CHAR_MAP, Group, HeartColor, Unit
from engine.models.generated_metadata import CHARACTER_IDS
# 0. Initialize params from various sources
if hasattr(source, "params"):
params = source.params
elif isinstance(source, dict):
params = source
else:
params = {}
params_upper = {str(k).upper(): v for k, v in params.items() if isinstance(k, str)}
# Extract filter string and other parameters
raw_filter_str = str(
params.get("filter")
or params_upper.get("FILTER")
or params.get("player_center_filter")
or params_upper.get("PLAYER_CENTER_FILTER")
or params.get("opponent_center_filter")
or params_upper.get("OPPONENT_CENTER_FILTER")
or ""
)
filter_str = raw_filter_str.upper()
# Structured object for Phase 3 parity
filter_obj = {
"target_player": 0,
"card_type": 0,
"group_enabled": False,
"group_id": 0,
"is_tapped": False,
"has_blade_heart": False,
"not_has_blade_heart": False,
"unique_names": False,
"unit_enabled": False,
"unit_id": 0,
"value_enabled": False,
"value_threshold": 0,
"is_le": False,
"is_cost_type": False,
"color_mask": 0,
"char_id_1": 0,
"char_id_2": 0,
"zone_mask": 0,
"special_id": 0,
"is_setsuna": False,
"compare_accumulated": False,
"is_optional": False,
"keyword_energy": False,
"keyword_member": False,
}
# Use a separate local variable for filter-specific value thresholding
# to avoid 'val' contamination from the parent command (e.g. COUNT_MEMBER(2) shouldn't require 2 hearts)
f_val = 0
colors = params.get("colors") or params.get("choices") or ([params.get("color")] if "color" in params else [])
sid = params.get("special_id") or params_upper.get("SPECIAL_ID")
# 1. Target Player (Bits 0-1)
if hasattr(source, "target") and source.target == TargetType.OPPONENT:
filter_obj["target_player"] = 2
elif params_upper.get("OPPONENT") or params_upper.get("TARGET_OPPONENT"):
filter_obj["target_player"] = 2
elif "OPPONENT" in filter_str:
filter_obj["target_player"] = 2
elif hasattr(source, "target") and source.target in (TargetType.PLAYER, TargetType.SELF):
filter_obj["target_player"] = 1
else:
filter_obj["target_player"] = 0 # Parity Fix: Default to 0 (Unspecified)
# Check for ANY_STAGE area parameter which indicates checking both players' stages
area_val = params.get("area") or params_upper.get("AREA")
if area_val:
area_str = str(area_val).upper()
if "ANY_STAGE" in area_str:
filter_obj["target_player"] = 3 # BOTH players' stages
# 2. Card Type (Bits 2-3)
ctype = str(params.get("type") or params.get("card_type") or "").lower()
if not ctype and "TYPE=" in filter_str:
m = re.search(r"TYPE=(\w+)", filter_str)
if m:
ctype = m.group(1).lower()
elif not ctype and "TYPE_" in filter_str:
# Handle TYPE_MEMBER, TYPE_LIVE format in filter string
m = re.search(r"TYPE_(\w+)", filter_str)
if m:
ctype = m.group(1).lower()
if "live" in ctype:
filter_obj["card_type"] = 2
elif "member" in ctype:
filter_obj["card_type"] = 1
# 3. Group Filter (Bit 4 + Bits 5-11)
group_val = params.get("group") or params.get("group_id") or params_upper.get("GROUP_ID")
if not group_val and "GROUP_ID=" in filter_str:
m = re.search(r"GROUP_ID=(\d+)", filter_str)
if m:
group_val = m.group(1)
elif not group_val and "GROUP_" in filter_str:
m = re.search(r"GROUP_([A-Z]+)", filter_str)
if m:
group_val = m.group(1)
elif not group_val and "NIJIGASAKI" in filter_str:
group_val = 2
elif not group_val and "LIELLA" in filter_str:
group_val = 3
elif not group_val and "HASUNOSORA" in filter_str:
group_val = 4
if group_val:
try:
g_id = (
int(str(group_val))
if str(group_val).isdigit()
else int(Group.from_japanese_name(str(group_val).replace("_", " ")))
)
filter_obj["group_enabled"] = True
filter_obj["group_id"] = g_id & 0x7F
except (AttributeError, TypeError, ValueError):
pass
# 4. Unit Filter (Bit 16 + Bits 17-23)
# NOTE: Some keywords like "UNIT_HASUNOSORA" are actually GROUP constraints, not UNIT constraints.
# We must intercept these and treat them as groups instead of trying to resolve them as units.
unit_val = params.get("unit") or params.get("unit_id") or params_upper.get("UNIT_ID")
if not unit_val and "UNIT_ID=" in filter_str:
m = re.search(r"UNIT_ID=(\d+)", filter_str)
if m:
unit_val = m.group(1)
elif not unit_val and "UNIT_" in filter_str:
m = re.search(r"UNIT_([A-Z0-9_]+)", filter_str)
if m:
unit_val = m.group(1)
elif not unit_val and "UNIT_BIBI" in filter_str:
unit_val = 2
# Check if unit_val is actually a GROUP keyword (HASUNOSORA, LIELLA, NIJIGASAKI, etc.)
# If so, treat it as a group constraint instead of a unit constraint
if unit_val and isinstance(unit_val, str):
unit_val_upper = str(unit_val).upper()
# Map group-like keywords that might appear as UNIT_*
group_keyword_map = {
"HASU": 4,
"HASUNOSORA": 4,
"LIELLA": 3,
"NIJIGASAKI": 2,
"AQUOURS": 1,
"MUS": 0,
}
# If this keyword is actually a group, handle it as a group instead
if unit_val_upper in group_keyword_map:
if not filter_obj.get("group_enabled"):
filter_obj["group_enabled"] = True
filter_obj["group_id"] = group_keyword_map[unit_val_upper]
unit_val = None # Clear so we don't process it as a unit below
if unit_val:
try:
u_id = (
int(str(unit_val))
if str(unit_val).isdigit()
else int(Unit.from_japanese_name(str(unit_val).replace("_", " ")))
)
filter_obj["unit_enabled"] = True
filter_obj["unit_id"] = u_id & 0x7F
except (AttributeError, TypeError, ValueError):
pass
# 5. Cost Filter (Bit 24 + Bits 25-29 + Bit 30=Mode + Bit 31=Type=1)
c_min = params.get("cost_ge") # Only check explicit cost GE/LE params
c_max = params.get("cost_le") or params.get("total_cost_le")
if c_min is None and ("COST_GE=" in filter_str or "COST_GE_" in filter_str):
m = re.search(r"COST_GE[=_](\d+)", filter_str)
if m:
c_min = m.group(1)
if c_max is None and ("COST_LE=" in filter_str or "COST_LE_" in filter_str):
m = re.search(r"COST_LE[=_](\d+)", filter_str)
if m:
c_max = m.group(1)
if "COST_LT_TARGET_VAL" in filter_str:
filter_obj["value_enabled"] = True
filter_obj["is_le"] = True
filter_obj["compare_accumulated"] = True
filter_obj["is_cost_type"] = True
elif "COST_EQ=BASE_COST+" in filter_str:
m = re.search(r"COST_EQ=BASE_COST\+(\d+)", filter_str)
if m:
filter_obj["value_enabled"] = True
filter_obj["value_threshold"] = int(m.group(1)) & 0x1F
filter_obj["compare_accumulated"] = True
filter_obj["is_cost_type"] = True
filter_obj["special_id"] = 5
elif "COST_EQ_TARGET_PLUS_" in filter_str:
m = re.search(r"COST_EQ_TARGET_PLUS_(\d+)", filter_str)
if m:
filter_obj["value_enabled"] = True
filter_obj["value_threshold"] = int(m.group(1)) & 0x1F
filter_obj["compare_accumulated"] = True
filter_obj["is_cost_type"] = True
filter_obj["special_id"] = 5
elif c_min is not None:
try:
val_c = int(c_min)
filter_obj["value_enabled"] = True
filter_obj["value_threshold"] = val_c & 0x1F
filter_obj["is_cost_type"] = True
filter_obj["is_le"] = False
except (TypeError, ValueError):
pass
elif c_max is not None:
try:
val_c = int(c_max)
filter_obj["value_enabled"] = True
filter_obj["value_threshold"] = val_c & 0x1F
filter_obj["is_cost_type"] = True
filter_obj["is_le"] = True
except (TypeError, ValueError):
pass
# 5.1 Heart Sum Support
sum_ge = params.get("sum_heart_total_ge") or params_upper.get("SUM_HEART_TOTAL_GE")
sum_le = params.get("sum_heart_total_le") or params_upper.get("SUM_HEART_TOTAL_LE")
# Also check filter_str for SUM_HEART_TOTAL_GE (e.g. "SUM_HEART_TOTAL_GE=8")
if not sum_ge and "SUM_HEART_TOTAL_GE=" in filter_str:
m = re.search(r"SUM_HEART_TOTAL_GE=(\d+)", filter_str)
if m:
sum_ge = m.group(1)
if not sum_le and "SUM_HEART_TOTAL_LE=" in filter_str:
m = re.search(r"SUM_HEART_TOTAL_LE=(\d+)", filter_str)
if m:
sum_le = m.group(1)
if sum_ge is not None:
filter_obj["value_enabled"] = True
filter_obj["value_threshold"] = int(sum_ge) & 0x1F
filter_obj["is_cost_type"] = False # Must be False for heart sum in engine
filter_obj["is_le"] = False
elif sum_le is not None:
filter_obj["value_enabled"] = True
filter_obj["value_threshold"] = int(sum_le) & 0x1F
filter_obj["is_cost_type"] = False
filter_obj["is_le"] = True
# 6. Character Filter (IDs at 39-45, 46-52 in Revision 5)
names = params.get("name") or params_upper.get("NAME")
if not names and "NAME=" in filter_str:
m = re.search(r"NAME=([^,]+)", raw_filter_str, re.IGNORECASE)
if m:
names = m.group(1)
if not names and "NAME_IN=[" in filter_str:
names = re.findall(r"'([^']+)'", raw_filter_str)
if not names and raw_filter_str and "/" in raw_filter_str and "=" not in raw_filter_str:
names = raw_filter_str
if names:
if isinstance(names, (list, tuple)):
n_list = [str(name) for name in names]
else:
n_list = str(names).split("/")
for i, n in enumerate(n_list[:3]):
try:
n_norm = n.strip().replace(" ", "").replace("驍オ・イ・つ€", "")
c_id = 0
for k, cid in CHAR_MAP.items():
if k.replace(" ", "").replace("驍オ・イ・つ€", "") == n_norm:
c_id = cid
break
if c_id == 0:
c_id = int(CHARACTER_IDS.get(n_norm.upper(), 0))
if c_id > 0:
if i < 2:
filter_obj[f"char_id_{i + 1}"] = c_id & 0x7F
elif not filter_obj["unit_enabled"]:
# Standard filter packing only has two dedicated character slots.
# Reuse the dormant unit_id bits as a third character-id fallback
# when there is no actual unit filter on this selector.
filter_obj["unit_id"] = c_id & 0x7F
except:
pass
# 7. Heart Value and Color Filter (Bits 25-29 Threshold + Bit 31=Type=0 + Bits 32-38 Color Mask)
if "HAS_HEART_" in filter_str:
match = re.search(r"HAS_HEART_(\d+)(?:_X(\d+))?", filter_str)
if match:
color_code = match.group(1)
count = match.group(2)
try:
c_idx = int(color_code)
if not colors:
colors = [c_idx]
if count:
f_val = int(count)
except (TypeError, ValueError):
pass
elif "HAS_COLOR_" in filter_str:
match = re.search(r"HAS_COLOR_([A-Z]+)(?:_X(\d+))?", filter_str)
if match:
color_name = match.group(1).upper()
count = match.group(2)
try:
c_idx = int(HeartColor[color_name])
if not colors:
colors = [c_idx]
if count:
f_val = int(count)
except (KeyError, TypeError, ValueError):
pass
if f_val > 0:
filter_obj["value_enabled"] = True
filter_obj["value_threshold"] = f_val & 0x1F
filter_obj["is_cost_type"] = False
if colors or "COLOR=" in filter_str:
color_mask = 0
for c in colors if isinstance(colors, list) else [colors]:
if c is None:
continue
try:
if isinstance(c, str):
if c.isdigit():
c_idx = int(c)
else:
c_idx = int(HeartColor[c.upper()])
else:
c_idx = int(c)
color_mask |= 1 << c_idx
except (KeyError, TypeError, ValueError):
pass
if color_mask > 0:
filter_obj["color_mask"] = color_mask & 0x7F
# 8. Meta Flags and Masks
# Zone Mask (Bits 53-55)
zone_val = params.get("zone") or params.get("source") or params_upper.get("ZONE")
if zone_val:
z_str = str(zone_val).upper()
z_mask = 0
if "STAGE" in z_str:
z_mask = int(EXTRA_CONSTANTS.get("ZONE_MASK_STAGE", 4))
elif "HAND" in z_str:
z_mask = int(EXTRA_CONSTANTS.get("ZONE_MASK_HAND", 6))
elif "DISCARD" in z_str:
z_mask = int(EXTRA_CONSTANTS.get("ZONE_MASK_DISCARD", 7))
if z_mask > 0:
filter_obj["zone_mask"] = z_mask & 0x07
# Special ID (Bits 56-58)
if not sid and "NOT_TARGET" in filter_str:
sid = 7
elif not sid and "NOT_SELF" in filter_str:
sid = 3
elif not sid and "SAME_NAME_AS_REVEALED" in filter_str:
sid = 4
elif not sid and "SELECTED_DISCARD" in filter_str:
sid = 6
if sid:
try:
sid_int = int(sid)
filter_obj["special_id"] = sid_int & 0x07
except (TypeError, ValueError):
pass
# Setsuna (Bit 59)
if params.get("is_setsuna") or params_upper.get("IS_SETSUNA"):
filter_obj["is_setsuna"] = True
elif names and "SETSUNA" in str(names).upper():
filter_obj["is_setsuna"] = True
# Internal Flags
if (
getattr(source, "is_optional", False)
or params.get("is_optional")
or "(Optional)" in str(params.get("pseudocode", ""))
):
filter_obj["is_optional"] = True
# Keywords (Bits 62-63)
keyword = str(params.get("keyword") or params.get("filter") or "").upper()
if params.get("KEYWORD_ENERGY") or "ACTIVATED_ENERGY" in keyword or "DID_ACTIVATE_ENERGY" in keyword:
filter_obj["keyword_energy"] = True
if params.get("KEYWORD_MEMBER") or "ACTIVATED_MEMBER" in keyword or "DID_ACTIVATE_MEMBER" in keyword:
filter_obj["keyword_member"] = True
# Legacy flags (Bits 12-15)
# Parse STATUS=TAPPED from filter string or params
if params.get("is_tapped") or "STATUS=TAPPED" in filter_str or "STATUS=TAP" in filter_str:
filter_obj["is_tapped"] = True
# Parse HAS_BLADE_HEART / NOT_HAS_BLADE_HEART from filter string or params
bh = params.get("has_blade_heart")
if bh is True or "HAS_BLADE_HEART" in filter_str:
filter_obj["has_blade_heart"] = True
elif bh is False or "NOT_HAS_BLADE_HEART" in filter_str:
filter_obj["not_has_blade_heart"] = True
# Parse UNIQUE_NAMES from filter string or params
if params.get("UNIQUE_NAMES") or params_upper.get("UNIQUE_NAMES") or "UNIQUE_NAMES" in filter_str:
filter_obj["unique_names"] = True
filter_spec = PackedFilterSpec(**filter_obj)
self.filters.append(filter_spec.to_debug_dict())
# Use generated packer to ensure bit parity with Rust
return filter_spec.pack()
def reconstruct_text(self, lang: str = "en") -> str:
from .ability_rendering import reconstruct_text
return reconstruct_text(self, lang=lang)