golfgame/docs/v3/refactor-ai.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

277 lines
9.8 KiB
Markdown

# Plan 2: ai.py Refactor
## Overview
`ai.py` is 1,978 lines with a single function (`choose_swap_or_discard`) at **666 lines** and cyclomatic complexity 50+. The goal is to decompose it into testable, understandable pieces without changing any AI behavior.
Key constraint: **AI behavior must remain identical.** This is pure structural refactoring. We can validate with `python server/simulate.py 500` before and after - stats should match within normal variance.
---
## The Problem Functions
| Function | Lines | What It Does |
|----------|-------|-------------|
| `choose_swap_or_discard()` | ~666 | Decides which position (0-5) to swap drawn card into, or None to discard |
| `calculate_swap_score()` | ~240 | Scores a single position for swapping |
| `should_take_discard()` | ~160 | Decides whether to take from discard pile |
| `process_cpu_turn()` | ~240 | Orchestrates a full CPU turn with timing |
---
## Refactoring Plan
### Step 1: Extract Named Constants
Create section at top of `ai.py` (or a separate `ai_constants.py` if preferred):
```python
# =============================================================================
# AI Decision Constants
# =============================================================================
# Expected value of an unknown (face-down) card, based on deck distribution
EXPECTED_HIDDEN_VALUE = 4.5
# Pessimistic estimate for hidden cards (used in go-out safety checks)
PESSIMISTIC_HIDDEN_VALUE = 6.0
# Conservative estimate (used by conservative personality)
CONSERVATIVE_HIDDEN_VALUE = 2.5
# Cards at or above this value should never be swapped into unknown positions
HIGH_CARD_THRESHOLD = 8
# Maximum card value for unpredictability swaps
UNPREDICTABLE_MAX_VALUE = 7
# Pair potential discount when adjacent card matches
PAIR_POTENTIAL_DISCOUNT = 0.25
# Blackjack target score
BLACKJACK_TARGET = 21
# Base acceptable score range for go-out decisions
GO_OUT_SCORE_BASE = 12
GO_OUT_SCORE_MAX = 20
```
**Locations to update:** ~30 magic number sites across the file. Each becomes a named reference.
### Step 2: Extract Column/Pair Utility Functions
The "iterate columns, check pairs" pattern appears 8+ times. Create shared utilities:
```python
def iter_columns(player: Player):
"""Yield (col_index, top_idx, bot_idx, top_card, bot_card) for each column."""
for col in range(3):
top_idx = col
bot_idx = col + 3
yield col, top_idx, bot_idx, player.cards[top_idx], player.cards[bot_idx]
def project_score(player: Player, swap_pos: int, new_card: Card, options: GameOptions) -> int:
"""Calculate what the player's score would be if new_card were swapped into swap_pos.
Handles pair cancellation correctly. Used by multiple decision paths.
"""
total = 0
for col, top_idx, bot_idx, top_card, bot_card in iter_columns(player):
# Substitute the new card if it's in this column
effective_top = new_card if top_idx == swap_pos else top_card
effective_bot = new_card if bot_idx == swap_pos else bot_card
if effective_top.rank == effective_bot.rank:
# Pair cancels (with house rule exceptions)
continue
total += get_ai_card_value(effective_top, options)
total += get_ai_card_value(effective_bot, options)
return total
def count_hidden(player: Player) -> int:
"""Count face-down cards."""
return sum(1 for c in player.cards if not c.face_up)
def hidden_positions(player: Player) -> list[int]:
"""Get indices of face-down cards."""
return [i for i, c in enumerate(player.cards) if not c.face_up]
def known_score(player: Player, options: GameOptions) -> int:
"""Calculate score from face-up cards only, using EXPECTED_HIDDEN_VALUE for unknowns."""
# Centralized version of the repeated estimation logic
...
```
This replaces duplicated loops at roughly lines: 679, 949, 1002, 1053, 1145, 1213, 1232.
### Step 3: Decompose `choose_swap_or_discard()`
Break into focused sub-functions. The current flow is roughly:
1. **Go-out safety check** (lines ~1087-1186) - "I'm about to go out, pick the best swap to minimize my score"
2. **Score all 6 positions** (lines ~1190-1270) - Calculate swap benefit for each position
3. **Filter and rank candidates** (lines ~1270-1330) - Safety filters, personality tie-breaking
4. **Blackjack special case** (lines ~1330-1380) - If blackjack rule enabled, check for 21
5. **Endgame safety** (lines ~1380-1410) - Don't swap 8+ into unknowns in endgame
6. **Denial logic** (lines ~1410-1480) - Block opponent by taking their useful cards
Proposed decomposition:
```python
def choose_swap_or_discard(player, drawn_card, profile, game, ...) -> Optional[int]:
"""Main orchestrator - delegates to focused sub-functions."""
# Check if we should force a go-out swap
go_out_pos = _check_go_out_swap(player, drawn_card, profile, game, ...)
if go_out_pos is not None:
return go_out_pos
# Score all positions
candidates = _score_all_positions(player, drawn_card, profile, game, ...)
# Apply filters and select best
best = _select_best_candidate(candidates, player, drawn_card, profile, game, ...)
if best is not None:
return best
# Try denial as fallback
return _check_denial_swap(player, drawn_card, profile, game, ...)
def _check_go_out_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
"""If player is close to going out, find the best position to minimize final score.
Handles:
- All-but-one face-up: find the best slot for the drawn card
- Acceptable score threshold based on game state and personality
- Pair completion opportunities
"""
# Lines ~1087-1186 of current choose_swap_or_discard
...
def _score_all_positions(player, drawn_card, profile, game, ...) -> list[tuple[int, float]]:
"""Calculate swap benefit score for each of the 6 positions.
Returns list of (position, score) tuples, sorted by score descending.
Each score represents how much the swap improves the player's hand.
"""
# Lines ~1190-1270 - calls calculate_swap_score() for each position
...
def _select_best_candidate(candidates, player, drawn_card, profile, game, ...) -> Optional[int]:
"""From scored candidates, apply personality modifiers and safety filters.
Handles:
- Minimum improvement threshold
- Personality tie-breaking (pair_hunter prefers pair columns, etc.)
- Unpredictability (occasional random choice with value threshold)
- High-card safety filter (never swap 8+ into hidden positions)
- Blackjack special case (swap to reach exactly 21)
- Endgame safety (discard 8+ rather than force into unknown)
"""
# Lines ~1270-1410
...
def _check_denial_swap(player, drawn_card, profile, game, ...) -> Optional[int]:
"""Check if we should swap to deny opponents a useful card.
Only triggers for profiles with denial_aggression > 0.
Skips hidden positions for high cards (8+).
"""
# Lines ~1410-1480
...
```
### Step 4: Simplify `calculate_swap_score()`
Currently ~240 lines. Some of its complexity comes from inlined pair calculations and standings pressure. Extract:
```python
def _pair_improvement(player, position, new_card, options) -> float:
"""Calculate pair-related benefit of swapping into this position."""
# Would the swap create a new pair? Break an existing pair?
...
def _standings_pressure(player, game) -> float:
"""Calculate how much standings position should affect decisions."""
# Shared between calculate_swap_score and should_take_discard
...
```
### Step 5: Simplify `should_take_discard()`
Currently ~160 lines. Much of the complexity is from re-deriving information that `calculate_swap_score` also computes. After Step 2's utilities exist, this should shrink significantly since `project_score()` and `known_score()` handle the repeated estimation logic.
### Step 6: Clean up `process_cpu_turn()`
Currently ~240 lines. This function is the CPU turn orchestrator and is mostly fine structurally, but has some inline logic for:
- Flip-as-action decisions (~30 lines)
- Knock-early decisions (~30 lines)
- Game logging (~20 lines repeated twice)
Extract:
```python
def _should_flip_as_action(player, game, profile) -> Optional[int]:
"""Decide whether to use flip-as-action and which position."""
...
def _should_knock_early(player, game, profile) -> bool:
"""Decide whether to knock early."""
...
def _log_cpu_action(game_id, player, action, card=None, position=None, reason=""):
"""Log a CPU action if logger is available."""
...
```
---
## Execution Order
1. **Step 1** (constants) - Safe, mechanical, reduces cognitive load immediately
2. **Step 2** (utilities) - Foundation for everything else
3. **Step 3** (decompose choose_swap_or_discard) - The big win
4. **Step 4** (simplify calculate_swap_score) - Benefits from Step 2 utilities
5. **Step 5** (simplify should_take_discard) - Benefits from Step 2 utilities
6. **Step 6** (clean up process_cpu_turn) - Lower priority
**Run `python server/simulate.py 500` before Step 1 and after each step to verify identical behavior.**
---
## Validation Strategy
```bash
# Before any changes - capture baseline
python server/simulate.py 500 > /tmp/ai_baseline.txt
# After each step
python server/simulate.py 500 > /tmp/ai_after_stepN.txt
# Compare key metrics:
# - Average scores per personality
# - "Swapped 8+ into unknown" rate (should stay < 0.1%)
# - Win rate distribution
```
---
## Files Touched
- `server/ai.py` - major restructuring (same file, new internal organization)
- No new files needed (all changes within ai.py unless we decide to split constants out)
## Risk Assessment
- **Low risk** if done mechanically (cut-paste into functions, update call sites)
- **Medium risk** if we accidentally change conditional logic order or miss an early return
- Simulation tests are the safety net - run after every step