- Extract WebSocket handlers from main.py into handlers.py - Add V3 feature docs (dealer rotation, dealing animation, round end reveal, column pair celebration, final turn urgency, opponent thinking, score tallying, card hover/selection, knock early drama, column pair indicator, swap animation improvements, draw source distinction, card value tooltips, active rules context, discard pile history, realistic card sounds) - Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements) - Add installation guide with Docker, systemd, and nginx setup - Add helper scripts (install.sh, dev-server.sh, docker-build.sh) - Add animation flow diagrams documentation - Add test files for handlers, rooms, and V3 features - Add e2e test specs for V3 features - Update README with complete project structure and current tech stack - Update CLAUDE.md with full architecture tree and server layer descriptions - Update .env.example to reflect PostgreSQL (remove SQLite references) - Update .gitignore to exclude virtualenv files, .claude/, and .db files - Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg) - Remove obsolete game_log.py (SQLite) and games.db Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9.3 KiB
Plan 1: main.py & game.py Refactor
Overview
Break apart the 575-line WebSocket handler in main.py into discrete message handlers, eliminate repeated patterns (logging, locking, error responses), and clean up game.py's scattered house rule display logic and options boilerplate.
No backwards-compatibility concerns - no existing userbase.
Part A: main.py WebSocket Handler Decomposition
A1. Create server/handlers.py - Message Handler Registry
Extract each elif msg_type == "..." block from websocket_endpoint() into standalone async handler functions. One function per message type:
# server/handlers.py
async def handle_create_room(ws, data, ctx) -> None: ...
async def handle_join_room(ws, data, ctx) -> None: ...
async def handle_get_cpu_profiles(ws, data, ctx) -> None: ...
async def handle_add_cpu(ws, data, ctx) -> None: ...
async def handle_remove_cpu(ws, data, ctx) -> None: ...
async def handle_start_game(ws, data, ctx) -> None: ...
async def handle_flip_initial(ws, data, ctx) -> None: ...
async def handle_draw(ws, data, ctx) -> None: ...
async def handle_swap(ws, data, ctx) -> None: ...
async def handle_discard(ws, data, ctx) -> None: ...
async def handle_cancel_draw(ws, data, ctx) -> None: ...
async def handle_flip_card(ws, data, ctx) -> None: ...
async def handle_skip_flip(ws, data, ctx) -> None: ...
async def handle_flip_as_action(ws, data, ctx) -> None: ...
async def handle_knock_early(ws, data, ctx) -> None: ...
async def handle_next_round(ws, data, ctx) -> None: ...
async def handle_leave_room(ws, data, ctx) -> None: ...
async def handle_leave_game(ws, data, ctx) -> None: ...
async def handle_end_game(ws, data, ctx) -> None: ...
Context object passed to every handler:
@dataclass
class ConnectionContext:
websocket: WebSocket
connection_id: str
player_id: str
auth_user_id: Optional[str]
authenticated_user: Optional[User]
current_room: Optional[Room] # mutable reference
Handler dispatch in websocket_endpoint() becomes:
HANDLERS = {
"create_room": handle_create_room,
"join_room": handle_join_room,
# ... etc
}
while True:
data = await websocket.receive_json()
handler = HANDLERS.get(data.get("type"))
if handler:
await handler(data, ctx)
This takes websocket_endpoint() from ~575 lines to ~30 lines.
A2. Extract Game Action Logger Helper
The pattern repeated 8 times across draw/swap/discard/flip/skip_flip/flip_as_action/knock_early:
game_logger = get_logger()
if game_logger and current_room.game_log_id and player:
game_logger.log_move(
game_id=current_room.game_log_id,
player=player,
is_cpu=False,
action="...",
card=...,
position=...,
game=current_room.game,
decision_reason="...",
)
Extract to:
# In handlers.py or a small helpers module
def log_human_action(room, player, action, card=None, position=None, reason=""):
game_logger = get_logger()
if game_logger and room.game_log_id and player:
game_logger.log_move(
game_id=room.game_log_id,
player=player,
is_cpu=False,
action=action,
card=card,
position=position,
game=room.game,
decision_reason=reason,
)
Each handler call site becomes a single line.
A3. Replace Static File Routes with StaticFiles Mount
Currently 15+ hand-written @app.get() routes for static files (lines 1188-1255). Replace with:
from fastapi.staticfiles import StaticFiles
# Serve specific HTML routes first
@app.get("/")
async def serve_index():
return FileResponse(os.path.join(client_path, "index.html"))
@app.get("/admin")
async def serve_admin():
return FileResponse(os.path.join(client_path, "admin.html"))
@app.get("/replay/{share_code}")
async def serve_replay_page(share_code: str):
return FileResponse(os.path.join(client_path, "index.html"))
# Mount static files for everything else (JS, CSS, SVG, etc.)
app.mount("/", StaticFiles(directory=client_path), name="static")
Eliminates ~70 lines and auto-handles any new client files without code changes.
A4. Clean Up Lifespan Service Init
The lifespan function (lines 83-242) has a deeply nested try/except block initializing ~8 services with lots of set_* calls. Simplify by extracting service init:
async def _init_database_services():
"""Initialize all PostgreSQL-dependent services. Returns dict of services."""
# All the import/init/set logic currently in lifespan
...
async def _init_redis(redis_url):
"""Initialize Redis client and rate limiter."""
...
@asynccontextmanager
async def lifespan(app: FastAPI):
if config.REDIS_URL:
await _init_redis(config.REDIS_URL)
if config.POSTGRES_URL:
await _init_database_services()
# health check setup
...
yield
# shutdown...
Part B: game.py Cleanup
B1. Data-Driven Active Rules Display
Replace the 38-line if-chain in get_state() (lines 1546-1584) with a declarative approach:
# On GameOptions class or as module-level constant
_RULE_DISPLAY = [
# (attribute, display_name, condition_fn_or_None)
("knock_penalty", "Knock Penalty", None),
("lucky_swing", "Lucky Swing", None),
("eagle_eye", "Eagle-Eye", None),
("super_kings", "Super Kings", None),
("ten_penny", "Ten Penny", None),
("knock_bonus", "Knock Bonus", None),
("underdog_bonus", "Underdog", None),
("tied_shame", "Tied Shame", None),
("blackjack", "Blackjack", None),
("wolfpack", "Wolfpack", None),
("flip_as_action", "Flip as Action", None),
("four_of_a_kind", "Four of a Kind", None),
("negative_pairs_keep_value", "Negative Pairs Keep Value", None),
("one_eyed_jacks", "One-Eyed Jacks", None),
("knock_early", "Early Knock", None),
]
def get_active_rules(self) -> list[str]:
rules = []
# Special: flip mode
if self.options.flip_mode == FlipMode.ALWAYS.value:
rules.append("Speed Golf")
elif self.options.flip_mode == FlipMode.ENDGAME.value:
rules.append("Endgame Flip")
# Special: jokers (only if not overridden by lucky_swing/eagle_eye)
if self.options.use_jokers and not self.options.lucky_swing and not self.options.eagle_eye:
rules.append("Jokers")
# Boolean rules
for attr, display_name, _ in _RULE_DISPLAY:
if getattr(self.options, attr):
rules.append(display_name)
return rules
B2. Simplify _options_to_dict()
Replace the 22-line manual dict construction (lines 791-813) with dataclasses.asdict() or a simple comprehension:
from dataclasses import asdict
def _options_to_dict(self) -> dict:
return asdict(self.options)
Or if we want to exclude deck_colors or similar:
def _options_to_dict(self) -> dict:
return {k: v for k, v in asdict(self.options).items()}
B3. Add GameOptions.to_start_game_dict() for main.py
The start_game handler in main.py (lines 663-689) manually maps 17 data.get() calls to GameOptions(). Add a classmethod:
@classmethod
def from_client_data(cls, data: dict) -> "GameOptions":
"""Build GameOptions from client WebSocket message data."""
return cls(
flip_mode=data.get("flip_mode", "never"),
initial_flips=max(0, min(2, data.get("initial_flips", 2))),
knock_penalty=data.get("knock_penalty", False),
use_jokers=data.get("use_jokers", False),
lucky_swing=data.get("lucky_swing", False),
super_kings=data.get("super_kings", False),
ten_penny=data.get("ten_penny", False),
knock_bonus=data.get("knock_bonus", False),
underdog_bonus=data.get("underdog_bonus", False),
tied_shame=data.get("tied_shame", False),
blackjack=data.get("blackjack", False),
eagle_eye=data.get("eagle_eye", False),
wolfpack=data.get("wolfpack", False),
flip_as_action=data.get("flip_as_action", False),
four_of_a_kind=data.get("four_of_a_kind", False),
negative_pairs_keep_value=data.get("negative_pairs_keep_value", False),
one_eyed_jacks=data.get("one_eyed_jacks", False),
knock_early=data.get("knock_early", False),
deck_colors=data.get("deck_colors", ["red", "blue", "gold"]),
)
This keeps the construction logic on the class that owns it and out of the WebSocket handler.
Execution Order
- B2, B3 (game.py small wins) - low risk, immediate cleanup
- A2 (log helper) - extract before moving handlers, so handlers are clean from the start
- A1 (handler extraction) - the big refactor, each handler is a cut-paste + cleanup
- A3 (static file mount) - easy win, independent
- B1 (active rules) - can do anytime
- A4 (lifespan cleanup) - lower priority, nice-to-have
Files Touched
server/main.py- major changes (handler extraction, static files, lifespan)server/handlers.py- new file with all message handlersserver/game.py- minor changes (active rules, options_to_dict, from_client_data)
Testing
- All existing tests in
test_game.pyshould continue passing (game.py changes are additive/cosmetic) - The WebSocket handler refactor is structural only - same logic, just reorganized
- Manual smoke test: create room, add CPU, play a round, verify everything works