This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.
## Game Models Refactor
**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
- on_first: Optional[LineupPlayerState]
- on_second: Optional[LineupPlayerState]
- on_third: Optional[LineupPlayerState]
- Updated helper methods:
- get_runner_at_base() now returns LineupPlayerState directly
- get_all_runners() returns List[Tuple[int, LineupPlayerState]]
- is_runner_on_X() simplified to direct None checks
**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic
**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data
## Terminal Client Refactor
**Modularization (DRY principle):**
Created separate modules for better code organization:
1. **terminal_client/commands.py** (10,243 bytes)
- Shared command functions for game operations
- Used by both CLI (main.py) and REPL (repl.py)
- Functions: submit_defensive_decision, submit_offensive_decision,
resolve_play, quick_play_sequence
- Single source of truth for command logic
2. **terminal_client/arg_parser.py** (7,280 bytes)
- Centralized argument parsing and validation
- Handles defensive/offensive decision arguments
- Validates formats (alignment, depths, hold runners, steal attempts)
3. **terminal_client/completions.py** (10,357 bytes)
- TAB completion support for REPL mode
- Command completions, option completions, dynamic completions
- Game ID completions, defensive/offensive option suggestions
4. **terminal_client/help_text.py** (10,839 bytes)
- Centralized help text and command documentation
- Detailed command descriptions
- Usage examples for all commands
**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions
**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns
## Documentation
**New files:**
- app/models/visual_model_relationships.md
- Visual documentation of model relationships
- Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
- Phased documentation for terminal client evolution
- Historical context for implementation decisions
## Tests
**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py
**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure
## Summary
- ✅ Simplified game state model (removed RunnerState)
- ✅ Better alignment with database structure
- ✅ Modularized terminal client (DRY principle)
- ✅ Shared command logic between CLI and REPL
- ✅ Comprehensive test coverage
- ✅ Improved documentation
Total changes: 26 files modified/created
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
27 KiB
● Terminal Client Improvement Plan - Part 5: Player Name Caching and Display
Overview
Enhance the display system to show player names, positions, and stats instead of just lineup IDs. This requires integration with the player model system from Week 6 and adds a caching layer for performance.
Note: This enhancement requires Week 6 player models to be implemented first. The code provided here creates the infrastructure that will be activated once player models are available.
Files to Create
- Create backend/terminal_client/player_cache.py
""" Player data caching for terminal client.
Caches player/card data from league APIs to avoid repeated lookups and provide fast name/stat display in the REPL.
Author: Claude Date: 2025-10-27 """ import logging from typing import Dict, Optional, Any from uuid import UUID from dataclasses import dataclass from datetime import datetime, timedelta
logger = logging.getLogger(f'{name}.player_cache')
@dataclass class CachedPlayer: """Cached player data."""
# Identification
card_id: Optional[int] = None
player_id: Optional[int] = None
# Basic info
name: str = "Unknown"
position: str = "?"
team_id: int = 0
# Display info
image_url: Optional[str] = None
handedness: Optional[str] = None
# League-specific
league: str = "unknown"
# Cache metadata
cached_at: datetime = None
def get_display_name(self) -> str:
"""Get formatted display name."""
if self.handedness:
hand_symbol = {"R": "⟩", "L": "⟨", "S": "⟨⟩"}.get(self.handedness, "")
return f"{self.name} {hand_symbol} ({self.position})"
return f"{self.name} ({self.position})"
def get_short_display(self) -> str:
"""Get short display format."""
return f"{self.name} {self.position}"
class PlayerCache: """ Cache for player data to avoid repeated API lookups.
Maintains in-memory cache with TTL and per-game player rosters.
"""
def __init__(self, ttl_seconds: int = 3600):
"""
Initialize player cache.
Args:
ttl_seconds: Time to live for cached entries (default: 1 hour)
"""
self.ttl_seconds = ttl_seconds
# Cache by card_id (PD league)
self._card_cache: Dict[int, CachedPlayer] = {}
# Cache by player_id (SBA league)
self._player_cache: Dict[int, CachedPlayer] = {}
# Cache by lineup_id for quick lookups
self._lineup_cache: Dict[int, CachedPlayer] = {}
# Track which game uses which players
self._game_rosters: Dict[UUID, Dict[int, CachedPlayer]] = {}
logger.info(f"PlayerCache initialized with TTL={ttl_seconds}s")
def get_by_lineup_id(self, lineup_id: int) -> Optional[CachedPlayer]:
"""
Get player by lineup ID (fastest lookup for display).
Args:
lineup_id: Lineup entry ID
Returns:
CachedPlayer or None if not cached
"""
player = self._lineup_cache.get(lineup_id)
if player and self._is_expired(player):
logger.debug(f"Lineup cache expired for {lineup_id}")
del self._lineup_cache[lineup_id]
return None
return player
def get_by_card_id(self, card_id: int) -> Optional[CachedPlayer]:
"""
Get player by card ID (PD league).
Args:
card_id: Card ID
Returns:
CachedPlayer or None if not cached
"""
player = self._card_cache.get(card_id)
if player and self._is_expired(player):
logger.debug(f"Card cache expired for {card_id}")
del self._card_cache[card_id]
return None
return player
def get_by_player_id(self, player_id: int) -> Optional[CachedPlayer]:
"""
Get player by player ID (SBA league).
Args:
player_id: Player ID
Returns:
CachedPlayer or None if not cached
"""
player = self._player_cache.get(player_id)
if player and self._is_expired(player):
logger.debug(f"Player cache expired for {player_id}")
del self._player_cache[player_id]
return None
return player
def add_player(
self,
player_data: Dict[str, Any],
lineup_id: Optional[int] = None
) -> CachedPlayer:
"""
Add player to cache.
Args:
player_data: Player data from database/API
lineup_id: Optional lineup ID for quick lookups
Returns:
CachedPlayer instance
"""
now = datetime.utcnow()
player = CachedPlayer(
card_id=player_data.get('card_id'),
player_id=player_data.get('player_id'),
name=player_data.get('name', 'Unknown'),
position=player_data.get('position', '?'),
team_id=player_data.get('team_id', 0),
image_url=player_data.get('image_url'),
handedness=player_data.get('handedness'),
league=player_data.get('league', 'unknown'),
cached_at=now
)
# Cache by appropriate ID
if player.card_id:
self._card_cache[player.card_id] = player
logger.debug(f"Cached card {player.card_id}: {player.name}")
if player.player_id:
self._player_cache[player.player_id] = player
logger.debug(f"Cached player {player.player_id}: {player.name}")
# Cache by lineup ID if provided
if lineup_id:
self._lineup_cache[lineup_id] = player
logger.debug(f"Cached lineup {lineup_id}: {player.name}")
return player
async def load_game_roster(self, game_id: UUID, league: str) -> int:
"""
Load all players for a game into cache.
This should be called when switching to a game to pre-populate
the cache with all players that will be referenced.
Args:
game_id: Game UUID
league: League ID ('sba' or 'pd')
Returns:
Number of players loaded
"""
# Import here to avoid circular dependency
from app.database.operations import DatabaseOperations
db_ops = DatabaseOperations()
try:
# Get both team lineups
home_lineup = await db_ops.get_active_lineup(game_id, None) # Will need team_id
away_lineup = await db_ops.get_active_lineup(game_id, None)
all_players = home_lineup + away_lineup
game_roster = {}
for lineup_entry in all_players:
# Extract player data based on league
player_data = {
'card_id': getattr(lineup_entry, 'card_id', None),
'player_id': getattr(lineup_entry, 'player_id', None),
'name': getattr(lineup_entry, 'name', 'Unknown'),
'position': lineup_entry.position,
'team_id': lineup_entry.team_id,
'league': league
}
# Add to cache
player = self.add_player(player_data, lineup_id=lineup_entry.id)
game_roster[lineup_entry.id] = player
# Store game roster mapping
self._game_rosters[game_id] = game_roster
logger.info(f"Loaded {len(all_players)} players for game {game_id}")
return len(all_players)
except Exception as e:
logger.error(f"Failed to load game roster: {e}", exc_info=True)
return 0
def get_game_roster(self, game_id: UUID) -> Dict[int, CachedPlayer]:
"""
Get cached roster for a game.
Args:
game_id: Game UUID
Returns:
Dictionary mapping lineup_id to CachedPlayer
"""
return self._game_rosters.get(game_id, {})
def clear_game(self, game_id: UUID) -> None:
"""
Clear cached data for a specific game.
Args:
game_id: Game UUID
"""
if game_id in self._game_rosters:
del self._game_rosters[game_id]
logger.info(f"Cleared cache for game {game_id}")
def clear_expired(self) -> int:
"""
Remove expired entries from cache.
Returns:
Number of entries removed
"""
count = 0
# Clear card cache
expired_cards = [
cid for cid, player in self._card_cache.items()
if self._is_expired(player)
]
for cid in expired_cards:
del self._card_cache[cid]
count += 1
# Clear player cache
expired_players = [
pid for pid, player in self._player_cache.items()
if self._is_expired(player)
]
for pid in expired_players:
del self._player_cache[pid]
count += 1
# Clear lineup cache
expired_lineups = [
lid for lid, player in self._lineup_cache.items()
if self._is_expired(player)
]
for lid in expired_lineups:
del self._lineup_cache[lid]
count += 1
if count > 0:
logger.info(f"Cleared {count} expired cache entries")
return count
def get_stats(self) -> Dict[str, int]:
"""
Get cache statistics.
Returns:
Dictionary with cache stats
"""
return {
'card_cache_size': len(self._card_cache),
'player_cache_size': len(self._player_cache),
'lineup_cache_size': len(self._lineup_cache),
'games_cached': len(self._game_rosters),
'ttl_seconds': self.ttl_seconds
}
def _is_expired(self, player: CachedPlayer) -> bool:
"""Check if cached player is expired."""
if player.cached_at is None:
return True
age = (datetime.utcnow() - player.cached_at).total_seconds()
return age > self.ttl_seconds
Global cache instance
player_cache = PlayerCache()
- Create backend/terminal_client/enhanced_display.py
""" Enhanced display functions with player names.
Extends the base display module with player name lookups and richer formatting.
Author: Claude Date: 2025-10-27 """ import logging from typing import Optional from rich.text import Text
from app.models.game_models import GameState from terminal_client import display from terminal_client.player_cache import player_cache, CachedPlayer
logger = logging.getLogger(f'{name}.enhanced_display')
def get_player_display(lineup_id: Optional[int]) -> str: """ Get display string for a player by lineup ID.
Args:
lineup_id: Lineup entry ID
Returns:
Formatted player string (name if available, ID otherwise)
"""
if lineup_id is None:
return "None"
# Try to get from cache
player = player_cache.get_by_lineup_id(lineup_id)
if player:
return player.get_short_display()
# Fall back to lineup ID
return f"Lineup #{lineup_id}"
def display_game_state_enhanced(state: GameState) -> None: """ Display game state with player names (enhanced version).
This is a drop-in replacement for display.display_game_state()
that adds player names when available.
Args:
state: Current game state
"""
from rich.panel import Panel
from rich import box
# Status color based on game status
status_color = {
"pending": "yellow",
"active": "green",
"paused": "yellow",
"completed": "blue"
}
# Build state display
state_text = Text()
state_text.append(f"Game ID: {state.game_id}\n", style="bold")
state_text.append(f"League: {state.league_id.upper()}\n")
state_text.append(f"Status: ", style="bold")
state_text.append(f"{state.status}\n", style=status_color.get(state.status, "white"))
state_text.append("\n")
# Score
state_text.append("Score: ", style="bold cyan")
state_text.append(f"Away {state.away_score} - {state.home_score} Home\n", style="cyan")
# Inning
state_text.append("Inning: ", style="bold magenta")
state_text.append(f"{state.inning} {state.half.capitalize()}\n", style="magenta")
# Outs
state_text.append("Outs: ", style="bold yellow")
state_text.append(f"{state.outs}\n", style="yellow")
# Runners (enhanced with names)
runners = state.get_all_runners()
if runners:
state_text.append("\nRunners: ", style="bold green")
runner_displays = []
for base, runner in runners:
player_name = get_player_display(runner.lineup_id)
runner_displays.append(f"{base}B: {player_name}")
state_text.append(f"{', '.join(runner_displays)}\n", style="green")
else:
state_text.append("\nBases: ", style="bold")
state_text.append("Empty\n", style="dim")
# Current players (enhanced with names)
if state.current_batter_lineup_id:
batter_name = get_player_display(state.current_batter_lineup_id)
state_text.append(f"\nBatter: {batter_name}\n")
if state.current_pitcher_lineup_id:
pitcher_name = get_player_display(state.current_pitcher_lineup_id)
state_text.append(f"Pitcher: {pitcher_name}\n")
# Pending decision
if state.pending_decision:
state_text.append(f"\nPending: ", style="bold red")
state_text.append(f"{state.pending_decision} decision\n", style="red")
# Last play result
if state.last_play_result:
state_text.append(f"\nLast Play: ", style="bold")
state_text.append(f"{state.last_play_result}\n", style="italic")
# Display panel
panel = Panel(
state_text,
title=f"[bold]Game State[/bold]",
border_style="blue",
box=box.ROUNDED
)
display.console.print(panel)
def show_cache_stats() -> None: """Display player cache statistics.""" stats = player_cache.get_stats()
display.console.print("\n[bold cyan]Player Cache Statistics:[/bold cyan]")
display.console.print(f" Card cache: {stats['card_cache_size']} entries")
display.console.print(f" Player cache: {stats['player_cache_size']} entries")
display.console.print(f" Lineup cache: {stats['lineup_cache_size']} entries")
display.console.print(f" Games cached: {stats['games_cached']}")
display.console.print(f" TTL: {stats['ttl_seconds']} seconds\n")
Files to Update
- Update backend/terminal_client/repl.py
Add imports at top
from terminal_client.player_cache import player_cache from terminal_client.enhanced_display import ( display_game_state_enhanced, show_cache_stats )
class GameREPL(GameREPLCompletions, cmd.Cmd): # ... existing code ...
async def _ensure_game_loaded(self, game_id: UUID) -> None:
"""
Ensure game is loaded in state_manager.
If game exists in database but not in memory, recover it.
Also pre-load player cache for better display.
"""
# Check if already in memory
state = state_manager.get_state(game_id)
if state is not None:
# Game loaded, check if cache needs refresh
roster = player_cache.get_game_roster(game_id)
if not roster:
# Load player names into cache
try:
count = await player_cache.load_game_roster(game_id, state.league_id)
if count > 0:
logger.debug(f"Loaded {count} players into cache")
except Exception as e:
logger.warning(f"Could not load player cache: {e}")
return
# Try to recover from database
try:
display.print_info(f"Loading game {game_id} from database...")
recovered_state = await state_manager.recover_game(game_id)
if recovered_state and recovered_state.status == "active":
await game_engine._prepare_next_play(recovered_state)
logger.debug(f"Prepared snapshot for recovered game {game_id}")
# Load player cache
try:
count = await player_cache.load_game_roster(
game_id,
recovered_state.league_id
)
if count > 0:
display.print_success(f"Loaded {count} players into display cache")
except Exception as e:
logger.warning(f"Could not load player cache: {e}")
display.print_success("Game loaded successfully")
except Exception as e:
display.print_error(f"Failed to load game: {e}")
logger.error(f"Game recovery failed for {game_id}: {e}", exc_info=True)
raise ValueError(f"Game {game_id} not found")
# Add new command for cache management
def do_cache(self, arg):
"""
Manage player cache.
Usage:
cache Show cache statistics
cache clear Clear expired entries
cache reload Reload current game roster
"""
async def _cache():
args = arg.split()
if not args or args[0] == 'stats':
# Show stats
show_cache_stats()
elif args[0] == 'clear':
# Clear expired
count = player_cache.clear_expired()
display.print_success(f"Cleared {count} expired entries")
elif args[0] == 'reload':
# Reload current game
try:
gid = self._ensure_game()
state = state_manager.get_state(gid)
if state:
count = await player_cache.load_game_roster(gid, state.league_id)
display.print_success(f"Loaded {count} players")
else:
display.print_error("No game state found")
except ValueError:
pass
else:
display.print_error(f"Unknown cache command: {args[0]}")
display.print_info("Usage: cache [stats|clear|reload]")
self._run_async(_cache())
# Update status command to use enhanced display
def do_status(self, arg):
"""Display current game state with player names."""
async def _status():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
state = await game_engine.get_game_state(gid)
if state:
# Use enhanced display if cache is populated
roster = player_cache.get_game_roster(gid)
if roster:
display_game_state_enhanced(state)
else:
display.display_game_state(state)
else:
display.print_error("Game state not found")
except ValueError:
pass
except Exception as e:
display.print_error(f"Failed: {e}")
self._run_async(_status())
Testing Plan
- Create backend/tests/unit/terminal_client/test_player_cache.py
""" Unit tests for player cache. """ import pytest from datetime import datetime, timedelta from uuid import uuid4
from terminal_client.player_cache import PlayerCache, CachedPlayer
class TestCachedPlayer: """Tests for CachedPlayer dataclass."""
def test_get_display_name_with_handedness(self):
"""Test display name includes handedness symbol."""
player = CachedPlayer(
name="Mike Trout",
position="CF",
handedness="R"
)
assert "⟩" in player.get_display_name()
assert "Mike Trout" in player.get_display_name()
assert "CF" in player.get_display_name()
def test_get_display_name_without_handedness(self):
"""Test display name without handedness."""
player = CachedPlayer(
name="Mike Trout",
position="CF"
)
result = player.get_display_name()
assert result == "Mike Trout (CF)"
def test_get_short_display(self):
"""Test short display format."""
player = CachedPlayer(
name="Mike Trout",
position="CF"
)
assert player.get_short_display() == "Mike Trout CF"
class TestPlayerCache: """Tests for PlayerCache."""
@pytest.fixture
def cache(self):
"""Create fresh cache for each test."""
return PlayerCache(ttl_seconds=3600)
def test_add_and_get_by_card_id(self, cache):
"""Test adding and retrieving by card ID."""
player_data = {
'card_id': 123,
'name': 'Test Player',
'position': 'SS',
'team_id': 1,
'league': 'pd'
}
player = cache.add_player(player_data)
assert player.name == 'Test Player'
assert player.position == 'SS'
# Retrieve by card ID
retrieved = cache.get_by_card_id(123)
assert retrieved is not None
assert retrieved.name == 'Test Player'
def test_add_and_get_by_player_id(self, cache):
"""Test adding and retrieving by player ID."""
player_data = {
'player_id': 456,
'name': 'Test Player',
'position': 'CF',
'team_id': 2,
'league': 'sba'
}
player = cache.add_player(player_data)
# Retrieve by player ID
retrieved = cache.get_by_player_id(456)
assert retrieved is not None
assert retrieved.name == 'Test Player'
def test_add_with_lineup_id(self, cache):
"""Test adding with lineup ID for quick lookups."""
player_data = {
'card_id': 123,
'name': 'Test Player',
'position': 'SS',
'team_id': 1,
'league': 'pd'
}
player = cache.add_player(player_data, lineup_id=10)
# Retrieve by lineup ID
retrieved = cache.get_by_lineup_id(10)
assert retrieved is not None
assert retrieved.name == 'Test Player'
def test_expired_entry_removed(self, cache):
"""Test that expired entries are not returned."""
cache_short_ttl = PlayerCache(ttl_seconds=0)
player_data = {
'card_id': 123,
'name': 'Test Player',
'position': 'SS',
'team_id': 1,
'league': 'pd'
}
player = cache_short_ttl.add_player(player_data)
player.cached_at = datetime.utcnow() - timedelta(seconds=10)
# Should return None for expired entry
retrieved = cache_short_ttl.get_by_card_id(123)
assert retrieved is None
def test_clear_expired(self, cache):
"""Test clearing expired entries."""
# Add fresh entry
cache.add_player({
'card_id': 1,
'name': 'Fresh',
'position': 'P',
'team_id': 1,
'league': 'pd'
})
# Add expired entry
old_player = cache.add_player({
'card_id': 2,
'name': 'Old',
'position': 'C',
'team_id': 1,
'league': 'pd'
})
old_player.cached_at = datetime.utcnow() - timedelta(seconds=10000)
# Clear expired
count = cache.clear_expired()
assert count > 0
assert cache.get_by_card_id(1) is not None # Fresh still there
assert cache.get_by_card_id(2) is None # Old removed
def test_get_stats(self, cache):
"""Test getting cache statistics."""
cache.add_player({'card_id': 1, 'name': 'P1', 'position': 'P', 'team_id': 1, 'league': 'pd'})
cache.add_player({'player_id': 2, 'name': 'P2', 'position': 'C', 'team_id': 1, 'league': 'sba'})
stats = cache.get_stats()
assert stats['card_cache_size'] == 1
assert stats['player_cache_size'] == 1
assert stats['ttl_seconds'] == 3600
def test_clear_game(self, cache):
"""Test clearing game-specific cache."""
game_id = uuid4()
# Simulate game roster
cache._game_rosters[game_id] = {
1: CachedPlayer(name="Player 1", position="P"),
2: CachedPlayer(name="Player 2", position="C")
}
assert len(cache.get_game_roster(game_id)) == 2
cache.clear_game(game_id)
assert len(cache.get_game_roster(game_id)) == 0
Configuration and Activation
- Create backend/terminal_client/config.py update
Add to existing config.py
class ClientConfig: """Configuration for terminal client features."""
# Feature flags
ENABLE_PLAYER_NAMES = False # Set to True when Week 6 models are ready
ENABLE_PLAYER_CACHE = False # Set to True when Week 6 models are ready
# Cache settings
PLAYER_CACHE_TTL = 3600 # 1 hour
AUTO_LOAD_ROSTER = True # Auto-load roster when switching games
@classmethod
def is_enhanced_display_enabled(cls) -> bool:
"""Check if enhanced display with player names is enabled."""
return cls.ENABLE_PLAYER_NAMES and cls.ENABLE_PLAYER_CACHE
Activation Instructions (For Future)
When Week 6 player models are implemented:
- Update feature flags:
In terminal_client/config.py
ENABLE_PLAYER_NAMES = True ENABLE_PLAYER_CACHE = True 2. Implement player data fetching: - Add methods to fetch player data from league APIs - Integrate with backend/app/data/api_client.py 3. Update database operations: - Ensure lineup queries return player names - Add joins to get player/card details 4. Test with real data: - Verify cache performance - Check TTL behavior - Validate display formatting
Example Output (When Activated)
Before (Current): Batter: Lineup #1 Pitcher: Lineup #10 Runners: 1B(#2), 3B(#5)
After (With Player Names): Batter: Mike Trout CF Pitcher: Clayton Kershaw P Runners: 1B: Aaron Judge RF, 3B: Mookie Betts RF
Benefits
- Better UX: See player names instead of cryptic IDs
- Performance: Caching prevents repeated database/API lookups
- Flexibility: Works with both PD and SBA leagues
- Future-ready: Infrastructure ready for Week 6 integration
- Optional: Can be disabled if not needed