mantimon-tcg/backend/app/core/AGENTS.md
Cal Corum 2252931fb8 Add core module AGENTS.md documentation (DOCS-001)
Creates comprehensive documentation for AI agents working with app/core/:
- Architecture overview with component diagram
- Import patterns (correct enum imports from app.core.enums)
- Core patterns: game creation, action execution, effects, config
- Key classes: GameState, PlayerState, CardDefinition, CardInstance, Zone
- Testing patterns with SeededRandom for determinism
- Security rules for hidden information
- Module independence guidelines for offline fork support
- File organization and quick commands

Updates PROJECT_PLAN.json: 31/32 tasks complete.
2026-01-26 14:50:52 -06:00

14 KiB

Core Game Engine - Agent Guidelines

app/core/ - The heart of Mantimon TCG

This document provides guidelines for AI agents working with the core game engine module. The engine is designed to be standalone and database-agnostic, enabling future extraction as an offline game.

Quick Reference

Module Purpose Key Exports
enums.py All enumeration types (foundational) CardType, EnergyType, TurnPhase, StatusCondition, etc.
config.py Rules configuration system RulesConfig, DeckConfig, PrizeConfig, etc.
rng.py Deterministic & secure randomness SeededRandom, SecureRandom, create_rng()
models/card.py Card templates and instances CardDefinition, CardInstance, Attack, Ability
models/actions.py Player action types Action union, AttackAction, PlayPokemonAction, etc.
models/game_state.py Game state hierarchy GameState, PlayerState, Zone, ForcedAction
rules_validator.py Action legality checking validate_action(), ValidationResult
turn_manager.py Turn/phase state machine TurnManager, TurnStartResult
win_conditions.py Win/loss detection check_win_conditions(), WinResult
visibility.py Hidden information filtering get_visible_state(), VisibleGameState
effects/ Effect handler system @effect_handler, resolve_effect(), EffectContext
engine.py Main orchestrator GameEngine, ActionResult

Architecture Overview

                    ┌─────────────────────────────────────────┐
                    │              GameEngine                  │
                    │  (Main public API - use this!)          │
                    └───────────────┬─────────────────────────┘
                                    │
          ┌─────────────────────────┼─────────────────────────┐
          │                         │                         │
          v                         v                         v
┌─────────────────┐    ┌────────────────────┐    ┌─────────────────┐
│ RulesValidator  │    │   TurnManager      │    │  WinConditions  │
│ validate_action │    │ start/end_turn     │    │ check_win       │
└─────────────────┘    │ phase transitions  │    └─────────────────┘
                       └────────────────────┘
                                    │
          ┌─────────────────────────┼─────────────────────────┐
          │                         │                         │
          v                         v                         v
┌─────────────────┐    ┌────────────────────┐    ┌─────────────────┐
│   GameState     │    │  EffectRegistry    │    │   Visibility    │
│ PlayerState     │    │  @effect_handler   │    │ get_visible_    │
│ Zone            │    │  resolve_effect    │    │ state()         │
└─────────────────┘    └────────────────────┘    └─────────────────┘

Import Patterns

from app.core import (
    GameEngine,
    RulesConfig,
    create_rng,
    CardType,
    EnergyType,
    TurnPhase,
)
from app.core.models import (
    CardDefinition,
    CardInstance,
    Action,
    AttackAction,
    GameState,
)
from app.core.effects import (
    effect_handler,
    resolve_effect,
    EffectContext,
)

Enum Import

Enums live in app/core/enums.py (the foundational module with zero dependencies). They're re-exported from both app.core and NOT from app.core.models (to avoid circular imports):

# CORRECT - import enums from core or enums module
from app.core import CardType, EnergyType, TurnPhase
from app.core.enums import StatusCondition, PokemonStage

# WRONG - models does NOT export enums
# from app.core.models import CardType  # This won't work!

Core Patterns

1. Game Creation Flow

from app.core import GameEngine, RulesConfig, create_rng

# 1. Create engine with rules and RNG
engine = GameEngine(
    rules=RulesConfig(),        # Default Mantimon rules
    rng=create_rng(seed=42),    # Seeded for tests, None for production
)

# 2. Prepare card registry and decks
card_registry: dict[str, CardDefinition] = {...}
player1_deck: list[CardInstance] = [...]
player2_deck: list[CardInstance] = [...]

# 3. Create game
result = engine.create_game(
    player_ids=["player1", "player2"],
    decks={"player1": player1_deck, "player2": player2_deck},
    energy_decks={"player1": p1_energy, "player2": p2_energy},  # Optional
    card_registry=card_registry,
)

if result.success:
    game = result.game

2. Action Execution Flow

# All actions go through engine.execute_action()
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)

if result.success:
    # Check for win
    if result.win_result:
        print(f"Game over! Winner: {result.win_result.winner_id}")
else:
    print(f"Action failed: {result.message}")

3. Effect Handler Pattern

Cards reference effects by ID. Handlers are registered with @effect_handler:

from app.core.effects import effect_handler, EffectContext, EffectResult

@effect_handler("coin_flip_damage")
async def handle_coin_flip_damage(ctx: EffectContext) -> EffectResult:
    """Deal bonus damage on heads."""
    base = ctx.get_param("base_damage", 0)
    bonus = ctx.get_param("bonus_on_heads", 0)
    
    if ctx.flip_coin():  # Uses RNG from context
        ctx.target_card.damage += base + bonus
        return EffectResult.success(f"Heads! Dealt {base + bonus} damage")
    else:
        ctx.target_card.damage += base
        return EffectResult.success(f"Tails. Dealt {base} damage")

Card definition references the effect:

Attack(
    name="Thunder Shock",
    cost=[EnergyType.LIGHTNING],
    damage=30,
    effect_id="coin_flip_damage",
    effect_params={"base_damage": 30, "bonus_on_heads": 10},
)

4. Configuration System

All game rules are driven by RulesConfig:

from app.core import RulesConfig

# Default Mantimon rules
rules = RulesConfig()

# Customize specific settings
rules = RulesConfig(
    deck=DeckConfig(min_size=60, max_size=60),  # Standard Pokemon
    prizes=PrizeConfig(count=6, use_prize_cards=True),
    first_turn=FirstTurnConfig(can_attack=False),  # No first-turn attacks
)

# Use standard Pokemon TCG preset
rules = RulesConfig.standard_pokemon_tcg()

5. Visibility Filtering

CRITICAL: Never send raw GameState to clients. Always filter:

# For players - sees own hand, opponent hand COUNT only
visible = engine.get_visible_state(game, "player1")

# For spectators - sees neither player's hand
visible = engine.get_spectator_state(game)

Hidden information:

  • Deck contents and order (both players)
  • Opponent's hand contents
  • Unrevealed prize cards
  • Energy deck order

Key Classes

GameState

The complete game state. Contains:

  • card_registry: All card definitions used in this game
  • players: Dict of player_id -> PlayerState
  • turn_order, current_player_id, turn_number
  • phase: Current TurnPhase
  • rules: RulesConfig for this game
  • winner, game_over, end_reason: Game conclusion state

PlayerState

One player's state. Contains zones and per-turn counters:

  • Zones: deck, hand, active, bench, prizes, discard, energy_deck, energy_zone
  • Counters: energy_attachments_this_turn, supporters_played_this_turn, etc.
  • Score: score (points earned from knockouts)

CardDefinition vs CardInstance

  • CardDefinition: Immutable template (the card as printed)
  • CardInstance: Mutable in-game state (damage, attached energy, status)
# Definition - what the card IS
pikachu_def = CardDefinition(
    id="pikachu-001",
    name="Pikachu",
    hp=60,
    attacks=[...],
)

# Instance - a specific copy in play
pikachu_in_play = CardInstance(
    instance_id="game1-pikachu-0",
    definition_id="pikachu-001",
    damage=20,
    attached_energy=[lightning_energy_instance],
    status_conditions=[StatusCondition.PARALYZED],
)

Zone

A collection of cards with operations:

  • add(card), remove(instance_id), get(instance_id)
  • draw(): Remove and return top card
  • shuffle(rng): Randomize order
  • __contains__, __len__, __iter__

Testing Patterns

Use SeededRandom for Determinism

from app.core import create_rng

def test_coin_flip():
    rng = create_rng(seed=42)
    # Same seed = same sequence every time
    results = [rng.coin_flip() for _ in range(5)]
    assert results == [True, False, True, True, False]  # Deterministic

Use Fixtures from conftest.py

# tests/core/conftest.py provides:
# - sample_pikachu, sample_raichu, sample_charmander
# - sample_lightning_energy, sample_fire_energy
# - create_test_game_state()
# - seeded_rng fixture

def test_with_fixtures(sample_pikachu, seeded_rng):
    instance = CardInstance(
        instance_id="test-1",
        definition_id=sample_pikachu.id,
    )
    ...

Test Docstrings Required

Every test must explain "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.
    """
    ...

Security Rules

Never Expose Hidden Information

# WRONG - leaks deck order
response = {"deck": [card.model_dump() for card in player.deck.cards]}

# CORRECT - only send count
response = {"deck_count": len(player.deck)}

Server Authority

All game logic runs server-side. The client sends intentions:

# Client sends: "I want to attack with attack index 0"
action = AttackAction(attack_index=0)

# Server validates and executes
result = await engine.execute_action(game, player_id, action)

# Server sends back result (never raw state)
visible_state = engine.get_visible_state(game, player_id)

Module Independence (Offline Fork Support)

The app/core/ module is designed to be extractable as a standalone offline game:

Rules for Core Module

DO DON'T
Accept CardDefinition objects as parameters Import from app.services or app.api
Use RandomProvider protocol for RNG Import database session types
Keep state self-contained in GameState Make network calls or database queries
Load configuration from RulesConfig Require authentication or user sessions

Import Boundaries

# 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.card_service import CardService  # NO - DB dependency
from app.api.deps import get_current_user          # NO - Auth dependency
from sqlalchemy.ext.asyncio import AsyncSession    # NO - DB dependency

File Organization

app/core/
├── __init__.py          # Main public API exports (34 items)
├── AGENTS.md            # This file
├── enums.py             # All enums (foundational, zero deps)
├── config.py            # RulesConfig and sub-configs
├── rng.py               # RandomProvider implementations
├── engine.py            # GameEngine orchestrator
├── rules_validator.py   # Action validation
├── turn_manager.py      # Turn/phase state machine
├── win_conditions.py    # Win/loss detection
├── visibility.py        # Hidden info filtering
├── models/
│   ├── __init__.py      # Model exports (29 items)
│   ├── card.py          # CardDefinition, CardInstance
│   ├── actions.py       # Action union types
│   └── game_state.py    # GameState, PlayerState, Zone
└── effects/
    ├── __init__.py      # Effect system exports (12 items)
    ├── base.py          # EffectContext, EffectResult
    ├── registry.py      # @effect_handler, resolve_effect
    └── handlers.py      # Built-in effect handlers (14 registered)

Common Tasks

Adding a New Effect Handler

  1. Add handler in effects/handlers.py:
    @effect_handler("my_effect")
    async def handle_my_effect(ctx: EffectContext) -> EffectResult:
        ...
    
  2. Effect is auto-registered on import
  3. Reference in card definition: effect_id="my_effect"

Adding a New Action Type

  1. Add action class in models/actions.py
  2. Add to Action union type
  3. Add phase mapping to VALID_PHASES_FOR_ACTION
  4. Add validation in rules_validator.py
  5. Add execution in engine.py._execute_action_internal()

Modifying Game Rules

  1. Add config field to appropriate class in config.py
  2. Update RulesConfig if adding new config class
  3. Update validation logic in rules_validator.py
  4. Update execution logic in engine.py or turn_manager.py

Test Coverage

Module Tests Coverage
config 31 100%
enums 28 100%
rng 42 100%
models/card 77 98%
models/actions 48 100%
models/game_state 62 97%
effects/base 30 100%
effects/registry 21 100%
effects/handlers 79 95%
rules_validator 95 96%
turn_manager 60 94%
win_conditions 53 100%
visibility 44 100%
engine 65 93%

Total: 826 tests, ~97% coverage


Quick Commands

# Run all core tests
cd backend && uv run pytest tests/core/ -v

# Run specific module tests
cd backend && uv run pytest tests/core/test_engine.py -v

# Run with coverage
cd backend && uv run pytest tests/core/ --cov=app/core --cov-report=term-missing

# Type check
cd backend && uv run mypy app/core/

# Interactive game demo
cd backend && uv run python references/game_walkthrough.py