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

459 lines
14 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, 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
```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:
...
```
### Required Pattern: Factory Injection for External Resources
For external resources (Redis, databases, engines), inject factory functions rather than instances:
```python
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(...)
```
```python
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:
```python
# 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
```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 |
### No Monkey Patching - Use Dependency Injection
**Never use `patch()` or `monkeypatch` for unit tests.** Instead, inject mocks via constructor.
```python
# 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
```python
@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
```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)
│ ├── 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