golfgame/docs/v3/refactor-main-game.md
adlee-was-taken 9fc6b83bba v3.0.0: V3 features, server refactoring, and documentation overhaul
- 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>
2026-02-14 10:03:45 -05:00

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

  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