Add long-term design consideration for forking the RPG campaign as a standalone offline experience. ARCHITECTURE.md: - Add 'Offline Standalone Fork' section explaining: - Why offline support matters (single-player RPG focus) - Architecture principles for fork compatibility - Core engine independence requirements - Potential package structures and distribution options - What stays vs what goes in a fork AGENTS.md: - Add 'Core Engine Independence' section with: - Rules for keeping app/core/ decoupled - Import boundary examples (allowed vs forbidden) - Link to full architecture docs This ensures all contributors understand the design constraint: the game engine must remain completely independent of network, database, and authentication concerns.
936 lines
32 KiB
Markdown
936 lines
32 KiB
Markdown
# Mantimon TCG - Technical Architecture
|
|
|
|
This document provides a detailed technical overview of the Mantimon TCG architecture.
|
|
|
|
## Table of Contents
|
|
|
|
1. [System Overview](#system-overview)
|
|
2. [Frontend Architecture](#frontend-architecture)
|
|
3. [Backend Architecture](#backend-architecture)
|
|
4. [Database Schema](#database-schema)
|
|
5. [Real-Time Communication](#real-time-communication)
|
|
6. [Game Engine](#game-engine)
|
|
7. [AI System](#ai-system)
|
|
8. [Security Considerations](#security-considerations)
|
|
9. [Offline Standalone Fork](#offline-standalone-fork)
|
|
|
|
---
|
|
|
|
## System Overview
|
|
|
|
```
|
|
+------------------+ +------------------+ +------------------+
|
|
| Browser | | Browser | | Browser |
|
|
| (Vue + Phaser) | | (Vue + Phaser) | | (Vue + Phaser) |
|
|
+--------+---------+ +--------+---------+ +--------+---------+
|
|
| | |
|
|
+------------------------+------------------------+
|
|
|
|
|
WebSocket + HTTP
|
|
|
|
|
+-------------+-------------+
|
|
| |
|
|
+--------v--------+ +--------v--------+
|
|
| FastAPI | | FastAPI |
|
|
| Instance 1 | | Instance 2 |
|
|
+--------+--------+ +--------+--------+
|
|
| |
|
|
+-------------+-------------+
|
|
|
|
|
+-------------------+-------------------+
|
|
| | |
|
|
+-------v-------+ +-------v-------+ +------v------+
|
|
| PostgreSQL | | Redis | | S3/CDN |
|
|
| (persistent) | | (realtime) | | (card art) |
|
|
+---------------+ +---------------+ +-------------+
|
|
```
|
|
|
|
---
|
|
|
|
## Frontend Architecture
|
|
|
|
### Technology Stack
|
|
|
|
- **Vue 3** with Composition API and `<script setup>` syntax
|
|
- **Phaser 3** for canvas-based game rendering
|
|
- **TypeScript** for type safety
|
|
- **Pinia** for state management
|
|
- **Tailwind CSS** for styling
|
|
- **Vite** for development and building
|
|
|
|
### Component Hierarchy
|
|
|
|
```
|
|
App.vue
|
|
├── layouts/
|
|
│ ├── DefaultLayout.vue # Nav, footer for non-game pages
|
|
│ └── GameLayout.vue # Minimal chrome for gameplay
|
|
├── pages/
|
|
│ ├── index.vue # Landing/home
|
|
│ ├── login.vue # Authentication
|
|
│ ├── collection.vue # Card collection browser
|
|
│ ├── decks/
|
|
│ │ ├── index.vue # Deck list
|
|
│ │ └── [id].vue # Deck builder/editor
|
|
│ ├── play/
|
|
│ │ ├── index.vue # Play menu (AI, PvP, Puzzle)
|
|
│ │ ├── match/[id].vue # Active game (mounts Phaser)
|
|
│ │ └── puzzle/[id].vue # Puzzle mode
|
|
│ ├── packs.vue # Pack opening (mounts Phaser)
|
|
│ └── profile.vue # User stats and settings
|
|
└── components/
|
|
├── cards/
|
|
│ ├── CardDisplay.vue # Single card render (DOM)
|
|
│ ├── CardGrid.vue # Grid of cards
|
|
│ └── CardDetail.vue # Full card modal
|
|
├── decks/
|
|
│ ├── DeckList.vue # List of user's decks
|
|
│ └── DeckBuilder.vue # Deck editing UI
|
|
├── game/
|
|
│ └── PhaserContainer.vue # Mounts Phaser canvas
|
|
└── common/
|
|
├── Modal.vue
|
|
├── Button.vue
|
|
└── Loading.vue
|
|
```
|
|
|
|
### Vue + Phaser Integration
|
|
|
|
Phaser is mounted within a Vue component and communicates via an event bridge:
|
|
|
|
```typescript
|
|
// components/game/PhaserContainer.vue
|
|
<script setup lang="ts">
|
|
import { onMounted, onUnmounted, ref } from 'vue'
|
|
import Phaser from 'phaser'
|
|
import { MatchScene } from '@/game/scenes/MatchScene'
|
|
import { useGameStore } from '@/stores/game'
|
|
|
|
const props = defineProps<{
|
|
gameId: string
|
|
}>()
|
|
|
|
const gameContainer = ref<HTMLDivElement>()
|
|
const phaserGame = ref<Phaser.Game>()
|
|
const gameStore = useGameStore()
|
|
|
|
onMounted(() => {
|
|
phaserGame.value = new Phaser.Game({
|
|
type: Phaser.AUTO,
|
|
parent: gameContainer.value,
|
|
width: 1280,
|
|
height: 720,
|
|
scene: [MatchScene],
|
|
backgroundColor: '#1a1a2e',
|
|
})
|
|
|
|
// Pass initial state to Phaser
|
|
phaserGame.value.registry.set('gameId', props.gameId)
|
|
|
|
// Bridge: Vue -> Phaser
|
|
gameStore.$onAction(({ name, args }) => {
|
|
if (name === 'setGameState') {
|
|
phaserGame.value?.events.emit('state:update', args[0])
|
|
}
|
|
})
|
|
|
|
// Bridge: Phaser -> Vue
|
|
phaserGame.value.events.on('player:action', (action) => {
|
|
gameStore.sendAction(action)
|
|
})
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
phaserGame.value?.destroy(true)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="gameContainer" class="w-full h-full" />
|
|
</template>
|
|
```
|
|
|
|
### Phaser Scene Structure
|
|
|
|
```
|
|
game/
|
|
├── scenes/
|
|
│ ├── MatchScene.ts # Main gameplay
|
|
│ ├── PackOpeningScene.ts # Animated pack reveals
|
|
│ └── PuzzleScene.ts # Puzzle mode
|
|
├── objects/
|
|
│ ├── Card.ts # Card game object
|
|
│ ├── Hand.ts # Hand container
|
|
│ ├── Battlefield.ts # Active/bench zones
|
|
│ ├── Deck.ts # Deck pile with animations
|
|
│ └── DamageCounter.ts # Damage display
|
|
├── animations/
|
|
│ ├── cardAnimations.ts # Draw, play, flip
|
|
│ ├── attackAnimations.ts # Attack effects
|
|
│ └── shuffleAnimations.ts # Deck shuffle
|
|
└── utils/
|
|
└── tweenHelpers.ts # Common tween patterns
|
|
```
|
|
|
|
### State Management (Pinia)
|
|
|
|
```typescript
|
|
// stores/game.ts
|
|
export const useGameStore = defineStore('game', () => {
|
|
// State
|
|
const gameState = ref<GameState | null>(null)
|
|
const pendingAction = ref<Action | null>(null)
|
|
const socket = ref<Socket | null>(null)
|
|
|
|
// Getters
|
|
const myHand = computed(() => gameState.value?.myHand ?? [])
|
|
const isMyTurn = computed(() =>
|
|
gameState.value?.currentPlayer === gameState.value?.myPlayerId
|
|
)
|
|
const canPlayCard = computed(() =>
|
|
isMyTurn.value && gameState.value?.phase === 'main'
|
|
)
|
|
|
|
// Actions
|
|
async function connectToGame(gameId: string) {
|
|
socket.value = io('/game', { query: { gameId } })
|
|
|
|
socket.value.on('game:state', (state) => {
|
|
gameState.value = state
|
|
})
|
|
|
|
socket.value.on('game:error', (error) => {
|
|
// Handle error
|
|
})
|
|
}
|
|
|
|
function sendAction(action: Action) {
|
|
socket.value?.emit('game:action', action)
|
|
}
|
|
|
|
return {
|
|
gameState,
|
|
myHand,
|
|
isMyTurn,
|
|
canPlayCard,
|
|
connectToGame,
|
|
sendAction,
|
|
}
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Backend Architecture
|
|
|
|
### Technology Stack
|
|
|
|
- **FastAPI** for REST API and WebSocket handling
|
|
- **Python 3.11+** with async/await
|
|
- **SQLAlchemy 2.0** (async) for ORM
|
|
- **PostgreSQL** for persistent storage
|
|
- **Redis** for caching and real-time state
|
|
- **python-socketio** for WebSocket server
|
|
- **Pydantic v2** for validation
|
|
|
|
### Module Structure
|
|
|
|
```
|
|
backend/
|
|
├── app/
|
|
│ ├── main.py # FastAPI app, Socket.io setup
|
|
│ ├── config.py # Settings (pydantic-settings)
|
|
│ ├── dependencies.py # Dependency injection
|
|
│ │
|
|
│ ├── api/ # REST endpoints
|
|
│ │ ├── auth.py # Login, register, OAuth
|
|
│ │ ├── cards.py # Card definitions
|
|
│ │ ├── collection.py # User collection CRUD
|
|
│ │ ├── decks.py # Deck CRUD
|
|
│ │ ├── packs.py # Pack opening
|
|
│ │ ├── matchmaking.py # Queue management
|
|
│ │ └── puzzles.py # Puzzle definitions
|
|
│ │
|
|
│ ├── websocket/ # Real-time handlers
|
|
│ │ ├── handlers.py # Socket.io event handlers
|
|
│ │ ├── connection_manager.py
|
|
│ │ └── events.py # Event type definitions
|
|
│ │
|
|
│ ├── core/ # Game engine
|
|
│ │ ├── game_engine.py # Main orchestrator
|
|
│ │ ├── turn_manager.py # Turn/phase state machine
|
|
│ │ ├── card_effects.py # Effect resolution
|
|
│ │ ├── rules_validator.py # Action legality
|
|
│ │ ├── win_conditions.py # Win/loss detection
|
|
│ │ ├── state_manager.py # In-memory game state
|
|
│ │ └── ai/ # AI opponents
|
|
│ │ ├── base.py # AI interface
|
|
│ │ ├── easy.py # Random legal moves
|
|
│ │ ├── medium.py # Heuristic-based
|
|
│ │ └── hard.py # MCTS/minimax
|
|
│ │
|
|
│ ├── models/ # Data models
|
|
│ │ ├── database.py # SQLAlchemy models
|
|
│ │ └── schemas.py # Pydantic schemas
|
|
│ │
|
|
│ ├── services/ # Business logic
|
|
│ │ ├── auth_service.py
|
|
│ │ ├── card_service.py
|
|
│ │ ├── collection_service.py
|
|
│ │ ├── deck_service.py
|
|
│ │ ├── pack_service.py
|
|
│ │ ├── game_service.py
|
|
│ │ └── puzzle_service.py
|
|
│ │
|
|
│ └── database/ # DB utilities
|
|
│ ├── session.py # Async session factory
|
|
│ └── migrations/ # Alembic migrations
|
|
│
|
|
├── tests/
|
|
│ ├── test_game_engine.py
|
|
│ ├── test_card_effects.py
|
|
│ ├── test_ai.py
|
|
│ └── conftest.py
|
|
│
|
|
├── pyproject.toml
|
|
└── alembic.ini
|
|
```
|
|
|
|
### Request Flow
|
|
|
|
```
|
|
HTTP Request
|
|
│
|
|
v
|
|
FastAPI Router ─────────────────────────────┐
|
|
│ │
|
|
v v
|
|
Dependency Injection WebSocket Connection
|
|
(auth, db session) │
|
|
│ v
|
|
v Socket.io Handler
|
|
Service Layer │
|
|
│ v
|
|
v Game Engine
|
|
Repository/ORM (in-memory state)
|
|
│ │
|
|
v v
|
|
PostgreSQL Redis
|
|
(state cache)
|
|
```
|
|
|
|
---
|
|
|
|
## Database Schema
|
|
|
|
### Entity Relationship Diagram
|
|
|
|
```
|
|
+---------------+ +------------------+ +---------------+
|
|
| users | | collections | | cards |
|
|
+---------------+ +------------------+ +---------------+
|
|
| id (PK) |<──────| user_id (FK) | | id (PK) |
|
|
| username | | card_id (FK) |───────| name |
|
|
| email | | quantity | | set_id |
|
|
| password_hash | +------------------+ | hp |
|
|
| currency | | type |
|
|
| created_at | | attacks (JSON)|
|
|
+---------------+ | abilities |
|
|
│ | rarity |
|
|
│ +---------------+
|
|
│
|
|
│ +------------------+
|
|
│ | decks |
|
|
│ +------------------+
|
|
└────────>| id (PK) |
|
|
| user_id (FK) |
|
|
| name |
|
|
| cards (JSON) |
|
|
| is_valid |
|
|
| created_at |
|
|
+------------------+
|
|
|
|
+------------------+ +------------------+
|
|
| matches | | match_players |
|
|
+------------------+ +------------------+
|
|
| id (PK) |<──────| match_id (FK) |
|
|
| status | | user_id (FK) |
|
|
| winner_id (FK) | | deck_id (FK) |
|
|
| started_at | | result |
|
|
| ended_at | +------------------+
|
|
| game_log (JSON) |
|
|
+------------------+
|
|
|
|
+------------------+ +------------------+
|
|
| packs | | pack_contents |
|
|
+------------------+ +------------------+
|
|
| id (PK) |<──────| pack_id (FK) |
|
|
| name | | card_id (FK) |
|
|
| cost | | rarity |
|
|
| set_id | | weight |
|
|
+------------------+ +------------------+
|
|
|
|
+------------------+
|
|
| puzzles |
|
|
+------------------+
|
|
| id (PK) |
|
|
| name |
|
|
| description |
|
|
| difficulty |
|
|
| initial_state |
|
|
| solution |
|
|
| hints (JSON) |
|
|
+------------------+
|
|
```
|
|
|
|
### Key Models
|
|
|
|
```python
|
|
# models/database.py
|
|
from sqlalchemy import Column, Integer, String, JSON, ForeignKey, DateTime
|
|
from sqlalchemy.orm import relationship
|
|
from app.database.session import Base
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
username = Column(String(50), unique=True, nullable=False)
|
|
email = Column(String(255), unique=True, nullable=False)
|
|
password_hash = Column(String(255), nullable=False)
|
|
currency = Column(Integer, default=0)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
|
|
collections = relationship("Collection", back_populates="user")
|
|
decks = relationship("Deck", back_populates="user")
|
|
|
|
|
|
class Card(Base):
|
|
__tablename__ = "cards"
|
|
|
|
id = Column(String(50), primary_key=True) # e.g., "pikachu_base_001"
|
|
name = Column(String(100), nullable=False)
|
|
set_id = Column(String(50), nullable=False)
|
|
card_type = Column(String(20), nullable=False) # pokemon, trainer, energy
|
|
hp = Column(Integer, nullable=True) # Only for Pokemon
|
|
pokemon_type = Column(String(20), nullable=True) # fire, water, etc.
|
|
attacks = Column(JSON, default=[])
|
|
abilities = Column(JSON, default=[])
|
|
weakness = Column(JSON, nullable=True)
|
|
resistance = Column(JSON, nullable=True)
|
|
retreat_cost = Column(Integer, default=0)
|
|
rarity = Column(String(20), nullable=False)
|
|
image_url = Column(String(500), nullable=True)
|
|
|
|
|
|
class Deck(Base):
|
|
__tablename__ = "decks"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
name = Column(String(100), nullable=False)
|
|
cards = Column(JSON, nullable=False) # [{"card_id": "...", "quantity": 2}, ...]
|
|
is_valid = Column(Boolean, default=False)
|
|
created_at = Column(DateTime, server_default=func.now())
|
|
updated_at = Column(DateTime, onupdate=func.now())
|
|
|
|
user = relationship("User", back_populates="decks")
|
|
```
|
|
|
|
---
|
|
|
|
## Real-Time Communication
|
|
|
|
### Socket.io Events
|
|
|
|
#### Client -> Server
|
|
|
|
| Event | Payload | Description |
|
|
|-------|---------|-------------|
|
|
| `game:action` | `{ type, ...params }` | Player performs action |
|
|
| `game:resign` | `{}` | Player resigns match |
|
|
| `matchmaking:join` | `{ deckId }` | Join matchmaking queue |
|
|
| `matchmaking:leave` | `{}` | Leave queue |
|
|
|
|
#### Server -> Client
|
|
|
|
| Event | Payload | Description |
|
|
|-------|---------|-------------|
|
|
| `game:state` | `VisibleGameState` | Full state update |
|
|
| `game:action_result` | `{ success, error? }` | Action confirmation |
|
|
| `game:ended` | `{ winner, reason }` | Match concluded |
|
|
| `matchmaking:found` | `{ gameId, opponent }` | Match found |
|
|
|
|
### Action Types
|
|
|
|
```python
|
|
class ActionType(str, Enum):
|
|
PLAY_POKEMON = "play_pokemon" # Play basic Pokemon to bench
|
|
EVOLVE = "evolve" # Evolve a Pokemon
|
|
ATTACH_ENERGY = "attach_energy" # Attach energy card
|
|
PLAY_TRAINER = "play_trainer" # Play trainer card
|
|
USE_ABILITY = "use_ability" # Activate Pokemon ability
|
|
ATTACK = "attack" # Declare attack
|
|
RETREAT = "retreat" # Retreat active Pokemon
|
|
PASS = "pass" # End turn / pass priority
|
|
```
|
|
|
|
### State Synchronization
|
|
|
|
```python
|
|
# websocket/handlers.py
|
|
@sio.on('game:action')
|
|
async def handle_action(sid, data: dict):
|
|
session = await get_session(sid)
|
|
game_id = session['game_id']
|
|
player_id = session['player_id']
|
|
|
|
game = await state_manager.get_game(game_id)
|
|
|
|
# Validate
|
|
result = await game_engine.validate_action(game, player_id, data)
|
|
if not result.valid:
|
|
await sio.emit('game:action_result', {
|
|
'success': False,
|
|
'error': result.reason
|
|
}, to=sid)
|
|
return
|
|
|
|
# Execute
|
|
new_state = await game_engine.execute_action(game, player_id, data)
|
|
await state_manager.save_game(game_id, new_state)
|
|
|
|
# Broadcast filtered state to each player
|
|
for pid, psid in game.player_sockets.items():
|
|
visible_state = game_engine.get_visible_state(new_state, pid)
|
|
await sio.emit('game:state', visible_state, to=psid)
|
|
|
|
# Check win conditions
|
|
winner = game_engine.check_win_condition(new_state)
|
|
if winner:
|
|
await handle_game_end(game_id, winner)
|
|
```
|
|
|
|
---
|
|
|
|
## Game Engine
|
|
|
|
### Turn Structure
|
|
|
|
```
|
|
GAME START
|
|
│
|
|
v
|
|
┌─────────────────────────────────┐
|
|
│ SETUP PHASE │
|
|
│ - Draw 7 cards each │
|
|
│ - Place active + bench │
|
|
│ - Set aside 6 prize cards │
|
|
│ - Flip coin for first turn │
|
|
└─────────────────────────────────┘
|
|
│
|
|
v
|
|
┌─────────────────────────────────┐
|
|
│ TURN START │◄──────────────────┐
|
|
│ - Draw 1 card │ │
|
|
└─────────────────────────────────┘ │
|
|
│ │
|
|
v │
|
|
┌─────────────────────────────────┐ │
|
|
│ MAIN PHASE (any order) │ │
|
|
│ - Attach 1 energy (once) │ │
|
|
│ - Play basic Pokemon to bench │ │
|
|
│ - Evolve Pokemon │ │
|
|
│ - Play trainer cards │ │
|
|
│ - Use abilities │ │
|
|
│ - Retreat active (once) │ │
|
|
└─────────────────────────────────┘ │
|
|
│ │
|
|
v │
|
|
┌─────────────────────────────────┐ │
|
|
│ ATTACK PHASE (optional) │ │
|
|
│ - Declare attack │ │
|
|
│ - Resolve damage + effects │ │
|
|
│ - Check knockouts │ │
|
|
│ - Take prize cards │ │
|
|
└─────────────────────────────────┘ │
|
|
│ │
|
|
v │
|
|
┌─────────────────────────────────┐ │
|
|
│ END PHASE │ │
|
|
│ - Apply end-of-turn effects │ │
|
|
│ - Switch to opponent │───────────────────┘
|
|
└─────────────────────────────────┘
|
|
│
|
|
v
|
|
┌─────────────────────────────────┐
|
|
│ WIN CONDITION CHECK │
|
|
│ - All prizes taken? │
|
|
│ - Opponent has no Pokemon? │
|
|
│ - Opponent can't draw? │
|
|
│ - Custom home rules? │
|
|
└─────────────────────────────────┘
|
|
```
|
|
|
|
### Card Effect System
|
|
|
|
Effects are data-driven with pluggable handlers:
|
|
|
|
```python
|
|
# core/card_effects.py
|
|
from typing import Protocol, Callable, Dict, Any
|
|
|
|
class EffectContext:
|
|
game: GameState
|
|
source_card: Card
|
|
source_player: str
|
|
target_card: Optional[Card]
|
|
target_player: Optional[str]
|
|
params: Dict[str, Any]
|
|
|
|
EffectHandler = Callable[[EffectContext], Awaitable[None]]
|
|
|
|
EFFECT_REGISTRY: Dict[str, EffectHandler] = {}
|
|
|
|
def effect_handler(name: str):
|
|
"""Decorator to register effect handlers."""
|
|
def decorator(func: EffectHandler):
|
|
EFFECT_REGISTRY[name] = func
|
|
return func
|
|
return decorator
|
|
|
|
# Example effects
|
|
@effect_handler("deal_damage")
|
|
async def deal_damage(ctx: EffectContext):
|
|
amount = ctx.params["amount"]
|
|
ctx.target_card.damage += amount
|
|
|
|
@effect_handler("heal")
|
|
async def heal(ctx: EffectContext):
|
|
amount = ctx.params["amount"]
|
|
ctx.target_card.damage = max(0, ctx.target_card.damage - amount)
|
|
|
|
@effect_handler("apply_status")
|
|
async def apply_status(ctx: EffectContext):
|
|
status = ctx.params["status"] # paralyzed, poisoned, asleep, etc.
|
|
ctx.target_card.status_conditions.append(status)
|
|
|
|
@effect_handler("draw_cards")
|
|
async def draw_cards(ctx: EffectContext):
|
|
count = ctx.params["count"]
|
|
player = ctx.game.players[ctx.source_player]
|
|
for _ in range(count):
|
|
if player.deck:
|
|
player.hand.append(player.deck.pop())
|
|
|
|
@effect_handler("coin_flip")
|
|
async def coin_flip(ctx: EffectContext):
|
|
"""Flip coin, execute sub-effect on heads."""
|
|
result = random.choice(["heads", "tails"])
|
|
if result == "heads" and "on_heads" in ctx.params:
|
|
sub_effect = ctx.params["on_heads"]
|
|
await resolve_effect(ctx.game, sub_effect, ctx)
|
|
```
|
|
|
|
---
|
|
|
|
## AI System
|
|
|
|
### Architecture
|
|
|
|
```python
|
|
# core/ai/base.py
|
|
from abc import ABC, abstractmethod
|
|
|
|
class AIPlayer(ABC):
|
|
"""Base class for AI opponents."""
|
|
|
|
@abstractmethod
|
|
async def choose_action(self, game_state: GameState) -> Action:
|
|
"""Given the current game state, return the best action."""
|
|
pass
|
|
|
|
def get_legal_actions(self, game_state: GameState) -> List[Action]:
|
|
"""Returns all legal actions for the AI player."""
|
|
actions = []
|
|
player = game_state.players[self.player_id]
|
|
|
|
# Can attach energy?
|
|
if not game_state.energy_attached_this_turn:
|
|
for card in player.hand:
|
|
if card.card_type == "energy":
|
|
for target in self.get_energy_targets(game_state):
|
|
actions.append(AttachEnergyAction(card.id, target.id))
|
|
|
|
# Can play Pokemon?
|
|
for card in player.hand:
|
|
if card.card_type == "pokemon" and card.stage == "basic":
|
|
if len(player.bench) < 5:
|
|
actions.append(PlayPokemonAction(card.id))
|
|
|
|
# ... more action types
|
|
|
|
actions.append(PassAction()) # Can always pass
|
|
return actions
|
|
```
|
|
|
|
### Difficulty Implementations
|
|
|
|
```python
|
|
# core/ai/easy.py
|
|
class EasyAI(AIPlayer):
|
|
"""Random legal moves with basic priorities."""
|
|
|
|
async def choose_action(self, game_state: GameState) -> Action:
|
|
actions = self.get_legal_actions(game_state)
|
|
|
|
# Slight preference: attack if possible
|
|
attacks = [a for a in actions if isinstance(a, AttackAction)]
|
|
if attacks and random.random() > 0.3:
|
|
return random.choice(attacks)
|
|
|
|
return random.choice(actions)
|
|
|
|
|
|
# core/ai/medium.py
|
|
class MediumAI(AIPlayer):
|
|
"""Heuristic-based decision making."""
|
|
|
|
async def choose_action(self, game_state: GameState) -> Action:
|
|
actions = self.get_legal_actions(game_state)
|
|
|
|
# Score each action
|
|
scored = [(self.score_action(game_state, a), a) for a in actions]
|
|
scored.sort(reverse=True, key=lambda x: x[0])
|
|
|
|
# Pick top action with some randomness
|
|
top_actions = [a for s, a in scored[:3]]
|
|
return random.choice(top_actions)
|
|
|
|
def score_action(self, state: GameState, action: Action) -> float:
|
|
score = 0.0
|
|
|
|
if isinstance(action, AttackAction):
|
|
# Prefer knockouts
|
|
damage = self.calculate_damage(state, action)
|
|
target = state.get_defending_pokemon()
|
|
if damage >= target.hp - target.damage:
|
|
score += 100 # Knockout!
|
|
else:
|
|
score += damage * 0.5
|
|
|
|
elif isinstance(action, PlayPokemonAction):
|
|
# Value bench presence
|
|
score += 10
|
|
|
|
# ... more heuristics
|
|
|
|
return score
|
|
|
|
|
|
# core/ai/hard.py
|
|
class HardAI(AIPlayer):
|
|
"""Monte Carlo Tree Search for strong play."""
|
|
|
|
def __init__(self, player_id: str, simulations: int = 1000):
|
|
super().__init__(player_id)
|
|
self.simulations = simulations
|
|
|
|
async def choose_action(self, game_state: GameState) -> Action:
|
|
root = MCTSNode(game_state, None, self.player_id)
|
|
|
|
for _ in range(self.simulations):
|
|
node = self.select(root)
|
|
node = self.expand(node)
|
|
result = self.simulate(node)
|
|
self.backpropagate(node, result)
|
|
|
|
# Return most visited child's action
|
|
best_child = max(root.children, key=lambda c: c.visits)
|
|
return best_child.action
|
|
```
|
|
|
|
---
|
|
|
|
## Security Considerations
|
|
|
|
### Hidden Information
|
|
|
|
**Never expose to clients:**
|
|
- Deck order (either player)
|
|
- Opponent's hand contents
|
|
- Unrevealed prize cards
|
|
- RNG seeds or future random results
|
|
|
|
```python
|
|
def get_visible_state(game: GameState, player_id: str) -> VisibleGameState:
|
|
"""Filter game state to only what player can see."""
|
|
opponent_id = get_opponent(game, player_id)
|
|
|
|
return VisibleGameState(
|
|
# My full information
|
|
my_hand=[card.to_dict() for card in game.players[player_id].hand],
|
|
my_deck_count=len(game.players[player_id].deck),
|
|
my_prizes=[
|
|
card.to_dict() if card.revealed else {"hidden": True}
|
|
for card in game.players[player_id].prizes
|
|
],
|
|
|
|
# Opponent's hidden information
|
|
opponent_hand_count=len(game.players[opponent_id].hand),
|
|
opponent_deck_count=len(game.players[opponent_id].deck),
|
|
opponent_prizes_remaining=len([
|
|
p for p in game.players[opponent_id].prizes if not p.taken
|
|
]),
|
|
|
|
# Public information
|
|
battlefield=game.battlefield.to_dict(),
|
|
discard_piles={
|
|
pid: [card.to_dict() for card in player.discard]
|
|
for pid, player in game.players.items()
|
|
},
|
|
current_turn=game.current_turn,
|
|
phase=game.phase,
|
|
)
|
|
```
|
|
|
|
### Server Authority
|
|
|
|
- All game logic runs server-side
|
|
- Client sends intentions, server validates and executes
|
|
- Never trust client-provided game state
|
|
- RNG uses `secrets` module for unpredictability
|
|
|
|
### Input Validation
|
|
|
|
```python
|
|
async def validate_action(game: GameState, player_id: str, action: dict) -> ValidationResult:
|
|
# Is it this player's turn?
|
|
if game.current_turn != player_id:
|
|
return ValidationResult(False, "Not your turn")
|
|
|
|
# Is this action type valid for current phase?
|
|
action_type = action.get("type")
|
|
if action_type not in VALID_ACTIONS_BY_PHASE[game.phase]:
|
|
return ValidationResult(False, f"Cannot {action_type} during {game.phase}")
|
|
|
|
# Validate specific action requirements
|
|
validator = ACTION_VALIDATORS.get(action_type)
|
|
if validator:
|
|
return await validator(game, player_id, action)
|
|
|
|
return ValidationResult(False, "Unknown action type")
|
|
```
|
|
|
|
---
|
|
|
|
## Offline Standalone Fork
|
|
|
|
> **Design Consideration**: The architecture intentionally supports forking as a completely offline, standalone experience for the RPG/campaign mode.
|
|
|
|
### Why This Matters
|
|
|
|
The primary gameplay experience is a single-player RPG campaign. While the live service provides multiplayer and cloud saves, many players may prefer:
|
|
- Offline play without internet dependency
|
|
- Local save files they fully control
|
|
- A self-contained executable with no account requirements
|
|
- Modding and customization freedom
|
|
|
|
### Architecture Principles for Fork Compatibility
|
|
|
|
The `backend/app/core/` game engine is designed to be **completely decoupled** from network and database concerns:
|
|
|
|
| Component | Live Service | Offline Fork |
|
|
|-----------|--------------|--------------|
|
|
| Game Engine (`core/`) | Same | Same (copy directly) |
|
|
| Card Definitions | PostgreSQL | Embedded JSON files |
|
|
| Save Data | PostgreSQL + Redis | Local JSON/SQLite files |
|
|
| Authentication | OAuth/JWT | None needed |
|
|
| Multiplayer | WebSocket/Socket.io | Removed entirely |
|
|
| RNG | SecureRandom | SeededRandom (for replays) |
|
|
|
|
### Core Engine Independence
|
|
|
|
The game engine has **zero dependencies** on:
|
|
- Database connections
|
|
- Network I/O
|
|
- Authentication/sessions
|
|
- Redis caching
|
|
- WebSocket communication
|
|
|
|
All it needs:
|
|
- `RulesConfig` (can be hardcoded or loaded from JSON)
|
|
- `CardDefinition` objects (can be embedded or loaded from files)
|
|
- `RandomProvider` (SeededRandom works offline)
|
|
|
|
### Potential Offline Package Structure
|
|
|
|
```
|
|
mantimon-offline/
|
|
├── game/
|
|
│ ├── core/ # Direct copy of backend/app/core/
|
|
│ ├── campaign/ # NPC definitions, dialog, progression
|
|
│ │ ├── clubs/ # Club data (NPCs, leaders, rewards)
|
|
│ │ ├── dialog/ # NPC dialog trees
|
|
│ │ └── progression.py # Campaign state machine
|
|
│ ├── cards/ # Card definitions as JSON
|
|
│ │ ├── base_set.json
|
|
│ │ └── expansion_1.json
|
|
│ └── saves/ # Local save directory
|
|
│ └── slot_1.json
|
|
├── ui/ # Could reuse Phaser or use PyGame/Godot
|
|
├── assets/ # Card images, sounds
|
|
└── main.py # Entry point
|
|
```
|
|
|
|
### Packaging Options for Distribution
|
|
|
|
| Approach | Bundle Size | Complexity | UI Reuse |
|
|
|----------|-------------|------------|----------|
|
|
| **Electron + Python** | ~150MB | Medium | Full web UI |
|
|
| **Tauri + Python** | ~30MB | High | Full web UI |
|
|
| **PyInstaller + PyGame** | ~50MB | Low | New UI needed |
|
|
| **Godot + GDScript port** | ~40MB | Medium | New UI needed |
|
|
| **Nuitka + embedded browser** | ~80MB | Medium | Phaser reuse |
|
|
|
|
### What Stays, What Goes
|
|
|
|
**Keep (copy to fork):**
|
|
- `backend/app/core/` - Entire game engine
|
|
- `frontend/src/game/` - Phaser scenes (if using web UI)
|
|
- Card definitions (export from DB to JSON)
|
|
- Campaign/NPC data
|
|
|
|
**Remove (not needed offline):**
|
|
- `backend/app/api/` - REST endpoints
|
|
- `backend/app/websocket/` - Multiplayer sync
|
|
- `backend/app/services/` - DB-backed services
|
|
- Authentication system
|
|
- Matchmaking/lobby
|
|
|
|
### Development Guidelines
|
|
|
|
To maintain fork compatibility, follow these rules when developing the core engine:
|
|
|
|
1. **No imports from `app.api`, `app.websocket`, or `app.services`** in `app.core`
|
|
2. **No database session dependencies** in core engine functions
|
|
3. **Card definitions passed in**, not fetched - `GameEngine.create_game()` receives a card registry
|
|
4. **RNG injected via protocol** - `RandomProvider` allows swapping implementations
|
|
5. **State is self-contained** - `GameState` includes everything needed to resume a game
|
|
6. **No async required** - Core logic is sync; async wrappers added at service layer
|
|
|
|
### Testing Offline Viability
|
|
|
|
The existing test suite validates offline compatibility:
|
|
- All `tests/core/` tests run without database
|
|
- `SeededRandom` enables deterministic testing
|
|
- Card fixtures are created in-memory, not loaded from DB
|
|
|
|
### Future: Official Offline Release
|
|
|
|
If demand exists, an official offline version could be released:
|
|
1. Export campaign data and card definitions to JSON
|
|
2. Bundle core engine with a lightweight UI wrapper
|
|
3. Distribute as standalone executable (itch.io, Steam, etc.)
|
|
4. Optional: Steam Workshop for community card sets
|