The game board now conditionally renders prize card zones based on the RulesConfig sent from the backend: - Add rules_config field to VisibleGameState in backend (visibility.py) - Add rules_config to frontend game types and game store - Update layout.ts to accept LayoutOptions with usePrizeCards and prizeCount - Update StateRenderer to conditionally create PrizeZone objects - Update Board to handle empty prize position arrays gracefully - Add game store computed properties: rulesConfig, usePrizeCards, prizeCount - Add tests for conditional prize zone rendering When use_prize_cards is false (Mantimon TCG points system), the prize zones are not rendered, saving screen space. When true (classic Pokemon TCG mode), the correct number of prize slots is rendered based on the rules config's prize count. https://claude.ai/code/session_01AAxKmpq2AGde327eX1nzUC
404 lines
13 KiB
Python
404 lines
13 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.config import RulesConfig
|
|
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).
|
|
rules_config: Rules configuration for UI rendering decisions (e.g., prize cards vs points).
|
|
"""
|
|
|
|
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)
|
|
|
|
# Rules configuration for UI rendering decisions
|
|
rules_config: RulesConfig = Field(default_factory=RulesConfig)
|
|
|
|
|
|
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,
|
|
rules_config=game.rules,
|
|
)
|
|
|
|
|
|
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,
|
|
rules_config=game.rules,
|
|
)
|