diff --git a/spirit/game/card_effects/trainers.py b/spirit/game/card_effects/trainers.py index 7df412b..9ab43e4 100644 --- a/spirit/game/card_effects/trainers.py +++ b/spirit/game/card_effects/trainers.py @@ -663,8 +663,7 @@ async def rotom_phone(ctx): ) if not picks: return - await ctx.shuffle_deck() - await ctx.put_on_top_of_deck(picks[0]) + await ctx.shuffle_deck_below(picks[0]) async def fan_of_waves(ctx): diff --git a/spirit/game/game_sequence_packets.py b/spirit/game/game_sequence_packets.py index 0374b3a..1442c1f 100644 --- a/spirit/game/game_sequence_packets.py +++ b/spirit/game/game_sequence_packets.py @@ -2,6 +2,15 @@ from typing import Any, Dict, List, Optional from spirit.game.attributes import GameSequence from spirit.network.message_names import OutboundMsg + +class NestedSequence: + """A child Start/Stop bracket embedded inside a parent game sequence.""" + + def __init__(self, name, messages: List[Dict[str, Any]]): + self.name: str = getattr(name, "value", name) + self.messages: List[Dict[str, Any]] = messages + + def _build_msg(name: str, value: Dict[str, Any]) -> Dict[str, Any]: """Helper to build standard Warg Protocol polymorphic JSON envelopes.""" return { diff --git a/spirit/game/session/effects.py b/spirit/game/session/effects.py index 4db8f9b..241d789 100644 --- a/spirit/game/session/effects.py +++ b/spirit/game/session/effects.py @@ -34,6 +34,7 @@ from spirit.game.data_utils import ( ) from spirit.game.models.board import BoardEntity, CardEntity, EnergyEntity, PokemonEntity from spirit.network.message_names import OutboundMsg +from spirit.game.game_sequence_packets import NestedSequence from .constants import PROMPT_NO, PROMPT_YES from .passives import ( TempPassive, @@ -1203,7 +1204,6 @@ class EffectContext: # bare "Draw" stack, which misses the path table (default linear # curve); nested moves animate FromDeck|ToHand with k.z's stagger -- # the same top-of-deck arc as the initial deal. - from .game_session import NestedSequence # circular-import guard for move in moved: self._queue(self.session._entity_introduced_msg(move["card"]), viewer_id=pid, bracket=GameSequence.DRAW.value) @@ -1609,14 +1609,22 @@ class EffectContext: ), bracket=bracket) return len(cards) + def _queue_pile_reordered(self, pile: BoardEntity): + """Synchronizes the complete pile order without revealing card attributes.""" + self._queue(self.session._build_msg( + OutboundMsg.PILE_REORDERED.value, + {"gameID": self.game_id, "entityID": pile.entity_id, + "children": [card.entity_id for card in pile.children]}, + ), bracket=GameSequence.GROUPED_MOVE.value) + async def reorder_deck_top(self, count: int, player_id: Optional[str] = None, prompt: str = "Rearrange the cards on top of your deck", ) -> List[CardEntity]: - """Looks at the top `count` deck cards and puts them back in any - order (ordered browser, owner-only; the opponent learns nothing -- - hidden-zone browser cards re-hide on close and no moves are sent). - Returns the new top order (topmost first).""" + """Privately chooses the top cards' order and synchronizes the pile. + + Returns the new top order (topmost first); card faces stay hidden. + """ pid = player_id or self.player_id top = self.deck_top(count, pid) if len(top) <= 1: @@ -1626,7 +1634,7 @@ class EffectContext: prompt=prompt, ordered=True, ) by_id = {c.entity_id: c for c in top} - order = [by_id[i] for i in picked_ids if i in by_id] + order = [by_id[i] for i in dict.fromkeys(picked_ids) if i in by_id] for card in top: if card not in order: order.append(card) @@ -1636,17 +1644,50 @@ class EffectContext: # First pick = new top; top of the deck is the LAST child. for card in reversed(order): deck.children.append(card) + self._queue_pile_reordered(deck) return order + async def shuffle_deck_below(self, card: CardEntity) -> bool: + """Shuffles the other deck cards and animates the chosen card onto the top.""" + owner = card.owning_player_id or self.player_id + deck = self.board.find_player_area(owner, "deck") + if not deck or card not in deck.children: + return False + position = deck.children.index(card) + if position == len(deck.children) - 1: + movement = GameSequence.MOVE_FROM_TOP_OF_DECK + elif position == 0: + movement = GameSequence.MOVE_FROM_BOTTOM_OF_DECK + else: + movement = GameSequence.MOVE_FROM_MIDDLE_OF_DECK + remaining = [child for child in deck.children if child is not card] + random.shuffle(remaining) + deck.children[:] = remaining + [card] + # The stock deck-motion paths require TrainerCard in the sequence stack. + self._queue(self.session._build_msg( + OutboundMsg.SHUFFLED.value, + {"gameID": self.game_id, "entityID": deck.entity_id}, + ), bracket=GameSequence.TRAINER_CARD.value) + # r.h consumes exactly one EntityMoved, then animates a sleeved card. + self._queue(NestedSequence(movement, [self.session._entity_moved_msg( + card.entity_id, deck.entity_id, len(deck.children) - 1, + )]), bracket=GameSequence.TRAINER_CARD.value) + self._queue_pile_reordered(deck) + return True + async def put_on_top_of_deck(self, card: CardEntity) -> bool: """Puts a card on top of its owner's deck.""" owner = card.owning_player_id or self.player_id deck = self.board.find_player_area(owner, "deck") if not deck or self._energy_removal_blocked(card): return False + same_pile = card.parent_id == deck.entity_id position = len(deck.children) if not self.board.move_card(card.entity_id, deck.entity_id): return False + if same_pile: + self._queue_pile_reordered(deck) + return True self._queue( self.session._entity_moved_msg(card.entity_id, deck.entity_id, position), bracket=GameSequence.GROUPED_MOVE.value, @@ -1659,8 +1700,12 @@ class EffectContext: deck = self.board.find_player_area(owner, "deck") if not deck or self._energy_removal_blocked(card): return False + same_pile = card.parent_id == deck.entity_id if not self.board.move_card(card.entity_id, deck.entity_id, 0): return False + if same_pile: + self._queue_pile_reordered(deck) + return True self._queue( self.session._entity_moved_msg(card.entity_id, deck.entity_id, 0), bracket=GameSequence.GROUPED_MOVE.value, diff --git a/spirit/game/session/game_session.py b/spirit/game/session/game_session.py index 0d1c16d..40ee6bf 100644 --- a/spirit/game/session/game_session.py +++ b/spirit/game/session/game_session.py @@ -73,6 +73,7 @@ from .constants import ( TEXT_ATTACH_TAX_DISCARD, ) from spirit.network.message_names import OutboundMsg +from spirit.game.game_sequence_packets import NestedSequence from spirit.game.attributes import ( AttrID, CLIENT_SPECIAL_CONDITION_NAMES, @@ -150,22 +151,6 @@ class GameOver(Exception): """Raised once the game has been decided; unwinds the gameplay sequence.""" -class NestedSequence: - """A child Start/Stop bracket embedded inside a parent game sequence. - - The client's SequenceParser keeps a stack of in-progress sequences: a - StartSequence (exempt from the envelope sequence-ID check) pushes a child, - its inner messages must ride the CHILD's sequenceID, and its StopSequence - folds the child into the parent as a single sequence command. This is how - GroupedMove batches EntityMoved commands so they animate together instead - of one-by-one (e.g. a mulliganed hand returning to the deck at once). - """ - - def __init__(self, name, messages: List[Dict[str, Any]]): - self.name: str = getattr(name, "value", name) - self.messages: List[Dict[str, Any]] = messages - - class GameOptions: """ Represents the gameplay options and match metadata sent to the client diff --git a/spirit/network/message_names.py b/spirit/network/message_names.py index 3db4632..d73bf2d 100644 --- a/spirit/network/message_names.py +++ b/spirit/network/message_names.py @@ -581,6 +581,23 @@ class OutboundMsg(str, Enum): CUSTOM_CHOICE_REQUIRED = "CustomChoiceRequired" NOTIFY_GAME_CHAT = "NotifyGameChat" + # Unimplemented message emissions: client_source/coredll/core/*Effect.cs. + ANIMATION_DELAY_EFFECT = "AnimationDelayEffect" + BLINK_EFFECT = "BlinkEffect" + PHASE_CHANGE_EFFECT = "PhaseChangeEffect" + WAIT_FOR_TARGET_ON_EFFECT = "WaitForTargetOnEffect" + WAIT_FOR_TARGET_OFF_EFFECT = "WaitForTargetOffEffect" + + # Unimplemented message emissions: client_source/pie/pie-src/*Effect.cs. + WAITING_FOR_OPPONENT_EFFECT = "WaitingForOpponentEffect" + DONE_WAITING_FOR_OPPONENT_EFFECT = "DoneWaitingForOpponentEffect" + POST_ACTION_PHASE_EFFECT = "PostActionPhaseEffect" + ROCK_PAPER_SCISSORS_EFFECT = "RockPaperScissorsEffect" + + # Unimplemented message emissions: dwd.core.match.interaction.messages.incoming. + PLAYER_INTERACTED_WITH_ENTITY_EFFECT = "PlayerInteractedWithEntityEffect" + PLAYER_STOPPED_INTERACTING_EFFECT = "PlayerStoppedInteractingEffect" + # Setup phase (post coin flip): entity movement + reveal + mulligan ENTITY_MOVED = "EntityMoved" ENTITY_INTRODUCED = "EntityIntroduced" @@ -591,8 +608,13 @@ class OutboundMsg(str, Enum): # Return=true tucks it back afterwards, Return=false leaves it in the # multiPresentArea for a following attach move (l.a) to consume. REVEAL_CARD_TO_ALL_EFFECT = "RevealCardToAllEffect" + # Unimplemented message emissions: client_source/pie/pie-src/RevealCardsTo*Effect.cs. + REVEAL_CARDS_TO_ALL_EFFECT = "RevealCardsToAllEffect" + REVEAL_CARDS_TO_PLAYER_EFFECT = "RevealCardsToPlayerEffect" MULLIGAN_CHOICE_REQUIRED = "MulliganChoiceRequired" SHUFFLED = "Shuffled" + # Source: PileReordered.cs; children contains the complete pile, bottom first. + PILE_REORDERED = "PileReordered" # Deck-lift animation trigger (S.I); required in HandShuffledAndMovedToDeck. PLACE_ON_BOTTOM = "PlaceOnBottom" # Full-screen carousel revealing a player's mulliganed hand(s) to the opponent. @@ -609,10 +631,32 @@ class OutboundMsg(str, Enum): # knockout replacement): pick one entity, no action tree. SELECTION_WITH_TARGETS_REQUIRED = "SelectionWithTargetsRequired" + # Unimplemented message emissions: client_source/pie/pie-src/Evolve*Effect.cs. + EVOLVE_EFFECT = "EvolveEffect" + EVOLVE_WITH_CONTEXT_EFFECT = "EvolveWithContextEffect" + # Unimplemented message emission: pie.code.pie.gameModules.playmat.messages. + ENERGY_SWAP_EFFECT = "EnergySwapEffect" + + # Unimplemented condition messages (mechanics currently use attribute updates). + ADD_SPECIAL_CONDITION_EFFECT = "AddSpecialConditionEffect" + REMOVE_SPECIAL_CONDITION_EFFECT = "RemoveSpecialConditionEffect" + BURN_EFFECT = "BurnEffect" + CONFUSE_EFFECT = "ConfuseEffect" + PARALYZE_EFFECT = "ParalyzeEffect" + POISON_EFFECT = "PoisonEffect" + SLEEP_EFFECT = "SleepEffect" + REMOVE_BURN_EFFECT = "RemoveBurnEffect" + REMOVE_CONFUSE_EFFECT = "RemoveConfuseEffect" + REMOVE_PARALYZE_EFFECT = "RemoveParalyzeEffect" + REMOVE_POISON_EFFECT = "RemovePoisonEffect" + REMOVE_SLEEP_EFFECT = "RemoveSleepEffect" + # Attack choreography (inside an "Attack" sequence bracket, in order): # ability-begin marker (L.o), damage popup + lunge trigger (m.m), # HP attribute update, ability-finished marker (k.U). ABILITY_PLAYED_EFFECT = "AbilityPlayedEffect" + # Unimplemented message emission: client_source/pie/pie-src/CakeAbilitySelectedEffect.cs. + CAKE_ABILITY_SELECTED_EFFECT = "CakeAbilitySelectedEffect" CAKE_ATTACK_EFFECT = "CakeAttackEffect" # Prevented-hit barrier choreography: {source, targets, wasDamage}. SHIELD_TARGETS_EFFECT = "ShieldTargetsEffect" @@ -627,6 +671,11 @@ class OutboundMsg(str, Enum): ABILITY_FINISHED_EFFECT = "AbilityFinishedEffect" # Named entity parameter for a bracket executor (e.g. Evolve needs "From"/"Into"). ENTITY_ID_DATA_EFFECT = "EntityIDDataEffect" + # Unimplemented message emissions: dwd.core.match.sequence.dataEffects. + ENTITY_ID_LIST_DATA_EFFECT = "EntityIDListDataEffect" + INT_DATA_EFFECT = "IntDataEffect" + SOURCE_TARGET_DATA_EFFECT = "SourceTargetDataEffect" + STRING_DATA_EFFECT = "StringDataEffect" # Orb-of-light targets: the Attack executor injects the r.u projectile group # from the playmat's attack-source entity to these targets. NON_DAMAGING_TARGETS_EFFECT = "NonDamagingTargetsEffect" @@ -635,6 +684,15 @@ class OutboundMsg(str, Enum): VSTAR_POWER_USED_EFFECT = "VSTARPowerUsedEffect" # GX equivalent of the VSTAR marker flip (handler b.M). GX_ATTACK_USED_EFFECT = "GXAttackUsedEffect" + + # Unimplemented generic combat messages: sausage-core/dwd/core/match/effects and core. + ATTACK_EFFECT = "AttackEffect" + CLEAR_ATTACK_EFFECT = "ClearAttackEffect" + SWAP_ATTACKER_EFFECT = "SwapAttackerEffect" + DEFEND_EFFECT = "DefendEffect" + CLEAR_DEFEND_EFFECT = "ClearDefendEffect" + SWAP_DEFENDER_EFFECT = "SwapDefenderEffect" + # End-of-game dialog (handler D.Z): winner/loser account IDs + reward info. GAME_COMPLETED_MESSAGE = "GameCompletedMessage"