Add visibility filter for client-safe game state views
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
This commit is contained in:
parent
eef857e972
commit
cbc1da3c03
@ -8,7 +8,7 @@
|
|||||||
"description": "Core game engine scaffolding for a highly configurable Pokemon TCG-inspired card game. The engine must support campaign mode with fixed rules and free play mode with user-configurable rules.",
|
"description": "Core game engine scaffolding for a highly configurable Pokemon TCG-inspired card game. The engine must support campaign mode with fixed rules and free play mode with user-configurable rules.",
|
||||||
"totalEstimatedHours": 48,
|
"totalEstimatedHours": 48,
|
||||||
"totalTasks": 32,
|
"totalTasks": 32,
|
||||||
"completedTasks": 25
|
"completedTasks": 27
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"critical": "Foundation components that block all other work",
|
"critical": "Foundation components that block all other work",
|
||||||
@ -468,15 +468,16 @@
|
|||||||
"description": "Implement hidden information filtering to create client-safe game state views",
|
"description": "Implement hidden information filtering to create client-safe game state views",
|
||||||
"category": "high",
|
"category": "high",
|
||||||
"priority": 26,
|
"priority": 26,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["HIGH-003"],
|
"dependencies": ["HIGH-003"],
|
||||||
"files": [
|
"files": [
|
||||||
{"path": "app/core/visibility.py", "issue": "File does not exist"}
|
{"path": "app/core/visibility.py", "status": "created"}
|
||||||
],
|
],
|
||||||
"suggestedFix": "VisibleGameState model with filtered data. get_visible_state(game, player_id) returns: full own hand, own prize count, opponent hand COUNT only, opponent deck COUNT only, public battlefield/discard. Never expose deck order or opponent hand contents.",
|
"suggestedFix": "VisibleGameState model with filtered data. get_visible_state(game, player_id) returns: full own hand, own prize count, opponent hand COUNT only, opponent deck COUNT only, public battlefield/discard. Never expose deck order or opponent hand contents.",
|
||||||
"estimatedHours": 2,
|
"estimatedHours": 2,
|
||||||
"notes": "CRITICAL SECURITY: This prevents cheating. Must never leak hidden information."
|
"notes": "VisibleGameState, VisiblePlayerState, VisibleZone models. get_visible_state() filters for player view, get_spectator_state() for spectators. Hidden: hand contents, deck order/contents, prize contents, energy deck order. Public: battlefield, discard, energy zone, scores, stadium.",
|
||||||
|
"completedDate": "2026-01-25"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "TEST-012",
|
"id": "TEST-012",
|
||||||
@ -484,15 +485,16 @@
|
|||||||
"description": "Test that hidden information is never leaked and public information is preserved",
|
"description": "Test that hidden information is never leaked and public information is preserved",
|
||||||
"category": "high",
|
"category": "high",
|
||||||
"priority": 27,
|
"priority": 27,
|
||||||
"completed": false,
|
"completed": true,
|
||||||
"tested": false,
|
"tested": true,
|
||||||
"dependencies": ["HIGH-008", "HIGH-004"],
|
"dependencies": ["HIGH-008", "HIGH-004"],
|
||||||
"files": [
|
"files": [
|
||||||
{"path": "tests/core/test_visibility.py", "issue": "File does not exist"}
|
{"path": "tests/core/test_visibility.py", "status": "created"}
|
||||||
],
|
],
|
||||||
"suggestedFix": "Test: opponent hand contents not visible, opponent deck order not visible, own hand fully visible, battlefield fully visible, prize counts visible but not contents",
|
"suggestedFix": "Test: opponent hand contents not visible, opponent deck order not visible, own hand fully visible, battlefield fully visible, prize counts visible but not contents",
|
||||||
"estimatedHours": 1.5,
|
"estimatedHours": 1.5,
|
||||||
"notes": "Security-critical tests. Verify no hidden data leaks through any path."
|
"notes": "44 security-critical tests covering own info visibility, opponent info hiding, public info, spectator mode, edge cases, and JSON serialization. Tests verify card IDs don't leak in serialized output.",
|
||||||
|
"completedDate": "2026-01-25"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "HIGH-009",
|
"id": "HIGH-009",
|
||||||
@ -662,17 +664,23 @@
|
|||||||
{
|
{
|
||||||
"item": "Deck order never sent to client",
|
"item": "Deck order never sent to client",
|
||||||
"module": "visibility.py",
|
"module": "visibility.py",
|
||||||
"verified": false
|
"verified": true,
|
||||||
|
"verifiedDate": "2026-01-25",
|
||||||
|
"notes": "test_own_deck_contents_also_hidden and test_opponent_deck_cards_not_leaked verify deck cards never appear in visible state JSON."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"item": "Opponent hand contents never sent",
|
"item": "Opponent hand contents never sent",
|
||||||
"module": "visibility.py",
|
"module": "visibility.py",
|
||||||
"verified": false
|
"verified": true,
|
||||||
|
"verifiedDate": "2026-01-25",
|
||||||
|
"notes": "test_opponent_hand_contents_hidden and test_opponent_hand_card_ids_not_leaked verify hand contents are empty for opponent and card IDs don't appear in JSON."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"item": "Prize card contents hidden until taken",
|
"item": "Prize card contents hidden until taken",
|
||||||
"module": "visibility.py",
|
"module": "visibility.py",
|
||||||
"verified": false
|
"verified": true,
|
||||||
|
"verifiedDate": "2026-01-25",
|
||||||
|
"notes": "test_opponent_prizes_hidden, test_opponent_prize_cards_not_leaked, test_own_prize_contents_hidden verify prize cards hidden for both players."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"item": "All actions validated server-side",
|
"item": "All actions validated server-side",
|
||||||
|
|||||||
390
backend/app/core/visibility.py
Normal file
390
backend/app/core/visibility.py
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
@ -761,6 +761,26 @@ class TestBetweenTurnEffects:
|
|||||||
assert result.between_turn_damage[active.instance_id] == 20
|
assert result.between_turn_damage[active.instance_id] == 20
|
||||||
assert active.damage == initial_damage + 20
|
assert active.damage == initial_damage + 20
|
||||||
|
|
||||||
|
def test_burn_damage_custom_amount(
|
||||||
|
self,
|
||||||
|
turn_manager: TurnManager,
|
||||||
|
two_player_game: GameState,
|
||||||
|
seeded_rng: SeededRandom,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test that burn damage uses configured amount.
|
||||||
|
|
||||||
|
Verifies StatusConfig.burn_damage is respected.
|
||||||
|
"""
|
||||||
|
two_player_game.rules.status.burn_damage = 30
|
||||||
|
two_player_game.phase = TurnPhase.END
|
||||||
|
active = two_player_game.get_current_player().get_active_pokemon()
|
||||||
|
active.add_status(StatusCondition.BURNED)
|
||||||
|
|
||||||
|
result = turn_manager.end_turn(two_player_game, seeded_rng)
|
||||||
|
|
||||||
|
assert result.between_turn_damage[active.instance_id] == 30
|
||||||
|
|
||||||
def test_poison_and_burn_stack(
|
def test_poison_and_burn_stack(
|
||||||
self,
|
self,
|
||||||
turn_manager: TurnManager,
|
turn_manager: TurnManager,
|
||||||
|
|||||||
798
backend/tests/core/test_visibility.py
Normal file
798
backend/tests/core/test_visibility.py
Normal file
@ -0,0 +1,798 @@
|
|||||||
|
"""Tests for the visibility filter module.
|
||||||
|
|
||||||
|
This module contains SECURITY-CRITICAL tests that verify hidden information
|
||||||
|
is never leaked to unauthorized viewers. These tests are essential for
|
||||||
|
preventing cheating in multiplayer games.
|
||||||
|
|
||||||
|
Test categories:
|
||||||
|
- Own information visibility (player can see their own hand, etc.)
|
||||||
|
- Opponent information hiding (opponent's hand, deck hidden)
|
||||||
|
- Public information visibility (battlefield, discard, scores)
|
||||||
|
- Spectator mode (no hands visible)
|
||||||
|
- Edge cases and error handling
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.config import RulesConfig
|
||||||
|
from app.core.models.card import CardDefinition, CardInstance
|
||||||
|
from app.core.models.enums import (
|
||||||
|
CardType,
|
||||||
|
EnergyType,
|
||||||
|
GameEndReason,
|
||||||
|
PokemonStage,
|
||||||
|
PokemonVariant,
|
||||||
|
TurnPhase,
|
||||||
|
)
|
||||||
|
from app.core.models.game_state import ForcedAction, GameState, PlayerState
|
||||||
|
from app.core.visibility import (
|
||||||
|
VisibleGameState,
|
||||||
|
get_spectator_state,
|
||||||
|
get_visible_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pokemon_def() -> CardDefinition:
|
||||||
|
"""Create a basic Pokemon card definition."""
|
||||||
|
return CardDefinition(
|
||||||
|
id="pikachu-001",
|
||||||
|
name="Pikachu",
|
||||||
|
card_type=CardType.POKEMON,
|
||||||
|
stage=PokemonStage.BASIC,
|
||||||
|
variant=PokemonVariant.NORMAL,
|
||||||
|
hp=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def energy_def() -> CardDefinition:
|
||||||
|
"""Create a basic energy card definition."""
|
||||||
|
return CardDefinition(
|
||||||
|
id="lightning-energy-001",
|
||||||
|
name="Lightning Energy",
|
||||||
|
card_type=CardType.ENERGY,
|
||||||
|
energy_type=EnergyType.LIGHTNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def trainer_def() -> CardDefinition:
|
||||||
|
"""Create a trainer card definition."""
|
||||||
|
return CardDefinition(
|
||||||
|
id="potion-001",
|
||||||
|
name="Potion",
|
||||||
|
card_type=CardType.TRAINER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def full_game(
|
||||||
|
pokemon_def: CardDefinition,
|
||||||
|
energy_def: CardDefinition,
|
||||||
|
trainer_def: CardDefinition,
|
||||||
|
) -> GameState:
|
||||||
|
"""Create a full game state with cards in various zones.
|
||||||
|
|
||||||
|
Player 1 has:
|
||||||
|
- 3 cards in hand (including a secret trainer)
|
||||||
|
- 1 active Pokemon
|
||||||
|
- 2 benched Pokemon
|
||||||
|
- 5 cards in deck
|
||||||
|
- 3 prize cards
|
||||||
|
- 2 cards in discard
|
||||||
|
- 3 cards in energy deck
|
||||||
|
- 1 card in energy zone
|
||||||
|
|
||||||
|
Player 2 has:
|
||||||
|
- 4 cards in hand
|
||||||
|
- 1 active Pokemon
|
||||||
|
- 1 benched Pokemon
|
||||||
|
- 4 cards in deck
|
||||||
|
- 3 prize cards
|
||||||
|
- 1 card in discard
|
||||||
|
- 2 cards in energy deck
|
||||||
|
- 2 cards in energy zone
|
||||||
|
"""
|
||||||
|
# Create card instances for player 1
|
||||||
|
p1_hand = [
|
||||||
|
CardInstance(instance_id="p1-hand-0", definition_id=pokemon_def.id),
|
||||||
|
CardInstance(instance_id="p1-hand-1", definition_id=energy_def.id),
|
||||||
|
CardInstance(instance_id="p1-hand-2", definition_id=trainer_def.id), # Secret!
|
||||||
|
]
|
||||||
|
p1_active = CardInstance(instance_id="p1-active", definition_id=pokemon_def.id)
|
||||||
|
p1_bench = [
|
||||||
|
CardInstance(instance_id="p1-bench-0", definition_id=pokemon_def.id),
|
||||||
|
CardInstance(instance_id="p1-bench-1", definition_id=pokemon_def.id),
|
||||||
|
]
|
||||||
|
p1_deck = [
|
||||||
|
CardInstance(instance_id=f"p1-deck-{i}", definition_id=pokemon_def.id) for i in range(5)
|
||||||
|
]
|
||||||
|
p1_prizes = [
|
||||||
|
CardInstance(instance_id=f"p1-prize-{i}", definition_id=pokemon_def.id) for i in range(3)
|
||||||
|
]
|
||||||
|
p1_discard = [
|
||||||
|
CardInstance(instance_id="p1-discard-0", definition_id=energy_def.id),
|
||||||
|
CardInstance(instance_id="p1-discard-1", definition_id=trainer_def.id),
|
||||||
|
]
|
||||||
|
p1_energy_deck = [
|
||||||
|
CardInstance(instance_id=f"p1-energy-deck-{i}", definition_id=energy_def.id)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
p1_energy_zone = [
|
||||||
|
CardInstance(instance_id="p1-energy-zone-0", definition_id=energy_def.id),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create card instances for player 2
|
||||||
|
p2_hand = [
|
||||||
|
CardInstance(instance_id=f"p2-hand-{i}", definition_id=pokemon_def.id) for i in range(4)
|
||||||
|
]
|
||||||
|
p2_active = CardInstance(instance_id="p2-active", definition_id=pokemon_def.id)
|
||||||
|
p2_bench = [
|
||||||
|
CardInstance(instance_id="p2-bench-0", definition_id=pokemon_def.id),
|
||||||
|
]
|
||||||
|
p2_deck = [
|
||||||
|
CardInstance(instance_id=f"p2-deck-{i}", definition_id=pokemon_def.id) for i in range(4)
|
||||||
|
]
|
||||||
|
p2_prizes = [
|
||||||
|
CardInstance(instance_id=f"p2-prize-{i}", definition_id=pokemon_def.id) for i in range(3)
|
||||||
|
]
|
||||||
|
p2_discard = [
|
||||||
|
CardInstance(instance_id="p2-discard-0", definition_id=energy_def.id),
|
||||||
|
]
|
||||||
|
p2_energy_deck = [
|
||||||
|
CardInstance(instance_id=f"p2-energy-deck-{i}", definition_id=energy_def.id)
|
||||||
|
for i in range(2)
|
||||||
|
]
|
||||||
|
p2_energy_zone = [
|
||||||
|
CardInstance(instance_id=f"p2-energy-zone-{i}", definition_id=energy_def.id)
|
||||||
|
for i in range(2)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build player states
|
||||||
|
p1 = PlayerState(player_id="player1", score=2)
|
||||||
|
for card in p1_hand:
|
||||||
|
p1.hand.add(card)
|
||||||
|
p1.active.add(p1_active)
|
||||||
|
for card in p1_bench:
|
||||||
|
p1.bench.add(card)
|
||||||
|
for card in p1_deck:
|
||||||
|
p1.deck.add(card)
|
||||||
|
for card in p1_prizes:
|
||||||
|
p1.prizes.add(card)
|
||||||
|
for card in p1_discard:
|
||||||
|
p1.discard.add(card)
|
||||||
|
for card in p1_energy_deck:
|
||||||
|
p1.energy_deck.add(card)
|
||||||
|
for card in p1_energy_zone:
|
||||||
|
p1.energy_zone.add(card)
|
||||||
|
|
||||||
|
p2 = PlayerState(player_id="player2", score=1)
|
||||||
|
for card in p2_hand:
|
||||||
|
p2.hand.add(card)
|
||||||
|
p2.active.add(p2_active)
|
||||||
|
for card in p2_bench:
|
||||||
|
p2.bench.add(card)
|
||||||
|
for card in p2_deck:
|
||||||
|
p2.deck.add(card)
|
||||||
|
for card in p2_prizes:
|
||||||
|
p2.prizes.add(card)
|
||||||
|
for card in p2_discard:
|
||||||
|
p2.discard.add(card)
|
||||||
|
for card in p2_energy_deck:
|
||||||
|
p2.energy_deck.add(card)
|
||||||
|
for card in p2_energy_zone:
|
||||||
|
p2.energy_zone.add(card)
|
||||||
|
|
||||||
|
# Build game state
|
||||||
|
game = GameState(
|
||||||
|
game_id="test-game",
|
||||||
|
rules=RulesConfig(),
|
||||||
|
card_registry={
|
||||||
|
pokemon_def.id: pokemon_def,
|
||||||
|
energy_def.id: energy_def,
|
||||||
|
trainer_def.id: trainer_def,
|
||||||
|
},
|
||||||
|
players={"player1": p1, "player2": p2},
|
||||||
|
current_player_id="player1",
|
||||||
|
turn_number=3,
|
||||||
|
phase=TurnPhase.MAIN,
|
||||||
|
turn_order=["player1", "player2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Own Information Visibility Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnInformationVisibility:
|
||||||
|
"""Tests verifying players can see their own information."""
|
||||||
|
|
||||||
|
def test_player_sees_own_hand_contents(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that a player can see the full contents of their own hand.
|
||||||
|
|
||||||
|
This is essential for gameplay - players must know what cards
|
||||||
|
they can play.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
own_state = visible.players["player1"]
|
||||||
|
|
||||||
|
assert own_state.hand.count == 3
|
||||||
|
assert len(own_state.hand.cards) == 3
|
||||||
|
# Verify specific cards are visible
|
||||||
|
hand_ids = [c.instance_id for c in own_state.hand.cards]
|
||||||
|
assert "p1-hand-0" in hand_ids
|
||||||
|
assert "p1-hand-1" in hand_ids
|
||||||
|
assert "p1-hand-2" in hand_ids
|
||||||
|
|
||||||
|
def test_player_sees_own_active_pokemon(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that a player can see their own active Pokemon.
|
||||||
|
|
||||||
|
Active Pokemon details are always public.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
own_state = visible.players["player1"]
|
||||||
|
|
||||||
|
assert own_state.active.count == 1
|
||||||
|
assert len(own_state.active.cards) == 1
|
||||||
|
assert own_state.active.cards[0].instance_id == "p1-active"
|
||||||
|
|
||||||
|
def test_player_sees_own_bench(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that a player can see their own benched Pokemon.
|
||||||
|
|
||||||
|
Bench contents are always public.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
own_state = visible.players["player1"]
|
||||||
|
|
||||||
|
assert own_state.bench.count == 2
|
||||||
|
assert len(own_state.bench.cards) == 2
|
||||||
|
|
||||||
|
def test_player_sees_own_discard(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that a player can see their own discard pile.
|
||||||
|
|
||||||
|
Discard piles are always public.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
own_state = visible.players["player1"]
|
||||||
|
|
||||||
|
assert own_state.discard.count == 2
|
||||||
|
assert len(own_state.discard.cards) == 2
|
||||||
|
|
||||||
|
def test_player_sees_own_deck_count_only(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that a player sees their deck count but not contents.
|
||||||
|
|
||||||
|
Even your own deck order is hidden to maintain game integrity.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
own_state = visible.players["player1"]
|
||||||
|
|
||||||
|
assert own_state.deck_count == 5
|
||||||
|
|
||||||
|
def test_player_sees_own_prize_count_only(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that a player sees their prize count but not contents.
|
||||||
|
|
||||||
|
Prize cards are revealed only when taken.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
own_state = visible.players["player1"]
|
||||||
|
|
||||||
|
assert own_state.prizes_count == 3
|
||||||
|
|
||||||
|
def test_player_sees_own_energy_zone(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that a player can see their energy zone (available energy).
|
||||||
|
|
||||||
|
Energy zone is public - it shows what energy can be attached.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
own_state = visible.players["player1"]
|
||||||
|
|
||||||
|
assert own_state.energy_zone.count == 1
|
||||||
|
assert len(own_state.energy_zone.cards) == 1
|
||||||
|
|
||||||
|
def test_player_sees_own_score(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that a player can see their own score.
|
||||||
|
|
||||||
|
Scores are always public.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
own_state = visible.players["player1"]
|
||||||
|
|
||||||
|
assert own_state.score == 2
|
||||||
|
|
||||||
|
def test_is_current_player_flag(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that is_current_player is set correctly for self.
|
||||||
|
|
||||||
|
This flag helps the UI know which player state is "mine".
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
|
||||||
|
assert visible.players["player1"].is_current_player is True
|
||||||
|
assert visible.players["player2"].is_current_player is False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Opponent Information Hiding Tests (SECURITY CRITICAL)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpponentInformationHiding:
|
||||||
|
"""SECURITY CRITICAL: Tests verifying opponent information is hidden."""
|
||||||
|
|
||||||
|
def test_opponent_hand_contents_hidden(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Opponent's hand contents must NEVER be visible.
|
||||||
|
|
||||||
|
This is the most critical security test. Leaking hand contents
|
||||||
|
would allow cheating.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
# Count should be visible
|
||||||
|
assert opponent_state.hand.count == 4
|
||||||
|
|
||||||
|
# Contents must be empty
|
||||||
|
assert len(opponent_state.hand.cards) == 0
|
||||||
|
|
||||||
|
def test_opponent_hand_card_ids_not_leaked(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Opponent's hand card IDs must not be exposed anywhere.
|
||||||
|
|
||||||
|
Verify no hand card IDs appear in the visible state.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
|
||||||
|
# Serialize to check for any leakage
|
||||||
|
json_str = visible.model_dump_json()
|
||||||
|
|
||||||
|
# These are player 2's hand cards - they should not appear
|
||||||
|
assert "p2-hand-0" not in json_str
|
||||||
|
assert "p2-hand-1" not in json_str
|
||||||
|
assert "p2-hand-2" not in json_str
|
||||||
|
assert "p2-hand-3" not in json_str
|
||||||
|
|
||||||
|
def test_opponent_deck_order_hidden(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Opponent's deck order must not be visible.
|
||||||
|
|
||||||
|
Only the count is allowed.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
# Only count, no card data
|
||||||
|
assert opponent_state.deck_count == 4
|
||||||
|
|
||||||
|
def test_opponent_deck_cards_not_leaked(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Opponent's deck card IDs must not appear anywhere.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
json_str = visible.model_dump_json()
|
||||||
|
|
||||||
|
# Player 2's deck cards should not appear
|
||||||
|
for i in range(4):
|
||||||
|
assert f"p2-deck-{i}" not in json_str
|
||||||
|
|
||||||
|
def test_opponent_prizes_hidden(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Opponent's prize card contents must be hidden.
|
||||||
|
|
||||||
|
Only count is visible.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
assert opponent_state.prizes_count == 3
|
||||||
|
|
||||||
|
def test_opponent_prize_cards_not_leaked(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Opponent's prize card IDs must not appear anywhere.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
json_str = visible.model_dump_json()
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
assert f"p2-prize-{i}" not in json_str
|
||||||
|
|
||||||
|
def test_opponent_energy_deck_hidden(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Opponent's energy deck order must be hidden.
|
||||||
|
|
||||||
|
Only count visible.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
assert opponent_state.energy_deck_count == 2
|
||||||
|
|
||||||
|
def test_own_deck_contents_also_hidden(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Even own deck contents are hidden (integrity).
|
||||||
|
|
||||||
|
This prevents any deck manipulation or tracking beyond what
|
||||||
|
cards have been seen.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
json_str = visible.model_dump_json()
|
||||||
|
|
||||||
|
# Own deck cards should also not appear
|
||||||
|
for i in range(5):
|
||||||
|
assert f"p1-deck-{i}" not in json_str
|
||||||
|
|
||||||
|
def test_own_prize_contents_hidden(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
SECURITY: Own prize card contents are hidden until taken.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
json_str = visible.model_dump_json()
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
assert f"p1-prize-{i}" not in json_str
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Public Information Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicInformation:
|
||||||
|
"""Tests verifying public information is correctly visible."""
|
||||||
|
|
||||||
|
def test_opponent_active_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that opponent's active Pokemon is fully visible.
|
||||||
|
|
||||||
|
Active Pokemon are on the battlefield - always public.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
assert opponent_state.active.count == 1
|
||||||
|
assert len(opponent_state.active.cards) == 1
|
||||||
|
assert opponent_state.active.cards[0].instance_id == "p2-active"
|
||||||
|
|
||||||
|
def test_opponent_bench_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that opponent's benched Pokemon are fully visible.
|
||||||
|
|
||||||
|
Bench is part of the battlefield - always public.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
assert opponent_state.bench.count == 1
|
||||||
|
assert len(opponent_state.bench.cards) == 1
|
||||||
|
assert opponent_state.bench.cards[0].instance_id == "p2-bench-0"
|
||||||
|
|
||||||
|
def test_opponent_discard_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that opponent's discard pile is fully visible.
|
||||||
|
|
||||||
|
Discard piles are always public knowledge.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
assert opponent_state.discard.count == 1
|
||||||
|
assert len(opponent_state.discard.cards) == 1
|
||||||
|
assert opponent_state.discard.cards[0].instance_id == "p2-discard-0"
|
||||||
|
|
||||||
|
def test_opponent_energy_zone_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that opponent's energy zone is visible.
|
||||||
|
|
||||||
|
Energy zone shows available energy - public information.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
assert opponent_state.energy_zone.count == 2
|
||||||
|
assert len(opponent_state.energy_zone.cards) == 2
|
||||||
|
|
||||||
|
def test_opponent_score_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that opponent's score is visible.
|
||||||
|
|
||||||
|
Scores are always public.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
opponent_state = visible.players["player2"]
|
||||||
|
|
||||||
|
assert opponent_state.score == 1
|
||||||
|
|
||||||
|
def test_stadium_visible(self, full_game: GameState, pokemon_def: CardDefinition):
|
||||||
|
"""
|
||||||
|
Test that stadium in play is visible to all.
|
||||||
|
"""
|
||||||
|
stadium = CardInstance(instance_id="stadium-1", definition_id=pokemon_def.id)
|
||||||
|
full_game.stadium_in_play = stadium
|
||||||
|
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
|
||||||
|
assert visible.stadium_in_play is not None
|
||||||
|
assert visible.stadium_in_play.instance_id == "stadium-1"
|
||||||
|
|
||||||
|
def test_card_registry_included(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that card registry is included for display purposes.
|
||||||
|
|
||||||
|
Clients need card definitions to render cards properly.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
|
||||||
|
assert len(visible.card_registry) == 3
|
||||||
|
assert "pikachu-001" in visible.card_registry
|
||||||
|
assert "lightning-energy-001" in visible.card_registry
|
||||||
|
assert "potion-001" in visible.card_registry
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Game State Information Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGameStateInformation:
|
||||||
|
"""Tests for game-level visible information."""
|
||||||
|
|
||||||
|
def test_game_id_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that game ID is included.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
assert visible.game_id == "test-game"
|
||||||
|
|
||||||
|
def test_viewer_id_set(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that viewer_id identifies who the view is for.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
assert visible.viewer_id == "player1"
|
||||||
|
|
||||||
|
def test_current_player_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that current player is visible.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
assert visible.current_player_id == "player1"
|
||||||
|
|
||||||
|
def test_turn_number_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that turn number is visible.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
assert visible.turn_number == 3
|
||||||
|
|
||||||
|
def test_phase_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that current phase is visible.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
assert visible.phase == TurnPhase.MAIN
|
||||||
|
|
||||||
|
def test_is_my_turn_flag(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that is_my_turn is correctly set.
|
||||||
|
"""
|
||||||
|
visible_p1 = get_visible_state(full_game, "player1")
|
||||||
|
visible_p2 = get_visible_state(full_game, "player2")
|
||||||
|
|
||||||
|
assert visible_p1.is_my_turn is True
|
||||||
|
assert visible_p2.is_my_turn is False
|
||||||
|
|
||||||
|
def test_winner_visible_when_game_over(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that winner is visible when game ends.
|
||||||
|
"""
|
||||||
|
full_game.winner_id = "player1"
|
||||||
|
full_game.end_reason = GameEndReason.PRIZES_TAKEN
|
||||||
|
|
||||||
|
visible = get_visible_state(full_game, "player2")
|
||||||
|
|
||||||
|
assert visible.winner_id == "player1"
|
||||||
|
assert visible.end_reason == GameEndReason.PRIZES_TAKEN
|
||||||
|
|
||||||
|
def test_forced_action_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that forced action information is visible.
|
||||||
|
|
||||||
|
Both players need to know when a forced action is pending.
|
||||||
|
"""
|
||||||
|
full_game.forced_action = ForcedAction(
|
||||||
|
player_id="player1",
|
||||||
|
action_type="select_active",
|
||||||
|
reason="Your active Pokemon was knocked out.",
|
||||||
|
)
|
||||||
|
|
||||||
|
visible = get_visible_state(full_game, "player2")
|
||||||
|
|
||||||
|
assert visible.forced_action_player == "player1"
|
||||||
|
assert visible.forced_action_type == "select_active"
|
||||||
|
assert visible.forced_action_reason == "Your active Pokemon was knocked out."
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Spectator Mode Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpectatorMode:
|
||||||
|
"""Tests for spectator view (no hands visible)."""
|
||||||
|
|
||||||
|
def test_spectator_sees_no_hands(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that spectators cannot see any player's hand.
|
||||||
|
"""
|
||||||
|
visible = get_spectator_state(full_game)
|
||||||
|
|
||||||
|
# Both hands should show count only
|
||||||
|
assert visible.players["player1"].hand.count == 3
|
||||||
|
assert len(visible.players["player1"].hand.cards) == 0
|
||||||
|
|
||||||
|
assert visible.players["player2"].hand.count == 4
|
||||||
|
assert len(visible.players["player2"].hand.cards) == 0
|
||||||
|
|
||||||
|
def test_spectator_sees_battlefield(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that spectators can see all battlefield information.
|
||||||
|
"""
|
||||||
|
visible = get_spectator_state(full_game)
|
||||||
|
|
||||||
|
# Active Pokemon visible
|
||||||
|
assert len(visible.players["player1"].active.cards) == 1
|
||||||
|
assert len(visible.players["player2"].active.cards) == 1
|
||||||
|
|
||||||
|
# Bench visible
|
||||||
|
assert len(visible.players["player1"].bench.cards) == 2
|
||||||
|
assert len(visible.players["player2"].bench.cards) == 1
|
||||||
|
|
||||||
|
# Discard visible
|
||||||
|
assert len(visible.players["player1"].discard.cards) == 2
|
||||||
|
assert len(visible.players["player2"].discard.cards) == 1
|
||||||
|
|
||||||
|
def test_spectator_is_not_current_player(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that spectator is never marked as current player.
|
||||||
|
"""
|
||||||
|
visible = get_spectator_state(full_game)
|
||||||
|
|
||||||
|
assert visible.is_my_turn is False
|
||||||
|
assert visible.players["player1"].is_current_player is False
|
||||||
|
assert visible.players["player2"].is_current_player is False
|
||||||
|
|
||||||
|
def test_spectator_viewer_id(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that spectator has special viewer ID.
|
||||||
|
"""
|
||||||
|
visible = get_spectator_state(full_game)
|
||||||
|
assert visible.viewer_id == "__spectator__"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Edge Cases and Error Handling
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Tests for edge cases and error handling."""
|
||||||
|
|
||||||
|
def test_invalid_viewer_raises_error(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that requesting view for non-existent player raises error.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
get_visible_state(full_game, "nonexistent")
|
||||||
|
|
||||||
|
assert "nonexistent" in str(exc_info.value)
|
||||||
|
assert "not a player" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_empty_zones_handled(self):
|
||||||
|
"""
|
||||||
|
Test that empty zones are handled correctly.
|
||||||
|
"""
|
||||||
|
game = GameState(
|
||||||
|
game_id="empty-game",
|
||||||
|
players={
|
||||||
|
"player1": PlayerState(player_id="player1"),
|
||||||
|
"player2": PlayerState(player_id="player2"),
|
||||||
|
},
|
||||||
|
current_player_id="player1",
|
||||||
|
turn_order=["player1", "player2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
visible = get_visible_state(game, "player1")
|
||||||
|
|
||||||
|
assert visible.players["player1"].hand.count == 0
|
||||||
|
assert visible.players["player1"].deck_count == 0
|
||||||
|
assert visible.players["player1"].active.count == 0
|
||||||
|
|
||||||
|
def test_no_stadium_in_play(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that missing stadium is handled correctly.
|
||||||
|
"""
|
||||||
|
full_game.stadium_in_play = None
|
||||||
|
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
|
||||||
|
assert visible.stadium_in_play is None
|
||||||
|
|
||||||
|
def test_no_forced_action(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that missing forced action is handled correctly.
|
||||||
|
"""
|
||||||
|
full_game.forced_action = None
|
||||||
|
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
|
||||||
|
assert visible.forced_action_player is None
|
||||||
|
assert visible.forced_action_type is None
|
||||||
|
assert visible.forced_action_reason is None
|
||||||
|
|
||||||
|
def test_gx_vstar_flags_visible(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that GX/VSTAR usage flags are visible.
|
||||||
|
|
||||||
|
These are public - opponents need to know if you've used
|
||||||
|
your once-per-game abilities.
|
||||||
|
"""
|
||||||
|
full_game.players["player1"].gx_attack_used = True
|
||||||
|
full_game.players["player2"].vstar_power_used = True
|
||||||
|
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
|
||||||
|
assert visible.players["player1"].gx_attack_used is True
|
||||||
|
assert visible.players["player2"].vstar_power_used is True
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Serialization Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerialization:
|
||||||
|
"""Tests verifying visible state serializes correctly."""
|
||||||
|
|
||||||
|
def test_visible_state_serializes_to_json(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that VisibleGameState can be serialized to JSON.
|
||||||
|
|
||||||
|
This is essential for sending to clients over WebSocket.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
json_str = visible.model_dump_json()
|
||||||
|
|
||||||
|
assert isinstance(json_str, str)
|
||||||
|
assert len(json_str) > 0
|
||||||
|
|
||||||
|
def test_visible_state_roundtrips(self, full_game: GameState):
|
||||||
|
"""
|
||||||
|
Test that VisibleGameState can be deserialized from JSON.
|
||||||
|
"""
|
||||||
|
visible = get_visible_state(full_game, "player1")
|
||||||
|
json_str = visible.model_dump_json()
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
restored = VisibleGameState.model_validate_json(json_str)
|
||||||
|
|
||||||
|
assert restored.game_id == visible.game_id
|
||||||
|
assert restored.viewer_id == visible.viewer_id
|
||||||
|
assert restored.turn_number == visible.turn_number
|
||||||
Loading…
Reference in New Issue
Block a user