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

333 lines
10 KiB
Markdown

# 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
```python
# 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
```python
# 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
```python
# 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
```python
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
```python
# 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
```python
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
```python
# 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
```python
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
```python
@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":
```python
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
```python
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