"""Visibility filtering for client-safe game state views. This module implements hidden information filtering to prevent cheating in multiplayer games. It creates filtered views of the game state that only expose information a specific player is allowed to see. SECURITY CRITICAL: This module is the primary defense against information leakage. All game state sent to clients MUST go through this filter. Hidden Information (NEVER expose to opponent): - Hand contents - Deck contents and order - Prize card contents (until taken) - Energy deck order Public Information (always visible): - Active and benched Pokemon (full details including damage, status, energy) - Discard piles (full contents) - Stadium in play - Scores, turn info, phase, game state - Zone counts (hand size, deck size, prize count) - Energy zone (available energy) Usage: from app.core.visibility import get_visible_state, VisibleGameState # Get filtered view for a specific player visible = get_visible_state(game, "player1") # Safe to serialize and send to client json_data = visible.model_dump_json() """ from __future__ import annotations from typing import TYPE_CHECKING from pydantic import BaseModel, Field from app.core.enums import GameEndReason, TurnPhase from app.core.models.card import CardDefinition, CardInstance if TYPE_CHECKING: from app.core.models.game_state import GameState class VisibleZone(BaseModel): """A zone with visibility-appropriate information. For hidden zones (deck, hand, prizes), only the count is shown. For public zones (active, bench, discard), full card data is included. Attributes: count: Number of cards in the zone. cards: List of visible cards (empty for hidden zones). zone_type: Type of zone for identification. """ count: int = 0 cards: list[CardInstance] = Field(default_factory=list) zone_type: str = "generic" class VisiblePlayerState(BaseModel): """Player state with visibility filtering applied. Contains full information for zones the viewing player can see, and only counts for hidden zones. Attributes: player_id: The player's ID. is_current_player: Whether this is the viewing player. deck_count: Number of cards in deck (order hidden). hand: Full hand if own player, just count if opponent. active: Active Pokemon with full details (public). bench: Benched Pokemon with full details (public). discard: Full discard pile contents (public). prizes_count: Number of prize cards remaining. energy_deck_count: Number of cards in energy deck. energy_zone: Available energy to attach (public). score: Current score/points. gx_attack_used: Whether GX attack has been used. vstar_power_used: Whether VSTAR power has been used. """ player_id: str is_current_player: bool = False # Hidden zones - counts only (for opponent), full for self deck_count: int = 0 hand: VisibleZone = Field(default_factory=lambda: VisibleZone(zone_type="hand")) prizes_count: int = 0 energy_deck_count: int = 0 # Public zones - full details active: VisibleZone = Field(default_factory=lambda: VisibleZone(zone_type="active")) bench: VisibleZone = Field(default_factory=lambda: VisibleZone(zone_type="bench")) discard: VisibleZone = Field(default_factory=lambda: VisibleZone(zone_type="discard")) energy_zone: VisibleZone = Field(default_factory=lambda: VisibleZone(zone_type="energy_zone")) # Game state score: int = 0 gx_attack_used: bool = False vstar_power_used: bool = False class VisibleGameState(BaseModel): """Game state filtered for a specific player's view. This is the safe-to-send-to-client version of GameState. It contains only information that the viewing player is allowed to see. Attributes: game_id: Unique game identifier. viewer_id: The player ID this view is for. players: Dict of player_id -> VisiblePlayerState. current_player_id: Whose turn it is. turn_number: Current turn number. phase: Current turn phase. is_my_turn: Whether it's the viewer's turn. winner_id: Winner if game is over. end_reason: Why the game ended. stadium_in_play: Current stadium card (public). stadium_owner_id: Player who played the current stadium (public). forced_action: Current forced action, if any. card_registry: Card definitions (needed to display cards). """ game_id: str viewer_id: str # Player states players: dict[str, VisiblePlayerState] = Field(default_factory=dict) # Turn tracking current_player_id: str = "" turn_number: int = 0 phase: TurnPhase = TurnPhase.SETUP is_my_turn: bool = False # Game end state winner_id: str | None = None end_reason: GameEndReason | None = None # Shared state stadium_in_play: CardInstance | None = None stadium_owner_id: str | None = None # Forced action (visible to know what's required) forced_action_player: str | None = None forced_action_type: str | None = None forced_action_reason: str | None = None # Card definitions for display card_registry: dict[str, CardDefinition] = Field(default_factory=dict) def _create_visible_zone( cards: list[CardInstance], zone_type: str, show_contents: bool, ) -> VisibleZone: """Create a VisibleZone with appropriate visibility. Args: cards: The cards in the zone. zone_type: Type identifier for the zone. show_contents: If True, include full card data. If False, only count. Returns: VisibleZone with count and optionally cards. """ return VisibleZone( count=len(cards), cards=list(cards) if show_contents else [], zone_type=zone_type, ) def _filter_player_state( game: GameState, player_id: str, viewer_id: str, ) -> VisiblePlayerState: """Create a visibility-filtered player state. Args: game: The full game state. player_id: The player whose state we're filtering. viewer_id: The player who will view this state. Returns: VisiblePlayerState with appropriate filtering. """ player = game.players[player_id] is_self = player_id == viewer_id # Hand: visible to self, count only to opponent hand = _create_visible_zone( cards=player.hand.cards, zone_type="hand", show_contents=is_self, ) # Active: always public (this is the battlefield) active = _create_visible_zone( cards=player.active.cards, zone_type="active", show_contents=True, ) # Bench: always public (this is the battlefield) bench = _create_visible_zone( cards=player.bench.cards, zone_type="bench", show_contents=True, ) # Discard: always public discard = _create_visible_zone( cards=player.discard.cards, zone_type="discard", show_contents=True, ) # Energy zone: always public (available energy to attach) energy_zone = _create_visible_zone( cards=player.energy_zone.cards, zone_type="energy_zone", show_contents=True, ) return VisiblePlayerState( player_id=player_id, is_current_player=is_self, deck_count=len(player.deck), hand=hand, prizes_count=len(player.prizes), energy_deck_count=len(player.energy_deck), active=active, bench=bench, discard=discard, energy_zone=energy_zone, score=player.score, gx_attack_used=player.gx_attack_used, vstar_power_used=player.vstar_power_used, ) def get_visible_state(game: GameState, viewer_id: str) -> VisibleGameState: """Create a visibility-filtered game state for a specific player. This is the main entry point for visibility filtering. It creates a VisibleGameState that only contains information the viewer is allowed to see. SECURITY: Always use this function before sending game state to clients. Args: game: The full game state. viewer_id: The player ID requesting the view. Returns: VisibleGameState safe to send to the client. Raises: ValueError: If viewer_id is not a player in the game. """ if viewer_id not in game.players: raise ValueError(f"Viewer {viewer_id} is not a player in game {game.game_id}") # Filter each player's state visible_players = {pid: _filter_player_state(game, pid, viewer_id) for pid in game.players} # Extract forced action info (if any) - only shows the current/first action in queue forced_action_player = None forced_action_type = None forced_action_reason = None current_forced = game.get_current_forced_action() if current_forced: forced_action_player = current_forced.player_id forced_action_type = current_forced.action_type forced_action_reason = current_forced.reason return VisibleGameState( game_id=game.game_id, viewer_id=viewer_id, players=visible_players, current_player_id=game.current_player_id, turn_number=game.turn_number, phase=game.phase, is_my_turn=game.current_player_id == viewer_id, winner_id=game.winner_id, end_reason=game.end_reason, stadium_in_play=game.stadium_in_play, stadium_owner_id=game.stadium_owner_id, forced_action_player=forced_action_player, forced_action_type=forced_action_type, forced_action_reason=forced_action_reason, card_registry=game.card_registry, ) def get_spectator_state(game: GameState) -> VisibleGameState: """Create a visibility-filtered game state for spectators. Spectators see the same as any player regarding public information, but cannot see any player's hidden information. Args: game: The full game state. Returns: VisibleGameState with all hands/decks hidden. """ # Use a fake viewer_id that won't match any player # This ensures all hands are hidden spectator_id = "__spectator__" # Filter each player's state as if viewed by spectator visible_players = {} for pid in game.players: player = game.players[pid] # All hands hidden for spectators hand = _create_visible_zone( cards=player.hand.cards, zone_type="hand", show_contents=False, ) # Public zones still visible active = _create_visible_zone( cards=player.active.cards, zone_type="active", show_contents=True, ) bench = _create_visible_zone( cards=player.bench.cards, zone_type="bench", show_contents=True, ) discard = _create_visible_zone( cards=player.discard.cards, zone_type="discard", show_contents=True, ) energy_zone = _create_visible_zone( cards=player.energy_zone.cards, zone_type="energy_zone", show_contents=True, ) visible_players[pid] = VisiblePlayerState( player_id=pid, is_current_player=False, deck_count=len(player.deck), hand=hand, prizes_count=len(player.prizes), energy_deck_count=len(player.energy_deck), active=active, bench=bench, discard=discard, energy_zone=energy_zone, score=player.score, gx_attack_used=player.gx_attack_used, vstar_power_used=player.vstar_power_used, ) # Extract forced action info - only shows the current/first action in queue forced_action_player = None forced_action_type = None forced_action_reason = None current_forced = game.get_current_forced_action() if current_forced: forced_action_player = current_forced.player_id forced_action_type = current_forced.action_type forced_action_reason = current_forced.reason return VisibleGameState( game_id=game.game_id, viewer_id=spectator_id, players=visible_players, current_player_id=game.current_player_id, turn_number=game.turn_number, phase=game.phase, is_my_turn=False, winner_id=game.winner_id, end_reason=game.end_reason, stadium_in_play=game.stadium_in_play, stadium_owner_id=game.stadium_owner_id, forced_action_player=forced_action_player, forced_action_type=forced_action_type, forced_action_reason=forced_action_reason, card_registry=game.card_registry, )