Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-02-14 - Redundant Schema Creation in View Logic
**Learning:** The `Game.to_schema()` method creates a full Pydantic model hierarchy of the game state. Using this method as a data source for `get_view_for_player` (which creates a *filtered* Pydantic model hierarchy) resulted in creating N+N^2 Pydantic objects during broadcasts (N calls to `to_schema`, each creating N `PlayerSchema` objects).
**Action:** When generating derived or filtered views, access the internal domain model or state (e.g., `self.players`) directly instead of converting to a full schema first. This avoids intermediate object allocation and iteration overhead.
50 changes: 30 additions & 20 deletions backend/app/models/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,9 @@ def get_view_for_player(self, viewer_id: str) -> GameStateSchema:
Create a filtered view of the game state for a specific player.
Hides roles and actions based on game rules.
"""
full_schema = self.to_schema()
is_game_over = full_schema.phase == GamePhase.GAME_OVER
# OPTIMIZATION: Build schema directly from internal state to avoid
# double iteration and intermediate object creation (to_schema overhead).
is_game_over = self.phase == GamePhase.GAME_OVER

viewer = self.players.get(viewer_id)
is_spectator = viewer and (viewer.role == RoleType.SPECTATOR or not viewer.is_alive)
Expand All @@ -169,7 +170,8 @@ def get_view_for_player(self, viewer_id: str) -> GameStateSchema:
revealed_to_viewer = set(self.seer_reveals.get(viewer_id, []))

filtered_players = {}
for pid, p in full_schema.players.items():
# Iterate over internal PlayerState objects directly
for pid, p in self.players.items():
# Determine visibility
is_self = pid == viewer_id

Expand Down Expand Up @@ -240,29 +242,37 @@ def get_view_for_player(self, viewer_id: str) -> GameStateSchema:
night_action_type=p.night_action_type if should_show_action else None,
night_action_confirmed=p.night_action_confirmed,
has_night_action=p.has_night_action if is_self else False,
# Map other role-specific fields needed by PlayerSchema
witch_has_heal=p.witch_has_heal,
witch_has_poison=p.witch_has_poison,
hunter_revenge_target=p.hunter_revenge_target,
last_protected_target=p.last_protected_target,
night_action_vote_distribution=vote_dist if is_self else None,
)

# Dynamic Role Info (Prompts, available actions)
if is_self and p.role and viewer and viewer.is_alive:
# We need the behavior instance from the internal state, not the schema
p_internal = self.players.get(pid)
if p_internal and p_internal.role_instance:
role_inst = p_internal.role_instance
filtered_players[pid].role_description = role_inst.get_description()

if self.phase == GamePhase.NIGHT or (
self.phase == GamePhase.HUNTER_REVENGE and p.role == RoleType.HUNTER
):
filtered_players[pid].night_info = role_inst.get_night_info(
self._state, pid
)
if is_self and p.role and viewer and viewer.is_alive and p.role_instance:
# Use the current p (PlayerState) directly
role_inst = p.role_instance
filtered_players[pid].role_description = role_inst.get_description()

full_schema.players = filtered_players
# Never expose the raw seer reveal map
full_schema.seer_reveals = {}
if self.phase == GamePhase.NIGHT or (
self.phase == GamePhase.HUNTER_REVENGE and p.role == RoleType.HUNTER
):
filtered_players[pid].night_info = role_inst.get_night_info(self._state, pid)

return full_schema
return GameStateSchema(
room_id=self.room_id,
phase=self.phase,
players=filtered_players,
settings=self.settings,
turn_count=self.turn_count,
winners=self.winners,
seer_reveals={}, # Never expose the raw seer reveal map
lovers=self.lovers,
voted_out_this_round=self.voted_out_this_round,
phase_start_time=self.phase_start_time,
)

def auto_balance_roles(self):
"""Automatically set default role distribution based on player count."""
Expand Down
8 changes: 4 additions & 4 deletions backend/app/schemas/game.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from enum import Enum
from enum import StrEnum

from pydantic import BaseModel, ConfigDict


class RoleType(str, Enum):
class RoleType(StrEnum):
VILLAGER = "VILLAGER"
WEREWOLF = "WEREWOLF"
SEER = "SEER"
Expand All @@ -17,7 +17,7 @@ class RoleType(str, Enum):
SPECTATOR = "SPECTATOR"


class GamePhase(str, Enum):
class GamePhase(StrEnum):
WAITING = "WAITING"
DAY = "DAY"
NIGHT = "NIGHT"
Expand All @@ -26,7 +26,7 @@ class GamePhase(str, Enum):
GAME_OVER = "GAME_OVER"


class NightActionType(str, Enum):
class NightActionType(StrEnum):
KILL = "KILL"
SAVE = "SAVE"
CHECK = "CHECK"
Expand Down
4 changes: 2 additions & 2 deletions backend/app/schemas/socket.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from enum import Enum
from enum import StrEnum
from typing import Annotated, Literal

from pydantic import BaseModel, Field

from app.schemas.game import GameStateSchema


class MessageType(str, Enum):
class MessageType(StrEnum):
STATE_UPDATE = "STATE_UPDATE"
ERROR = "ERROR"
CHAT = "CHAT"
Expand Down