mantimon-tcg/backend/app/core/visibility.py
Claude 7885b272a4
Honor RulesConfig for prize cards vs points in frontend game board
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
2026-02-02 09:22:44 +00:00

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