Spaces:
Sleeping
Sleeping
| 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. | |
| 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 | |
| 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 | |
| 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. | |
| class Cost: | |
| type: AbilityCostType | |
| value: int = 0 | |
| params: Dict[str, Any] = field(default_factory=dict) | |
| is_optional: bool = False | |
| def cost_type(self) -> AbilityCostType: | |
| return self.type | |
| class Ability: | |
| raw_text: str | |
| trigger: TriggerType | |
| effects: List[Effect] | |
| conditions: List[Condition] = field(default_factory=list) | |
| costs: List[Cost] = field(default_factory=list) | |
| modal_options: List[List[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) | |