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>
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
- Simplicity: No complex server-side config management
- Flexibility: Same backend supports multiple game modes
- Testability: Pure functions with explicit inputs
- 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
- Testability: Dependencies can be mocked without patching globals
- Offline Fork: Services can be swapped for local implementations
- Explicit Dependencies: Constructor shows all requirements
- 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
LocalCollectionRepositoryusing 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
Nonemeans "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
- Define constructor with repository and service dependencies
- Use repository protocols for data access
- Accept config (DeckConfig, RulesConfig) as method parameters, not constructor params
- Keep business logic separate from data access
Creating a Repository
- Define protocol in
app/repositories/protocols.py - Create DTO dataclasses for protocol returns
- Implement in
app/repositories/postgres/ - Use
_to_dto()method to convert ORM -> DTO
Adding Static Data
- Place in
app/data/module - Use Protocol for any config dependencies
- 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 guidelinesapp/core/README.md- Core module documentationapp/core/effects/README.md- Effect system documentation