from __future__ import annotations import copy import random from typing import Any, Dict import numpy as np from engine.game.effects.choices import queue_select_from_list_choice from engine.game.effects.hand import discard_hand_cards from engine.models.ability import ConditionType, Effect, EffectType, TargetType from engine.models.enums import Group, Unit from engine.models.opcodes import Opcode def _decode_real_value(p: Any, v: int, a: int, s_packed: int) -> int: """Resolve bytecode scaling/dynamic-count semantics into a concrete value.""" real_v = v if (a & 0x40) == 0: return real_v cond_type = ConditionType(v) if cond_type == ConditionType.COUNT_STAGE: raw_count = len([c for c in p.stage if c >= 0]) elif cond_type == ConditionType.COUNT_HAND: raw_count = len(p.hand) elif cond_type == ConditionType.COUNT_DISCARD: raw_count = len(p.discard) elif cond_type == ConditionType.COUNT_ENERGY: raw_count = len(p.energy_zone) elif cond_type == ConditionType.COUNT_SUCCESS_LIVE: raw_count = len(p.success_lives) elif cond_type == ConditionType.COUNT_LIVE_ZONE: raw_count = len(p.live_zone) else: raw_count = 0 if (a & 0x20) != 0: scaling_factor = s_packed >> 4 if scaling_factor > 0: return (s_packed & 0x0F) * (raw_count // scaling_factor) return 0 return raw_count def _append_deck_top_to_discard(game: Any, player: Any, count: int) -> None: for _ in range(count): if not player.main_deck: game._resolve_deck_refresh(player) if player.main_deck: player.discard.append(player.main_deck.pop(0)) def _append_energy_to_discard(player: Any, count: int) -> None: for _ in range(count): if player.energy_zone: player.discard.append(player.energy_zone.pop()) def resolve_effect_opcode(game: Any, opcode: Opcode, seg: Any, context: Dict[str, Any]) -> None: self = game """Execute a single quadruple from bytecode.""" p = self.active_player opp_idx = 1 - p.player_id v = seg[1] a = seg[2] s_packed = seg[3] real_v = _decode_real_value(p, v, a, s_packed) s = s_packed & 0x0F # Condition Check Opcodes (Return/Jump logic usually handled in loop, but we need 'cond' state) # In this Python version, _resolve_pending_effect's loop needs to handle cond. # But for now, we only trigger effects. if self.verbose: print(f"DEBUG_OP: {opcode.name} v={v} a={a} s={s}") if opcode == Opcode.DRAW: self._draw_cards(p, real_v) elif opcode == Opcode.ADD_BLADES: p.continuous_effects.append( { "source_card_id": context.get("source_card_id", context.get("card_id", -1)), "effect": Effect(EffectType.ADD_BLADES, real_v, TargetType.SELF), "target_slot": s if s < 3 else -1, "expiry": "TURN_END", } ) elif opcode == Opcode.ADD_HEARTS: p.continuous_effects.append( { "source_card_id": context.get("source_card_id", context.get("card_id", -1)), "effect": Effect(EffectType.ADD_HEARTS, real_v, TargetType.SELF, {"color": a & 0x3F}), "expiry": "TURN_END", } ) elif opcode == Opcode.GRANT_ABILITY: # Granting an ability (Rule 1.3.1) # a: attribute of granted ability (trigger type) # v: Index into member_db if external, or reference to current # For Sumire: It's usually a predefined ID or a special payload. # In V2 compiler, GRANT_ABILITY often carries a whole Ability object or pre-compiled bytecode. # But here we handle the "Legacy" or "Simple" case for Starters. # If it's "SELF" (s == 0), we apply to current member. source_id = context.get("source_card_id", -1) target_p = p # In Sumire's case, the pseudocode is: # EFFECT: GRANT_ABILITY(SELF, TRIGGER="CONSTANT", CONDITION="IS_ON_STAGE", EFFECT="BOOST_SCORE(1)") # This is compiled into Opcode 60. # For simplicity in this engine version, we convert it to a Continuous Effect # that mimics the granted ability. target_slot = context.get("area", -1) if target_slot == -1 and source_id != -1: # Find where source_id is on stage for i, cid in enumerate(p.stage): if cid == source_id: target_slot = i break if target_slot != -1: # Create a pseudo-effect that matches the granted ability # Sumire grants BOOST_SCORE(1) while on stage. # In basic engine terms, this is just a Score Buff continuous effect. p.continuous_effects.append( { "source_card_id": source_id, "effect": Effect(EffectType.BOOST_SCORE, real_v, TargetType.SELF), "target_slot": target_slot, "expiry": "TURN_END", # Usually until end of turn or permanent? # Starter Sumire is "This turn". } ) elif opcode == Opcode.LOOK_DECK: # Reveal top V cards (Target awareness) target_tp = self.players[opp_idx] if s == 2 else p # s=2 is OPPONENT in TargetType if self.verbose: print(f"DEBUG: LOOK_DECK Target={target_tp.player_id} DeckLen={len(target_tp.main_deck)}") if not target_tp.main_deck: self._resolve_deck_refresh(target_tp) self.looked_cards = [] for _ in range(v): if target_tp.main_deck: self.looked_cards.append(target_tp.main_deck.pop(0)) if self.verbose: print(f"DEBUG: LOOK_DECK Result={self.looked_cards}") elif opcode == Opcode.LOOK_AND_CHOOSE: # Trigger SLIST choice if self.looked_cards: dest = "discard" if (a & 0x0F) == 1 else "hand" target_pid = opp_idx if s == 2 else p.player_id queue_select_from_list_choice( self, { "player_id": p.player_id, "source_card_id": context.get("source_card_id", context.get("card_id", -1)), }, self.looked_cards, real_v, "look_and_choose", "Choose cards from looked list", target_pid, extra_params={"destination": dest}, ) elif opcode == Opcode.TAP_OPPONENT: is_all = (a & 0x80) != 0 requires_selection = (a & 0x20) != 0 # Bit 5 flag cost_max = v blades_max = a & 0x1F # Mask out flags # Dynamic Resolution: If cost_max is 99, try to find context card cost if cost_max == 99: scid = context.get("source_card_id", context.get("card_id", -1)) if scid != -1 and scid in self.member_db: cost_max = self.member_db[scid].cost def passes_filter(slot_id): cid = opp.stage[slot_id] if cid < 0: return False card = self.member_db[int(cid)] # Cost check if cost_max != 99 and card.cost > cost_max: return False # Blades check if blades_max != 99 and card.total_blades > blades_max: return False return True if is_all: for i in range(3): if passes_filter(i): opp.tapped_members[i] = True opp.members_tapped_by_opponent_this_turn.add(opp.stage[i]) else: # Check for interactive selection if requires_selection or (s == 2 and v == 1): # s=2 is OPPONENT, v=1 count choice = context.get("choice_index", -1) if choice == -1: # Pause for selection # Create a pending choice # Valid targets valid_slots = [i for i in range(3) if passes_filter(i) and not opp.tapped_members[i]] if valid_slots: self.pending_choices.append( ( "TARGET_OPPONENT_MEMBER", { "player_id": self.current_player, "valid_slots": valid_slots, "effect": "tap_opponent", "pending_context": context, # Store context to resume # "resume_opcode": opcode, ... (Python engine handles resume differently usually) # Using standard pending_choices format }, ) ) # In Python engine, pending_choices usually halts execution flow implicitly or explicitly? # resolve_bytecode doesn't return status. # We might need to ensure this halts. # But standard python engine relies on pending_choices check in outer loop. pass else: # Apply to chosen slot if 0 <= choice < 3 and passes_filter(choice): opp.tapped_members[choice] = True opp.members_tapped_by_opponent_this_turn.add(opp.stage[choice]) elif s < 3: # Fallback for directed target (if s is actually a fixed slot index) # Note: s=2 (Right Slot) vs s=2 (Opponent TargetType) is ambiguous without flags. # This is why we added the flag. # If flag is NOT set, and s < 3, assume fixed slot? # But previously s=2 tapped right slot. if passes_filter(s): opp.tapped_members[s] = True opp.members_tapped_by_opponent_this_turn.add(opp.stage[s]) elif opcode == Opcode.ACTIVATE_MEMBER: if s < 3: p.tapped_members[s] = False elif opcode == Opcode.REVEAL_CARDS: # s=1 means reveal looking cards (usually for yel animation/trigger) pass elif opcode == Opcode.RECOVER_LIVE: # Trigger interactive selection instead of broken automatic recovery live_cards_in_discard = [cid for cid in p.discard if int(cid) in self.live_db] if live_cards_in_discard: self.pending_choices.append( ( "SELECT_FROM_DISCARD", { "source_card_id": context.get("source_card_id", -1), "cards": live_cards_in_discard, "count": real_v, "filter": "live", "effect": "return_to_hand", "effect_description": "蝗槫庶縺吶k繝ゥ繧、繝悶r驕ク繧薙〒縺上□縺輔>", }, ) ) elif opcode == Opcode.RECOVER_MEMBER: # Trigger interactive selection instead of broken automatic recovery member_cards_in_discard = [cid for cid in p.discard if int(cid) in self.member_db] if member_cards_in_discard: self.pending_choices.append( ( "SELECT_FROM_DISCARD", { "source_card_id": context.get("source_card_id", -1), "cards": member_cards_in_discard, "count": real_v, "filter": "member", "effect": "return_to_hand", "effect_description": "蝗槫庶縺吶k繝。繝ウ繝舌・繧帝∈繧薙〒縺上□縺輔>", }, ) ) elif opcode == Opcode.SWAP_CARDS: # Discard v, then draw v for _ in range(v): if p.hand: cid = p.hand.pop(0) p.hand_added_turn.pop(0) p.discard.append(cid) self._draw_cards(p, v) elif opcode == Opcode.REDUCE_COST: p.continuous_effects.append( { "source_card_id": context.get("source_card_id", context.get("card_id", -1)), "effect": Effect(EffectType.REDUCE_COST, v, TargetType.SELF), "target_slot": s if s < 3 else -1, "expiry": "TURN_END", } ) elif opcode == Opcode.REDUCE_HEART_REQ: p.continuous_effects.append( { "source_card_id": context.get("source_card_id", context.get("card_id", -1)), "effect": Effect(EffectType.REDUCE_HEART_REQ, v, TargetType.SELF), "expiry": "LIVE_END" if context.get("until") == "live_end" else "TURN_END", } ) elif opcode == Opcode.BATON_TOUCH_MOD: p.continuous_effects.append( { "source_card_id": context.get("source_card_id", context.get("card_id", -1)), "effect": Effect(EffectType.BATON_TOUCH_MOD, v, TargetType.SELF), "expiry": "TURN_END", } ) elif opcode == Opcode.META_RULE: # Handle specific meta rules that have engine impact (cheer_mod, etc) rule_type = context.get("type", "") if rule_type == "cheer_mod": p.continuous_effects.append( { "source_card_id": context.get("source_card_id", context.get("card_id", -1)), "effect": Effect(EffectType.META_RULE, v, TargetType.SELF, {"type": "cheer_mod"}), "expiry": "LIVE_END" if context.get("until") == "live_end" else "TURN_END", } ) elif rule_type == "fragment_cleanup": pass # Already handled by parser filter else: p.meta_rules.add(str(rule_type)) elif opcode == Opcode.TRANSFORM_COLOR: target = context.get("target", "base_hearts") p.continuous_effects.append( { "source_card_id": context.get("source_card_id", context.get("card_id", -1)), "effect": Effect(EffectType.TRANSFORM_COLOR, v, TargetType.SELF, {"target": target, "color": a}), "expiry": "LIVE_END" if context.get("until") == "live_end" else "TURN_END", } ) elif opcode == Opcode.PLAY_MEMBER_FROM_HAND: # v = count, a = source_attr (e.g. Group) # From hand is standard, but if context says discard: source_zone = context.get("from", "hand") self.pending_choices.append( ( "TARGET_MEMBER_SLOT", { **context, "player_id": p.player_id, "effect": "place_member", "source_zone": source_zone, "count": v, "filter_group": a if a != 0 else None, }, ) ) elif opcode == Opcode.FLAVOR_ACTION: # Trigger modal choice "What do you like?" self.pending_choices.append( ( "MODAL_CHOICE", { **context, "player_id": opp_idx, "title": "What do you like?", "options": [ "Choco Mint", "Strawberry Flavor", "Cookies & Cream", "You", "Anything else", ], "reason": "flavor_action", }, ) ) elif opcode == Opcode.ADD_TO_HAND: # v = count, a = source (0=looked, 1=discard, 2=deck) if a == 0 and self.looked_cards: for _ in range(v): if self.looked_cards: cid = self.looked_cards.pop(0) p.hand.append(cid) p.hand_added_turn.append(self.turn_number) elif a == 1: # Handled by RECOVER_LIVE/MEMBER usually, but for generic: for _ in range(v): if p.discard: cid = p.discard.pop() p.hand.append(cid) p.hand_added_turn.append(self.turn_number) elif a == 2: self._draw_cards(p, v) elif opcode == Opcode.BOOST_SCORE: p.live_score_bonus += v elif opcode == Opcode.ENERGY_CHARGE: count = v for _ in range(count): if p.main_deck: cid = p.main_deck.pop(0) p.energy_zone.append(cid) p.tapped_energy[len(p.energy_zone) - 1] = False elif p.discard: self._resolve_deck_refresh(p) if p.main_deck: cid = p.main_deck.pop(0) p.energy_zone.append(cid) p.tapped_energy[len(p.energy_zone) - 1] = False elif opcode == Opcode.MOVE_MEMBER: dest = context.get("target_slot", -1) if 0 <= s < 3 and 0 <= dest < 3: self._move_member(p, s, dest) elif opcode == Opcode.MOVE_TO_DISCARD: # v = count, a = source (1=deck_top, 2=hand, 3=energy), s = target_slot if a == 1: # From Deck Top _append_deck_top_to_discard(self, p, v) elif a == 2: # From Hand discard_hand_cards(p, v) elif a == 3: # From Energy _append_energy_to_discard(p, v) elif s == 0: # Target SELF (Member on stage) scid = context.get("source_card_id", context.get("card_id", -1)) for i in range(3): if p.stage[i] == scid: p.stage[i] = -1 p.tapped_members[i] = False p.discard.append(scid) break elif opcode == Opcode.JUMP_IF_FALSE: # This requires a more complex loop in _resolve_pending_effect # For now, standard step() might not use jumps heavily in Python mode pass __all__ = ["resolve_effect_opcode"]