- 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>
14 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, 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
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:
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(...)
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:
# 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
- 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
- No Monkey Patching: Tests use constructor injection, not
patch()
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 |
No Monkey Patching - Use Dependency Injection
Never use patch() or monkeypatch for unit tests. Instead, inject mocks via constructor.
# 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
@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
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
- 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 |
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 guidelinesapp/core/README.md- Core module documentationapp/core/effects/README.md- Effect system documentationapp/services/README.md- Services layer patternsapp/socketio/README.md- WebSocket module documentation