- 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>
459 lines
14 KiB
Markdown
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
|