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)