# 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