mantimon-tcg/backend/CLAUDE.md
Cal Corum ebe776d54d Update project plans and documentation for Phase 3 completion
Project plan updates:
- Mark all 14 Phase 3 tasks as completed
- Update acceptance criteria to met
- Update master plan status to Phase 4 next
- Add detailed deliverables list for Phase 3

Documentation updates:
- Add UNSET sentinel pattern to CLAUDE.md
- Document when to use UNSET vs None for nullable fields

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

10 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 and other services.

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

Required Pattern

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:
        ...

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

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

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)
├── 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

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