diff --git a/spirit/game/models/board.py b/spirit/game/models/board.py index e8c50cd..7be593f 100644 --- a/spirit/game/models/board.py +++ b/spirit/game/models/board.py @@ -196,17 +196,32 @@ class CardEntity(BoardEntity): # on draw); prize cards are never introduced until taken. HIDDEN_FROM_OWNER_AREAS = ("deck", "prizePile") + # Zones whose cards are public knowledge to BOTH players. During live play a + # non-owner learns these on sight (EntityIntroduced), but the reconnect SGS + # is the SOLE source of truth for a fresh client, so they MUST serialize + # face-up regardless of viewer or the reconnecting player sees card backs. + PUBLIC_AREAS = ("activePokemonArea", "bench", "discard", "lostZone", + "activeStadium", "activeTrainer") + + def _containing_area_name(self) -> Optional[str]: + """Name of the PlayArea this card ultimately sits in, walking up through + any Pokemon it is attached to.""" + node = self.parent + while node is not None: + if isinstance(node, PlayArea): + return node.get_attribute(AttrID.NAME) + node = node.parent + return None + def is_hidden_from(self, viewer_id: Optional[str]) -> bool: - """A card's identity is hidden from non-owners always, and from its - owner too while it sits in a hidden-knowledge zone (deck/prizes).""" + """A card's identity is hidden from non-owners unless it sits in a public + zone; from its owner only while in a hidden-knowledge zone (deck/prizes).""" if viewer_id is None: return False + area_name = self._containing_area_name() if viewer_id != self.owning_player_id: - return True - return ( - self.parent is not None - and self.parent.get_attribute(AttrID.NAME) in self.HIDDEN_FROM_OWNER_AREAS - ) + return area_name not in self.PUBLIC_AREAS + return area_name in self.HIDDEN_FROM_OWNER_AREAS class PokemonEntity(CardEntity): diff --git a/spirit/game/session/game_session.py b/spirit/game/session/game_session.py index 2772bcd..9ff85c9 100644 --- a/spirit/game/session/game_session.py +++ b/spirit/game/session/game_session.py @@ -199,6 +199,11 @@ class GameSession: # account_ids that have reconnected and are awaiting their PlayerReady so # we re-send SerializedGameState (+ replay their pending offer) to them. self._reconnecting: set = set() + # account_ids with an OUTSTANDING disconnect the opponent was told about + # (a "Disconnection!" dialog is showing on their screen). Pairs 1:1 with a + # later PlayerReconnected; gates both so eviction races / duplicate + # ReconnectToGame never orphan a stuck dialog or double the popup. + self._disconnected: set = set() # detached account_id -> grace-window timer task (awards the win on expiry). self._grace_tasks: Dict[str, asyncio.Task] = {} @@ -651,9 +656,14 @@ class GameSession: return if self.game_phase == GamePhase.GAME_OVER: return + # Detach + (re)arm grace ALWAYS (liveness), but the opponent-facing dialog + # message is guarded: a 2nd PlayerDisconnected while one is still outstanding + # stacks a 2nd "Disconnection!" popup the client can no longer dismiss (m.q + # overwrites its single dialog reference, orphaning the first on-screen). + already_notified = account_id in self._disconnected logging.info( - f"[Session {self.game_id}] Player {account_id} disconnected — " - f"detaching (grace {RECONNECT_GRACE_SECONDS}s)." + f"[Session {self.game_id}] Player {account_id} disconnected — detaching " + f"(grace {RECONNECT_GRACE_SECONDS}s, already_notified={already_notified})." ) player.client_handler = None self._reconnecting.discard(account_id) @@ -661,8 +671,12 @@ class GameSession: if existing is not None and not existing.done(): existing.cancel() self._grace_tasks[account_id] = self._spawn(self._disconnect_grace(account_id)) + if already_notified: + return + self._disconnected.add(account_id) # PlayerDisconnected (m.q): shows the opponent the "disconnected, waiting Ns" # dialog and freezes the chess clock until PlayerReconnected. + logging.info(f"[Session {self.game_id}] -> PlayerDisconnected to opponents of {account_id}.") await self._notify_opponents_connection( account_id, OutboundMsg.PLAYER_DISCONNECTED.value, {"waitTime": RECONNECT_GRACE_SECONDS * 1000}, @@ -719,20 +733,37 @@ class GameSession: player = self.players.get(account_id) if not isinstance(player, NetworkPlayer) or not player.connected: return - await self.send_serialized_game_state(only_player_id=account_id) - # The client never re-requests its offer, so replay whatever it owed a reply to. - offer = player._pending_offer - if offer is not None: - name, value, flags = offer - await player.send_packet(name, value, flags) - logging.info( - f"[Session {self.game_id}] Replayed pending offer to reconnected {account_id}." + # Resync the board + replay the pending offer. Never let a serialization + # error swallow the PlayerReconnected below — that would strand the + # opponent's "Disconnection!" dialog on-screen forever. + try: + await self.send_serialized_game_state(only_player_id=account_id) + offer = player._pending_offer + if offer is not None: + name, value, flags = offer + await player.send_packet(name, value, flags) + logging.info( + f"[Session {self.game_id}] Replayed pending offer to reconnected {account_id}." + ) + except Exception as e: + logging.exception( + f"[Session {self.game_id}] Error resyncing state to reconnected {account_id}: {e}" ) # PlayerReconnected (m.r): dismisses the opponent's wait dialog and restarts - # the chess clock now that this player is fully back on the board. - await self._notify_opponents_connection( - account_id, OutboundMsg.PLAYER_RECONNECTED.value, - ) + # the chess clock. Paired 1:1 with the outstanding disconnect — a duplicate + # ReconnectToGame still resyncs the board above but must NOT fire a second + # Reconnection popup, so only send it when a disconnect is actually pending. + if account_id in self._disconnected: + self._disconnected.discard(account_id) + logging.info(f"[Session {self.game_id}] -> PlayerReconnected to opponents of {account_id}.") + await self._notify_opponents_connection( + account_id, OutboundMsg.PLAYER_RECONNECTED.value, + ) + else: + logging.info( + f"[Session {self.game_id}] Reconnect resync for {account_id} with no outstanding " + f"disconnect (duplicate ReconnectToGame) — no PlayerReconnected sent." + ) async def run_gameplay_sequence(self): """Sequential gameplay workflow starting from the pre-game coin flip.""" @@ -3045,6 +3076,13 @@ class GameSession: slot = self.board_state.bench_slot_of(target) + # Damage counters carry through evolution: capture what the + # pre-evolution had taken (before attachments move off it) so it can be + # transferred onto the evolution card, which otherwise enters at full HP. + damage_taken = max( + 0, effective_max_hp(self.board_state, target) - target.get_attribute(AttrID.HP, 0) + ) + moves = [] if not self.board_state.move_card(card.entity_id, area.entity_id, slot): return @@ -3059,6 +3097,15 @@ class GameSession: self.board_state.attach_card(target.entity_id, card.entity_id) moves.append(self._entity_moved_msg(target.entity_id, card.entity_id, position)) + # The counters now live on the evolution; clear the tucked-under + # pre-evolution's HP so its stale damage isn't re-rendered on inspect. + self.reset_pokemon_damage(target) + if damage_taken: + card.set_attribute( + AttrID.HP, + max(0, effective_max_hp(self.board_state, card) - damage_taken), + ) + # The Evolve executor (M.k) plays the spin FX itself; its ctor requires # the "From"/"Into" data effects and crashes without them. data_effects = [ @@ -3069,6 +3116,13 @@ class GameSession: player_id, GameSequence.EVOLVE, data_effects + moves, [card] ) + # Push the transferred damage so the evolution renders its HP tracker. + if damage_taken: + envelope = self._sequence_envelope( + EMPTY_SEQUENCE_ID, self._hp_attribute_msg(card) + ) + await self.broadcast_packet(OutboundMsg.SEQUENCE_MESSAGE.value, envelope) + # Freshly entered play: cannot evolve again this turn. self.turn_state.mark_entered_play(card.entity_id) logging.info(