mantimon-tcg/backend/CLAUDE.md
Cal Corum f512c7b2b3 Refactor to dependency injection pattern - no monkey patching
- ConnectionManager: Add redis_factory constructor parameter
- GameService: Add engine_factory constructor parameter
- AuthHandler: New class replacing standalone functions with
  token_verifier and conn_manager injection
- Update all tests to use constructor DI instead of patch()
- Update CLAUDE.md with factory injection patterns
- Update services README with new patterns
- Add socketio README documenting AuthHandler and events

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:54:57 -06:00

14 KiB

Mantimon TCG Backend - AI Agent Guidelines

This document defines architecture requirements, patterns, and constraints for AI agents working on this codebase.

Critical Architecture Requirement: Offline Fork Support

The app/core/ module must remain extractable as a standalone offline game.

This is a primary design goal. The core game engine should work without network, database, or authentication dependencies.

Module Independence Rules

Module Can Import From Cannot Import From
app/core/ Python stdlib, pydantic app/services/, app/api/, app/db/, sqlalchemy
app/services/ app/core/, app/repositories/ app/api/
app/repositories/ app/core/, app/db/ app/services/, app/api/
app/api/ All modules -

Import Boundary Examples

# ALLOWED in app/core/
from app.core.models import CardDefinition, GameState
from app.core.config import RulesConfig
from app.core.rng import RandomProvider

# FORBIDDEN in app/core/
from app.services import CardService           # NO - service dependency
from app.api.deps import get_current_user      # NO - auth dependency
from sqlalchemy.ext.asyncio import AsyncSession # NO - DB dependency

Stateless Backend - Config from Request

The backend is stateless. Rules and configuration come from the request, provided by the frontend.

This is a key architectural principle: the frontend knows what context the user is in (campaign mode, freeplay mode, custom rules) and tells the API what rules to apply.

Why Frontend Provides Config

  1. Simplicity: No complex server-side config management
  2. Flexibility: Same backend supports multiple game modes
  3. Testability: Pure functions with explicit inputs
  4. Offline Fork: No server-side config to replicate

Pattern: Pure Functions with Config Parameter

# CORRECT - Config comes from caller
def validate_deck(
    cards: dict[str, int],
    energy_cards: dict[str, int],
    deck_config: DeckConfig,  # Provided by frontend
    card_lookup: Callable[[str], CardDefinition | None],
    owned_cards: dict[str, int] | None = None,
) -> ValidationResult:
    """Pure function - all inputs from caller."""
    ...

Forbidden Patterns

# WRONG - Service with baked-in config
class DeckValidator:
    def __init__(self, config: DeckConfig):  # Config at construction
        self._config = config

# WRONG - Default config hides dependency
def validate_deck(cards, config: DeckConfig | None = None):
    config = config or DeckConfig()  # Hidden creation!

Dependency Injection for Services

Services use constructor-based dependency injection for repositories, services, and external resources.

Services use DI for data access and external dependencies, but config comes from method parameters (request).

Required Pattern: Services and Repositories

class DeckService:
    """Dependencies injected via constructor. Config from method params."""

    def __init__(
        self,
        deck_repository: DeckRepository,
        card_service: CardService,
        collection_repository: CollectionRepository | None = None,
    ) -> None:
        self._deck_repo = deck_repository
        self._card_service = card_service
        self._collection_repo = collection_repository

    async def create_deck(
        self,
        user_id: UUID,
        cards: dict[str, int],
        energy_cards: dict[str, int],
        deck_config: DeckConfig,  # Config from request!
        max_decks: int,
    ) -> DeckEntry:
        ...

Required Pattern: Factory Injection for External Resources

For external resources (Redis, databases, engines), inject factory functions rather than instances:

from collections.abc import AsyncIterator, Callable

# Type aliases for factories
RedisFactory = Callable[[], AsyncIterator["Redis"]]
EngineFactory = Callable[[GameState], GameEngine]
TokenVerifier = Callable[[str], UUID | None]

class ConnectionManager:
    """External resources injected as factories."""

    def __init__(
        self,
        conn_ttl_seconds: int = 3600,
        redis_factory: RedisFactory | None = None,  # Factory, not instance
    ) -> None:
        self.conn_ttl_seconds = conn_ttl_seconds
        # Fall back to global only if not provided
        self._get_redis = redis_factory if redis_factory is not None else get_redis

    async def register_connection(self, sid: str, user_id: UUID) -> None:
        async with self._get_redis() as redis:  # Use injected factory
            await redis.hset(...)
class GameService:
    """Engine created via injected factory for testability."""

    def __init__(
        self,
        state_manager: GameStateManager | None = None,
        engine_factory: EngineFactory | None = None,  # Factory for engine creation
    ) -> None:
        self._state_manager = state_manager or game_state_manager
        self._engine_factory = engine_factory or self._default_engine_factory

    async def execute_action(self, game_id: str, player_id: str, action: Action):
        state = await self.get_game_state(game_id)
        engine = self._engine_factory(state)  # Create via factory
        result = await engine.execute_action(state, player_id, action)

Global Singletons with DI Support

Create global instances for production, but always support injection for testing:

# Global singleton for production use
connection_manager = ConnectionManager()

# Tests inject mocks via constructor - no patching needed
def test_register_connection(mock_redis):
    @asynccontextmanager
    async def mock_redis_factory():
        yield mock_redis

    manager = ConnectionManager(redis_factory=mock_redis_factory)
    # Test with injected mock...

Why This Matters

  1. Testability: Dependencies can be mocked without patching globals
  2. Offline Fork: Services can be swapped for local implementations
  3. Explicit Dependencies: Constructor shows all requirements
  4. Stateless Operations: Config comes from request, not server state
  5. No Monkey Patching: Tests use constructor injection, not patch()

Repository Protocol Pattern

Services access data through repository protocols, not directly through ORM models.

Pattern

# Protocol defines interface (app/repositories/protocols.py)
class CollectionRepository(Protocol):
    async def get_all(self, user_id: UUID) -> list[CollectionEntry]: ...
    async def upsert(self, ...) -> CollectionEntry: ...

# PostgreSQL implementation (app/repositories/postgres/)
class PostgresCollectionRepository:
    def __init__(self, db: AsyncSession) -> None:
        self._db = db

# Service uses protocol, not implementation
class CollectionService:
    def __init__(self, repository: CollectionRepository, card_service: CardService):
        self._repo = repository
        self._card_service = card_service

Benefits

  • Offline Fork: Can implement LocalCollectionRepository using JSON files
  • Testing: Can inject mock repositories
  • Decoupling: Services don't know about SQLAlchemy

UNSET Sentinel Pattern

For nullable fields that can be explicitly cleared (set to None), use the UNSET sentinel to distinguish between "not provided" (keep existing) and "set to null" (clear the value).

Pattern

from app.repositories.protocols import UNSET

# In repository/service method signatures
async def update(
    self,
    deck_id: UUID,
    name: str | None = None,  # None means "don't change"
    description: str | None = UNSET,  # type: ignore[assignment]
    # UNSET = keep existing, None = clear, str = set new value
) -> DeckEntry | None:
    ...
    if description is not UNSET:
        record.description = description  # Could be None (clear) or string (set)

API Layer Usage

# Check if field was explicitly provided in request
description = deck_in.description if "description" in deck_in.model_fields_set else UNSET

When to Use

  • Fields that can be meaningfully set to None (descriptions, notes, optional refs)
  • Not needed for fields where None means "don't update" (name, cards, etc.)

Configuration Classes

Game rules are defined in app/core/config.py as Pydantic models. These are passed from the frontend as request parameters.

Available Config Classes

from app.core.config import DeckConfig, RulesConfig

# DeckConfig - deck building rules
class DeckConfig(BaseModel):
    min_size: int = 40
    max_size: int = 40
    max_copies_per_card: int = 4
    min_basic_pokemon: int = 1
    energy_deck_size: int = 20

# Frontend can customize for different modes
freeplay_config = DeckConfig(max_copies_per_card=10)  # Relaxed rules
campaign_config = DeckConfig()  # Standard rules

API Endpoints Accept Config

@router.post("/decks")
async def create_deck(
    deck_in: DeckCreate,  # Contains deck_config from frontend
    current_user: User = Depends(get_current_user),
    deck_service: DeckService = Depends(get_deck_service),
):
    return await deck_service.create_deck(
        user_id=current_user.id,
        cards=deck_in.cards,
        deck_config=deck_in.deck_config,  # From request
        ...
    )

Testing Requirements

Test Docstrings Required

Every test must have a docstring explaining "what" and "why":

def test_paralyzed_pokemon_cannot_attack():
    """
    Test that paralyzed Pokemon are blocked from attacking.

    Paralysis should prevent all attack actions until cleared
    at the end of the affected player's turn.
    """
    ...

Unit Tests vs Integration Tests

Directory Purpose Database
tests/unit/ Pure unit tests, mocked dependencies No
tests/services/ Integration tests with real DB Yes
tests/core/ Core engine tests No
tests/api/ API endpoint tests Yes

No Monkey Patching - Use Dependency Injection

Never use patch() or monkeypatch for unit tests. Instead, inject mocks via constructor.

# WRONG - Monkey patching
async def test_execute_action():
    with patch.object(game_service, "_create_engine") as mock:
        mock.return_value = mock_engine
        result = await game_service.execute_action(...)

# CORRECT - Dependency injection
@pytest.fixture
def mock_engine():
    engine = MagicMock()
    engine.execute_action = AsyncMock(return_value=ActionResult(success=True))
    return engine

@pytest.fixture
def game_service(mock_state_manager, mock_engine):
    return GameService(
        state_manager=mock_state_manager,
        engine_factory=lambda state: mock_engine,  # Inject via constructor
    )

async def test_execute_action(game_service, mock_engine):
    result = await game_service.execute_action(...)
    mock_engine.execute_action.assert_called_once()

Factory Fixtures for External Resources

@pytest.fixture
def mock_redis():
    redis = AsyncMock()
    redis.hset = AsyncMock()
    redis.hget = AsyncMock(return_value=None)
    return redis

@pytest.fixture
def connection_manager(mock_redis):
    @asynccontextmanager
    async def mock_redis_factory():
        yield mock_redis

    return ConnectionManager(redis_factory=mock_redis_factory)

Use Seeded RNG for Determinism

from app.core import create_rng

def test_coin_flip():
    rng = create_rng(seed=42)
    results = [rng.coin_flip() for _ in range(5)]
    assert results == [True, False, True, True, False]  # Deterministic

Code Organization

app/
├── core/           # Game engine (MUST be standalone-capable)
│   ├── models/     # Pydantic models for game state
│   ├── effects/    # Effect handler system
│   ├── config.py   # RulesConfig and sub-configs
│   └── engine.py   # GameEngine orchestrator
├── services/       # Business logic (uses repositories)
│   ├── game_service.py       # Game orchestration (uses engine_factory DI)
│   ├── connection_manager.py # WebSocket tracking (uses redis_factory DI)
│   └── ...
├── socketio/       # WebSocket real-time communication
│   ├── server.py   # Socket.IO server setup and handlers
│   └── auth.py     # AuthHandler class (uses token_verifier DI)
├── repositories/   # Data access layer
│   ├── protocols.py    # Repository protocols (interfaces)
│   └── postgres/       # PostgreSQL implementations
├── schemas/        # Pydantic schemas for API
├── api/            # FastAPI routes
├── db/             # Database models and migrations
└── data/           # Static data (starter decks, etc.)

Quick Reference

Creating a New Service

  1. Define constructor with repository and service dependencies
  2. Use repository protocols for data access
  3. Accept config (DeckConfig, RulesConfig) as method parameters, not constructor params
  4. Keep business logic separate from data access

Creating a Repository

  1. Define protocol in app/repositories/protocols.py
  2. Create DTO dataclasses for protocol returns
  3. Implement in app/repositories/postgres/
  4. Use _to_dto() method to convert ORM -> DTO

Adding Static Data

  1. Place in app/data/ module
  2. Use Protocol for any config dependencies
  3. Provide validation functions that accept config as parameter

Common Mistakes to Avoid

Mistake Correct Approach
get_card_service() in method body Inject CardService via constructor
config or DeckConfig() default Make config required method parameter
Baking config into service constructor Accept config as method parameter (from request)
Importing from app.services in app.core Core must remain standalone
Hardcoded magic numbers Use values from config parameter
Tests without docstrings Always explain what and why
Unit tests in tests/services/ Use tests/unit/ for no-DB tests
Using patch() in unit tests Inject mocks via constructor DI
async with get_redis() in method body Inject redis_factory via constructor
Importing dependencies inside functions Import at module level, inject via constructor

See Also

  • app/core/AGENTS.md - Detailed core engine guidelines
  • app/core/README.md - Core module documentation
  • app/core/effects/README.md - Effect system documentation
  • app/services/README.md - Services layer patterns
  • app/socketio/README.md - WebSocket module documentation