- 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>
280 lines
9.3 KiB
Markdown
280 lines
9.3 KiB
Markdown
# 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:
|
|
|
|
```python
|
|
# 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:
|
|
```python
|
|
@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:
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
from dataclasses import asdict
|
|
|
|
def _options_to_dict(self) -> dict:
|
|
return asdict(self.options)
|
|
```
|
|
|
|
Or if we want to exclude `deck_colors` or similar:
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
@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
|
|
|
|
1. **B2, B3** (game.py small wins) - low risk, immediate cleanup
|
|
2. **A2** (log helper) - extract before moving handlers, so handlers are clean from the start
|
|
3. **A1** (handler extraction) - the big refactor, each handler is a cut-paste + cleanup
|
|
4. **A3** (static file mount) - easy win, independent
|
|
5. **B1** (active rules) - can do anytime
|
|
6. **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 handlers
|
|
- `server/game.py` - minor changes (active rules, options_to_dict, from_client_data)
|
|
|
|
## Testing
|
|
|
|
- All existing tests in `test_game.py` should 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
|