FEAT: Handle pile reordering & add missing effect packet headers

Fixes animation on cards that re-order piles such as Rotom Phone
This commit is contained in:
Brandon Nguyen
2026-09-05 13:36:35 -07:00
parent 4c78271345
commit 83702419a5
5 changed files with 120 additions and 24 deletions

View File

@@ -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):

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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

View File

@@ -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"