- REST endpoints for auth, users, collections, decks, games - WebSocket events (client→server and server→client) - Key backend files to reference - Type definitions to mirror from backend schemas Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
554 lines
14 KiB
Markdown
554 lines
14 KiB
Markdown
# Mantimon TCG Frontend - AI Agent Guidelines
|
|
|
|
Guidelines for AI agents working on the frontend codebase.
|
|
|
|
## Tech Stack
|
|
|
|
| Technology | Purpose |
|
|
|------------|---------|
|
|
| Vue 3 | UI framework (Composition API + `<script setup>`) |
|
|
| Phaser 3 | Game canvas (matches, pack opening) |
|
|
| TypeScript | Type safety (strict mode) |
|
|
| Pinia | State management |
|
|
| Tailwind CSS | Styling (mobile-first) |
|
|
| Socket.io-client | Real-time communication |
|
|
| Vite | Build tool |
|
|
| Vitest | Unit/component testing |
|
|
|
|
---
|
|
|
|
## Quick Commands
|
|
|
|
```bash
|
|
npm run dev # Start dev server
|
|
npm run build # Production build
|
|
npm run preview # Preview production build
|
|
npm run test # Run tests
|
|
npm run test:watch # Run tests in watch mode
|
|
npm run lint # ESLint
|
|
npm run typecheck # TypeScript check
|
|
```
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
src/
|
|
├── api/ # API client and composables
|
|
│ ├── client.ts # Base HTTP client with auth
|
|
│ └── types.ts # API response types
|
|
├── assets/ # Static assets, global CSS
|
|
├── components/ # Reusable Vue components
|
|
│ ├── ui/ # Generic UI (Button, Modal, Toast)
|
|
│ ├── cards/ # Card display components
|
|
│ ├── deck/ # Deck builder components
|
|
│ └── game/ # Game UI overlays
|
|
├── composables/ # Vue composables (useAuth, useDecks, etc.)
|
|
├── game/ # Phaser game code
|
|
│ ├── scenes/ # Phaser scenes
|
|
│ ├── objects/ # Game objects (Card, Board)
|
|
│ ├── animations/ # Animation helpers
|
|
│ └── bridge.ts # Vue-Phaser communication
|
|
├── layouts/ # Layout components
|
|
├── pages/ # Route pages
|
|
├── router/ # Vue Router config
|
|
├── socket/ # Socket.IO client
|
|
├── stores/ # Pinia stores
|
|
├── types/ # TypeScript types
|
|
└── utils/ # Utility functions
|
|
```
|
|
|
|
---
|
|
|
|
## Code Style
|
|
|
|
### Vue Components
|
|
|
|
**Single File Component structure:**
|
|
```vue
|
|
<script setup lang="ts">
|
|
// 1. Imports (Vue, then external, then local)
|
|
import { ref, computed, onMounted } from 'vue'
|
|
|
|
import { useGameStore } from '@/stores/game'
|
|
import type { Card } from '@/types'
|
|
|
|
// 2. Props and emits
|
|
const props = defineProps<{
|
|
cardId: string
|
|
size?: 'sm' | 'md' | 'lg'
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
click: [cardId: string]
|
|
}>()
|
|
|
|
// 3. Composables and stores
|
|
const gameStore = useGameStore()
|
|
|
|
// 4. Reactive state
|
|
const isHovered = ref(false)
|
|
|
|
// 5. Computed
|
|
const card = computed(() => gameStore.getCard(props.cardId))
|
|
|
|
// 6. Methods
|
|
function handleClick() {
|
|
emit('click', props.cardId)
|
|
}
|
|
|
|
// 7. Lifecycle
|
|
onMounted(() => {
|
|
// ...
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Single root element preferred -->
|
|
<div
|
|
class="card"
|
|
:class="{ 'card--hovered': isHovered }"
|
|
@click="handleClick"
|
|
@mouseenter="isHovered = true"
|
|
@mouseleave="isHovered = false"
|
|
>
|
|
<slot />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Component-specific styles if Tailwind isn't enough */
|
|
</style>
|
|
```
|
|
|
|
### TypeScript
|
|
|
|
**Use strict typing:**
|
|
```typescript
|
|
// Good - explicit types
|
|
interface CardProps {
|
|
cardId: string
|
|
size?: 'sm' | 'md' | 'lg'
|
|
}
|
|
|
|
// Good - type imports
|
|
import type { Card, GameState } from '@/types'
|
|
|
|
// Good - const assertions for literals
|
|
const SIZES = ['sm', 'md', 'lg'] as const
|
|
type Size = typeof SIZES[number]
|
|
|
|
// Avoid - any
|
|
const data: any = response // NO
|
|
const data: unknown = response // OK if you'll narrow it
|
|
```
|
|
|
|
### Naming Conventions
|
|
|
|
| Type | Convention | Example |
|
|
|------|------------|---------|
|
|
| Components | PascalCase | `CardDisplay.vue`, `DeckBuilder.vue` |
|
|
| Composables | camelCase with `use` prefix | `useAuth.ts`, `useDeckValidation.ts` |
|
|
| Stores | camelCase with descriptive name | `auth.ts`, `game.ts` |
|
|
| Types/Interfaces | PascalCase | `Card`, `GameState`, `ApiResponse` |
|
|
| Constants | UPPER_SNAKE_CASE | `MAX_HAND_SIZE`, `API_BASE_URL` |
|
|
| CSS classes | kebab-case (BEM optional) | `card-display`, `card-display--active` |
|
|
|
|
---
|
|
|
|
## Pinia Stores
|
|
|
|
**Use setup store syntax:**
|
|
```typescript
|
|
// stores/game.ts
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import type { GameState } from '@/types'
|
|
|
|
export const useGameStore = defineStore('game', () => {
|
|
// State
|
|
const gameState = ref<GameState | null>(null)
|
|
const isConnected = ref(false)
|
|
|
|
// Getters (computed)
|
|
const myHand = computed(() => gameState.value?.myHand ?? [])
|
|
const isMyTurn = computed(() => gameState.value?.currentPlayer === 'me')
|
|
|
|
// Actions
|
|
function setGameState(state: GameState) {
|
|
gameState.value = state
|
|
}
|
|
|
|
function clearGame() {
|
|
gameState.value = null
|
|
isConnected.value = false
|
|
}
|
|
|
|
return {
|
|
// State
|
|
gameState,
|
|
isConnected,
|
|
// Getters
|
|
myHand,
|
|
isMyTurn,
|
|
// Actions
|
|
setGameState,
|
|
clearGame,
|
|
}
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Composables
|
|
|
|
**Pattern for API composables:**
|
|
```typescript
|
|
// composables/useDecks.ts
|
|
import { ref } from 'vue'
|
|
import { apiClient } from '@/api/client'
|
|
import type { Deck, DeckCreate } from '@/types'
|
|
|
|
export function useDecks() {
|
|
const decks = ref<Deck[]>([])
|
|
const isLoading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
async function fetchDecks() {
|
|
isLoading.value = true
|
|
error.value = null
|
|
try {
|
|
decks.value = await apiClient.get<Deck[]>('/api/decks')
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to fetch decks'
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function createDeck(data: DeckCreate) {
|
|
isLoading.value = true
|
|
error.value = null
|
|
try {
|
|
const deck = await apiClient.post<Deck>('/api/decks', data)
|
|
decks.value.push(deck)
|
|
return deck
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to create deck'
|
|
throw e
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
return {
|
|
decks,
|
|
isLoading,
|
|
error,
|
|
fetchDecks,
|
|
createDeck,
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Vue-Phaser Integration
|
|
|
|
**Phaser is for rendering only. Game logic lives in backend.**
|
|
|
|
### Communication Pattern
|
|
|
|
```typescript
|
|
// Vue -> Phaser (intentions)
|
|
phaserGame.value?.events.emit('card:play', { cardId, targetZone })
|
|
phaserGame.value?.events.emit('attack:select', { attackIndex })
|
|
|
|
// Phaser -> Vue (completions/UI requests)
|
|
phaserGame.value?.events.on('animation:complete', handleAnimationComplete)
|
|
phaserGame.value?.events.on('card:clicked', handleCardClicked)
|
|
```
|
|
|
|
### State Sync
|
|
|
|
```typescript
|
|
// Phaser scene reads from Pinia store
|
|
import { useGameStore } from '@/stores/game'
|
|
|
|
class MatchScene extends Phaser.Scene {
|
|
private gameStore = useGameStore()
|
|
|
|
update() {
|
|
// React to store changes
|
|
if (this.gameStore.gameState) {
|
|
this.renderState(this.gameStore.gameState)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Tailwind Guidelines
|
|
|
|
**Mobile-first responsive:**
|
|
```html
|
|
<!-- Base styles for mobile, override for larger screens -->
|
|
<div class="p-2 md:p-4 lg:p-6">
|
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
<!-- ... -->
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**Use design system colors:**
|
|
```html
|
|
<!-- Pokemon type colors defined in tailwind.config.js -->
|
|
<div class="bg-type-fire text-white">Fire Type</div>
|
|
<div class="bg-type-water text-white">Water Type</div>
|
|
|
|
<!-- UI colors -->
|
|
<button class="bg-primary hover:bg-primary-dark">Action</button>
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
**Component tests with Vitest + Vue Test Utils:**
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import CardDisplay from '@/components/cards/CardDisplay.vue'
|
|
|
|
describe('CardDisplay', () => {
|
|
it('renders card name', () => {
|
|
/**
|
|
* Test that the card component displays the card name.
|
|
*
|
|
* Card names must be visible for players to identify cards
|
|
* in their hand and on the board.
|
|
*/
|
|
const wrapper = mount(CardDisplay, {
|
|
props: { card: { id: '1', name: 'Pikachu', hp: 60 } }
|
|
})
|
|
expect(wrapper.text()).toContain('Pikachu')
|
|
})
|
|
|
|
it('emits click event with card id', async () => {
|
|
/**
|
|
* Test that clicking a card emits the correct event.
|
|
*
|
|
* Card clicks drive all game interactions - playing cards,
|
|
* selecting targets, viewing details.
|
|
*/
|
|
const wrapper = mount(CardDisplay, {
|
|
props: { card: { id: '1', name: 'Pikachu', hp: 60 } }
|
|
})
|
|
await wrapper.trigger('click')
|
|
expect(wrapper.emitted('click')?.[0]).toEqual(['1'])
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Critical Rules
|
|
|
|
1. **Mobile-first** - Base styles for mobile, use `md:` and `lg:` for larger screens
|
|
2. **TypeScript strict** - No `any`, explicit types for props/emits/returns
|
|
3. **Composition API only** - No Options API, use `<script setup>`
|
|
4. **Phaser for rendering** - No game logic in Phaser, only visualization
|
|
5. **Backend is authoritative** - Never trust client state for game logic
|
|
6. **Test docstrings** - Every test needs a docstring explaining what and why
|
|
|
|
---
|
|
|
|
## Path Aliases
|
|
|
|
```typescript
|
|
// Use @/ for src/
|
|
import { useAuth } from '@/composables/useAuth'
|
|
import CardDisplay from '@/components/cards/CardDisplay.vue'
|
|
import type { Card } from '@/types'
|
|
|
|
// Don't use relative paths for deeply nested imports
|
|
import { useAuth } from '../../../composables/useAuth' // NO
|
|
```
|
|
|
|
---
|
|
|
|
## Environment Variables
|
|
|
|
```bash
|
|
# .env.development
|
|
VITE_API_BASE_URL=http://localhost:8000
|
|
VITE_WS_URL=http://localhost:8000
|
|
|
|
# .env.production
|
|
VITE_API_BASE_URL=https://api.play.mantimon.com
|
|
VITE_WS_URL=https://api.play.mantimon.com
|
|
```
|
|
|
|
**Access in code:**
|
|
```typescript
|
|
const apiUrl = import.meta.env.VITE_API_BASE_URL
|
|
```
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Loading States
|
|
```vue
|
|
<template>
|
|
<div v-if="isLoading" class="flex justify-center p-8">
|
|
<LoadingSpinner />
|
|
</div>
|
|
<div v-else-if="error" class="text-error p-4">
|
|
{{ error }}
|
|
</div>
|
|
<div v-else>
|
|
<!-- Content -->
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### Route Guards
|
|
```typescript
|
|
// router/guards.ts
|
|
export function requireAuth(to, from, next) {
|
|
const auth = useAuthStore()
|
|
if (!auth.isAuthenticated) {
|
|
next({ name: 'Login', query: { redirect: to.fullPath } })
|
|
} else {
|
|
next()
|
|
}
|
|
}
|
|
```
|
|
|
|
### API Error Handling
|
|
```typescript
|
|
try {
|
|
await apiClient.post('/api/games', data)
|
|
} catch (e) {
|
|
if (e instanceof ApiError) {
|
|
if (e.status === 401) {
|
|
// Token expired, redirect to login
|
|
auth.logout()
|
|
} else {
|
|
toast.error(e.message)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Backend API Reference
|
|
|
|
### REST Endpoints
|
|
|
|
**Auth** (`/api/auth/`)
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| GET | `/auth/google` | Start Google OAuth (redirect) |
|
|
| GET | `/auth/google/callback` | Google OAuth callback |
|
|
| GET | `/auth/discord` | Start Discord OAuth (redirect) |
|
|
| GET | `/auth/discord/callback` | Discord OAuth callback |
|
|
| POST | `/auth/refresh` | Refresh access token |
|
|
| POST | `/auth/logout` | Revoke refresh token |
|
|
| POST | `/auth/logout-all` | Revoke all user tokens |
|
|
| GET | `/auth/link/google` | Link Google account (authed) |
|
|
| GET | `/auth/link/discord` | Link Discord account (authed) |
|
|
|
|
**Users** (`/api/users/`)
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| GET | `/users/me` | Get current user profile |
|
|
| PATCH | `/users/me` | Update profile (display_name, avatar_url) |
|
|
| GET | `/users/me/linked-accounts` | List linked OAuth accounts |
|
|
| DELETE | `/users/me/link/{provider}` | Unlink OAuth provider |
|
|
| GET | `/users/me/sessions` | Get active session count |
|
|
| GET | `/users/me/starter-status` | Check if starter deck selected |
|
|
| POST | `/users/me/starter-deck` | Select starter deck |
|
|
|
|
**Collections** (`/api/collections/`)
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| GET | `/collections/me` | Get user's card collection |
|
|
| GET | `/collections/me/cards/{card_id}` | Get specific card quantity |
|
|
|
|
**Decks** (`/api/decks/`)
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| GET | `/decks` | List user's decks |
|
|
| POST | `/decks` | Create new deck |
|
|
| GET | `/decks/{id}` | Get deck by ID |
|
|
| PUT | `/decks/{id}` | Update deck |
|
|
| DELETE | `/decks/{id}` | Delete deck |
|
|
| POST | `/decks/validate` | Validate deck without saving |
|
|
|
|
**Games** (`/api/games/`)
|
|
| Method | Endpoint | Description |
|
|
|--------|----------|-------------|
|
|
| POST | `/games` | Create new game |
|
|
| GET | `/games/{id}` | Get game info |
|
|
| GET | `/games/me/active` | List user's active games |
|
|
| POST | `/games/{id}/resign` | Resign from game (HTTP fallback) |
|
|
|
|
### WebSocket Events (Socket.IO)
|
|
|
|
**Namespace:** `/game`
|
|
|
|
**Client → Server:**
|
|
| Event | Payload | Description |
|
|
|-------|---------|-------------|
|
|
| `game:join` | `{ game_id }` | Join/rejoin a game room |
|
|
| `game:action` | `{ action_type, ...data }` | Execute game action |
|
|
| `game:resign` | `{ game_id }` | Resign from game |
|
|
| `game:spectate` | `{ game_id }` | Join as spectator |
|
|
| `game:leave_spectate` | `{ game_id }` | Leave spectator mode |
|
|
|
|
**Server → Client:**
|
|
| Event | Payload | Description |
|
|
|-------|---------|-------------|
|
|
| `game:state` | `GameState` | Full game state (visibility filtered) |
|
|
| `game:action_result` | `ActionResult` | Result of player action |
|
|
| `game:error` | `{ message, code }` | Error notification |
|
|
| `game:turn_timeout_warning` | `{ remaining_seconds }` | Turn timer warning |
|
|
| `game:game_over` | `{ winner, reason }` | Game ended |
|
|
| `game:opponent_connected` | `{ connected }` | Opponent connection status |
|
|
| `game:reconnected` | `{ game_id, state }` | Auto-reconnected to game |
|
|
| `game:spectator_count` | `{ count }` | Spectator count update |
|
|
|
|
### Key Backend Files
|
|
|
|
| File | Description |
|
|
|------|-------------|
|
|
| `backend/app/api/auth.py` | Auth endpoints implementation |
|
|
| `backend/app/api/users.py` | User endpoints implementation |
|
|
| `backend/app/api/games.py` | Game REST endpoints |
|
|
| `backend/app/schemas/` | Pydantic request/response schemas |
|
|
| `backend/app/schemas/ws_messages.py` | WebSocket message schemas |
|
|
| `backend/app/socketio/game_namespace.py` | WebSocket event handlers |
|
|
|
|
### Type Definitions to Mirror
|
|
|
|
When creating frontend types, reference these backend schemas:
|
|
- `backend/app/schemas/user.py` → `UserResponse`, `UserUpdate`
|
|
- `backend/app/schemas/deck.py` → `DeckResponse`, `DeckCreate`, `DeckUpdate`
|
|
- `backend/app/schemas/game.py` → `GameCreateRequest`, `GameResponse`
|
|
- `backend/app/schemas/ws_messages.py` → All WebSocket message types
|
|
|
|
---
|
|
|
|
## See Also
|
|
|
|
- `PROJECT_PLAN_FRONTEND.json` - Phase plan and tasks
|
|
- `../backend/CLAUDE.md` - Backend guidelines
|
|
- `../backend/app/schemas/` - API schema definitions
|
|
- `../backend/app/socketio/README.md` - WebSocket documentation
|
|
- `../CLAUDE.md` - Project-wide guidelines
|