SECURITY: Implement hidden information filtering to prevent cheating. - Create VisibleGameState, VisiblePlayerState, VisibleZone models - get_visible_state(game, player_id): filtered view for a player - get_spectator_state(game): filtered view for spectators Hidden Information (NEVER exposed): - Opponent's hand contents (count only) - All deck contents and order - All prize card contents - Energy deck order Public Information (always visible): - Active and benched Pokemon (full details) - Discard piles (full contents) - Energy zone (available energy) - Scores, turn info, phase - Stadium in play - 44 security-critical tests verifying no information leakage - Tests check JSON serialization for hidden card ID leaks - Also adds test for configurable burn damage Completes HIGH-008 and TEST-012 from PROJECT_PLAN.json Updates security checklist: 4/5 items now verified
391 lines
12 KiB
Python
391 lines
12 KiB
Python
"""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.models.card import CardDefinition, CardInstance
|
|
from app.core.models.enums import GameEndReason, TurnPhase
|
|
|
|
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).
|
|
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
|
|
|
|
# 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)
|
|
forced_action_player = None
|
|
forced_action_type = None
|
|
forced_action_reason = None
|
|
if game.forced_action:
|
|
forced_action_player = game.forced_action.player_id
|
|
forced_action_type = game.forced_action.action_type
|
|
forced_action_reason = game.forced_action.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,
|
|
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
|
|
forced_action_player = None
|
|
forced_action_type = None
|
|
forced_action_reason = None
|
|
if game.forced_action:
|
|
forced_action_player = game.forced_action.player_id
|
|
forced_action_type = game.forced_action.action_type
|
|
forced_action_reason = game.forced_action.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,
|
|
forced_action_player=forced_action_player,
|
|
forced_action_type=forced_action_type,
|
|
forced_action_reason=forced_action_reason,
|
|
card_registry=game.card_registry,
|
|
)
|