Scaffold Vue 3 + TypeScript frontend (Phase F0)

Vue 3 with Composition API, Pinia, Vue Router, Tailwind v4:
- Vite build with @/ path aliases and backend proxy
- TypeScript strict mode with proper type imports
- Pinia stores (auth, game) with setup syntax and persistence
- Vue Router with auth guards and lazy-loaded routes
- Tailwind v4 with custom theme (Pokemon types, dark UI)
- Vitest configured for component testing
- ESLint v9 flat config with Vue/TypeScript support

Pages: Home, Login, Register, Campaign, Collection, DeckBuilder, Match
Components: AppHeader with auth-aware navigation
Types: Card, GameState, Player, Deck, Campaign types

Also adds frontend-code-audit skill with patterns for:
- Error handling (unhandled promises, empty catches)
- Security (XSS, token storage, input validation)
- Architecture (Composition API, Pinia patterns, Phaser rules)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-30 09:23:53 -06:00
parent 5b12df0cb1
commit b9b803da66
34 changed files with 7543 additions and 0 deletions

View File

@ -0,0 +1,153 @@
# Frontend Code Audit Skill
Perform systematic code audits on Vue/TypeScript/Phaser frontend code to find anti-patterns, hidden errors, security issues, and architecture violations.
## Usage
```
/frontend-code-audit [category] [path]
```
**Categories:**
- `errors` - Unhandled errors, silent failures, missing error states
- `security` - XSS risks, data exposure, auth token handling
- `architecture` - Vue/Pinia patterns, Phaser integration, component design
- `all` - Run all audit categories (default)
**Path:** Optional path to audit (defaults to `frontend/src/`)
## Examples
```
/frontend-code-audit # Full audit of frontend/src/
/frontend-code-audit errors # Just error handling patterns
/frontend-code-audit security stores/ # Security audit of stores/
/frontend-code-audit architecture game/ # Architecture audit of Phaser code
```
## Instructions
When this skill is invoked:
1. **Load Pattern Definitions**
Read the YAML files in `patterns/` directory for the requested category.
2. **Search Codebase**
For each pattern, use Grep with the specified regex to find matches.
Focus on production code (skip test files unless pattern specifies otherwise).
3. **Analyze Context**
For each match, read surrounding code to determine verdict:
- **ISSUE**: Code should be changed (explain why)
- **WARNING**: Potential problem, needs review
- **OK**: Pattern detected but implementation is correct (explain why)
4. **Generate Report**
Output structured report with:
- Summary stats (issues/warnings/ok by category)
- Detailed findings with file:line references
- Recommendations for each issue
## Verdict Guidelines
### Error Handling
**ISSUE** when:
- Promise rejection unhandled (no `.catch()` or try/catch)
- Fetch error caught but UI shows no feedback
- Error logged but component renders as if success
- Empty catch block swallows error
**OK** when:
- Error caught and user notified (toast, error state)
- Error boundary catches and displays fallback
- Retry logic with eventual user feedback
- Graceful degradation documented
### Security
**ISSUE** when:
- `v-html` used with any user-provided data
- Auth token stored in localStorage without httpOnly consideration
- Sensitive data (passwords, tokens) in console.log
- Query params contain auth tokens
**OK** when:
- `v-html` only used with sanitized/trusted content
- Token storage follows project auth pattern
- Logging excludes sensitive fields
- Auth handled via httpOnly cookies or secure headers
### Architecture
**ISSUE** when:
- Game logic (damage calc, turn state) in Phaser scene
- API calls made directly in component (not via composable/store)
- Phaser scene imports from stores without bridge pattern
- Component has >200 lines (should split)
**OK** when:
- Phaser only handles rendering/animation
- API access through typed composables
- Vue-Phaser communication via event bridge
- Large component is page-level layout
## Pattern File Format
```yaml
name: Category Name
description: What this category audits
patterns:
- id: unique-pattern-id
description: Human-readable description
grep_pattern: 'regex pattern for Grep tool'
file_glob: '*.vue' # Optional, defaults to *.{vue,ts}
exclude_tests: true # Optional, defaults to true
multiline: false # Optional, for cross-line patterns
context_lines: 3 # Lines to read around match
verdict_hints:
ISSUE_IF: "condition that makes this an issue"
OK_IF: "condition that makes this acceptable"
```
## Output Format
```markdown
# Frontend Code Audit Report
**Scope:** frontend/src/
**Categories:** errors, security, architecture
**Date:** YYYY-MM-DD
## Summary
| Category | Issues | Warnings | OK |
|----------|--------|----------|-----|
| Errors | 2 | 1 | 5 |
| Security | 0 | 2 | 3 |
| Architecture | 1 | 0 | 4 |
## Issues (Fix Required)
### [ISSUE] security.v-html-user-data
**File:** src/components/CardDescription.vue:42
**Pattern:** v-html with potentially unsafe content
```vue
<div v-html="card.description" />
```
**Problem:** Card description from API could contain malicious scripts.
**Recommendation:** Use text interpolation or sanitize with DOMPurify.
---
## Warnings (Review Needed)
...
## OK (Verified Correct)
...
```

View File

@ -0,0 +1,204 @@
# Frontend Architecture Anti-Patterns
# Patterns that violate Mantimon TCG frontend architecture principles
name: Architecture
description: |
Detects violations of the project's frontend architecture principles:
- Vue Composition API patterns (no Options API)
- Pinia store patterns (setup syntax, proper actions)
- Phaser integration (rendering only, no game logic)
- Component organization (composables for reuse)
- Backend authority (client never authoritative for game state)
patterns:
# Vue Composition API
- id: options-api-usage
description: Options API instead of Composition API
grep_pattern: 'export default \{[\s\S]*?(data\(\)|methods:|computed:|watch:)'
multiline: true
file_glob: '*.vue'
context_lines: 5
verdict_hints:
ISSUE_IF: "Using Options API - project uses Composition API only"
OK_IF: "Never OK - convert to <script setup> with Composition API"
- id: missing-script-setup
description: Script without setup attribute
grep_pattern: '<script lang="ts">\s*$'
file_glob: '*.vue'
context_lines: 2
verdict_hints:
ISSUE_IF: "Missing setup - should use <script setup lang=\"ts\">"
OK_IF: "Rare cases needing defineComponent; document why"
- id: options-api-definecomponent
description: defineComponent with options object
grep_pattern: 'defineComponent\(\{[\s\S]*?(data|methods|computed):'
multiline: true
file_glob: '*.vue'
context_lines: 5
verdict_hints:
ISSUE_IF: "Options API via defineComponent"
OK_IF: "Never OK - use <script setup>"
# Pinia Store Patterns
- id: store-options-syntax
description: Pinia store using options syntax instead of setup
grep_pattern: 'defineStore\([^,]+,\s*\{[\s\S]*?state:'
multiline: true
file_glob: 'stores/*.ts'
context_lines: 5
verdict_hints:
ISSUE_IF: "Options syntax - project uses setup store syntax"
OK_IF: "Never OK - convert to setup syntax with ref/computed"
- id: store-mutation-outside-action
description: Store state mutated directly from component
grep_pattern: 'Store\(\)\.\w+\s*='
file_glob: '*.vue'
context_lines: 3
verdict_hints:
ISSUE_IF: "Direct state mutation - use store action instead"
OK_IF: "Assigning to local ref that shadows store property"
- id: store-no-error-state
description: Store without error state for async operations
grep_pattern: 'defineStore.*\{[\s\S]*?async.*fetch[\s\S]*?\}'
multiline: true
file_glob: 'stores/*.ts'
context_lines: 20
verdict_hints:
ISSUE_IF: "Async store without error ref for failure handling"
OK_IF: "Error state exists; or errors handled by caller"
# Phaser Integration
- id: phaser-game-logic
description: Game logic in Phaser scene (should be backend)
grep_pattern: '(calculateDamage|resolveTurn|checkWin|applyEffect)'
file_glob: 'game/**/*.ts'
context_lines: 5
verdict_hints:
ISSUE_IF: "Game logic in Phaser - backend is authoritative"
OK_IF: "Animation/display logic with same name; not actual game rules"
- id: phaser-direct-api
description: Phaser scene making API calls directly
grep_pattern: '(fetch|axios|api\.)'
file_glob: 'game/**/*.ts'
context_lines: 4
verdict_hints:
ISSUE_IF: "Phaser calling API - should go through Vue layer"
OK_IF: "Asset loading (images, audio); not game data"
- id: phaser-store-import
description: Phaser importing store directly (use bridge)
grep_pattern: 'import.*from.*stores/'
file_glob: 'game/**/*.ts'
context_lines: 3
verdict_hints:
ISSUE_IF: "Direct store import - use event bridge pattern"
OK_IF: "Type-only import (import type); bridge.ts file"
- id: phaser-modifies-state
description: Phaser scene modifying game state
grep_pattern: 'gameState\.\w+\s*='
file_glob: 'game/**/*.ts'
context_lines: 4
verdict_hints:
ISSUE_IF: "Phaser modifying state - should only render"
OK_IF: "Local animation state; not shared game state"
# Component Organization
- id: api-call-in-component
description: Direct fetch/API call in component (use composable)
grep_pattern: 'fetch\(|axios\.|api\.'
file_glob: 'components/**/*.vue'
context_lines: 5
verdict_hints:
ISSUE_IF: "API call in component - extract to composable"
OK_IF: "Component is in pages/; or uses composable internally"
- id: large-component
description: Component with many lines (consider splitting)
grep_pattern: '</script>'
file_glob: '*.vue'
context_lines: 0
verdict_hints:
ISSUE_IF: "Script section >150 lines - split into composables"
OK_IF: "Page component; or logic is cohesive and documented"
- id: prop-drilling
description: Props passed through multiple levels
grep_pattern: 'defineProps.*\{[\s\S]*?(Props|\.\.\.)'
multiline: true
file_glob: '*.vue'
context_lines: 10
verdict_hints:
ISSUE_IF: "Props drilled >2 levels - use provide/inject or store"
OK_IF: "Props are component-specific; not passed through"
# Type Safety
- id: any-type-usage
description: Using 'any' type
grep_pattern: ':\s*any\b|as\s+any\b|<any>'
file_glob: '*.{vue,ts}'
context_lines: 2
verdict_hints:
ISSUE_IF: "Explicit any - defeats TypeScript benefits"
OK_IF: "Temporary during migration; or external lib without types"
- id: type-assertion-abuse
description: Type assertion (as) that might hide errors
grep_pattern: '\s+as\s+[A-Z]\w+'
file_glob: '*.{vue,ts}'
context_lines: 3
verdict_hints:
ISSUE_IF: "Assertion used to bypass type error"
OK_IF: "Narrowing from unknown; or after runtime check"
- id: missing-prop-types
description: Props without TypeScript types
grep_pattern: 'defineProps\(\[\s*["\']'
file_glob: '*.vue'
context_lines: 3
verdict_hints:
ISSUE_IF: "Array prop syntax - no type safety"
OK_IF: "Never OK - use defineProps<{...}>()"
# Import Organization
- id: relative-import-deep
description: Deep relative imports (use @ alias)
grep_pattern: 'from ["\']\.\.\/\.\.\/\.\.\/'
file_glob: '*.{vue,ts}'
context_lines: 2
verdict_hints:
ISSUE_IF: "Deep relative path - use @/ alias"
OK_IF: "Never OK for 3+ levels - always use @/"
- id: missing-type-import
description: Type import without 'import type'
grep_pattern: 'import \{[^}]*(Type|Interface|Props)[^}]*\} from'
file_glob: '*.{vue,ts}'
context_lines: 2
verdict_hints:
ISSUE_IF: "Type imported as value - use 'import type'"
OK_IF: "Also used as value (class, enum); not pure type"
# Backend Authority
- id: client-state-authority
description: Client treating its state as authoritative
grep_pattern: '(emit|send)\([^)]*\{[^}]*(hp|damage|winner|gameState)'
file_glob: '*.{vue,ts}'
context_lines: 5
verdict_hints:
ISSUE_IF: "Sending computed game state to server - server is authority"
OK_IF: "Sending action intent (playCard, attack); server computes result"
- id: local-game-calculation
description: Calculating game outcomes client-side
grep_pattern: '(damage|hp|energy)\s*[-+*]=|Math\.(random|floor).*damage'
file_glob: '*.{vue,ts}'
context_lines: 5
verdict_hints:
ISSUE_IF: "Game calculation on client - server must compute"
OK_IF: "Animation preview; actual result from server"

View File

@ -0,0 +1,103 @@
# Frontend Error Handling Anti-Patterns
# Patterns that hide errors or fail to provide user feedback
name: Error Handling
description: |
Detects patterns where errors are silently swallowed, promises unhandled,
or users left without feedback when operations fail. These patterns lead
to confusing UX and hard-to-debug issues.
patterns:
- id: unhandled-promise
description: Async function called without await or .catch()
grep_pattern: '^\s+\w+\([^)]*\)\s*$'
file_glob: '*.{vue,ts}'
context_lines: 3
verdict_hints:
ISSUE_IF: "Async function (fetch, API call) with no error handling"
OK_IF: "Sync function; or fire-and-forget with documented reason"
- id: empty-catch-block
description: Catch block that does nothing with the error
grep_pattern: 'catch\s*\([^)]*\)\s*\{\s*\}'
file_glob: '*.{vue,ts}'
context_lines: 3
verdict_hints:
ISSUE_IF: "Error completely swallowed - user gets no feedback"
OK_IF: "Never OK - at minimum log or set error state"
- id: catch-only-console
description: Catch block that only logs to console
grep_pattern: 'catch.*\{[\s\n]*console\.(log|error|warn)'
multiline: true
file_glob: '*.{vue,ts}'
context_lines: 5
verdict_hints:
ISSUE_IF: "Error logged but UI shows success/no feedback to user"
OK_IF: "Also sets error state or shows toast notification"
- id: fetch-no-error-check
description: Fetch without checking response.ok
grep_pattern: 'fetch\([^)]+\)[\s\S]*?\.json\(\)'
multiline: true
file_glob: '*.{vue,ts}'
context_lines: 7
verdict_hints:
ISSUE_IF: "Response not checked for errors before parsing JSON"
OK_IF: "Uses wrapper that handles errors; or response.ok checked"
- id: missing-loading-state
description: Async operation without loading indicator
grep_pattern: 'async.*\{[\s\S]*?await.*fetch'
multiline: true
file_glob: '*.vue'
context_lines: 10
verdict_hints:
ISSUE_IF: "No isLoading ref set before/after async operation"
OK_IF: "Loading state managed by composable; or instant operation"
- id: missing-error-state
description: API call without error state handling
grep_pattern: 'await.*(fetch|api|axios)'
file_glob: '*.vue'
context_lines: 8
verdict_hints:
ISSUE_IF: "No error ref or error handling visible in component"
OK_IF: "Error handling in composable; or uses error boundary"
- id: silent-store-action
description: Store action that catches but doesn't surface errors
grep_pattern: 'catch.*\{[\s\S]*?return (false|null|undefined|\[\]|\{\})'
multiline: true
file_glob: 'stores/*.ts'
context_lines: 6
verdict_hints:
ISSUE_IF: "Store swallows error, returns falsy - caller can't show error"
OK_IF: "Also sets store.error state that UI can display"
- id: onmounted-no-error-handling
description: onMounted with async call but no error handling
grep_pattern: 'onMounted\(\s*async'
file_glob: '*.vue'
context_lines: 10
verdict_hints:
ISSUE_IF: "Async onMounted without try/catch - errors crash silently"
OK_IF: "Has try/catch with error state; or uses error boundary"
- id: watch-async-no-catch
description: Watch callback with async but no error handling
grep_pattern: 'watch\([^)]+,\s*async'
file_glob: '*.vue'
context_lines: 8
verdict_hints:
ISSUE_IF: "Async watch without error handling"
OK_IF: "Has try/catch; or errors handled by called function"
- id: socket-no-error-handler
description: Socket.io without error event handler
grep_pattern: 'socket\.(on|emit)\('
file_glob: '*.{vue,ts}'
context_lines: 10
verdict_hints:
ISSUE_IF: "Socket used without socket.on('error') or socket.on('connect_error')"
OK_IF: "Error handlers registered elsewhere (socket setup file)"

View File

@ -0,0 +1,152 @@
# Frontend Security Anti-Patterns
# Patterns that may expose security vulnerabilities in Vue/TypeScript
name: Security
description: |
Detects patterns that could lead to security vulnerabilities including
XSS risks, sensitive data exposure, insecure storage, and auth issues.
Specific to Vue 3, TypeScript, and browser security concerns.
patterns:
# XSS Risks
- id: v-html-usage
description: v-html directive (potential XSS)
grep_pattern: 'v-html\s*='
file_glob: '*.vue'
context_lines: 3
verdict_hints:
ISSUE_IF: "Content from API, user input, or any external source"
OK_IF: "Only static/trusted content; or sanitized with DOMPurify"
- id: innerhtml-assignment
description: Direct innerHTML assignment
grep_pattern: '\.innerHTML\s*='
file_glob: '*.{vue,ts}'
context_lines: 3
verdict_hints:
ISSUE_IF: "Assigns user-controlled or API content"
OK_IF: "Static trusted HTML only; prefer v-html with sanitization"
- id: document-write
description: document.write usage (XSS and performance)
grep_pattern: 'document\.write'
file_glob: '*.{vue,ts}'
context_lines: 2
verdict_hints:
ISSUE_IF: "Almost never acceptable in modern apps"
OK_IF: "Never OK - refactor to DOM manipulation"
# Sensitive Data Exposure
- id: console-log-sensitive
description: Console logging potentially sensitive data
grep_pattern: 'console\.(log|debug|info)\(.*\b(token|password|secret|auth|credential|key)\b'
file_glob: '*.{vue,ts}'
context_lines: 2
verdict_hints:
ISSUE_IF: "Logs sensitive values - visible in browser devtools"
OK_IF: "Logs field names only, not values; or dev-only code"
- id: localstorage-sensitive
description: Storing sensitive data in localStorage
grep_pattern: 'localStorage\.setItem\([^)]*\b(password|secret|credit|ssn)\b'
file_glob: '*.{vue,ts}'
context_lines: 3
verdict_hints:
ISSUE_IF: "Sensitive data in localStorage - accessible to XSS"
OK_IF: "Never OK for passwords/secrets; tokens need careful review"
- id: token-in-url
description: Auth token in URL or query params
grep_pattern: '(url|href|src).*\?(.*&)?(token|auth|key)='
file_glob: '*.{vue,ts}'
context_lines: 3
verdict_hints:
ISSUE_IF: "Token in URL - logged in server logs, browser history"
OK_IF: "One-time tokens (email verification); not session tokens"
- id: hardcoded-secret
description: Hardcoded API key or secret
grep_pattern: '(api_?key|secret|password)\s*[:=]\s*["\'][^"\']{8,}["\']'
file_glob: '*.{vue,ts}'
context_lines: 2
exclude_tests: false
verdict_hints:
ISSUE_IF: "Real credential in source code"
OK_IF: "Placeholder; real value from VITE_* env variable"
# Auth Issues
- id: auth-check-client-only
description: Auth check only on client side
grep_pattern: '(isAuthenticated|isLoggedIn|user)\s*\?\s*<'
file_glob: '*.vue'
context_lines: 5
verdict_hints:
ISSUE_IF: "UI hidden but API not protected - security by obscurity"
OK_IF: "UI hint only; API has proper auth middleware"
- id: token-in-state
description: Auth token stored in reactive state without persistence consideration
grep_pattern: 'ref<.*token.*>\s*\('
file_glob: '*.{vue,ts}'
context_lines: 4
verdict_hints:
ISSUE_IF: "Token in memory only - lost on refresh without secure persistence"
OK_IF: "Intentional session-only token; or persisted securely elsewhere"
# Game-Specific Security
- id: game-state-trust
description: Trusting game state from client
grep_pattern: '(damage|hp|energy|prize|winner)\s*=\s*(props|data|event)'
file_glob: '*.{vue,ts}'
context_lines: 5
verdict_hints:
ISSUE_IF: "Game state from client used without server validation"
OK_IF: "Display only; actual state from server via WebSocket"
- id: hidden-info-client
description: Accessing hidden game info that should be server-only
grep_pattern: '\.(deck|opponentHand|prizes)\.(cards|contents|order)'
file_glob: '*.{vue,ts}'
context_lines: 4
verdict_hints:
ISSUE_IF: "Accessing hidden zone contents - should only have counts"
OK_IF: "Own hand/prizes (visible to player); or mock data in tests"
# Input Validation
- id: unsanitized-user-input
description: User input used directly without validation
grep_pattern: '(v-model|@input).*\n.*(?!.*validate|sanitize|escape)'
multiline: true
file_glob: '*.vue'
context_lines: 5
verdict_hints:
ISSUE_IF: "Input sent to API without validation"
OK_IF: "Validated before submission; or display-only local state"
- id: eval-usage
description: eval() or Function() constructor
grep_pattern: '\b(eval|Function)\s*\('
file_glob: '*.{vue,ts}'
context_lines: 3
verdict_hints:
ISSUE_IF: "Executing dynamic code - XSS/injection risk"
OK_IF: "Never OK in frontend code"
# Network Security
- id: http-url
description: HTTP (non-HTTPS) URL in code
grep_pattern: '["\']http://(?!localhost|127\.0\.0\.1)'
file_glob: '*.{vue,ts}'
context_lines: 2
verdict_hints:
ISSUE_IF: "Production URL using HTTP - data sent unencrypted"
OK_IF: "Development URL; production uses HTTPS"
- id: cors-credential-any
description: Credentials with wildcard or any origin
grep_pattern: 'credentials:\s*["\']include["\']'
file_glob: '*.{vue,ts}'
context_lines: 4
verdict_hints:
ISSUE_IF: "Credentials sent to any origin - CSRF risk"
OK_IF: "Server has strict CORS; or same-origin requests only"

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

37
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,37 @@
import js from '@eslint/js'
import globals from 'globals'
import vue from 'eslint-plugin-vue'
import tseslint from 'typescript-eslint'
export default [
js.configs.recommended,
...tseslint.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.{ts,vue}'],
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
parser: tseslint.parser,
},
},
rules: {
// Vue rules
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
// TypeScript rules
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// General rules - allow console in dev guards
'no-console': 'off',
},
},
{
ignores: ['dist/', 'node_modules/', '*.config.js', '*.config.ts'],
},
]

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mantimon-frontend-scaffold</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5621
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "mantimon-tcg-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"test:watch": "vitest --watch",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.18",
"pinia": "^3.0.2",
"pinia-plugin-persistedstate": "^4.2.0",
"socket.io-client": "^4.8.1",
"vue": "^3.5.24",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.21.0",
"eslint-plugin-vue": "^10.1.0",
"globals": "^17.2.0",
"jsdom": "^27.4.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.4",
"typescript": "~5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^7.2.4",
"vitest": "^3.1.2",
"vue-tsc": "^3.1.4"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

20
frontend/src/App.vue Normal file
View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
const route = useRoute()
// Hide header on match page for full-screen game view
const showHeader = computed(() => route.name !== 'match')
</script>
<template>
<div class="min-h-screen flex flex-col">
<AppHeader v-if="showHeader" />
<main class="flex-1">
<RouterView />
</main>
</div>
</template>

View File

@ -0,0 +1,74 @@
@import "tailwindcss";
/* Import pixel font for retro GBC aesthetic */
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
/* Custom theme */
@theme {
/* Pokemon type colors */
--color-type-grass: #78C850;
--color-type-fire: #F08030;
--color-type-water: #6890F0;
--color-type-lightning: #F8D030;
--color-type-psychic: #F85888;
--color-type-fighting: #C03028;
--color-type-darkness: #705848;
--color-type-metal: #B8B8D0;
--color-type-fairy: #EE99AC;
--color-type-dragon: #7038F8;
--color-type-colorless: #A8A878;
/* UI colors */
--color-primary: #3B82F6;
--color-primary-dark: #2563EB;
--color-primary-light: #60A5FA;
--color-secondary: #6366F1;
--color-secondary-dark: #4F46E5;
--color-secondary-light: #818CF8;
--color-success: #22C55E;
--color-warning: #F59E0B;
--color-error: #EF4444;
/* Dark theme surfaces */
--color-surface: #374151;
--color-surface-dark: #1F2937;
--color-surface-light: #4B5563;
/* Font family */
--font-pixel: "Press Start 2P", cursive;
}
@layer base {
html {
@apply bg-gray-900 text-gray-100;
}
body {
@apply min-h-screen;
}
}
@layer components {
/* Card styling */
.card {
@apply bg-surface rounded-lg shadow-lg overflow-hidden;
}
/* Button variants */
.btn {
@apply px-4 py-2 rounded font-medium transition-colors duration-200;
}
.btn-primary {
@apply bg-primary hover:bg-primary-dark text-white;
}
.btn-secondary {
@apply bg-secondary hover:bg-secondary-dark text-white;
}
/* Type badges */
.type-badge {
@apply px-2 py-1 rounded text-xs font-bold uppercase;
}
}

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const isAuthenticated = computed(() => authStore.isAuthenticated)
const username = computed(() => authStore.user?.username)
function handleLogout() {
authStore.logout()
router.push({ name: 'home' })
}
</script>
<template>
<header class="bg-surface-dark border-b border-gray-700">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<RouterLink
to="/"
class="text-xl font-bold text-primary-light"
>
Mantimon TCG
</RouterLink>
<nav class="flex items-center gap-6">
<template v-if="isAuthenticated">
<RouterLink
to="/campaign"
class="text-gray-300 hover:text-white transition-colors"
>
Campaign
</RouterLink>
<RouterLink
to="/collection"
class="text-gray-300 hover:text-white transition-colors"
>
Collection
</RouterLink>
<RouterLink
to="/deck-builder"
class="text-gray-300 hover:text-white transition-colors"
>
Decks
</RouterLink>
<div class="flex items-center gap-3 ml-4 pl-4 border-l border-gray-600">
<span class="text-gray-400">{{ username }}</span>
<button
class="text-gray-400 hover:text-white transition-colors"
@click="handleLogout"
>
Logout
</button>
</div>
</template>
<template v-else>
<RouterLink
to="/login"
class="text-gray-300 hover:text-white transition-colors"
>
Login
</RouterLink>
<RouterLink
to="/register"
class="btn btn-primary"
>
Sign Up
</RouterLink>
</template>
</nav>
</div>
</header>
</template>

19
frontend/src/main.ts Normal file
View File

@ -0,0 +1,19 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from './stores'
import { useAuthStore } from './stores/auth'
import './assets/main.css'
const app = createApp(App)
app.use(pinia)
app.use(router)
// Initialize auth state from localStorage
const authStore = useAuthStore()
authStore.init()
app.mount('#app')

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
// Campaign page - placeholder for Phase F2
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">
Campaign
</h1>
<div class="bg-surface p-8 rounded-lg text-center">
<p class="text-gray-400 mb-4">
Campaign mode coming in Phase F2
</p>
<p class="text-sm text-gray-500">
Challenge NPCs, defeat Club Leaders, and become Champion!
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
// Collection page - placeholder for Phase F3
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">
Collection
</h1>
<div class="bg-surface p-8 rounded-lg text-center">
<p class="text-gray-400 mb-4">
Collection view coming in Phase F3
</p>
<p class="text-sm text-gray-500">
Browse your cards, filter by type, and track your collection progress.
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
// Deck builder page - placeholder for Phase F3
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">
Deck Builder
</h1>
<div class="bg-surface p-8 rounded-lg text-center">
<p class="text-gray-400 mb-4">
Deck builder coming in Phase F3
</p>
<p class="text-sm text-gray-500">
Build and manage your decks with drag-and-drop interface.
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const isAuthenticated = computed(() => authStore.isAuthenticated)
</script>
<template>
<div class="container mx-auto px-4 py-16">
<div class="max-w-2xl mx-auto text-center">
<h1 class="text-4xl md:text-5xl font-bold text-primary-light mb-6">
Mantimon TCG
</h1>
<p class="text-xl text-gray-300 mb-8">
A single-player trading card game adventure inspired by the
classic Gameboy Color Pokemon TCG.
</p>
<div
v-if="isAuthenticated"
class="space-y-4"
>
<RouterLink
to="/campaign"
class="btn btn-primary block w-full text-lg py-3"
>
Continue Campaign
</RouterLink>
<RouterLink
to="/collection"
class="btn btn-secondary block w-full text-lg py-3"
>
View Collection
</RouterLink>
</div>
<div
v-else
class="space-y-4"
>
<RouterLink
to="/register"
class="btn btn-primary block w-full text-lg py-3"
>
Start Your Journey
</RouterLink>
<RouterLink
to="/login"
class="btn btn-secondary block w-full text-lg py-3"
>
Continue Adventure
</RouterLink>
</div>
<div class="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 text-left">
<div class="bg-surface p-6 rounded-lg">
<h3 class="text-lg font-bold text-type-fire mb-2">
Campaign Mode
</h3>
<p class="text-gray-400">
Challenge NPCs at themed clubs, defeat leaders, and collect medals.
</p>
</div>
<div class="bg-surface p-6 rounded-lg">
<h3 class="text-lg font-bold text-type-water mb-2">
Build Your Deck
</h3>
<p class="text-gray-400">
Collect cards from booster packs and build powerful decks.
</p>
</div>
<div class="bg-surface p-6 rounded-lg">
<h3 class="text-lg font-bold text-type-lightning mb-2">
PvP Battles
</h3>
<p class="text-gray-400">
Test your skills against other players in multiplayer matches.
</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
async function handleSubmit() {
const success = await authStore.login(username.value, password.value)
if (success) {
const redirect = route.query.redirect as string
router.push(redirect || '/campaign')
}
}
</script>
<template>
<div class="container mx-auto px-4 py-16">
<div class="max-w-md mx-auto">
<h1 class="text-3xl font-bold text-center mb-8">
Login
</h1>
<form
class="space-y-6"
@submit.prevent="handleSubmit"
>
<div
v-if="authStore.error"
class="bg-error/20 text-error p-4 rounded"
>
{{ authStore.error }}
</div>
<div>
<label
for="username"
class="block text-sm font-medium mb-2"
>
Username
</label>
<input
id="username"
v-model="username"
type="text"
required
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
>
</div>
<div>
<label
for="password"
class="block text-sm font-medium mb-2"
>
Password
</label>
<input
id="password"
v-model="password"
type="password"
required
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
>
</div>
<button
type="submit"
:disabled="authStore.isLoading"
class="btn btn-primary w-full py-3"
>
{{ authStore.isLoading ? 'Logging in...' : 'Login' }}
</button>
</form>
<p class="mt-6 text-center text-gray-400">
Don't have an account?
<RouterLink
to="/register"
class="text-primary-light hover:underline"
>
Sign up
</RouterLink>
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGameStore } from '@/stores/game'
const route = useRoute()
const router = useRouter()
const gameStore = useGameStore()
const matchId = route.params.id as string | undefined
onMounted(() => {
if (!matchId) {
// No match ID - redirect to campaign
router.push('/campaign')
return
}
// TODO: Connect to game via WebSocket (Phase F4)
if (import.meta.env.DEV) {
console.log('Joining match:', matchId)
}
})
onUnmounted(() => {
gameStore.clearGame()
})
</script>
<template>
<div class="h-screen bg-surface-dark flex items-center justify-center">
<div
v-if="!gameStore.gameState"
class="text-center"
>
<div class="animate-pulse mb-4">
<div class="w-16 h-16 mx-auto bg-primary rounded-full" />
</div>
<p class="text-gray-400">
Connecting to match...
</p>
</div>
<div
v-else
class="w-full h-full"
>
<!-- Phaser game canvas will mount here in Phase F4 -->
<p class="text-center text-gray-400 pt-8">
Match UI coming in Phase F4
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const localError = ref<string | null>(null)
async function handleSubmit() {
localError.value = null
if (password.value !== confirmPassword.value) {
localError.value = 'Passwords do not match'
return
}
if (password.value.length < 8) {
localError.value = 'Password must be at least 8 characters'
return
}
const success = await authStore.register(
username.value,
email.value,
password.value
)
if (success) {
router.push('/campaign')
}
}
</script>
<template>
<div class="container mx-auto px-4 py-16">
<div class="max-w-md mx-auto">
<h1 class="text-3xl font-bold text-center mb-8">
Create Account
</h1>
<form
class="space-y-6"
@submit.prevent="handleSubmit"
>
<div
v-if="localError || authStore.error"
class="bg-error/20 text-error p-4 rounded"
>
{{ localError || authStore.error }}
</div>
<div>
<label
for="username"
class="block text-sm font-medium mb-2"
>
Username
</label>
<input
id="username"
v-model="username"
type="text"
required
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
>
</div>
<div>
<label
for="email"
class="block text-sm font-medium mb-2"
>
Email
</label>
<input
id="email"
v-model="email"
type="email"
required
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
>
</div>
<div>
<label
for="password"
class="block text-sm font-medium mb-2"
>
Password
</label>
<input
id="password"
v-model="password"
type="password"
required
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
>
</div>
<div>
<label
for="confirmPassword"
class="block text-sm font-medium mb-2"
>
Confirm Password
</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
required
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
>
</div>
<button
type="submit"
:disabled="authStore.isLoading"
class="btn btn-primary w-full py-3"
>
{{ authStore.isLoading ? 'Creating account...' : 'Create Account' }}
</button>
</form>
<p class="mt-6 text-center text-gray-400">
Already have an account?
<RouterLink
to="/login"
class="text-primary-light hover:underline"
>
Login
</RouterLink>
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,63 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/pages/HomePage.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/LoginPage.vue'),
},
{
path: '/register',
name: 'register',
component: () => import('@/pages/RegisterPage.vue'),
},
{
path: '/campaign',
name: 'campaign',
component: () => import('@/pages/CampaignPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/collection',
name: 'collection',
component: () => import('@/pages/CollectionPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/deck-builder',
name: 'deck-builder',
component: () => import('@/pages/DeckBuilderPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/match/:id?',
name: 'match',
component: () => import('@/pages/MatchPage.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// Navigation guard for auth
router.beforeEach((to, _from, next) => {
const isAuthenticated = localStorage.getItem('auth_token')
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else {
next()
}
})
export default router

View File

@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from './auth'
describe('useAuthStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts with no authenticated user', () => {
/**
* Test that the auth store initializes in an unauthenticated state.
*
* New users should not be authenticated until they log in,
* ensuring protected routes are inaccessible by default.
*/
const store = useAuthStore()
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
expect(store.token).toBeNull()
})
it('tracks loading state', () => {
/**
* Test that the loading state ref is accessible.
*
* Components need to check loading state to show spinners
* and disable buttons during async operations.
*/
const store = useAuthStore()
expect(store.isLoading).toBe(false)
})
it('tracks error state', () => {
/**
* Test that the error state ref is accessible.
*
* Components need to display error messages when
* login or registration fails.
*/
const store = useAuthStore()
expect(store.error).toBeNull()
})
})

112
frontend/src/stores/auth.ts Normal file
View File

@ -0,0 +1,112 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export interface User {
id: string
username: string
email: string
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
async function login(username: string, password: string): Promise<boolean> {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.detail || 'Login failed')
}
const data = await response.json()
token.value = data.access_token
user.value = data.user
// Note: localStorage is used for token persistence across page reloads.
// This is standard for SPAs but tokens are accessible to XSS. The backend
// should use short-lived access tokens + httpOnly refresh cookies for
// production. See: https://auth0.com/docs/secure/security-guidance/data-security/token-storage
localStorage.setItem('auth_token', data.access_token)
return true
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed'
return false
} finally {
isLoading.value = false
}
}
async function register(
username: string,
email: string,
password: string
): Promise<boolean> {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password }),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.detail || 'Registration failed')
}
// Auto-login after registration
return await login(username, password)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Registration failed'
return false
} finally {
isLoading.value = false
}
}
function logout() {
user.value = null
token.value = null
localStorage.removeItem('auth_token')
}
// Restore token from localStorage on init
function init() {
const storedToken = localStorage.getItem('auth_token')
if (storedToken) {
token.value = storedToken
// TODO: Validate token and fetch user profile
}
}
return {
user,
token,
isLoading,
error,
isAuthenticated,
login,
register,
logout,
init,
}
}, {
persist: {
pick: ['token'],
},
})

View File

@ -0,0 +1,74 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { GameState, Card, Player } from '@/types'
export const useGameStore = defineStore('game', () => {
const gameState = ref<GameState | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed properties for current player's view
const myPlayer = computed<Player | null>(() => {
if (!gameState.value) return null
return gameState.value.players.find(p => p.isMe) ?? null
})
const opponent = computed<Player | null>(() => {
if (!gameState.value) return null
return gameState.value.players.find(p => !p.isMe) ?? null
})
const myHand = computed<Card[]>(() => myPlayer.value?.hand ?? [])
const myActive = computed<Card | null>(() => myPlayer.value?.active ?? null)
const myBench = computed<Card[]>(() => myPlayer.value?.bench ?? [])
const isMyTurn = computed(() => {
if (!gameState.value || !myPlayer.value) return false
return gameState.value.currentPlayerId === myPlayer.value.id
})
const currentPhase = computed(() => gameState.value?.phase ?? null)
// Actions
function updateGameState(state: GameState) {
gameState.value = state
}
function clearGame() {
gameState.value = null
error.value = null
}
function setError(message: string) {
error.value = message
}
function clearError() {
error.value = null
}
return {
// State
gameState,
isLoading,
error,
// Computed
myPlayer,
opponent,
myHand,
myActive,
myBench,
isMyTurn,
currentPhase,
// Actions
updateGameState,
clearGame,
setError,
clearError,
}
})

View File

@ -0,0 +1,11 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia
// Re-export stores for convenience
export { useAuthStore } from './auth'
export { useGameStore } from './game'

148
frontend/src/types/index.ts Normal file
View File

@ -0,0 +1,148 @@
// Card Types
export type CardType =
| 'grass'
| 'fire'
| 'water'
| 'lightning'
| 'psychic'
| 'fighting'
| 'darkness'
| 'metal'
| 'fairy'
| 'dragon'
| 'colorless'
export type CardCategory = 'pokemon' | 'trainer' | 'energy'
export interface Card {
id: string
definitionId: string
name: string
category: CardCategory
type?: CardType
hp?: number
currentHp?: number
attacks?: Attack[]
imageUrl?: string
// Status effects for Pokemon
status?: StatusEffect[]
// Attached cards (energy, tools)
attachments?: Card[]
}
export interface Attack {
name: string
cost: CardType[]
damage: number
effect?: string
effectParams?: Record<string, unknown>
}
export interface StatusEffect {
type: 'poisoned' | 'burned' | 'asleep' | 'paralyzed' | 'confused'
turnsRemaining?: number
}
// Game State Types
export type GamePhase = 'setup' | 'draw' | 'main' | 'attack' | 'end'
export interface Player {
id: string
username: string
isMe: boolean
hand: Card[]
active: Card | null
bench: Card[]
prizes: Card[]
prizeCount: number
deckCount: number
discardPile: Card[]
}
export interface GameState {
id: string
players: Player[]
currentPlayerId: string
phase: GamePhase
turnNumber: number
winner?: string
// Actions available to current player
availableActions?: string[]
}
// API Response Types
export interface ApiError {
detail: string
code?: string
}
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
// Collection Types
export interface CollectionCard {
cardDefinitionId: string
quantity: number
card: CardDefinition
}
export interface CardDefinition {
id: string
name: string
category: CardCategory
type?: CardType
hp?: number
attacks?: Attack[]
imageUrl: string
rarity: 'common' | 'uncommon' | 'rare' | 'holo' | 'ultra'
setId: string
setNumber: number
}
// Deck Types
export interface Deck {
id: string
name: string
cards: DeckCard[]
isValid: boolean
cardCount: number
}
export interface DeckCard {
cardDefinitionId: string
quantity: number
card: CardDefinition
}
// Campaign Types
export interface Club {
id: string
name: string
description: string
iconUrl: string
members: ClubMember[]
leader: ClubMember
isDefeated: boolean
}
export interface ClubMember {
id: string
name: string
portraitUrl: string
difficulty: 'easy' | 'medium' | 'hard' | 'leader'
isDefeated: boolean
deckTheme: CardType
}
export interface CampaignProgress {
clubsDefeated: string[]
medalsEarned: string[]
currentClub?: string
totalWins: number
totalLosses: number
}

View File

@ -0,0 +1,22 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

27
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
},
'/socket.io': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true
}
}
}
})

17
frontend/vitest.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts}'],
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})