Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef636f644 | ||
|
|
e135d92abb | ||
|
|
b3cbec85bd | ||
|
|
1eefcc89a5 | ||
|
|
1ec8cc7640 | ||
|
|
5b91b71078 | ||
|
|
d790e5e278 | ||
|
|
7b112a98ba | ||
|
|
0c05a7c6bb | ||
|
|
e5be09ef5f | ||
|
|
693fe6bb9f | ||
|
|
9f62623684 | ||
|
|
6f6cb14650 | ||
|
|
61196ccbff | ||
|
|
f775fc55e5 | ||
|
|
3cef4fcbac | ||
|
|
a51e539d02 | ||
|
|
7d1a351ed6 | ||
|
|
3e81c1f480 | ||
|
|
0e3c058707 | ||
|
|
0e76bcd79a | ||
|
|
de30bec479 | ||
|
|
0eed26966c | ||
|
|
49219e34cd | ||
|
|
499ee525a8 | ||
|
|
843b45b14e | ||
|
|
76a8472f12 | ||
|
|
bf02bc14fd | ||
|
|
ffb626f403 | ||
|
|
860fd607be | ||
|
|
3bae00b9a9 | ||
|
|
ff791409cf | ||
|
|
c1be0277c1 | ||
|
|
04c9944f2e | ||
|
|
260bdc2e9d | ||
|
|
8dbdc837cc | ||
|
|
1bd73544df | ||
|
|
506bfeccf2 | ||
|
|
38e554e6ea | ||
|
|
8f8f112de2 | ||
|
|
f3080b50d9 | ||
|
|
de0efd5932 | ||
|
|
c9e5c473e6 | ||
|
|
487ca7fba6 | ||
|
|
8f7eebce4b | ||
|
|
94ef39234d | ||
|
|
6d14dd8b47 | ||
|
|
0ee7739091 | ||
|
|
5c135358fc | ||
|
|
74554c3669 | ||
|
|
a89d3a9701 | ||
|
|
180e213d00 | ||
|
|
c33698ce52 | ||
|
|
5b4cb7d35a | ||
|
|
eacbafb1ea | ||
|
|
08fe4326a6 | ||
|
|
fabb33252c | ||
|
|
00dcb872b7 |
203
CHANGELOG.md
203
CHANGELOG.md
@@ -1,3 +1,206 @@
|
||||
## [4.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.24.0...v4.0.0) (2025-10-16)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **db:** Database schemas now accept any string for game names
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **db:** remove database schema coupling for game names ([e135d92](https://github.com/antialias/soroban-abacus-flashcards/commit/e135d92abb4d27f646c1fbeff6524a729d107426)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
|
||||
|
||||
## [3.24.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.23.0...v3.24.0) (2025-10-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **math-sprint:** add game manifest ([1eefcc8](https://github.com/antialias/soroban-abacus-flashcards/commit/1eefcc89a58b79f928932a7425d6b88fb45a5526))
|
||||
|
||||
## [3.23.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.3...v3.23.0) (2025-10-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** add Math Sprint game implementation ([e5be09e](https://github.com/antialias/soroban-abacus-flashcards/commit/e5be09ef5f170c7544557f75b9eca17bb2069246))
|
||||
* **arcade:** register Math Sprint in game system ([0c05a7c](https://github.com/antialias/soroban-abacus-flashcards/commit/0c05a7c6bbc8d6f6e1f92e15e691d7e1aba0d8f7)), closes [#2](https://github.com/antialias/soroban-abacus-flashcards/issues/2) [#3](https://github.com/antialias/soroban-abacus-flashcards/issues/3)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** add 'math-sprint' to settings endpoint validation ([d790e5e](https://github.com/antialias/soroban-abacus-flashcards/commit/d790e5e278f81686077dbe3ef4adca49574ae434)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
|
||||
* **db:** add 'math-sprint' to database schema enums ([7b112a9](https://github.com/antialias/soroban-abacus-flashcards/commit/7b112a98babe782d4c254ef18a0295e7cbf8fefa)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add architecture quality audit [#2](https://github.com/antialias/soroban-abacus-flashcards/issues/2) ([5b91b71](https://github.com/antialias/soroban-abacus-flashcards/commit/5b91b710782dc450405583bc196e5156a296d0df))
|
||||
|
||||
## [3.22.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.2...v3.22.3) (2025-10-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **number-guesser:** add turn indicators, error feedback, and fix player ordering ([9f62623](https://github.com/antialias/soroban-abacus-flashcards/commit/9f626236845493ef68e1b3626e80efa35637b449))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **arcade:** update docs for unified validator registry ([6f6cb14](https://github.com/antialias/soroban-abacus-flashcards/commit/6f6cb14650ba3636a7e2b036e2a2a9410492e7c3))
|
||||
|
||||
## [3.22.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.1...v3.22.2) (2025-10-16)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** create unified validator registry to fix dual registration ([f775fc5](https://github.com/antialias/soroban-abacus-flashcards/commit/f775fc55e50af0c3a29b3e00fc722e7d7ce90212)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
|
||||
|
||||
## [3.22.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.22.0...v3.22.1) (2025-10-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** add Number Guesser to game config helpers ([7d1a351](https://github.com/antialias/soroban-abacus-flashcards/commit/7d1a351ed6a1442ae34f6b75d46039bfa77a921b))
|
||||
* **nav:** update types for registry games with nullable gameName ([a51e539](https://github.com/antialias/soroban-abacus-flashcards/commit/a51e539d023681daf639ec104e79079c8ceec98e))
|
||||
|
||||
## [3.22.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.21.0...v3.22.0) (2025-10-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** add Number Guesser demo game with plugin architecture ([0e3c058](https://github.com/antialias/soroban-abacus-flashcards/commit/0e3c0587073a69574a50f05c467f2499296012bf))
|
||||
|
||||
## [3.21.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.20.0...v3.21.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** add modular game SDK and registry system ([de30bec](https://github.com/antialias/soroban-abacus-flashcards/commit/de30bec47923565fe5d1d5a6f719f3fc4e9d1509))
|
||||
|
||||
## [3.20.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.19.0...v3.20.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* adjust tier probabilities for more abacus flavor ([49219e3](https://github.com/antialias/soroban-abacus-flashcards/commit/49219e34cde32736155a11929d10581e783cba69))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* use per-word-type tier selection for name generation ([499ee52](https://github.com/antialias/soroban-abacus-flashcards/commit/499ee525a835249b439044cf602bf9f0ff322cec))
|
||||
|
||||
## [3.19.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.1...v3.19.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement avatar-themed name generation with probabilistic mixing ([76a8472](https://github.com/antialias/soroban-abacus-flashcards/commit/76a8472f12d251071b97f2288f62f0b358576232))
|
||||
|
||||
## [3.18.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.18.0...v3.18.1) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** prevent empty update in settings API when only gameConfig changes ([ffb626f](https://github.com/antialias/soroban-abacus-flashcards/commit/ffb626f4038fd32d0f40dba8d83ae4d881d698d0))
|
||||
|
||||
## [3.18.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.14...v3.18.0) (2025-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add drizzle migration for room_game_configs table ([3bae00b](https://github.com/antialias/soroban-abacus-flashcards/commit/3bae00b9a9dc925039a02fe07d036a2fc5e0fb79))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* document manual migration of room_game_configs table ([ff79140](https://github.com/antialias/soroban-abacus-flashcards/commit/ff791409cf4bae1a5df43eb974eacbc7612d8eec))
|
||||
|
||||
## [3.17.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.13...v3.17.14) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** resolve TypeScript errors in game config helpers ([04c9944](https://github.com/antialias/soroban-abacus-flashcards/commit/04c9944f2ed1025f5a4ece61761889edd08cc60d))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **arcade:** update GAME_SETTINGS_PERSISTENCE.md for new schema ([260bdc2](https://github.com/antialias/soroban-abacus-flashcards/commit/260bdc2e9d458cb42a96d3ed36a18134260b4520))
|
||||
|
||||
## [3.17.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.12...v3.17.13) (2025-10-15)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** migrate game settings to normalized database schema ([1bd7354](https://github.com/antialias/soroban-abacus-flashcards/commit/1bd73544df6d62416961eea0b358955aaf82b79d))
|
||||
|
||||
## [3.17.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.11...v3.17.12) (2025-10-15)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** remove non-productive debug logging from memory-quiz ([38e554e](https://github.com/antialias/soroban-abacus-flashcards/commit/38e554e6ea0386e48798338dd938e50ba73d5576))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **arcade:** document game settings persistence architecture ([8f8f112](https://github.com/antialias/soroban-abacus-flashcards/commit/8f8f112de222e40901d4b3168fa751d233337e4b))
|
||||
|
||||
## [3.17.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.10...v3.17.11) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **memory-quiz:** fix playMode persistence by updating validator ([de0efd5](https://github.com/antialias/soroban-abacus-flashcards/commit/de0efd59321ec779cddb900724035884290419b7))
|
||||
|
||||
## [3.17.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.9...v3.17.10) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **memory-quiz:** persist playMode setting across game switches ([487ca7f](https://github.com/antialias/soroban-abacus-flashcards/commit/487ca7fba62e370c85bc3779ca8a96eb2c2cc3e3))
|
||||
|
||||
## [3.17.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.8...v3.17.9) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** read nested gameConfig correctly when creating sessions ([94ef392](https://github.com/antialias/soroban-abacus-flashcards/commit/94ef39234d362b82e032cb69d3561b9fcb436eaf))
|
||||
|
||||
## [3.17.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.7...v3.17.8) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** preserve game settings when returning to game selection ([0ee7739](https://github.com/antialias/soroban-abacus-flashcards/commit/0ee7739091d60580d2f98cfe288b8586b03348f3))
|
||||
|
||||
## [3.17.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.6...v3.17.7) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** prevent gameConfig from being overwritten when switching games ([a89d3a9](https://github.com/antialias/soroban-abacus-flashcards/commit/a89d3a970137471e2652de992c45370dbb97416d))
|
||||
|
||||
## [3.17.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.5...v3.17.6) (2025-10-15)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **logging:** use JSON.stringify for all object logging ([c33698c](https://github.com/antialias/soroban-abacus-flashcards/commit/c33698ce52ebdc18ce3a0d856f9241c7389ed651))
|
||||
|
||||
## [3.17.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.4...v3.17.5) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** implement settings persistence for matching game ([08fe432](https://github.com/antialias/soroban-abacus-flashcards/commit/08fe4326a6a7c484b9058a241f4ff79b3fb5125f))
|
||||
|
||||
## [3.17.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.3...v3.17.4) (2025-10-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **matching:** add settings persistence to matching game ([00dcb87](https://github.com/antialias/soroban-abacus-flashcards/commit/00dcb872b7e70bdb7de301b56fe42195e6ee923f))
|
||||
|
||||
## [3.17.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.17.2...v3.17.3) (2025-10-15)
|
||||
|
||||
|
||||
|
||||
@@ -108,3 +108,31 @@ npm run check # Biome check (format + lint + organize imports)
|
||||
- Lint checks
|
||||
|
||||
**Status:** Known issue, does not block development or deployment.
|
||||
|
||||
## Game Settings Persistence
|
||||
|
||||
When working on arcade room game settings, refer to:
|
||||
|
||||
- **`.claude/GAME_SETTINGS_PERSISTENCE.md`** - Complete architecture documentation
|
||||
- How settings are stored (nested by game name)
|
||||
- Three critical systems that must stay in sync
|
||||
- Common bugs and their solutions
|
||||
- Debugging checklist
|
||||
- Step-by-step guide for adding new settings
|
||||
|
||||
- **`.claude/GAME_SETTINGS_REFACTORING.md`** - Recommended improvements
|
||||
- Shared config types to prevent inconsistencies
|
||||
- Helper functions to reduce duplication
|
||||
- Type-safe validation
|
||||
- Migration strategy
|
||||
|
||||
**Quick Reference:**
|
||||
|
||||
Settings are stored as: `gameConfig[gameName][setting]`
|
||||
|
||||
Three places must handle settings correctly:
|
||||
1. **Provider** (`Room{Game}Provider.tsx`) - Merges saved config with defaults
|
||||
2. **Socket Server** (`socket-server.ts`) - Creates session from saved config
|
||||
3. **Validator** (`{Game}Validator.ts`) - `getInitialState()` must accept ALL settings
|
||||
|
||||
If a setting doesn't persist, check all three locations.
|
||||
|
||||
421
apps/web/.claude/GAME_SETTINGS_PERSISTENCE.md
Normal file
421
apps/web/.claude/GAME_SETTINGS_PERSISTENCE.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Game Settings Persistence Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Game settings in room mode persist across game switches using a normalized database schema. Settings for each game are stored in a dedicated `room_game_configs` table with one row per game per room.
|
||||
|
||||
## Database Schema
|
||||
|
||||
Settings are stored in the `room_game_configs` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE room_game_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL REFERENCES arcade_rooms(id) ON DELETE CASCADE,
|
||||
game_name TEXT NOT NULL CHECK(game_name IN ('matching', 'memory-quiz', 'complement-race')),
|
||||
config TEXT NOT NULL, -- JSON
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(room_id, game_name)
|
||||
);
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Type-safe config access with shared types
|
||||
- ✅ Smaller rows (only configs for games that have been used)
|
||||
- ✅ Easier updates (single row vs entire JSON blob)
|
||||
- ✅ Better concurrency (no lock contention between games)
|
||||
- ✅ Foundation for per-game audit trail
|
||||
- ✅ Can query/index individual game settings
|
||||
|
||||
**Example Row:**
|
||||
```json
|
||||
{
|
||||
"id": "clxyz123",
|
||||
"room_id": "room_abc",
|
||||
"game_name": "memory-quiz",
|
||||
"config": {
|
||||
"selectedCount": 8,
|
||||
"displayTime": 3.0,
|
||||
"selectedDifficulty": "medium",
|
||||
"playMode": "competitive"
|
||||
},
|
||||
"created_at": 1234567890,
|
||||
"updated_at": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
## Shared Type System
|
||||
|
||||
All game configs are defined in `src/lib/arcade/game-configs.ts`:
|
||||
|
||||
```typescript
|
||||
// Shared config types (single source of truth)
|
||||
export interface MatchingGameConfig {
|
||||
gameType: 'abacus-numeral' | 'complement-pairs'
|
||||
difficulty: number
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MemoryQuizGameConfig {
|
||||
selectedCount: 2 | 5 | 8 | 12 | 15
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
playMode: 'cooperative' | 'competitive'
|
||||
}
|
||||
|
||||
// Default configs
|
||||
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
|
||||
selectedCount: 5,
|
||||
displayTime: 2.0,
|
||||
selectedDifficulty: 'easy',
|
||||
playMode: 'cooperative',
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Matters:**
|
||||
- TypeScript enforces that validators, helpers, and API routes all use the same types
|
||||
- Adding a new setting requires changes in only ONE place (the type definition)
|
||||
- Impossible to forget a setting or use wrong type
|
||||
|
||||
## Critical Components
|
||||
|
||||
Settings persistence requires coordination between FOUR systems:
|
||||
|
||||
### 1. Helper Functions
|
||||
**Location:** `src/lib/arcade/game-config-helpers.ts`
|
||||
|
||||
**Responsibilities:**
|
||||
- Read/write game configs from `room_game_configs` table
|
||||
- Provide type-safe access with automatic defaults
|
||||
- Validate configs at runtime
|
||||
|
||||
**Key Functions:**
|
||||
```typescript
|
||||
// Get config with defaults (type-safe)
|
||||
const config = await getGameConfig(roomId, 'memory-quiz')
|
||||
// Returns: MemoryQuizGameConfig
|
||||
|
||||
// Set/update config (upsert)
|
||||
await setGameConfig(roomId, 'memory-quiz', {
|
||||
playMode: 'competitive',
|
||||
selectedCount: 8,
|
||||
})
|
||||
|
||||
// Get all game configs for a room
|
||||
const allConfigs = await getAllGameConfigs(roomId)
|
||||
// Returns: { matching?: MatchingGameConfig, 'memory-quiz'?: MemoryQuizGameConfig }
|
||||
```
|
||||
|
||||
### 2. API Routes
|
||||
**Location:**
|
||||
- `src/app/api/arcade/rooms/current/route.ts` (read)
|
||||
- `src/app/api/arcade/rooms/[roomId]/settings/route.ts` (write)
|
||||
|
||||
**Responsibilities:**
|
||||
- Aggregate game configs from database
|
||||
- Return them to client in `room.gameConfig`
|
||||
- Write config updates to `room_game_configs` table
|
||||
|
||||
**Read Example:** `GET /api/arcade/rooms/current`
|
||||
```typescript
|
||||
const gameConfig = await getAllGameConfigs(roomId)
|
||||
|
||||
return NextResponse.json({
|
||||
room: {
|
||||
...room,
|
||||
gameConfig, // Aggregated from room_game_configs table
|
||||
},
|
||||
members,
|
||||
memberPlayers,
|
||||
})
|
||||
```
|
||||
|
||||
**Write Example:** `PATCH /api/arcade/rooms/[roomId]/settings`
|
||||
```typescript
|
||||
if (body.gameConfig !== undefined) {
|
||||
// body.gameConfig: { matching: {...}, memory-quiz: {...} }
|
||||
for (const [gameName, config] of Object.entries(body.gameConfig)) {
|
||||
await setGameConfig(roomId, gameName, config)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Socket Server (Session Creation)
|
||||
**Location:** `src/socket-server.ts:70-90`
|
||||
|
||||
**Responsibilities:**
|
||||
- Create initial arcade session when user joins room
|
||||
- Read saved settings using `getGameConfig()` helper
|
||||
- Pass settings to validator's `getInitialState()`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const room = await getRoomById(roomId)
|
||||
const validator = getValidator(room.gameName as GameName)
|
||||
|
||||
// Get config from database (type-safe, includes defaults)
|
||||
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
|
||||
|
||||
// Pass to validator (types match automatically)
|
||||
const initialState = validator.getInitialState(gameConfig)
|
||||
|
||||
await createArcadeSession({ userId, gameName, initialState, roomId })
|
||||
```
|
||||
|
||||
**Key Point:** No more manual config extraction or default fallbacks!
|
||||
|
||||
### 4. Game Validators
|
||||
**Location:** `src/lib/arcade/validation/*Validator.ts`
|
||||
|
||||
**Responsibilities:**
|
||||
- Define `getInitialState()` method with shared config type
|
||||
- Create initial game state from config
|
||||
- TypeScript enforces all settings are handled
|
||||
|
||||
**Example:** `MemoryQuizGameValidator.ts`
|
||||
```typescript
|
||||
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
|
||||
|
||||
class MemoryQuizGameValidator {
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
return {
|
||||
selectedCount: config.selectedCount,
|
||||
displayTime: config.displayTime,
|
||||
selectedDifficulty: config.selectedDifficulty,
|
||||
playMode: config.playMode, // TypeScript ensures this field exists!
|
||||
// ...other state
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Client Providers (Unchanged)
|
||||
**Location:** `src/app/arcade/{game}/context/Room{Game}Provider.tsx`
|
||||
|
||||
**Responsibilities:**
|
||||
- Read settings from `roomData.gameConfig[gameName]`
|
||||
- Merge with `initialState` defaults
|
||||
- Works transparently with new backend structure
|
||||
|
||||
**Example:** `RoomMemoryQuizProvider.tsx:211-233`
|
||||
```typescript
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any>
|
||||
const savedConfig = gameConfig?.['memory-quiz']
|
||||
|
||||
if (!savedConfig) {
|
||||
return initialState
|
||||
}
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
|
||||
displayTime: savedConfig.displayTime ?? initialState.displayTime,
|
||||
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
|
||||
playMode: savedConfig.playMode ?? initialState.playMode,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
```
|
||||
|
||||
## Common Bugs and Solutions
|
||||
|
||||
### Bug #1: Settings Not Persisting
|
||||
**Symptom:** Settings reset to defaults after game switch
|
||||
|
||||
**Root Cause:** One of the following:
|
||||
1. API route not writing to `room_game_configs` table
|
||||
2. Helper function not being used correctly
|
||||
3. Validator not using shared config type
|
||||
|
||||
**Solution:** Verify the data flow:
|
||||
```bash
|
||||
# 1. Check database write
|
||||
SELECT * FROM room_game_configs WHERE room_id = '...';
|
||||
|
||||
# 2. Check API logs for setGameConfig() calls
|
||||
# Look for: [GameConfig] Updated {game} config for room {roomId}
|
||||
|
||||
# 3. Check socket server logs for getGameConfig() calls
|
||||
# Look for: [join-arcade-session] Got validator for: {game}
|
||||
|
||||
# 4. Check validator signature matches shared type
|
||||
# MemoryQuizGameValidator.getInitialState(config: MemoryQuizGameConfig)
|
||||
```
|
||||
|
||||
### Bug #2: TypeScript Errors About Missing Fields
|
||||
**Symptom:** `Property '{field}' is missing in type ...`
|
||||
|
||||
**Root Cause:** Validator's `getInitialState()` signature doesn't match shared config type
|
||||
|
||||
**Solution:** Import and use the shared config type:
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
getInitialState(config: {
|
||||
selectedCount: number
|
||||
displayTime: number
|
||||
// Missing playMode!
|
||||
}): SorobanQuizState
|
||||
|
||||
// ✅ CORRECT
|
||||
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
|
||||
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState
|
||||
```
|
||||
|
||||
### Bug #3: Settings Wiped When Returning to Game Selection
|
||||
**Symptom:** Settings reset when going back to game selection
|
||||
|
||||
**Root Cause:** Sending `gameConfig: null` in PATCH request
|
||||
|
||||
**Solution:** Only send `gameName: null`, don't touch gameConfig:
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
body: JSON.stringify({ gameName: null, gameConfig: null })
|
||||
|
||||
// ✅ CORRECT
|
||||
body: JSON.stringify({ gameName: null })
|
||||
```
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
When a setting doesn't persist:
|
||||
|
||||
1. **Check database:**
|
||||
- Query `room_game_configs` table
|
||||
- Verify row exists for room + game
|
||||
- Verify JSON config has correct structure
|
||||
|
||||
2. **Check API write path:**
|
||||
- `/api/arcade/rooms/[roomId]/settings` logs
|
||||
- Verify `setGameConfig()` is called
|
||||
- Check for errors in console
|
||||
|
||||
3. **Check API read path:**
|
||||
- `/api/arcade/rooms/current` logs
|
||||
- Verify `getAllGameConfigs()` returns data
|
||||
- Check `room.gameConfig` in response
|
||||
|
||||
4. **Check socket server:**
|
||||
- `socket-server.ts` logs for `getGameConfig()`
|
||||
- Verify config passed to validator
|
||||
- Check `initialState` has correct values
|
||||
|
||||
5. **Check validator:**
|
||||
- Signature uses shared config type
|
||||
- All config fields used (not hardcoded)
|
||||
- Add logging to see received config
|
||||
|
||||
## Adding a New Setting
|
||||
|
||||
To add a new setting to an existing game:
|
||||
|
||||
1. **Update the shared config type** (`game-configs.ts`):
|
||||
```typescript
|
||||
export interface MemoryQuizGameConfig {
|
||||
selectedCount: 2 | 5 | 8 | 12 | 15
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
playMode: 'cooperative' | 'competitive'
|
||||
newSetting: string // ← Add here
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
|
||||
selectedCount: 5,
|
||||
displayTime: 2.0,
|
||||
selectedDifficulty: 'easy',
|
||||
playMode: 'cooperative',
|
||||
newSetting: 'default', // ← Add default
|
||||
}
|
||||
```
|
||||
|
||||
2. **TypeScript will now enforce:**
|
||||
- ✅ Validator must accept `newSetting` (compile error if missing)
|
||||
- ✅ Helper functions will include it automatically
|
||||
- ✅ Client providers will need to handle it
|
||||
|
||||
3. **Update the validator** (`*Validator.ts`):
|
||||
```typescript
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
return {
|
||||
// ...
|
||||
newSetting: config.newSetting, // TypeScript enforces this
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Update the UI** to expose the new setting
|
||||
- No changes needed to API routes or helper functions!
|
||||
- They automatically handle any field in the config type
|
||||
|
||||
## Testing Settings Persistence
|
||||
|
||||
Manual test procedure:
|
||||
|
||||
1. Join a room and select a game
|
||||
2. Change each setting to a non-default value
|
||||
3. Go back to game selection (gameName becomes null)
|
||||
4. Select the same game again
|
||||
5. **Verify ALL settings retained their values**
|
||||
|
||||
**Expected behavior:** All settings should be exactly as you left them.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
**Old Schema:**
|
||||
- Settings stored in `arcade_rooms.game_config` JSON column
|
||||
- Config stored directly for currently selected game only
|
||||
- Config lost when switching games
|
||||
|
||||
**New Schema:**
|
||||
- Settings stored in `room_game_configs` table
|
||||
- One row per game per room
|
||||
- Unique constraint on (room_id, game_name)
|
||||
- Configs persist when switching between games
|
||||
|
||||
**Migration:** See `.claude/MANUAL_MIGRATION_0011.md` for complete details
|
||||
|
||||
**Summary:**
|
||||
- Manual migration applied on 2025-10-15
|
||||
- Created `room_game_configs` table via sqlite3 CLI
|
||||
- Migrated 6000 existing configs (5991 matching, 9 memory-quiz)
|
||||
- Table created directly instead of through drizzle migration system
|
||||
|
||||
**Rollback Plan:**
|
||||
- Old `game_config` column still exists in `arcade_rooms` table
|
||||
- Old data preserved (was only read, not deleted)
|
||||
- Can revert to reading from old column if needed
|
||||
- New table can be dropped: `DROP TABLE room_game_configs`
|
||||
|
||||
## Architecture Benefits
|
||||
|
||||
**Type Safety:**
|
||||
- Single source of truth for config types
|
||||
- TypeScript enforces consistency everywhere
|
||||
- Impossible to forget a setting
|
||||
|
||||
**DRY (Don't Repeat Yourself):**
|
||||
- No duplicated default values
|
||||
- No manual config extraction
|
||||
- No manual merging with defaults
|
||||
|
||||
**Maintainability:**
|
||||
- Adding a setting touches fewer places
|
||||
- Clear separation of concerns
|
||||
- Easier to trace data flow
|
||||
|
||||
**Performance:**
|
||||
- Smaller database rows
|
||||
- Better query performance
|
||||
- Less network payload
|
||||
|
||||
**Correctness:**
|
||||
- Runtime validation available
|
||||
- Database constraints (unique index)
|
||||
- Impossible to create duplicate configs
|
||||
479
apps/web/.claude/GAME_SETTINGS_REFACTORING.md
Normal file
479
apps/web/.claude/GAME_SETTINGS_REFACTORING.md
Normal file
@@ -0,0 +1,479 @@
|
||||
# Game Settings Persistence - Refactoring Recommendations
|
||||
|
||||
## Current Pain Points
|
||||
|
||||
1. **Type safety is weak** - Easy to forget to add a setting in one place
|
||||
2. **Duplication** - Config reading logic duplicated in socket-server.ts for each game
|
||||
3. **Manual synchronization** - Have to manually keep validator signature, provider, and socket server in sync
|
||||
4. **Error-prone** - Easy to hardcode values or forget to read from config
|
||||
|
||||
## Recommended Refactorings
|
||||
|
||||
### 1. Create Shared Config Types (HIGHEST PRIORITY)
|
||||
|
||||
**Problem:** Each game's settings are defined in multiple places with no type enforcement
|
||||
|
||||
**Solution:** Define a single source of truth for each game's config
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/game-configs.ts
|
||||
|
||||
export interface MatchingGameConfig {
|
||||
gameType: 'abacus-numeral' | 'complement-pairs'
|
||||
difficulty: number
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MemoryQuizGameConfig {
|
||||
selectedCount: 2 | 5 | 8 | 12 | 15
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
playMode: 'cooperative' | 'competitive'
|
||||
}
|
||||
|
||||
export interface ComplementRaceGameConfig {
|
||||
// ... future settings
|
||||
}
|
||||
|
||||
export interface RoomGameConfig {
|
||||
matching?: MatchingGameConfig
|
||||
'memory-quiz'?: MemoryQuizGameConfig
|
||||
'complement-race'?: ComplementRaceGameConfig
|
||||
}
|
||||
|
||||
// Default configs
|
||||
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
|
||||
selectedCount: 5,
|
||||
displayTime: 2.0,
|
||||
selectedDifficulty: 'easy',
|
||||
playMode: 'cooperative',
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single source of truth for each game's settings
|
||||
- TypeScript enforces consistency across codebase
|
||||
- Easy to see what settings each game has
|
||||
|
||||
### 2. Create Config Helper Functions
|
||||
|
||||
**Problem:** Config reading logic is duplicated and error-prone
|
||||
|
||||
**Solution:** Centralized helper functions with type safety
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/game-config-helpers.ts
|
||||
|
||||
import type { GameName } from './validation'
|
||||
import type { RoomGameConfig, MatchingGameConfig, MemoryQuizGameConfig } from './game-configs'
|
||||
import { DEFAULT_MATCHING_CONFIG, DEFAULT_MEMORY_QUIZ_CONFIG } from './game-configs'
|
||||
|
||||
/**
|
||||
* Get game-specific config from room's gameConfig with defaults
|
||||
*/
|
||||
export function getGameConfig<T extends GameName>(
|
||||
roomGameConfig: RoomGameConfig | null | undefined,
|
||||
gameName: T
|
||||
): T extends 'matching'
|
||||
? MatchingGameConfig
|
||||
: T extends 'memory-quiz'
|
||||
? MemoryQuizGameConfig
|
||||
: never {
|
||||
|
||||
if (!roomGameConfig) {
|
||||
return getDefaultGameConfig(gameName) as any
|
||||
}
|
||||
|
||||
const savedConfig = roomGameConfig[gameName]
|
||||
if (!savedConfig) {
|
||||
return getDefaultGameConfig(gameName) as any
|
||||
}
|
||||
|
||||
// Merge saved config with defaults to handle missing fields
|
||||
const defaults = getDefaultGameConfig(gameName)
|
||||
return { ...defaults, ...savedConfig } as any
|
||||
}
|
||||
|
||||
function getDefaultGameConfig(gameName: GameName) {
|
||||
switch (gameName) {
|
||||
case 'matching':
|
||||
return DEFAULT_MATCHING_CONFIG
|
||||
case 'memory-quiz':
|
||||
return DEFAULT_MEMORY_QUIZ_CONFIG
|
||||
case 'complement-race':
|
||||
// return DEFAULT_COMPLEMENT_RACE_CONFIG
|
||||
throw new Error('complement-race config not implemented')
|
||||
default:
|
||||
throw new Error(`Unknown game: ${gameName}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific game's config in the room's gameConfig
|
||||
*/
|
||||
export function updateGameConfig<T extends GameName>(
|
||||
currentRoomConfig: RoomGameConfig | null | undefined,
|
||||
gameName: T,
|
||||
updates: Partial<T extends 'matching' ? MatchingGameConfig : T extends 'memory-quiz' ? MemoryQuizGameConfig : never>
|
||||
): RoomGameConfig {
|
||||
const current = currentRoomConfig || {}
|
||||
const gameConfig = current[gameName] || getDefaultGameConfig(gameName)
|
||||
|
||||
return {
|
||||
...current,
|
||||
[gameName]: {
|
||||
...gameConfig,
|
||||
...updates,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in socket-server.ts:**
|
||||
```typescript
|
||||
// BEFORE (error-prone, duplicated)
|
||||
const memoryQuizConfig = (room.gameConfig as any)?.['memory-quiz'] || {}
|
||||
initialState = validator.getInitialState({
|
||||
selectedCount: memoryQuizConfig.selectedCount || 5,
|
||||
displayTime: memoryQuizConfig.displayTime || 2.0,
|
||||
selectedDifficulty: memoryQuizConfig.selectedDifficulty || 'easy',
|
||||
playMode: memoryQuizConfig.playMode || 'cooperative',
|
||||
})
|
||||
|
||||
// AFTER (type-safe, concise)
|
||||
const config = getGameConfig(room.gameConfig, 'memory-quiz')
|
||||
initialState = validator.getInitialState(config)
|
||||
```
|
||||
|
||||
**Usage in RoomMemoryQuizProvider.tsx:**
|
||||
```typescript
|
||||
// BEFORE (verbose, error-prone)
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any>
|
||||
const savedConfig = gameConfig?.['memory-quiz']
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
selectedCount: savedConfig?.selectedCount ?? initialState.selectedCount,
|
||||
displayTime: savedConfig?.displayTime ?? initialState.displayTime,
|
||||
selectedDifficulty: savedConfig?.selectedDifficulty ?? initialState.selectedDifficulty,
|
||||
playMode: savedConfig?.playMode ?? initialState.playMode,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// AFTER (type-safe, concise)
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const config = getGameConfig(roomData?.gameConfig, 'memory-quiz')
|
||||
return {
|
||||
...initialState,
|
||||
...config, // Spread config directly - all settings included
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- No more manual property-by-property merging
|
||||
- Type-safe
|
||||
- Defaults handled automatically
|
||||
- Reusable across codebase
|
||||
|
||||
### 3. Enforce Validator Config Type from Game Config
|
||||
|
||||
**Problem:** Easy to forget to add a new setting to validator's `getInitialState()` signature
|
||||
|
||||
**Solution:** Make validator use the shared config type
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
|
||||
|
||||
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
|
||||
|
||||
export class MemoryQuizGameValidator {
|
||||
// BEFORE: Manual type definition
|
||||
// getInitialState(config: {
|
||||
// selectedCount: number
|
||||
// displayTime: number
|
||||
// selectedDifficulty: DifficultyLevel
|
||||
// playMode?: 'cooperative' | 'competitive'
|
||||
// }): SorobanQuizState
|
||||
|
||||
// AFTER: Use shared type
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
return {
|
||||
// ...
|
||||
selectedCount: config.selectedCount,
|
||||
displayTime: config.displayTime,
|
||||
selectedDifficulty: config.selectedDifficulty,
|
||||
playMode: config.playMode, // TypeScript ensures all fields are handled
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- If you add a setting to `MemoryQuizGameConfig`, TypeScript forces you to handle it
|
||||
- Impossible to forget a setting
|
||||
- Impossible to use wrong type
|
||||
|
||||
### 4. Add Exhaustiveness Checking
|
||||
|
||||
**Problem:** Easy to miss handling a setting field
|
||||
|
||||
**Solution:** Use TypeScript's exhaustiveness checking
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validation/MemoryQuizGameValidator.ts
|
||||
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
// Exhaustiveness check - ensures all config fields are used
|
||||
const _exhaustivenessCheck: Record<keyof MemoryQuizGameConfig, boolean> = {
|
||||
selectedCount: true,
|
||||
displayTime: true,
|
||||
selectedDifficulty: true,
|
||||
playMode: true,
|
||||
}
|
||||
|
||||
return {
|
||||
// ... use all config fields
|
||||
selectedCount: config.selectedCount,
|
||||
displayTime: config.displayTime,
|
||||
selectedDifficulty: config.selectedDifficulty,
|
||||
playMode: config.playMode,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you add a new field to `MemoryQuizGameConfig`, TypeScript will error on `_exhaustivenessCheck` until you add it.
|
||||
|
||||
### 5. Validate Config on Save
|
||||
|
||||
**Problem:** Invalid config can be saved to database
|
||||
|
||||
**Solution:** Add runtime validation
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/game-config-helpers.ts
|
||||
|
||||
export function validateGameConfig(
|
||||
gameName: GameName,
|
||||
config: any
|
||||
): config is MatchingGameConfig | MemoryQuizGameConfig {
|
||||
switch (gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
typeof config.gameType === 'string' &&
|
||||
['abacus-numeral', 'complement-pairs'].includes(config.gameType) &&
|
||||
typeof config.difficulty === 'number' &&
|
||||
config.difficulty > 0 &&
|
||||
typeof config.turnTimer === 'number' &&
|
||||
config.turnTimer > 0
|
||||
)
|
||||
|
||||
case 'memory-quiz':
|
||||
return (
|
||||
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
|
||||
typeof config.displayTime === 'number' &&
|
||||
config.displayTime > 0 &&
|
||||
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) &&
|
||||
['cooperative', 'competitive'].includes(config.playMode)
|
||||
)
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use in settings API:
|
||||
```typescript
|
||||
// src/app/api/arcade/rooms/[roomId]/settings/route.ts
|
||||
|
||||
if (body.gameConfig !== undefined) {
|
||||
if (!validateGameConfig(room.gameName, body.gameConfig[room.gameName])) {
|
||||
return NextResponse.json({ error: 'Invalid game config' }, { status: 400 })
|
||||
}
|
||||
updateData.gameConfig = body.gameConfig
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Refactoring: Separate Table for Game Configs
|
||||
|
||||
### Current Problem
|
||||
|
||||
All game configs are stored in a single JSON column in `arcade_rooms.gameConfig`:
|
||||
|
||||
```json
|
||||
{
|
||||
"matching": { "gameType": "...", "difficulty": 15 },
|
||||
"memory-quiz": { "selectedCount": 8, "playMode": "competitive" }
|
||||
}
|
||||
```
|
||||
|
||||
**Issues:**
|
||||
- No schema validation
|
||||
- Inefficient updates (read/parse/modify/serialize entire blob)
|
||||
- Grows without bounds as more games added
|
||||
- Can't query or index individual game settings
|
||||
- No audit trail
|
||||
- Potential concurrent update race conditions
|
||||
|
||||
### Recommended: Separate Table
|
||||
|
||||
Create `room_game_configs` table with one row per game per room:
|
||||
|
||||
```typescript
|
||||
// src/db/schema/room-game-configs.ts
|
||||
|
||||
export const roomGameConfigs = sqliteTable('room_game_configs', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
roomId: text('room_id')
|
||||
.notNull()
|
||||
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
config: text('config', { mode: 'json' }).notNull(), // Game-specific JSON
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
}, (table) => ({
|
||||
uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName),
|
||||
}))
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Smaller rows (only configs for games that have been used)
|
||||
- ✅ Easier updates (single row, not entire JSON blob)
|
||||
- ✅ Can track updatedAt per game
|
||||
- ✅ Better concurrency (no lock contention between games)
|
||||
- ✅ Foundation for future audit trail
|
||||
|
||||
**Migration Strategy:**
|
||||
1. Create new table
|
||||
2. Migrate existing data from `arcade_rooms.gameConfig`
|
||||
3. Update all config read/write code
|
||||
4. Deploy and test
|
||||
5. Drop old `gameConfig` column from `arcade_rooms`
|
||||
|
||||
See migration SQL below.
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Schema Migration (HIGHEST PRIORITY)
|
||||
1. **Create new table** - Add `room_game_configs` schema
|
||||
2. **Create migration** - SQL to migrate existing data
|
||||
3. **Update helper functions** - Adapt to new table structure
|
||||
4. **Update all read/write code** - Use new table
|
||||
5. **Test thoroughly** - Verify all settings persist correctly
|
||||
6. **Drop old column** - Remove `gameConfig` from `arcade_rooms`
|
||||
|
||||
### Phase 2: Type Safety (HIGH)
|
||||
1. **Create shared config types** (`game-configs.ts`) - Prevents type mismatches
|
||||
2. **Create helper functions** (`game-config-helpers.ts`) - Now queries new table
|
||||
3. **Update validators** to use shared types - Enforces consistency
|
||||
|
||||
### Phase 3: Compile-Time Safety (MEDIUM)
|
||||
1. **Add exhaustiveness checking** - Catches missing fields at compile time
|
||||
2. **Enforce validator config types** - Use shared types
|
||||
|
||||
### Phase 4: Runtime Safety (LOW)
|
||||
1. **Add runtime validation** - Prevents invalid data from being saved
|
||||
|
||||
## Detailed Migration SQL
|
||||
|
||||
```sql
|
||||
-- drizzle/migrations/XXXX_split_game_configs.sql
|
||||
|
||||
-- Create new table
|
||||
CREATE TABLE room_game_configs (
|
||||
id TEXT PRIMARY KEY,
|
||||
room_id TEXT NOT NULL REFERENCES arcade_rooms(id) ON DELETE CASCADE,
|
||||
game_name TEXT NOT NULL CHECK(game_name IN ('matching', 'memory-quiz', 'complement-race')),
|
||||
config TEXT NOT NULL, -- JSON
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX room_game_idx ON room_game_configs(room_id, game_name);
|
||||
|
||||
-- Migrate existing 'matching' configs
|
||||
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
id,
|
||||
'matching',
|
||||
json_extract(game_config, '$.matching'),
|
||||
created_at,
|
||||
last_activity
|
||||
FROM arcade_rooms
|
||||
WHERE json_extract(game_config, '$.matching') IS NOT NULL;
|
||||
|
||||
-- Migrate existing 'memory-quiz' configs
|
||||
INSERT INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
id,
|
||||
'memory-quiz',
|
||||
json_extract(game_config, '$."memory-quiz"'),
|
||||
created_at,
|
||||
last_activity
|
||||
FROM arcade_rooms
|
||||
WHERE json_extract(game_config, '$."memory-quiz"') IS NOT NULL;
|
||||
|
||||
-- After testing and verifying all works:
|
||||
-- ALTER TABLE arcade_rooms DROP COLUMN game_config;
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Step-by-Step with Checkpoints
|
||||
|
||||
**Checkpoint 1: Schema & Migration**
|
||||
1. Create `src/db/schema/room-game-configs.ts`
|
||||
2. Export from `src/db/schema/index.ts`
|
||||
3. Generate and apply migration
|
||||
4. Verify data migrated correctly
|
||||
|
||||
**Checkpoint 2: Helper Functions**
|
||||
1. Create shared config types in `src/lib/arcade/game-configs.ts`
|
||||
2. Create helper functions in `src/lib/arcade/game-config-helpers.ts`
|
||||
3. Add unit tests for helpers
|
||||
|
||||
**Checkpoint 3: Update Config Reads**
|
||||
1. Update socket-server.ts to read from new table
|
||||
2. Update RoomMemoryQuizProvider to read from new table
|
||||
3. Update RoomMemoryPairsProvider to read from new table
|
||||
4. Test: Load room and verify settings appear
|
||||
|
||||
**Checkpoint 4: Update Config Writes**
|
||||
1. Update useRoomData.ts updateGameConfig to write to new table
|
||||
2. Update settings API to write to new table
|
||||
3. Test: Change settings and verify they persist
|
||||
|
||||
**Checkpoint 5: Update Validators**
|
||||
1. Update validators to use shared config types
|
||||
2. Test: All games work correctly
|
||||
|
||||
**Checkpoint 6: Cleanup**
|
||||
1. Remove old gameConfig column references
|
||||
2. Drop gameConfig column from arcade_rooms table
|
||||
3. Final testing of all games
|
||||
|
||||
## Benefits Summary
|
||||
|
||||
- **Type Safety:** TypeScript enforces consistency across all systems
|
||||
- **DRY:** Config reading logic not duplicated
|
||||
- **Maintainability:** Adding a setting requires changes in fewer places
|
||||
- **Correctness:** Impossible to forget a setting or use wrong type
|
||||
- **Debugging:** Centralized config logic easier to trace
|
||||
- **Testing:** Can test config helpers in isolation
|
||||
120
apps/web/.claude/MANUAL_MIGRATION_0011.md
Normal file
120
apps/web/.claude/MANUAL_MIGRATION_0011.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Manual Migration: room_game_configs Table
|
||||
|
||||
**Date:** 2025-10-15
|
||||
**Migration:** Create `room_game_configs` table (equivalent to drizzle migration 0011)
|
||||
|
||||
## Context
|
||||
|
||||
This migration was applied manually using sqlite3 CLI instead of through drizzle-kit's migration system, because the interactive prompt from `drizzle-kit push` cannot be automated in the deployment pipeline.
|
||||
|
||||
## What Was Done
|
||||
|
||||
### 1. Created Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS room_game_configs (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
game_name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (room_id) REFERENCES arcade_rooms(id) ON UPDATE NO ACTION ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Created Index
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS room_game_idx ON room_game_configs (room_id, game_name);
|
||||
```
|
||||
|
||||
### 3. Migrated Existing Data
|
||||
|
||||
Migrated 6000 game configs from the old `arcade_rooms.game_config` column to the new normalized table:
|
||||
|
||||
```sql
|
||||
INSERT OR IGNORE INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))) as id,
|
||||
id as room_id,
|
||||
game_name,
|
||||
game_config as config,
|
||||
created_at,
|
||||
last_activity as updated_at
|
||||
FROM arcade_rooms
|
||||
WHERE game_config IS NOT NULL
|
||||
AND game_name IS NOT NULL;
|
||||
```
|
||||
|
||||
**Results:**
|
||||
- 5991 matching game configs migrated
|
||||
- 9 memory-quiz game configs migrated
|
||||
- Total: 6000 configs
|
||||
|
||||
## Old vs New Schema
|
||||
|
||||
**Old Schema:**
|
||||
- `arcade_rooms.game_config` (TEXT/JSON) - stored config for currently selected game only
|
||||
- Config was lost when switching games
|
||||
|
||||
**New Schema:**
|
||||
- `room_game_configs` table - one row per game per room
|
||||
- Unique constraint on (room_id, game_name)
|
||||
- Configs persist when switching between games
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Verify table exists
|
||||
sqlite3 data/sqlite.db ".tables" | grep room_game_configs
|
||||
|
||||
# Verify schema
|
||||
sqlite3 data/sqlite.db ".schema room_game_configs"
|
||||
|
||||
# Count migrated data
|
||||
sqlite3 data/sqlite.db "SELECT COUNT(*) FROM room_game_configs;"
|
||||
# Expected: 6000
|
||||
|
||||
# Check data distribution
|
||||
sqlite3 data/sqlite.db "SELECT game_name, COUNT(*) FROM room_game_configs GROUP BY game_name;"
|
||||
# Expected: matching: 5991, memory-quiz: 9
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
This migration supports the refactoring documented in:
|
||||
- `.claude/GAME_SETTINGS_PERSISTENCE.md` - Architecture documentation
|
||||
- `src/lib/arcade/game-configs.ts` - Shared config types
|
||||
- `src/lib/arcade/game-config-helpers.ts` - Database access helpers
|
||||
|
||||
## Note on Drizzle Migration Tracking
|
||||
|
||||
This migration was NOT recorded in drizzle's `__drizzle_migrations` table because it was applied manually. This is acceptable because:
|
||||
|
||||
1. The schema definition exists in code (`src/db/schema/room-game-configs.ts`)
|
||||
2. The table was created with the exact schema drizzle would generate
|
||||
3. Future schema changes will go through proper drizzle migrations
|
||||
4. The `arcade_rooms.game_config` column is preserved for rollback safety
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, the old system can be restored by:
|
||||
|
||||
1. Reverting code changes (game-config-helpers.ts, API routes, validators)
|
||||
2. The old `game_config` column still exists in `arcade_rooms` table
|
||||
3. Data is still there (we only read from it, didn't delete it)
|
||||
|
||||
The new `room_game_configs` table can be dropped if needed:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS room_game_configs;
|
||||
```
|
||||
|
||||
## Future Work
|
||||
|
||||
Once this migration is stable in production:
|
||||
|
||||
1. Consider dropping the old `arcade_rooms.game_config` column
|
||||
2. Add this migration to drizzle's migration journal for tracking (optional)
|
||||
3. Monitor for any issues with settings persistence
|
||||
@@ -70,7 +70,15 @@
|
||||
"Bash(lsof:*)",
|
||||
"Bash(killall:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(git restore:*)"
|
||||
"Bash(git restore:*)",
|
||||
"Bash(timeout 10 npm run dev:*)",
|
||||
"Bash(timeout 30 npm run dev)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(for i in {1..30})",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')",
|
||||
"Bash(tsc:*)",
|
||||
"Bash(tsc-alias:*)",
|
||||
"Bash(npx tsc-alias:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
451
apps/web/docs/AUDIT_2_ARCHITECTURE_QUALITY.md
Normal file
451
apps/web/docs/AUDIT_2_ARCHITECTURE_QUALITY.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Architecture Quality Audit #2
|
||||
|
||||
**Date**: 2025-10-16
|
||||
**Context**: After implementing Number Guesser (turn-based) and starting Math Sprint (free-for-all)
|
||||
**Goal**: Assess if the system is truly modular or if there's too much boilerplate
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Status**: ⚠️ **Good Foundation, But Boilerplate Issues**
|
||||
|
||||
The unified validator registry successfully solved the dual registration problem. However, implementing a second game revealed **significant boilerplate** and **database schema coupling** that violate the modular architecture goals.
|
||||
|
||||
**Grade**: **B-** (Down from B+ after implementation testing)
|
||||
|
||||
---
|
||||
|
||||
## Issues Found
|
||||
|
||||
### 🚨 Issue #1: Database Schema Coupling (CRITICAL)
|
||||
|
||||
**Problem**: The `room_game_configs` table schema hard-codes game names, preventing true modularity.
|
||||
|
||||
**Evidence**:
|
||||
```typescript
|
||||
// db/schema/room-game-configs.ts
|
||||
gameName: text('game_name').$type<'matching' | 'memory-quiz' | 'number-guesser' | 'complement-race'>()
|
||||
```
|
||||
|
||||
When adding 'math-sprint':
|
||||
```
|
||||
Type '"math-sprint"' is not assignable to type '"matching" | "memory-quiz" | "number-guesser" | "complement-race"'
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- ❌ Must manually update database schema for every new game
|
||||
- ❌ TypeScript errors force schema migration
|
||||
- ❌ Breaks "just register and go" promise
|
||||
- ❌ Requires database migration for each game
|
||||
|
||||
**Root Cause**: The schema uses a union type instead of a string with runtime validation.
|
||||
|
||||
**Fix Required**: Change schema to accept any string, validate against registry at runtime.
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Issue #2: game-config-helpers.ts Boilerplate
|
||||
|
||||
**Problem**: Three switch statements must be updated for every new game:
|
||||
|
||||
1. `getDefaultGameConfig()` - add case
|
||||
2. Import default config constant
|
||||
3. `validateGameConfig()` - add validation logic
|
||||
|
||||
**Example** (from Math Sprint):
|
||||
```typescript
|
||||
// Must add to imports
|
||||
import { DEFAULT_MATH_SPRINT_CONFIG } from './game-configs'
|
||||
|
||||
// Must add case to switch #1
|
||||
case 'math-sprint':
|
||||
return DEFAULT_MATH_SPRINT_CONFIG
|
||||
|
||||
// Must add case to switch #2
|
||||
case 'math-sprint':
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
['easy', 'medium', 'hard'].includes(config.difficulty) &&
|
||||
// ... 10+ lines of validation
|
||||
)
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- ⏱️ 5-10 minutes of boilerplate per game
|
||||
- 🐛 Easy to forget a switch case
|
||||
- 📝 Repetitive validation logic
|
||||
|
||||
**Better Approach**: Config defaults and validation should be part of the game definition.
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Issue #3: game-configs.ts Boilerplate
|
||||
|
||||
**Problem**: Must update 4 places in game-configs.ts:
|
||||
|
||||
1. Import types from game
|
||||
2. Define `XGameConfig` interface
|
||||
3. Add to `GameConfigByName` union
|
||||
4. Add to `RoomGameConfig` interface
|
||||
5. Create `DEFAULT_X_CONFIG` constant
|
||||
|
||||
**Example** (from Math Sprint):
|
||||
```typescript
|
||||
// 1. Import
|
||||
import type { Difficulty as MathSprintDifficulty } from '@/arcade-games/math-sprint/types'
|
||||
|
||||
// 2. Interface
|
||||
export interface MathSprintGameConfig {
|
||||
difficulty: MathSprintDifficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number
|
||||
}
|
||||
|
||||
// 3. Add to union
|
||||
export type GameConfigByName = {
|
||||
'math-sprint': MathSprintGameConfig
|
||||
// ...
|
||||
}
|
||||
|
||||
// 4. Add to RoomGameConfig
|
||||
export interface RoomGameConfig {
|
||||
'math-sprint'?: MathSprintGameConfig
|
||||
// ...
|
||||
}
|
||||
|
||||
// 5. Default constant
|
||||
export const DEFAULT_MATH_SPRINT_CONFIG: MathSprintGameConfig = {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
}
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- ⏱️ 10-15 lines of boilerplate per game
|
||||
- 🐛 Easy to forget one of the 5 updates
|
||||
- 🔄 Repeating type information (already in game definition)
|
||||
|
||||
**Better Approach**: Game config types should be inferred from game definitions.
|
||||
|
||||
---
|
||||
|
||||
### 📊 Issue #4: High Boilerplate Ratio
|
||||
|
||||
**Files Required Per Game**:
|
||||
|
||||
| Category | Files | Purpose |
|
||||
|----------|-------|---------|
|
||||
| **Game Code** | 7 files | types.ts, Validator.ts, Provider.tsx, index.ts, 3x components |
|
||||
| **Registration** | 2 files | validators.ts, game-registry.ts |
|
||||
| **Config** | 2 files | game-configs.ts, game-config-helpers.ts |
|
||||
| **Database** | 1 file | schema migration |
|
||||
| **Total** | **12 files** | For one game! |
|
||||
|
||||
**Lines of Boilerplate** (non-game-logic):
|
||||
- game-configs.ts: ~15 lines
|
||||
- game-config-helpers.ts: ~25 lines
|
||||
- validators.ts: ~2 lines
|
||||
- game-registry.ts: ~2 lines
|
||||
- **Total: ~44 lines of pure boilerplate per game**
|
||||
|
||||
**Comparison**:
|
||||
- Number Guesser: ~500 lines of actual game logic
|
||||
- Boilerplate: ~44 lines (8.8% overhead) ✅ Acceptable
|
||||
- But spread across 4 different files ⚠️ Developer friction
|
||||
|
||||
---
|
||||
|
||||
## Positive Aspects
|
||||
|
||||
### ✅ What Works Well
|
||||
|
||||
1. **SDK Abstraction**
|
||||
- `useArcadeSession` is clean and reusable
|
||||
- `buildPlayerMetadata` helper reduces duplication
|
||||
- Hook-based API is intuitive
|
||||
|
||||
2. **Provider Pattern**
|
||||
- Consistent across games
|
||||
- Clear separation of concerns
|
||||
- Easy to understand
|
||||
|
||||
3. **Component Structure**
|
||||
- SetupPhase, PlayingPhase, ResultsPhase pattern is clear
|
||||
- GameComponent wrapper is simple
|
||||
- PageWithNav integration is seamless
|
||||
|
||||
4. **Unified Validator Registry**
|
||||
- Single source of truth for validators ✅
|
||||
- Auto-derived GameName type ✅
|
||||
- Type-safe validator access ✅
|
||||
|
||||
5. **Error Feedback**
|
||||
- lastError/clearError pattern works well
|
||||
- Auto-dismiss UX is good
|
||||
- Consistent error handling
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Number Guesser vs. Math Sprint
|
||||
|
||||
### Similarities (Good!)
|
||||
- ✅ Same file structure
|
||||
- ✅ Same SDK usage patterns
|
||||
- ✅ Same Provider pattern
|
||||
- ✅ Same component phases
|
||||
|
||||
### Differences (Revealing!)
|
||||
- Math Sprint uses TEAM_MOVE (no turn owner)
|
||||
- Math Sprint has server-generated questions
|
||||
- Database schema didn't support Math Sprint name
|
||||
|
||||
**Key Insight**: The SDK handles different game types well (turn-based vs. free-for-all), but infrastructure (database, config system) is rigid.
|
||||
|
||||
---
|
||||
|
||||
## Developer Experience Score
|
||||
|
||||
### Time to Add a Game
|
||||
|
||||
| Task | Time | Notes |
|
||||
|------|------|-------|
|
||||
| Write game logic | 2-4 hours | Validator, state management, components |
|
||||
| Registration boilerplate | 15-20 min | 4 files to update |
|
||||
| Database migration | 10-15 min | Schema update, migration file |
|
||||
| Debugging type errors | 10-30 min | Database schema mismatches |
|
||||
| **Total** | **3-5 hours** | For a simple game |
|
||||
|
||||
### Pain Points
|
||||
|
||||
1. **Database Schema** ⚠️ Critical blocker
|
||||
- Must update schema for each game
|
||||
- Requires migration
|
||||
- TypeScript errors are confusing
|
||||
|
||||
2. **Config System** ⚠️ Medium friction
|
||||
- 5 places to update in game-configs.ts
|
||||
- Easy to miss one
|
||||
- Repetitive type definitions
|
||||
|
||||
3. **Helper Functions** ⚠️ Low friction
|
||||
- Switch statements in game-config-helpers.ts
|
||||
- Not hard, just tedious
|
||||
|
||||
### What Developers Like
|
||||
|
||||
1. ✅ SDK is intuitive
|
||||
2. ✅ Pattern is consistent
|
||||
3. ✅ Error messages are clear (once you know where to look)
|
||||
4. ✅ Documentation is comprehensive
|
||||
|
||||
---
|
||||
|
||||
## Architectural Recommendations
|
||||
|
||||
### Critical (Before Adding More Games)
|
||||
|
||||
**1. Fix Database Schema Coupling**
|
||||
|
||||
**Current**:
|
||||
```typescript
|
||||
gameName: text('game_name').$type<'matching' | 'memory-quiz' | 'number-guesser' | 'complement-race'>()
|
||||
```
|
||||
|
||||
**Recommended**:
|
||||
```typescript
|
||||
// Accept any string, validate at runtime
|
||||
gameName: text('game_name').$type<string>().notNull()
|
||||
|
||||
// Runtime validation in helper functions
|
||||
export function validateGameName(gameName: string): gameName is GameName {
|
||||
return hasValidator(gameName)
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ No schema migration per game
|
||||
- ✅ Works with auto-derived GameName
|
||||
- ✅ Runtime validation is sufficient
|
||||
|
||||
---
|
||||
|
||||
**2. Infer Config Types from Game Definitions**
|
||||
|
||||
**Current** (manual):
|
||||
```typescript
|
||||
// In game-configs.ts
|
||||
export interface MathSprintGameConfig { ... }
|
||||
export const DEFAULT_MATH_SPRINT_CONFIG = { ... }
|
||||
|
||||
// In game definition
|
||||
const defaultConfig: MathSprintGameConfig = { ... }
|
||||
```
|
||||
|
||||
**Recommended**:
|
||||
```typescript
|
||||
// In game definition (single source of truth)
|
||||
export const mathSprintGame = defineGame({
|
||||
defaultConfig: {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
},
|
||||
validator: mathSprintValidator,
|
||||
// ...
|
||||
})
|
||||
|
||||
// Auto-infer types
|
||||
type MathSprintConfig = typeof mathSprintGame.defaultConfig
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ No duplication
|
||||
- ✅ Single source of truth
|
||||
- ✅ Type inference handles it
|
||||
|
||||
---
|
||||
|
||||
**3. Move Config Validation to Game Definition**
|
||||
|
||||
**Current** (switch statement in helper):
|
||||
```typescript
|
||||
function validateGameConfig(gameName: GameName, config: any): boolean {
|
||||
switch (gameName) {
|
||||
case 'math-sprint':
|
||||
return /* 15 lines of validation */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Recommended**:
|
||||
```typescript
|
||||
// In game definition
|
||||
export const mathSprintGame = defineGame({
|
||||
defaultConfig: { ... },
|
||||
validateConfig: (config: any): config is MathSprintConfig => {
|
||||
return /* validation logic */
|
||||
},
|
||||
// ...
|
||||
})
|
||||
|
||||
// In helper (generic)
|
||||
export function validateGameConfig(gameName: GameName, config: any): boolean {
|
||||
const game = getGame(gameName)
|
||||
return game?.validateConfig?.(config) ?? true
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ No switch statement
|
||||
- ✅ Validation lives with game
|
||||
- ✅ One place to update
|
||||
|
||||
---
|
||||
|
||||
### Medium Priority
|
||||
|
||||
**4. Create CLI Tool for Game Generation**
|
||||
|
||||
```bash
|
||||
npm run create-game math-sprint "Math Sprint" "🧮"
|
||||
```
|
||||
|
||||
Generates:
|
||||
- File structure
|
||||
- Boilerplate code
|
||||
- Registration entries
|
||||
- Types
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Eliminates manual boilerplate
|
||||
- ✅ Consistent structure
|
||||
- ✅ Reduces errors
|
||||
|
||||
---
|
||||
|
||||
**5. Add Runtime Registry Validation**
|
||||
|
||||
On app start, verify:
|
||||
- ✅ All games in registry have validators
|
||||
- ✅ All validators have games
|
||||
- ✅ No orphaned configs
|
||||
- ✅ All game names are unique
|
||||
|
||||
```typescript
|
||||
function validateRegistries() {
|
||||
const games = getAllGames()
|
||||
const validators = getRegisteredGameNames()
|
||||
|
||||
for (const game of games) {
|
||||
if (!validators.includes(game.manifest.name)) {
|
||||
throw new Error(`Game ${game.manifest.name} has no validator!`)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Updated Compliance Table
|
||||
|
||||
| Intention | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Modularity | ⚠️ Partial | Validators unified, but database/config not modular |
|
||||
| Self-registration | ✅ Pass | Two registration points (validator + game), both clear |
|
||||
| Type safety | ⚠️ Partial | Types work, but database schema breaks for new games |
|
||||
| No core changes | ⚠️ Partial | Must update 4 files + database schema |
|
||||
| Drop-in games | ❌ Fail | Database migration required |
|
||||
| Stable SDK API | ✅ Pass | SDK is excellent |
|
||||
| Clear patterns | ✅ Pass | Patterns are consistent |
|
||||
| Low boilerplate | ⚠️ Partial | SDK usage is clean, registration is verbose |
|
||||
|
||||
**Overall Grade**: **B-** (Was B+, downgraded after implementation testing)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### What We Learned
|
||||
|
||||
✅ **The Good**:
|
||||
- SDK design is solid
|
||||
- Unified validator registry works
|
||||
- Pattern is consistent and learnable
|
||||
- Number Guesser proves the concept
|
||||
|
||||
⚠️ **The Not-So-Good**:
|
||||
- Database schema couples to game names (critical blocker)
|
||||
- Config system has too much boilerplate
|
||||
- 12 files touched per game is high
|
||||
|
||||
❌ **The Bad**:
|
||||
- Can't truly "drop in" a game without schema migration
|
||||
- Config types are duplicated
|
||||
- Helper switch statements are tedious
|
||||
|
||||
### Verdict
|
||||
|
||||
The system **works** and is **usable**, but falls short of "modular architecture" goals due to:
|
||||
1. Database schema hard-coding
|
||||
2. Config system boilerplate
|
||||
3. Required schema migrations
|
||||
|
||||
**Recommendation**:
|
||||
1. **Option A (Quick Fix)**: Document the 12-file checklist, live with boilerplate for now
|
||||
2. **Option B (Proper Fix)**: Implement Critical recommendations 1-3 before adding Math Sprint
|
||||
|
||||
**My Recommendation**: Option A for now (get Math Sprint working), then Option B as a refactoring sprint.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Document "Adding a Game" checklist (12 files)
|
||||
2. 🔴 Fix database schema to accept any game name
|
||||
3. 🟡 Test Math Sprint with current architecture
|
||||
4. 🟡 Evaluate if boilerplate is acceptable in practice
|
||||
5. 🟢 Consider config system refactoring for later
|
||||
|
||||
519
apps/web/docs/AUDIT_MODULAR_GAME_SYSTEM.md
Normal file
519
apps/web/docs/AUDIT_MODULAR_GAME_SYSTEM.md
Normal file
@@ -0,0 +1,519 @@
|
||||
# Modular Game System Audit
|
||||
|
||||
**Date**: 2025-10-15
|
||||
**Updated**: 2025-10-15
|
||||
**Status**: ✅ CRITICAL ISSUE RESOLVED
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The modular game system **now meets its stated intentions** after implementing the unified validator registry. The critical dual registration issue has been resolved.
|
||||
|
||||
**Original Issue**: Client-side implementation (SDK, registry, game definitions) was well-designed, but server-side validation used a hard-coded legacy system, breaking the core premise of modularity.
|
||||
|
||||
**Resolution**: Created unified isomorphic validator registry (`src/lib/arcade/validators.ts`) that serves both client and server needs, with auto-derived GameName type.
|
||||
|
||||
**Verdict**: ✅ **Production Ready** - System is now truly modular with single registration point
|
||||
|
||||
---
|
||||
|
||||
## Intention vs. Reality
|
||||
|
||||
### Stated Intentions
|
||||
|
||||
> "A modular, plugin-based architecture for building multiplayer arcade games"
|
||||
>
|
||||
> **Goals:**
|
||||
> 1. **Modularity**: Each game is self-contained and independently deployable
|
||||
> 2. Games register themselves with a central registry
|
||||
> 3. No need to modify core infrastructure when adding games
|
||||
|
||||
### Current Reality
|
||||
|
||||
✅ **Client-Side**: Fully modular, games use SDK and register themselves
|
||||
❌ **Server-Side**: Hard-coded validator map, requires manual code changes
|
||||
❌ **Overall**: **System is NOT modular** - adding a game requires editing 2 different registries
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### ✅ Issue #1: Dual Registration System (RESOLVED)
|
||||
|
||||
**Original Problem**: Games had to register in TWO separate places:
|
||||
|
||||
1. **Client Registry** (`src/lib/arcade/game-registry.ts`)
|
||||
2. **Server Validator Map** (`src/lib/arcade/validation/index.ts`)
|
||||
|
||||
**Impact**:
|
||||
- ❌ Broke modularity - couldn't just drop in a new game
|
||||
- ❌ Easy to forget one registration, causing runtime errors
|
||||
- ❌ Violated DRY principle
|
||||
- ❌ Two sources of truth for "what games exist"
|
||||
|
||||
**Resolution** (Implemented 2025-10-15):
|
||||
|
||||
Created unified isomorphic validator registry at `src/lib/arcade/validators.ts`:
|
||||
|
||||
```typescript
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
// Add new games here - GameName type auto-updates!
|
||||
} as const
|
||||
|
||||
// Auto-derived type - no manual updates needed!
|
||||
export type GameName = keyof typeof validatorRegistry
|
||||
```
|
||||
|
||||
**Changes Made**:
|
||||
1. ✅ Created `src/lib/arcade/validators.ts` - Unified validator registry (isomorphic)
|
||||
2. ✅ Updated `validation/index.ts` - Now re-exports from unified registry (backwards compatible)
|
||||
3. ✅ Updated `validation/types.ts` - GameName now auto-derived (no more hard-coded union)
|
||||
4. ✅ Updated `session-manager.ts` - Imports from unified registry
|
||||
5. ✅ Updated `socket-server.ts` - Imports from unified registry
|
||||
6. ✅ Updated `route.ts` - Uses `hasValidator()` instead of hard-coded array
|
||||
7. ✅ Updated `game-config-helpers.ts` - Handles ExtendedGameName for legacy games
|
||||
8. ✅ Updated `game-registry.ts` - Added runtime validation check
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Single registration point for validators
|
||||
- ✅ Auto-derived GameName type (no manual updates)
|
||||
- ✅ Type-safe validator access
|
||||
- ✅ Backwards compatible with existing code
|
||||
- ✅ Runtime warnings for registration mismatches
|
||||
|
||||
**Commit**: `refactor(arcade): create unified validator registry to fix dual registration` (9459f37b)
|
||||
|
||||
---
|
||||
|
||||
### ✅ Issue #2: Validators Not Accessible from Registry (RESOLVED)
|
||||
|
||||
**Original Problem**: The `GameDefinition` contained validators, but server couldn't access them because `game-registry.ts` imported React components.
|
||||
|
||||
**Resolution**: Created separate isomorphic validator registry that server can import without pulling in client-only code.
|
||||
|
||||
**How It Works Now**:
|
||||
- `src/lib/arcade/validators.ts` - Isomorphic, server can import safely
|
||||
- `src/lib/arcade/game-registry.ts` - Client-only, imports React components
|
||||
- Both use the same validator instances (verified at runtime)
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Server has direct access to validators
|
||||
- ✅ No need for dual validator maps
|
||||
- ✅ Clear separation: validators (isomorphic) vs UI (client-only)
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Issue #3: Type System Fragmentation
|
||||
|
||||
**Problem**: Multiple overlapping type definitions for same concepts:
|
||||
|
||||
**GameValidator** has THREE definitions:
|
||||
1. `validation/types.ts` - Legacy validator interface
|
||||
2. `game-sdk/types.ts` - SDK validator interface (extends legacy)
|
||||
3. Individual game validators - Implement one or both?
|
||||
|
||||
**GameMove** has TWO type systems:
|
||||
1. `validation/types.ts` - Legacy move types (MatchingFlipCardMove, etc.)
|
||||
2. Game-specific types in each game's `types.ts`
|
||||
|
||||
**GameName** is hard-coded:
|
||||
```typescript
|
||||
// validation/types.ts:9
|
||||
export type GameName = 'matching' | 'memory-quiz' | 'complement-race' | 'number-guesser'
|
||||
```
|
||||
|
||||
This must be manually updated for every new game!
|
||||
|
||||
**Impact**:
|
||||
- Confusing which types to use
|
||||
- Easy to use wrong import
|
||||
- GameName type doesn't auto-update from registry
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Issue #4: Old Games Not Migrated
|
||||
|
||||
**Problem**: Existing games (matching, memory-quiz) still use old structure:
|
||||
|
||||
**Old Pattern** (matching, memory-quiz):
|
||||
```
|
||||
src/app/arcade/matching/
|
||||
├── context/ (Old pattern)
|
||||
│ └── RoomMemoryPairsProvider.tsx
|
||||
└── components/
|
||||
```
|
||||
|
||||
**New Pattern** (number-guesser):
|
||||
```
|
||||
src/arcade-games/number-guesser/
|
||||
├── index.ts (New pattern)
|
||||
├── Validator.ts
|
||||
├── Provider.tsx
|
||||
└── components/
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Inconsistent codebase structure
|
||||
- Two different patterns developers must understand
|
||||
- Documentation shows new pattern, but most games use old pattern
|
||||
- Confusing for new developers
|
||||
|
||||
**Evidence**:
|
||||
- `src/app/arcade/matching/` - Uses old structure
|
||||
- `src/app/arcade/memory-quiz/` - Uses old structure
|
||||
- `src/arcade-games/number-guesser/` - Uses new structure
|
||||
|
||||
---
|
||||
|
||||
### ✅ Issue #5: Manual GameName Type Updates (RESOLVED)
|
||||
|
||||
**Original Problem**: `GameName` type was a hard-coded union that had to be manually updated for each new game.
|
||||
|
||||
**Resolution**: Changed validator registry from Map to const object, enabling type derivation:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validators.ts
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
// Add new games here...
|
||||
} as const
|
||||
|
||||
// Auto-derived! No manual updates needed!
|
||||
export type GameName = keyof typeof validatorRegistry
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ GameName type updates automatically when adding to registry
|
||||
- ✅ Impossible to forget type update (it's derived)
|
||||
- ✅ Single registration step (just add to validatorRegistry)
|
||||
- ✅ Type-safe throughout codebase
|
||||
|
||||
---
|
||||
|
||||
## Secondary Issues
|
||||
|
||||
### Issue #6: No Server-Side Registry Access
|
||||
|
||||
**Problem**: Server code cannot import `game-registry.ts` because it contains React components.
|
||||
|
||||
**Why**:
|
||||
- `GameDefinition` includes `Provider` and `GameComponent` (React components)
|
||||
- Server-side code runs in Node.js, can't import React components
|
||||
- No way to access just the validator from registry
|
||||
|
||||
**Potential Solutions**:
|
||||
1. Split registry into isomorphic and client-only parts
|
||||
2. Separate validator registration from game registration
|
||||
3. Use conditional exports in package.json
|
||||
|
||||
---
|
||||
|
||||
### Issue #7: Documentation Doesn't Match Reality
|
||||
|
||||
**Problem**: Documentation describes a fully modular system, but reality requires manual edits in multiple places.
|
||||
|
||||
**From README.md**:
|
||||
> "Step 7: Register Game - Add to src/lib/arcade/game-registry.ts"
|
||||
|
||||
**Missing Steps**:
|
||||
- Also add to `validation/index.ts` validator map
|
||||
- Also add to `GameName` type union
|
||||
- Import validator in server files
|
||||
|
||||
**Impact**: Developers follow docs, game doesn't work, confusion ensues.
|
||||
|
||||
---
|
||||
|
||||
### Issue #8: No Validation of Registered Games
|
||||
|
||||
**Problem**: Registration is type-safe but has no runtime validation:
|
||||
|
||||
```typescript
|
||||
registerGame(numberGuesserGame) // No validation that validator works
|
||||
```
|
||||
|
||||
**Missing Checks**:
|
||||
- Does validator implement all required methods?
|
||||
- Does manifest match expected schema?
|
||||
- Are all required fields present?
|
||||
- Does validator.getInitialState() return valid state?
|
||||
|
||||
**Impact**: Bugs only caught at runtime when game is played.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### Solution 1: Unified Server-Side Registry (RECOMMENDED)
|
||||
|
||||
**Create isomorphic validator registry**:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/validators.ts (NEW FILE - isomorphic)
|
||||
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
|
||||
import { matchingGameValidator } from '@/lib/arcade/validation/MatchingGameValidator'
|
||||
// ... other validators
|
||||
|
||||
export const validatorRegistry = new Map([
|
||||
['number-guesser', numberGuesserValidator],
|
||||
['matching', matchingGameValidator],
|
||||
// ...
|
||||
])
|
||||
|
||||
export function getValidator(gameName: string) {
|
||||
const validator = validatorRegistry.get(gameName)
|
||||
if (!validator) throw new Error(`No validator for game: ${gameName}`)
|
||||
return validator
|
||||
}
|
||||
|
||||
export type GameName = keyof typeof validatorRegistry // Auto-derived!
|
||||
```
|
||||
|
||||
**Update game-registry.ts** to use this:
|
||||
|
||||
```typescript
|
||||
// src/lib/arcade/game-registry.ts
|
||||
import { getValidator } from './validators'
|
||||
|
||||
export function registerGame(game: GameDefinition) {
|
||||
const { name } = game.manifest
|
||||
|
||||
// Verify validator is registered server-side
|
||||
const validator = getValidator(name)
|
||||
if (validator !== game.validator) {
|
||||
console.warn(`[Registry] Validator mismatch for ${name}`)
|
||||
}
|
||||
|
||||
registry.set(name, game)
|
||||
}
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Single source of truth for validators
|
||||
- Auto-derived GameName type
|
||||
- Client and server use same validator
|
||||
- Only one registration needed
|
||||
|
||||
**Cons**:
|
||||
- Still requires manual import in validators.ts
|
||||
- Doesn't solve "drop in a game" fully
|
||||
|
||||
---
|
||||
|
||||
### Solution 2: Code Generation
|
||||
|
||||
**Auto-generate validator registry from file system**:
|
||||
|
||||
```typescript
|
||||
// scripts/generate-registry.ts
|
||||
// Scans src/arcade-games/**/Validator.ts
|
||||
// Generates validators.ts and game-registry imports
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Truly modular - just add folder, run build
|
||||
- No manual registration
|
||||
- Auto-derived types
|
||||
|
||||
**Cons**:
|
||||
- Build-time complexity
|
||||
- Magic (harder to understand)
|
||||
- May not work with all bundlers
|
||||
|
||||
---
|
||||
|
||||
### Solution 3: Split GameDefinition
|
||||
|
||||
**Separate client and server concerns**:
|
||||
|
||||
```typescript
|
||||
// Isomorphic (client + server)
|
||||
export interface GameValidatorDefinition {
|
||||
name: string
|
||||
validator: GameValidator
|
||||
defaultConfig: GameConfig
|
||||
}
|
||||
|
||||
// Client-only
|
||||
export interface GameUIDefinition {
|
||||
name: string
|
||||
manifest: GameManifest
|
||||
Provider: GameProviderComponent
|
||||
GameComponent: GameComponent
|
||||
}
|
||||
|
||||
// Combined (client-only)
|
||||
export interface GameDefinition extends GameValidatorDefinition, GameUIDefinition {}
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Clear separation of concerns
|
||||
- Server can import just validator definition
|
||||
- Type-safe
|
||||
|
||||
**Cons**:
|
||||
- More complexity
|
||||
- Still requires two registries
|
||||
|
||||
---
|
||||
|
||||
## Immediate Action Items
|
||||
|
||||
### Critical (Do Before Next Game)
|
||||
|
||||
1. **✅ Document the dual registration requirement** (COMPLETED)
|
||||
- ✅ Update README with both registration steps
|
||||
- ✅ Add troubleshooting section for "game not found" errors
|
||||
- ✅ Document unified validator registry in Step 7
|
||||
|
||||
2. **✅ Unify validator registration** (COMPLETED 2025-10-15)
|
||||
- ✅ Chose Solution 1 (Unified Server-Side Registry)
|
||||
- ✅ Implemented unified registry (src/lib/arcade/validators.ts)
|
||||
- ✅ Updated session-manager.ts and socket-server.ts
|
||||
- ✅ Tested with number-guesser (no TypeScript errors)
|
||||
|
||||
3. **✅ Auto-derive GameName type** (COMPLETED 2025-10-15)
|
||||
- ✅ Removed hard-coded union
|
||||
- ✅ Derive from validator registry using `keyof typeof`
|
||||
- ✅ Updated all usages (backwards compatible via re-exports)
|
||||
|
||||
### High Priority
|
||||
|
||||
4. **🟡 Migrate old games to new pattern**
|
||||
- Move matching to `arcade-games/matching/`
|
||||
- Move memory-quiz to `arcade-games/memory-quiz/`
|
||||
- Update imports and tests
|
||||
- OR document that old games use old pattern (transitional)
|
||||
|
||||
5. **🟡 Add validator registration validation**
|
||||
- Runtime check in registerGame()
|
||||
- Warn if validator missing
|
||||
- Validate manifest schema
|
||||
|
||||
### Medium Priority
|
||||
|
||||
6. **🟢 Clean up type definitions**
|
||||
- Consolidate GameValidator types
|
||||
- Single source of truth for GameMove
|
||||
- Clear documentation on which to use
|
||||
|
||||
7. **🟢 Update documentation**
|
||||
- Add "dual registry" warning
|
||||
- Update step-by-step guide
|
||||
- Add troubleshooting for common mistakes
|
||||
|
||||
---
|
||||
|
||||
## Architectural Debt
|
||||
|
||||
### Technical Debt Accumulated
|
||||
|
||||
1. **Old validation system** (`validation/types.ts`, `validation/index.ts`)
|
||||
- Used by server-side code
|
||||
- Hard-coded game list
|
||||
- No migration path documented
|
||||
|
||||
2. **Mixed game structures** (old in `app/arcade/`, new in `arcade-games/`)
|
||||
- Confusing for developers
|
||||
- Inconsistent imports
|
||||
- Harder to maintain
|
||||
|
||||
3. **Type fragmentation** (3 GameValidator definitions)
|
||||
- Unclear which to use
|
||||
- Potential for bugs
|
||||
- Harder to refactor
|
||||
|
||||
### Migration Path
|
||||
|
||||
**Option A: Big Bang** (Risky)
|
||||
- Migrate all games to new structure in one PR
|
||||
- Update server to use unified registry
|
||||
- High risk of breakage
|
||||
|
||||
**Option B: Incremental** (Safer)
|
||||
- Document dual registration as "current reality"
|
||||
- Create unified validator registry (doesn't break old games)
|
||||
- Slowly migrate old games one by one
|
||||
- Eventually deprecate old validation system
|
||||
|
||||
**Recommendation**: Option B (Incremental)
|
||||
|
||||
---
|
||||
|
||||
## Compliance with Intentions
|
||||
|
||||
| Intention | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Modularity | ✅ Pass | Single registration in validators.ts + game-registry.ts |
|
||||
| Self-registration | ✅ Pass | Both client and server use unified registry |
|
||||
| Type safety | ✅ Pass | Good TypeScript coverage + auto-derived GameName |
|
||||
| No core changes | ⚠️ Improved | Must edit validators.ts, but one central file |
|
||||
| Drop-in games | ⚠️ Improved | Two registration points (validator + game def) |
|
||||
| Stable SDK API | ✅ Pass | SDK is well-designed and consistent |
|
||||
| Clear patterns | ⚠️ Partial | New pattern is clear, but old games don't follow it |
|
||||
|
||||
**Original Grade**: **D** (Failed core modularity requirement)
|
||||
**Current Grade**: **B+** (Modularity achieved, some legacy migration pending)
|
||||
|
||||
---
|
||||
|
||||
## Positive Aspects (What Works Well)
|
||||
|
||||
1. **✅ SDK Design** - Clean, well-documented, type-safe
|
||||
2. **✅ Client-Side Registry** - Simple, effective pattern
|
||||
3. **✅ GameDefinition Structure** - Good separation of concerns
|
||||
4. **✅ Documentation** - Comprehensive (though doesn't match reality)
|
||||
5. **✅ defineGame() Helper** - Makes game creation easy
|
||||
6. **✅ Type Safety** - Excellent TypeScript coverage
|
||||
7. **✅ Number Guesser Example** - Good reference implementation
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate (This Sprint)
|
||||
|
||||
1. ✅ **Document current reality** - Update docs to show both registrations required
|
||||
2. 🔴 **Create unified validator registry** - Implement Solution 1
|
||||
3. 🔴 **Update server to use unified registry** - Modify session-manager.ts and socket-server.ts
|
||||
|
||||
### Next Sprint
|
||||
|
||||
4. 🟡 **Migrate one old game** - Move matching to new structure as proof of concept
|
||||
5. 🟡 **Add registration validation** - Runtime checks for validator consistency
|
||||
6. 🟡 **Auto-derive GameName** - Remove hard-coded type union
|
||||
|
||||
### Future
|
||||
|
||||
7. 🟢 **Code generation** - Explore automated registry generation
|
||||
8. 🟢 **Plugin system** - True drop-in games with discovery
|
||||
9. 🟢 **Deprecate old validation system** - Once all games migrated
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The modular game system has a **solid foundation** but is **not truly modular** due to server-side technical debt. The client-side implementation is excellent, but the server still uses a legacy hard-coded validation system.
|
||||
|
||||
**Status**: Needs significant refactoring before claiming "modular architecture"
|
||||
|
||||
**Path Forward**: Implement unified validator registry (Solution 1), then incrementally migrate old games.
|
||||
|
||||
**Risk**: If we add more games before fixing this, technical debt will compound.
|
||||
|
||||
---
|
||||
|
||||
*This audit was conducted by reviewing:*
|
||||
- `src/lib/arcade/game-registry.ts`
|
||||
- `src/lib/arcade/validation/index.ts`
|
||||
- `src/lib/arcade/session-manager.ts`
|
||||
- `src/socket-server.ts`
|
||||
- `src/lib/arcade/game-sdk/`
|
||||
- `src/arcade-games/number-guesser/`
|
||||
- Documentation in `docs/` and `src/arcade-games/README.md`
|
||||
792
apps/web/docs/arcade-game-architecture.md
Normal file
792
apps/web/docs/arcade-game-architecture.md
Normal file
@@ -0,0 +1,792 @@
|
||||
# Arcade Game Architecture
|
||||
|
||||
> **Design Philosophy**: Modular, type-safe, multiplayer-first game development with real-time synchronization
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Design Goals](#design-goals)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Core Concepts](#core-concepts)
|
||||
- [Implementation Details](#implementation-details)
|
||||
- [Design Decisions](#design-decisions)
|
||||
- [Lessons Learned](#lessons-learned)
|
||||
- [Future Improvements](#future-improvements)
|
||||
|
||||
---
|
||||
|
||||
## Design Goals
|
||||
|
||||
### Primary Goals
|
||||
|
||||
1. **Modularity**
|
||||
- Each game is a self-contained module
|
||||
- Games can be added/removed without affecting the core system
|
||||
- No tight coupling between games and infrastructure
|
||||
|
||||
2. **Type Safety**
|
||||
- Full TypeScript support throughout the stack
|
||||
- Compile-time validation of game definitions
|
||||
- Type-safe move validation and state management
|
||||
|
||||
3. **Multiplayer-First**
|
||||
- Real-time state synchronization via WebSocket
|
||||
- Optimistic updates for instant feedback
|
||||
- Server-authoritative validation to prevent cheating
|
||||
|
||||
4. **Developer Experience**
|
||||
- Simple, intuitive API for game creators
|
||||
- Minimal boilerplate
|
||||
- Clear separation of concerns
|
||||
- Comprehensive error messages
|
||||
|
||||
5. **Consistency**
|
||||
- Shared navigation and UI components
|
||||
- Standardized player management
|
||||
- Common error handling patterns
|
||||
- Unified room/lobby experience
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Supporting non-multiplayer games (use existing game routes for that)
|
||||
- Backwards compatibility with old game implementations
|
||||
- Supporting games outside the monorepo
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### System Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
│ - GameSelector (game discovery) │
|
||||
│ - Room management │
|
||||
│ - Player management │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Registry Layer │
|
||||
│ - Game registration │
|
||||
│ - Game discovery (getGame, getAllGames) │
|
||||
│ - Manifest validation │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SDK Layer │
|
||||
│ - Stable API surface │
|
||||
│ - React hooks (useArcadeSession, etc.) │
|
||||
│ - Type definitions │
|
||||
│ - Utilities (buildPlayerMetadata, etc.) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game Layer │
|
||||
│ Individual games (number-guesser, math-sprint, etc.) │
|
||||
│ Each game: Validator + Provider + Components + Types │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Infrastructure Layer │
|
||||
│ - WebSocket (useArcadeSocket) │
|
||||
│ - Optimistic state (useOptimisticGameState) │
|
||||
│ - Database (room data, player data) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow: Move Execution
|
||||
|
||||
```
|
||||
1. User clicks button
|
||||
│
|
||||
▼
|
||||
2. Provider calls sendMove()
|
||||
│
|
||||
▼
|
||||
3. useArcadeSession
|
||||
├─→ Apply optimistically (instant UI update)
|
||||
└─→ Send via WebSocket to server
|
||||
│
|
||||
▼
|
||||
4. Server validates move
|
||||
│
|
||||
├─→ VALID:
|
||||
│ ├─→ Apply to server state
|
||||
│ ├─→ Increment version
|
||||
│ ├─→ Broadcast to all clients
|
||||
│ └─→ Client: Remove from pending, confirm state
|
||||
│
|
||||
└─→ INVALID:
|
||||
├─→ Send rejection message
|
||||
└─→ Client: Rollback optimistic state, show error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Game Definition
|
||||
|
||||
A game is defined by five core pieces:
|
||||
|
||||
```typescript
|
||||
interface GameDefinition<TConfig, TState, TMove> {
|
||||
manifest: GameManifest // Display metadata
|
||||
Provider: GameProviderComponent // React context provider
|
||||
GameComponent: GameComponent // Main UI component
|
||||
validator: GameValidator // Server validation logic
|
||||
defaultConfig: TConfig // Default settings
|
||||
}
|
||||
```
|
||||
|
||||
**Why this structure?**
|
||||
- `manifest`: Declarative metadata for discovery and UI
|
||||
- `Provider`: Encapsulates all game logic and state management
|
||||
- `GameComponent`: Pure UI component, no business logic
|
||||
- `validator`: Server-authoritative validation prevents cheating
|
||||
- `defaultConfig`: Sensible defaults, can be overridden per-room
|
||||
|
||||
### 2. Validator (Server-Side)
|
||||
|
||||
The validator is the **source of truth** for game logic.
|
||||
|
||||
```typescript
|
||||
interface GameValidator<TState, TMove> {
|
||||
validateMove(state: TState, move: TMove): ValidationResult
|
||||
isGameComplete(state: TState): boolean
|
||||
getInitialState(config: unknown): TState
|
||||
}
|
||||
```
|
||||
|
||||
**Key Principles:**
|
||||
- **Pure functions**: No side effects, no I/O
|
||||
- **Deterministic**: Same input → same output
|
||||
- **Complete game logic**: All rules enforced here
|
||||
- **Returns new state**: Immutable state updates
|
||||
|
||||
**Why server-side?**
|
||||
- Prevents cheating (client can't fake moves)
|
||||
- Single source of truth (no client/server divergence)
|
||||
- Easier debugging (all logic in one place)
|
||||
- Can add server-only features (analytics, anti-cheat)
|
||||
|
||||
### 3. Provider (Client-Side)
|
||||
|
||||
The provider manages client state and provides a clean API.
|
||||
|
||||
```typescript
|
||||
interface GameContextValue {
|
||||
state: GameState // Current game state
|
||||
lastError: string | null // Last validation error
|
||||
startGame: () => void // Action creators
|
||||
makeMove: (data) => void // ...
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Responsibilities:**
|
||||
- Wrap `useArcadeSession` with game-specific actions
|
||||
- Build player metadata from game mode context
|
||||
- Provide clean, typed API to components
|
||||
- Handle room config persistence
|
||||
|
||||
**Anti-Pattern:** Don't put game logic here. The provider is a **thin wrapper** around the SDK.
|
||||
|
||||
### 4. Optimistic Updates
|
||||
|
||||
The system uses **optimistic UI** for instant feedback:
|
||||
|
||||
1. User makes a move → UI updates immediately
|
||||
2. Move sent to server for validation
|
||||
3. Server validates:
|
||||
- ✓ Valid → Confirm optimistic state
|
||||
- ✗ Invalid → Rollback and show error
|
||||
|
||||
**Why optimistic updates?**
|
||||
- Instant feedback (no perceived latency)
|
||||
- Better UX for fast-paced games
|
||||
- Handles network issues gracefully
|
||||
|
||||
**Tradeoff:**
|
||||
- More complex state management
|
||||
- Need rollback logic
|
||||
- Potential for flashing/jumpy UI on rollback
|
||||
|
||||
**When NOT to use:**
|
||||
- High-stakes actions (payments, permanent changes)
|
||||
- Actions with irreversible side effects
|
||||
- When server latency is acceptable
|
||||
|
||||
### 5. State Synchronization
|
||||
|
||||
State is synchronized across all clients in a room:
|
||||
|
||||
```
|
||||
Client A makes move → Server validates → Broadcast to all clients
|
||||
├─→ Client A: Confirm optimistic update
|
||||
├─→ Client B: Apply server state
|
||||
└─→ Client C: Apply server state
|
||||
```
|
||||
|
||||
**Conflict Resolution:**
|
||||
- Server state is **always authoritative**
|
||||
- Version numbers prevent out-of-order updates
|
||||
- Pending moves are reapplied after server sync
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### SDK Design
|
||||
|
||||
The SDK provides a **stable API surface** that games import from:
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Import from SDK
|
||||
import { useArcadeSession, type GameDefinition } from '@/lib/arcade/game-sdk'
|
||||
|
||||
// ❌ BAD: Import internal implementation
|
||||
import { useArcadeSocket } from '@/hooks/useArcadeSocket'
|
||||
```
|
||||
|
||||
**Why?**
|
||||
- **Stability**: Internal APIs can change, SDK stays stable
|
||||
- **Discoverability**: One place to find all game APIs
|
||||
- **Encapsulation**: Hide implementation details
|
||||
- **Documentation**: SDK is the "public API" to document
|
||||
|
||||
**SDK Exports:**
|
||||
|
||||
```typescript
|
||||
// Types
|
||||
export type { GameDefinition, GameValidator, GameState, GameMove, ... }
|
||||
|
||||
// React Hooks
|
||||
export { useArcadeSession, useRoomData, useGameMode, useViewerId }
|
||||
|
||||
// Utilities
|
||||
export { defineGame, buildPlayerMetadata, loadManifest }
|
||||
```
|
||||
|
||||
### Registry Pattern
|
||||
|
||||
Games register themselves on module load:
|
||||
|
||||
```typescript
|
||||
// game-registry.ts
|
||||
const registry = new Map<string, GameDefinition>()
|
||||
|
||||
export function registerGame(game: GameDefinition) {
|
||||
registry.set(game.manifest.name, game)
|
||||
}
|
||||
|
||||
export function getGame(name: string) {
|
||||
return registry.get(name)
|
||||
}
|
||||
|
||||
// At bottom of file
|
||||
import { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
registerGame(numberGuesserGame)
|
||||
```
|
||||
|
||||
**Why self-registration?**
|
||||
- No central "game list" to maintain
|
||||
- Games are automatically discovered
|
||||
- Import errors are caught at module load time
|
||||
- Easy to enable/disable games (comment out registration)
|
||||
|
||||
**Alternative Considered:** Auto-discovery via file system
|
||||
|
||||
```typescript
|
||||
// ❌ Rejected: Magic, fragile, breaks with bundlers
|
||||
const games = import.meta.glob('../arcade-games/*/index.ts')
|
||||
```
|
||||
|
||||
### Player Metadata
|
||||
|
||||
Player metadata is built from multiple sources:
|
||||
|
||||
```typescript
|
||||
function buildPlayerMetadata(
|
||||
playerIds: string[],
|
||||
existingMetadata: Record<string, unknown>,
|
||||
playerMap: Map<string, Player>,
|
||||
viewerId?: string
|
||||
): Record<string, PlayerMetadata>
|
||||
```
|
||||
|
||||
**Sources:**
|
||||
1. `playerIds`: Which players are active
|
||||
2. `existingMetadata`: Carry over existing data (for reconnects)
|
||||
3. `playerMap`: Player details (name, emoji, color, userId)
|
||||
4. `viewerId`: Current user (for ownership checks)
|
||||
|
||||
**Why so complex?**
|
||||
- Players can be local or remote (in rooms)
|
||||
- Need to preserve data across state updates
|
||||
- Must map player IDs to user IDs for permissions
|
||||
- Support for guest players vs. authenticated users
|
||||
|
||||
### Move Validation Flow
|
||||
|
||||
```typescript
|
||||
// 1. Client sends move
|
||||
sendMove({
|
||||
type: 'MAKE_GUESS',
|
||||
playerId: 'player-123',
|
||||
userId: 'user-456',
|
||||
timestamp: Date.now(),
|
||||
data: { guess: 42 }
|
||||
})
|
||||
|
||||
// 2. Optimistic update (client-side)
|
||||
const optimisticState = applyMove(currentState, move)
|
||||
setOptimisticState(optimisticState)
|
||||
|
||||
// 3. Server validates
|
||||
const result = validator.validateMove(serverState, move)
|
||||
|
||||
// 4a. Valid → Broadcast new state
|
||||
if (result.valid) {
|
||||
serverState = result.newState
|
||||
version++
|
||||
broadcastToAllClients({ gameState: serverState, version })
|
||||
}
|
||||
|
||||
// 4b. Invalid → Send rejection
|
||||
else {
|
||||
sendToClient({ error: result.error, move })
|
||||
}
|
||||
|
||||
// 5. Client handles response
|
||||
// Valid: Confirm optimistic state, remove from pending
|
||||
// Invalid: Rollback optimistic state, show error
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Optimistic update happens **before** server response
|
||||
- Server is **authoritative** (client state can be overwritten)
|
||||
- Version numbers prevent stale updates
|
||||
- Rejected moves trigger error UI
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Decision: Server-Authoritative Validation
|
||||
|
||||
**Choice:** All game logic runs on server, client is "dumb"
|
||||
|
||||
**Rationale:**
|
||||
- Prevents cheating (client can't manipulate state)
|
||||
- Single source of truth (no client/server divergence)
|
||||
- Easier testing (one codebase for game logic)
|
||||
- Can add server-side features (analytics, matchmaking)
|
||||
|
||||
**Tradeoff:**
|
||||
- ➕ Secure, consistent, easier to maintain
|
||||
- ➖ Network latency affects UX (mitigated by optimistic updates)
|
||||
- ➖ Can't play offline
|
||||
|
||||
**Alternative Considered:** Client-side validation + server verification
|
||||
- Rejected: Duplicate logic, potential for divergence
|
||||
|
||||
### Decision: Optimistic Updates
|
||||
|
||||
**Choice:** Apply moves immediately, rollback on rejection
|
||||
|
||||
**Rationale:**
|
||||
- Instant feedback (no perceived latency)
|
||||
- Better UX for turn-based games
|
||||
- Handles network issues gracefully
|
||||
|
||||
**Tradeoff:**
|
||||
- ➕ Feels instant, smooth UX
|
||||
- ➖ More complex state management
|
||||
- ➖ Potential for jarring rollbacks
|
||||
|
||||
**When to disable:** High-stakes actions (payments, permanent bans)
|
||||
|
||||
### Decision: TypeScript Everywhere
|
||||
|
||||
**Choice:** Full TypeScript on client and server
|
||||
|
||||
**Rationale:**
|
||||
- Compile-time validation catches bugs early
|
||||
- Better IDE support (autocomplete, refactoring)
|
||||
- Self-documenting code (types as documentation)
|
||||
- Easier refactoring (compiler catches breakages)
|
||||
|
||||
**Tradeoff:**
|
||||
- ➕ Fewer runtime errors, better DX
|
||||
- ➖ Slower initial development (must define types)
|
||||
- ➖ Learning curve for new developers
|
||||
|
||||
**Alternative Considered:** JavaScript with JSDoc
|
||||
- Rejected: JSDoc is not type-safe, easy to drift
|
||||
|
||||
### Decision: React Context for State
|
||||
|
||||
**Choice:** Each game has a Provider that wraps game logic
|
||||
|
||||
**Rationale:**
|
||||
- Natural React pattern
|
||||
- Easy to compose (Provider wraps GameComponent)
|
||||
- No prop drilling
|
||||
- Easy to test (can provide mock context)
|
||||
|
||||
**Tradeoff:**
|
||||
- ➕ Clean component APIs, easy to understand
|
||||
- ➖ Can't use context outside React tree
|
||||
- ➖ Re-renders if not memoized carefully
|
||||
|
||||
**Alternative Considered:** Zustand/Redux
|
||||
- Rejected: Overkill for game-specific state, harder to isolate per-game
|
||||
|
||||
### Decision: Phase-Based UI
|
||||
|
||||
**Choice:** Each game has distinct phases (setup, playing, results)
|
||||
|
||||
**Rationale:**
|
||||
- Clear separation of concerns
|
||||
- Easy to understand game flow
|
||||
- Each phase is independently testable
|
||||
- Natural mapping to game states
|
||||
|
||||
**Tradeoff:**
|
||||
- ➕ Organized, predictable
|
||||
- ➖ Some duplication (multiple components)
|
||||
- ➖ Can't have overlapping phases
|
||||
|
||||
**Pattern:**
|
||||
|
||||
```typescript
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
```
|
||||
|
||||
### Decision: Player Order from Set Iteration
|
||||
|
||||
**Choice:** Don't sort player arrays, use Set iteration order
|
||||
|
||||
**Rationale:**
|
||||
- Set order is consistent within a session
|
||||
- Matches UI display order (PageWithNav uses same Set)
|
||||
- Avoids alphabetical bias (first player isn't always "AAA")
|
||||
|
||||
**Tradeoff:**
|
||||
- ➕ UI and game logic always match
|
||||
- ➖ Order is not predictable across sessions
|
||||
- ➖ Different players see different orders (based on join time)
|
||||
|
||||
**Why not sort?**
|
||||
- Creates mismatch: UI shows Set order, game uses sorted order
|
||||
- Causes "skipping first player" bug (discovered in Number Guesser)
|
||||
|
||||
### Decision: No Optimistic Logic in Provider
|
||||
|
||||
**Choice:** Provider's `applyMove` just returns current state
|
||||
|
||||
```typescript
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
applyMove: (state, move) => state // Don't apply, wait for server
|
||||
})
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Keeps client logic minimal (less code to maintain)
|
||||
- Prevents client/server logic divergence
|
||||
- Server is authoritative (no client-side cheats)
|
||||
|
||||
**Tradeoff:**
|
||||
- ➕ Simple, secure
|
||||
- ➖ Slightly slower UX (wait for server)
|
||||
|
||||
**When to use client-side `applyMove`:**
|
||||
- Very fast-paced games (60fps animations)
|
||||
- Purely cosmetic updates (particles, sounds)
|
||||
- Never for game logic (scoring, winning, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
### From Number Guesser Implementation
|
||||
|
||||
#### 1. Type Coercion is Critical
|
||||
|
||||
**Problem:** WebSocket/JSON serialization converts numbers to strings.
|
||||
|
||||
```typescript
|
||||
// Client sends
|
||||
sendMove({ data: { guess: 42 } })
|
||||
|
||||
// Server receives
|
||||
move.data.guess === "42" // String! 😱
|
||||
```
|
||||
|
||||
**Solution:** Explicit coercion in validator
|
||||
|
||||
```typescript
|
||||
validateMove(state, move) {
|
||||
case 'MAKE_GUESS':
|
||||
return this.validateGuess(state, Number(move.data.guess))
|
||||
}
|
||||
```
|
||||
|
||||
**Lesson:** Always coerce types from `move.data` in validator.
|
||||
|
||||
**Symptom Observed:** User reported "first guess always rejected, second guess always correct" which was caused by:
|
||||
- First guess: `"42" < 1` evaluates to `false` (string comparison)
|
||||
- Validator thinks it's valid, calculates distance as `NaN`
|
||||
- `NaN === 0` is false, so guess is "wrong"
|
||||
- Second guess: `"50" < 1` also evaluates oddly, but `Math.abs("50" - 42)` coerces correctly
|
||||
- The behavior was unpredictable due to mixed type coercion
|
||||
|
||||
**Root Cause:** String comparison operators (`<`, `>`) have weird behavior with string numbers.
|
||||
|
||||
#### 2. Player Ordering Must Be Consistent
|
||||
|
||||
**Problem:** Set iteration order differed from sorted order, causing "skipped player" bug.
|
||||
|
||||
**Root Cause:**
|
||||
- UI used `Array.from(Set)` → Set iteration order
|
||||
- Game used `Array.from(Set).sort()` → Alphabetical order
|
||||
- Leftmost UI player ≠ First game player
|
||||
|
||||
**Solution:** Remove `.sort()` everywhere, use raw Set order.
|
||||
|
||||
**Lesson:** Player order must be identical in UI and game logic.
|
||||
|
||||
#### 3. Error Feedback is Essential
|
||||
|
||||
**Problem:** Moves rejected silently, users confused.
|
||||
|
||||
**Solution:** `lastError` state with auto-dismiss UI.
|
||||
|
||||
```typescript
|
||||
const { lastError, clearError } = useArcadeSession()
|
||||
|
||||
{lastError && (
|
||||
<ErrorBanner message={lastError} onDismiss={clearError} />
|
||||
)}
|
||||
```
|
||||
|
||||
**Lesson:** Always surface validation errors to users.
|
||||
|
||||
#### 4. Turn Indicators Improve UX
|
||||
|
||||
**Problem:** Players didn't know whose turn it was.
|
||||
|
||||
**Solution:** `currentPlayerId` prop to `PageWithNav`.
|
||||
|
||||
```typescript
|
||||
<PageWithNav
|
||||
currentPlayerId={state.currentPlayer}
|
||||
playerScores={state.scores}
|
||||
>
|
||||
```
|
||||
|
||||
**Lesson:** Visual feedback for turn-based games is critical.
|
||||
|
||||
#### 5. Round vs. Game Completion
|
||||
|
||||
**Problem:** Validator checked `!state.winner` for next round, but winner is only set when game ends.
|
||||
|
||||
**Root Cause:** Confused "round complete" (someone guessed) with "game complete" (someone won).
|
||||
|
||||
**Solution:** Check if last guess was correct:
|
||||
|
||||
```typescript
|
||||
const roundComplete = state.guesses.length > 0 &&
|
||||
state.guesses[state.guesses.length - 1].distance === 0
|
||||
```
|
||||
|
||||
**Lesson:** Be precise about what "complete" means (round vs. game).
|
||||
|
||||
#### 6. Debug Logging is Invaluable
|
||||
|
||||
**Problem:** Type issues caused subtle bugs (always correct guess).
|
||||
|
||||
**Solution:** Add logging in validator:
|
||||
|
||||
```typescript
|
||||
console.log('[NumberGuesser] Validating guess:', {
|
||||
guess,
|
||||
guessType: typeof guess,
|
||||
secretNumber: state.secretNumber,
|
||||
secretNumberType: typeof state.secretNumber,
|
||||
distance: Math.abs(guess - state.secretNumber)
|
||||
})
|
||||
```
|
||||
|
||||
**Lesson:** Log types and values during development.
|
||||
|
||||
---
|
||||
|
||||
## Future Improvements
|
||||
|
||||
### 1. Automated Testing
|
||||
|
||||
**Current State:** Manual testing only
|
||||
|
||||
**Proposal:**
|
||||
- Unit tests for validators (pure functions, easy to test)
|
||||
- Integration tests for Provider + useArcadeSession
|
||||
- E2E tests for full game flows (Playwright)
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
describe('NumberGuesserValidator', () => {
|
||||
it('should reject out-of-bounds guess', () => {
|
||||
const validator = new NumberGuesserValidator()
|
||||
const state = { minNumber: 1, maxNumber: 100, ... }
|
||||
const move = { type: 'MAKE_GUESS', data: { guess: 200 } }
|
||||
|
||||
const result = validator.validateMove(state, move)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('must be between')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Move History / Replay
|
||||
|
||||
**Current State:** No move history
|
||||
|
||||
**Proposal:**
|
||||
- Store all moves in database
|
||||
- Allow "replay" of games
|
||||
- Enable undo/redo (for certain games)
|
||||
- Analytics on player behavior
|
||||
|
||||
**Schema:**
|
||||
|
||||
```typescript
|
||||
interface GameSession {
|
||||
id: string
|
||||
roomId: string
|
||||
gameType: string
|
||||
moves: GameMove[]
|
||||
finalState: GameState
|
||||
startTime: number
|
||||
endTime: number
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Game Analytics
|
||||
|
||||
**Current State:** No analytics
|
||||
|
||||
**Proposal:**
|
||||
- Track game completions, durations, winners
|
||||
- Player skill ratings (Elo, TrueSkill)
|
||||
- Popular games dashboard
|
||||
- A/B testing for game variants
|
||||
|
||||
### 4. Spectator Mode
|
||||
|
||||
**Current State:** Only active players can view game
|
||||
|
||||
**Proposal:**
|
||||
- Allow non-players to watch
|
||||
- Spectators can't send moves (read-only)
|
||||
- Show spectator count in room
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
interface RoomMember {
|
||||
userId: string
|
||||
role: 'player' | 'spectator' | 'host'
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Game Variants
|
||||
|
||||
**Current State:** One config per game
|
||||
|
||||
**Proposal:**
|
||||
- Preset variants (Easy, Medium, Hard)
|
||||
- Custom rules per room
|
||||
- "House rules" feature
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const variants = {
|
||||
beginner: { minNumber: 1, maxNumber: 20, roundsToWin: 1 },
|
||||
standard: { minNumber: 1, maxNumber: 100, roundsToWin: 3 },
|
||||
expert: { minNumber: 1, maxNumber: 1000, roundsToWin: 5 },
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Tournaments / Brackets
|
||||
|
||||
**Current State:** Single-room games only
|
||||
|
||||
**Proposal:**
|
||||
- Multi-round tournaments
|
||||
- Bracket generation
|
||||
- Leaderboards
|
||||
|
||||
### 7. Game Mod Support
|
||||
|
||||
**Current State:** Games are hard-coded
|
||||
|
||||
**Proposal:**
|
||||
- Load games from external bundles
|
||||
- Community-created games
|
||||
- Sandboxed execution (Deno, WASM)
|
||||
|
||||
**Challenges:**
|
||||
- Security (untrusted code)
|
||||
- Type safety (dynamic loading)
|
||||
- Versioning (breaking changes)
|
||||
|
||||
### 8. Voice/Video Chat
|
||||
|
||||
**Current State:** Text chat only (if implemented)
|
||||
|
||||
**Proposal:**
|
||||
- WebRTC voice/video
|
||||
- Per-room channels
|
||||
- Mute/kick controls
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Key Files Reference
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/arcade/game-sdk/index.ts` | SDK exports (public API) |
|
||||
| `src/lib/arcade/game-registry.ts` | Game registration |
|
||||
| `src/lib/arcade/manifest-schema.ts` | Manifest validation |
|
||||
| `src/hooks/useArcadeSession.ts` | Session management hook |
|
||||
| `src/hooks/useArcadeSocket.ts` | WebSocket connection |
|
||||
| `src/hooks/useOptimisticGameState.ts` | Optimistic state management |
|
||||
| `src/contexts/GameModeContext.tsx` | Player management |
|
||||
| `src/components/PageWithNav.tsx` | Game navigation wrapper |
|
||||
| `src/arcade-games/number-guesser/` | Example game implementation |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Game Development Guide](../arcade-games/README.md) - Step-by-step guide to creating games
|
||||
- [API Reference](./arcade-game-api-reference.md) - Complete SDK API documentation (TODO)
|
||||
- [Deployment Guide](./arcade-game-deployment.md) - How to deploy new games (TODO)
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-10-15*
|
||||
33
apps/web/drizzle/0011_add_room_game_configs.sql
Normal file
33
apps/web/drizzle/0011_add_room_game_configs.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Create room_game_configs table for normalized game settings storage
|
||||
-- This migration is safe to run multiple times (uses IF NOT EXISTS)
|
||||
|
||||
-- Create the table
|
||||
CREATE TABLE IF NOT EXISTS `room_game_configs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`game_name` text NOT NULL,
|
||||
`config` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Create unique index
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `room_game_idx` ON `room_game_configs` (`room_id`,`game_name`);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Migrate existing game configs from arcade_rooms.game_config column
|
||||
-- This INSERT will only run if data hasn't been migrated yet
|
||||
INSERT OR IGNORE INTO room_game_configs (id, room_id, game_name, config, created_at, updated_at)
|
||||
SELECT
|
||||
lower(hex(randomblob(16))) as id,
|
||||
id as room_id,
|
||||
game_name,
|
||||
game_config as config,
|
||||
created_at,
|
||||
last_activity as updated_at
|
||||
FROM arcade_rooms
|
||||
WHERE game_config IS NOT NULL
|
||||
AND game_name IS NOT NULL
|
||||
AND game_name IN ('matching', 'memory-quiz', 'complement-race');
|
||||
@@ -78,6 +78,13 @@
|
||||
"when": 1760700000000,
|
||||
"tag": "0010_make_game_name_nullable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1760800000000,
|
||||
"tag": "0011_add_room_game_configs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"drizzle-orm": "^0.44.6",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"jose": "^6.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"make-plural": "^7.4.0",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -69,7 +70,8 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-layout": "^0.7.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.1",
|
||||
@@ -80,6 +82,7 @@
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
|
||||
@@ -7,6 +7,9 @@ import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getAllGameConfigs, setGameConfig } from '@/lib/arcade/game-config-helpers'
|
||||
import { isValidGameName } from '@/lib/arcade/validators'
|
||||
import type { GameName } from '@/lib/arcade/validators'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -18,8 +21,11 @@ type RouteContext = {
|
||||
* Body:
|
||||
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
|
||||
* - password?: string (plain text, will be hashed)
|
||||
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
|
||||
* - gameName?: string | null (any game with a registered validator)
|
||||
* - gameConfig?: object (game-specific settings)
|
||||
*
|
||||
* Note: gameName is validated at runtime against the validator registry.
|
||||
* No need to update this file when adding new games!
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
@@ -27,6 +33,36 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
console.log(
|
||||
'[Settings API] PATCH request received:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId,
|
||||
body,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Read current room state from database BEFORE any changes
|
||||
const [currentRoom] = await db
|
||||
.select()
|
||||
.from(schema.arcadeRooms)
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
|
||||
console.log(
|
||||
'[Settings API] Current room state in database BEFORE update:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameName: currentRoom?.gameName,
|
||||
gameConfig: currentRoom?.gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
@@ -60,11 +96,15 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
)
|
||||
}
|
||||
|
||||
// Validate gameName if provided
|
||||
// Validate gameName if provided - check against validator registry at runtime
|
||||
if (body.gameName !== undefined && body.gameName !== null) {
|
||||
const validGames = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
if (!isValidGameName(body.gameName)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid game name: ${body.gameName}. Game must have a registered validator.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +132,23 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
updateData.gameName = body.gameName
|
||||
}
|
||||
|
||||
// Update game config if provided
|
||||
if (body.gameConfig !== undefined) {
|
||||
updateData.gameConfig = body.gameConfig
|
||||
// Handle game config updates - write to new room_game_configs table
|
||||
if (body.gameConfig !== undefined && body.gameConfig !== null) {
|
||||
// body.gameConfig is expected to be nested by game name: { matching: {...}, memory-quiz: {...} }
|
||||
// Extract each game's config and write to the new table
|
||||
for (const [gameName, config] of Object.entries(body.gameConfig)) {
|
||||
if (config && typeof config === 'object') {
|
||||
await setGameConfig(roomId, gameName as GameName, config)
|
||||
console.log(`[Settings API] Wrote ${gameName} config to room_game_configs table`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[Settings API] Update data to be written to database:',
|
||||
JSON.stringify(updateData, null, 2)
|
||||
)
|
||||
|
||||
// If game is being changed (or cleared), delete the existing arcade session
|
||||
// This ensures a fresh session will be created with the new game settings
|
||||
if (body.gameName !== undefined) {
|
||||
@@ -104,12 +156,30 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId))
|
||||
}
|
||||
|
||||
// Update room settings
|
||||
const [updatedRoom] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set(updateData)
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.returning()
|
||||
// Update room settings (only if there's something to update)
|
||||
let updatedRoom = currentRoom
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
;[updatedRoom] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
.set(updateData)
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.returning()
|
||||
}
|
||||
|
||||
// Get aggregated game configs from new table
|
||||
const gameConfig = await getAllGameConfigs(roomId)
|
||||
|
||||
console.log(
|
||||
'[Settings API] Room state in database AFTER update:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameName: updatedRoom.gameName,
|
||||
gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Broadcast game change to all room members
|
||||
if (body.gameName !== undefined) {
|
||||
@@ -117,11 +187,17 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
if (io) {
|
||||
try {
|
||||
console.log(`[Settings API] Broadcasting game change to room ${roomId}: ${body.gameName}`)
|
||||
io.to(`room:${roomId}`).emit('room-game-changed', {
|
||||
const broadcastData: {
|
||||
roomId: string
|
||||
gameName: string | null
|
||||
gameConfig?: Record<string, unknown>
|
||||
} = {
|
||||
roomId,
|
||||
gameName: body.gameName,
|
||||
gameConfig: body.gameConfig || {},
|
||||
})
|
||||
gameConfig, // Include aggregated configs from new table
|
||||
}
|
||||
|
||||
io.to(`room:${roomId}`).emit('room-game-changed', broadcastData)
|
||||
} catch (socketError) {
|
||||
console.error('[Settings API] Failed to broadcast game change:', socketError)
|
||||
}
|
||||
@@ -194,7 +270,15 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ room: updatedRoom }, { status: 200 })
|
||||
return NextResponse.json(
|
||||
{
|
||||
room: {
|
||||
...updatedRoom,
|
||||
gameConfig, // Include aggregated configs from new table
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update room settings:', error)
|
||||
return NextResponse.json({ error: 'Failed to update room settings' }, { status: 500 })
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getAllGameConfigs } from '@/lib/arcade/game-config-helpers'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms/current
|
||||
@@ -28,6 +29,22 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get game configs from new room_game_configs table
|
||||
const gameConfig = await getAllGameConfigs(roomId)
|
||||
|
||||
console.log(
|
||||
'[Current Room API] Room data READ from database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId,
|
||||
gameName: room.gameName,
|
||||
gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Get members
|
||||
const members = await getRoomMembers(roomId)
|
||||
|
||||
@@ -41,7 +58,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
room: {
|
||||
...room,
|
||||
gameConfig, // Override with configs from new table
|
||||
},
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import type { GameName } from '@/lib/arcade/validation'
|
||||
import { hasValidator, type GameName } from '@/lib/arcade/validators'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms
|
||||
@@ -72,8 +72,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// Validate game name if provided (gameName is now optional)
|
||||
if (body.gameName) {
|
||||
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
if (!hasValidator(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import {
|
||||
buildPlayerMetadata as buildPlayerMetadataUtil,
|
||||
@@ -240,6 +240,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // Fetch room data for room-based play
|
||||
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
@@ -247,8 +248,77 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// NO LOCAL STATE - Configuration lives in session state
|
||||
// Changes are sent as moves and synchronized across all room members
|
||||
// Track roomData.gameConfig changes
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] roomData.gameConfig changed:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameConfig: roomData?.gameConfig,
|
||||
roomId: roomData?.id,
|
||||
gameName: roomData?.gameName,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}, [roomData?.gameConfig, roomData?.id, roomData?.gameName])
|
||||
|
||||
// Merge saved game config from room with initialState
|
||||
// Settings are scoped by game name to preserve settings when switching games
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Loading settings from database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameConfig,
|
||||
roomId: roomData?.id,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomMemoryPairsProvider] No gameConfig, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
// Get settings for this specific game (matching)
|
||||
const savedConfig = gameConfig.matching as Record<string, any> | null | undefined
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saved config for matching:',
|
||||
JSON.stringify(savedConfig, null, 2)
|
||||
)
|
||||
|
||||
if (!savedConfig) {
|
||||
console.log('[RoomMemoryPairsProvider] No saved config for matching, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...initialState,
|
||||
// Restore settings from saved config
|
||||
gameType: savedConfig.gameType ?? initialState.gameType,
|
||||
difficulty: savedConfig.difficulty ?? initialState.difficulty,
|
||||
turnTimer: savedConfig.turnTimer ?? initialState.turnTimer,
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Merged state:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameType: merged.gameType,
|
||||
difficulty: merged.difficulty,
|
||||
turnTimer: merged.turnTimer,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
return merged
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration WITH room sync
|
||||
const {
|
||||
@@ -259,7 +329,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
|
||||
initialState,
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
@@ -498,6 +568,8 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setGameType = useCallback(
|
||||
(gameType: typeof state.gameType) => {
|
||||
console.log('[RoomMemoryPairsProvider] setGameType called:', gameType)
|
||||
|
||||
// Use first active player as playerId, or empty string if none
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
@@ -506,12 +578,45 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId || '',
|
||||
data: { field: 'gameType', value: gameType },
|
||||
})
|
||||
|
||||
// Save setting to room's gameConfig for persistence
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
matching: {
|
||||
...currentMatchingConfig,
|
||||
gameType,
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving gameType to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
updatedConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save gameType - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove, viewerId]
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const setDifficulty = useCallback(
|
||||
(difficulty: typeof state.difficulty) => {
|
||||
console.log('[RoomMemoryPairsProvider] setDifficulty called:', difficulty)
|
||||
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
@@ -519,12 +624,45 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId || '',
|
||||
data: { field: 'difficulty', value: difficulty },
|
||||
})
|
||||
|
||||
// Save setting to room's gameConfig for persistence
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
matching: {
|
||||
...currentMatchingConfig,
|
||||
difficulty,
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving difficulty to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
updatedConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save difficulty - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove, viewerId]
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const setTurnTimer = useCallback(
|
||||
(turnTimer: typeof state.turnTimer) => {
|
||||
console.log('[RoomMemoryPairsProvider] setTurnTimer called:', turnTimer)
|
||||
|
||||
const playerId = activePlayers[0] || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
@@ -532,8 +670,39 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId || '',
|
||||
data: { field: 'turnTimer', value: turnTimer },
|
||||
})
|
||||
|
||||
// Save setting to room's gameConfig for persistence
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
const currentMatchingConfig = (currentGameConfig.matching as Record<string, any>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
matching: {
|
||||
...currentMatchingConfig,
|
||||
turnTimer,
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving turnTimer to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
updatedConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save turnTimer - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove, viewerId]
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
|
||||
@@ -210,23 +210,19 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
// Settings are scoped by game name to preserve settings when switching games
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
|
||||
console.log('[RoomMemoryQuizProvider] Initializing - gameConfig:', gameConfig)
|
||||
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomMemoryQuizProvider] No gameConfig, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
// Get settings for this specific game (memory-quiz)
|
||||
const savedConfig = gameConfig['memory-quiz'] as Record<string, any> | null | undefined
|
||||
console.log('[RoomMemoryQuizProvider] Loading saved config for memory-quiz:', savedConfig)
|
||||
|
||||
if (!savedConfig) {
|
||||
console.log('[RoomMemoryQuizProvider] No saved config for memory-quiz, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
const merged = {
|
||||
return {
|
||||
...initialState,
|
||||
// Restore settings from saved config
|
||||
selectedCount: savedConfig.selectedCount ?? initialState.selectedCount,
|
||||
@@ -234,14 +230,6 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
selectedDifficulty: savedConfig.selectedDifficulty ?? initialState.selectedDifficulty,
|
||||
playMode: savedConfig.playMode ?? initialState.playMode,
|
||||
}
|
||||
console.log('[RoomMemoryQuizProvider] Merged state:', {
|
||||
selectedCount: merged.selectedCount,
|
||||
displayTime: merged.displayTime,
|
||||
selectedDifficulty: merged.selectedDifficulty,
|
||||
playMode: merged.playMode,
|
||||
})
|
||||
|
||||
return merged
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration WITH room sync
|
||||
@@ -399,26 +387,19 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
|
||||
// Settings are scoped by game name to preserve settings when switching games
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
console.log('[RoomMemoryQuizProvider] Current gameConfig:', currentGameConfig)
|
||||
|
||||
const currentMemoryQuizConfig =
|
||||
(currentGameConfig['memory-quiz'] as Record<string, any>) || {}
|
||||
console.log('[RoomMemoryQuizProvider] Current memory-quiz config:', currentMemoryQuizConfig)
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'memory-quiz': {
|
||||
...currentMemoryQuizConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
console.log('[RoomMemoryQuizProvider] Saving updated gameConfig:', updatedConfig)
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
gameConfig: {
|
||||
...currentGameConfig,
|
||||
'memory-quiz': {
|
||||
...currentMemoryQuizConfig,
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryQuizProvider] No roomData.id, cannot save config')
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
// Map GameType keys to internal game names
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
@@ -89,13 +90,35 @@ export default function RoomPage() {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
const gameConfig = GAMES_CONFIG[gameType]
|
||||
// Check if it's a registry game first
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType, // Use the game name directly for registry games
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy game handling
|
||||
const gameConfig = GAMES_CONFIG[gameType as keyof typeof GAMES_CONFIG]
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Game config:', {
|
||||
name: gameConfig.name,
|
||||
available: gameConfig.available,
|
||||
available: 'available' in gameConfig ? gameConfig.available : true,
|
||||
})
|
||||
|
||||
if (gameConfig.available === false) {
|
||||
if ('available' in gameConfig && gameConfig.available === false) {
|
||||
console.log('[RoomPage] Game not available, blocking selection')
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
@@ -160,64 +183,158 @@ export default function RoomPage() {
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={config.available === false}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: config.available === false ? 'not-allowed' : 'pointer',
|
||||
opacity: config.available === false ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover:
|
||||
config.available === false
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the appropriate game based on room's gameName
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
|
||||
816
apps/web/src/arcade-games/README.md
Normal file
816
apps/web/src/arcade-games/README.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# Arcade Game System
|
||||
|
||||
A modular, plugin-based architecture for building multiplayer arcade games with real-time synchronization.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [Game SDK](#game-sdk)
|
||||
- [Creating a New Game](#creating-a-new-game)
|
||||
- [File Structure](#file-structure)
|
||||
- [Examples](#examples)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Modularity**: Each game is self-contained and independently deployable
|
||||
2. **Type Safety**: Full TypeScript support with compile-time validation
|
||||
3. **Real-time Sync**: Built-in multiplayer support via WebSocket
|
||||
4. **Optimistic Updates**: Instant client feedback with server validation
|
||||
5. **Consistent UX**: Shared navigation, player management, and room features
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Plugin Architecture**: Games register themselves with a central registry
|
||||
- **Stable SDK API**: Games only import from `@/lib/arcade/game-sdk`
|
||||
- **Server-side Validation**: All moves validated server-side with client rollback
|
||||
- **Automatic State Sync**: Multi-client synchronization handled automatically
|
||||
- **Turn Indicators**: Built-in UI for showing active player
|
||||
- **Error Handling**: Standardized error feedback to users
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game Registry │
|
||||
│ - Registers all available games │
|
||||
│ - Provides game discovery │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Game SDK │
|
||||
│ - Stable API surface for games │
|
||||
│ - React hooks (useArcadeSession, useRoomData, etc.) │
|
||||
│ - Type definitions and utilities │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Individual Games │
|
||||
│ number-guesser/ │
|
||||
│ ├── index.ts (Game definition) │
|
||||
│ ├── Validator.ts (Server validation) │
|
||||
│ ├── Provider.tsx (Client state management) │
|
||||
│ ├── GameComponent.tsx (Main UI) │
|
||||
│ ├── types.ts (TypeScript types) │
|
||||
│ └── components/ (Phase UIs) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User Action → Provider (sendMove)
|
||||
↓
|
||||
useArcadeSession
|
||||
↓
|
||||
Optimistic Update (instant UI feedback)
|
||||
↓
|
||||
WebSocket → Server
|
||||
↓
|
||||
Validator.validateMove()
|
||||
↓
|
||||
✓ Valid: State Update → Broadcast
|
||||
✗ Invalid: Reject → Client Rollback
|
||||
↓
|
||||
Client receives validated state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Game SDK
|
||||
|
||||
### Core API Surface
|
||||
|
||||
```typescript
|
||||
import {
|
||||
// Types
|
||||
type GameDefinition,
|
||||
type GameValidator,
|
||||
type GameState,
|
||||
type GameMove,
|
||||
type GameConfig,
|
||||
type ValidationResult,
|
||||
|
||||
// React Hooks
|
||||
useArcadeSession,
|
||||
useRoomData,
|
||||
useGameMode,
|
||||
useViewerId,
|
||||
useUpdateGameConfig,
|
||||
|
||||
// Utilities
|
||||
defineGame,
|
||||
buildPlayerMetadata,
|
||||
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
#### GameDefinition
|
||||
|
||||
Complete description of a game:
|
||||
|
||||
```typescript
|
||||
interface GameDefinition<TConfig, TState, TMove> {
|
||||
manifest: GameManifest // Display info, max players, etc.
|
||||
Provider: GameProviderComponent // React context provider
|
||||
GameComponent: GameComponent // Main UI component
|
||||
validator: GameValidator // Server-side validation
|
||||
defaultConfig: TConfig // Default game settings
|
||||
}
|
||||
```
|
||||
|
||||
#### GameState
|
||||
|
||||
The complete game state that's synchronized across all clients:
|
||||
|
||||
```typescript
|
||||
interface GameState {
|
||||
gamePhase: string // Current phase (setup, playing, results)
|
||||
activePlayers: string[] // Array of player IDs
|
||||
playerMetadata: Record<string, PlayerMeta> // Player info (name, emoji, etc.)
|
||||
// ... game-specific state
|
||||
}
|
||||
```
|
||||
|
||||
#### GameMove
|
||||
|
||||
Actions that players take, validated server-side:
|
||||
|
||||
```typescript
|
||||
interface GameMove {
|
||||
type: string // Move type (e.g., 'FLIP_CARD', 'MAKE_GUESS')
|
||||
playerId: string // Player making the move
|
||||
userId: string // User ID (for authentication)
|
||||
timestamp: number // Client timestamp
|
||||
data: Record<string, unknown> // Move-specific payload
|
||||
}
|
||||
```
|
||||
|
||||
#### GameValidator
|
||||
|
||||
Server-side validation logic:
|
||||
|
||||
```typescript
|
||||
interface GameValidator<TState, TMove> {
|
||||
validateMove(state: TState, move: TMove): ValidationResult
|
||||
isGameComplete(state: TState): boolean
|
||||
getInitialState(config: unknown): TState
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating a New Game
|
||||
|
||||
### Step 1: Create Game Directory
|
||||
|
||||
```bash
|
||||
mkdir -p src/arcade-games/my-game/components
|
||||
```
|
||||
|
||||
### Step 2: Define Types (`types.ts`)
|
||||
|
||||
```typescript
|
||||
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
|
||||
|
||||
// Game configuration (persisted to database)
|
||||
export interface MyGameConfig extends GameConfig {
|
||||
difficulty: number
|
||||
timer: number
|
||||
}
|
||||
|
||||
// Game state (synchronized across clients)
|
||||
export interface MyGameState extends GameState {
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, PlayerMetadata>
|
||||
currentPlayer: string
|
||||
score: Record<string, number>
|
||||
// ... your game-specific state
|
||||
}
|
||||
|
||||
// Move types
|
||||
export type MyGameMove =
|
||||
| { type: 'START_GAME'; playerId: string; userId: string; timestamp: number; data: { activePlayers: string[] } }
|
||||
| { type: 'MAKE_MOVE'; playerId: string; userId: string; timestamp: number; data: { /* move data */ } }
|
||||
| { type: 'END_GAME'; playerId: string; userId: string; timestamp: number; data: {} }
|
||||
```
|
||||
|
||||
### Step 3: Create Validator (`Validator.ts`)
|
||||
|
||||
```typescript
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type { MyGameState, MyGameMove } from './types'
|
||||
|
||||
export class MyGameValidator implements GameValidator<MyGameState, MyGameMove> {
|
||||
validateMove(state: MyGameState, move: MyGameMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers)
|
||||
case 'MAKE_MOVE':
|
||||
return this.validateMakeMove(state, move.playerId, move.data)
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(state: MyGameState, activePlayers: string[]): ValidationResult {
|
||||
if (activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
const newState: MyGameState = {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
activePlayers,
|
||||
currentPlayer: activePlayers[0],
|
||||
score: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// ... more validation methods
|
||||
|
||||
isGameComplete(state: MyGameState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
getInitialState(config: unknown): MyGameState {
|
||||
const { difficulty, timer } = config as MyGameConfig
|
||||
return {
|
||||
difficulty,
|
||||
timer,
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
currentPlayer: '',
|
||||
score: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const myGameValidator = new MyGameValidator()
|
||||
```
|
||||
|
||||
### Step 4: Create Provider (`Provider.tsx`)
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo } from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import type { MyGameState } from './types'
|
||||
|
||||
interface MyGameContextValue {
|
||||
state: MyGameState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
makeMove: (data: any) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const MyGameContext = createContext<MyGameContextValue | null>(null)
|
||||
|
||||
export function useMyGame() {
|
||||
const context = useContext(MyGameContext)
|
||||
if (!context) throw new Error('useMyGame must be used within MyGameProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function MyGameProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
|
||||
// Get active players as array (keep Set iteration order to match UI display)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
const initialState = useMemo(() => ({
|
||||
difficulty: 1,
|
||||
timer: 30,
|
||||
gamePhase: 'setup' as const,
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
currentPlayer: '',
|
||||
score: {},
|
||||
}), [])
|
||||
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<MyGameState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: (state, move) => state, // Server handles all updates
|
||||
})
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: activePlayers[0],
|
||||
userId: viewerId || '',
|
||||
data: { activePlayers, playerMetadata },
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const makeMove = useCallback((data: any) => {
|
||||
sendMove({
|
||||
type: 'MAKE_MOVE',
|
||||
playerId: state.currentPlayer,
|
||||
userId: viewerId || '',
|
||||
data,
|
||||
})
|
||||
}, [state.currentPlayer, viewerId, sendMove])
|
||||
|
||||
return (
|
||||
<MyGameContext.Provider value={{
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
makeMove,
|
||||
clearError,
|
||||
exitSession,
|
||||
}}>
|
||||
{children}
|
||||
</MyGameContext.Provider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Create Game Component (`GameComponent.tsx`)
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useMyGame } from '../Provider'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession } = useMyGame()
|
||||
|
||||
// Determine whose turn it is for the turn indicator
|
||||
const currentPlayerId = state.gamePhase === 'playing' ? state.currentPlayer : undefined
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="My Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={state.score}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Define Game (`index.ts`)
|
||||
|
||||
```typescript
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { MyGameProvider } from './Provider'
|
||||
import type { MyGameConfig, MyGameMove, MyGameState } from './types'
|
||||
import { myGameValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'my-game',
|
||||
displayName: 'My Awesome Game',
|
||||
icon: '🎮',
|
||||
description: 'A fun multiplayer game',
|
||||
longDescription: 'Detailed description of gameplay...',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '🎲 Turn-Based'],
|
||||
color: 'blue',
|
||||
gradient: 'linear-gradient(135deg, #bfdbfe, #93c5fd)',
|
||||
borderColor: 'blue.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: MyGameConfig = {
|
||||
difficulty: 1,
|
||||
timer: 30,
|
||||
}
|
||||
|
||||
export const myGame = defineGame<MyGameConfig, MyGameState, MyGameMove>({
|
||||
manifest,
|
||||
Provider: MyGameProvider,
|
||||
GameComponent,
|
||||
validator: myGameValidator,
|
||||
defaultConfig,
|
||||
})
|
||||
```
|
||||
|
||||
### Step 7: Register Game
|
||||
|
||||
#### 7a. Register Validator (Server-Side)
|
||||
|
||||
Add your validator to the unified registry in `src/lib/arcade/validators.ts`:
|
||||
|
||||
```typescript
|
||||
import { myGameValidator } from '@/arcade-games/my-game/Validator'
|
||||
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
'my-game': myGameValidator, // Add your game here!
|
||||
// GameName type will auto-update from these keys
|
||||
} as const
|
||||
```
|
||||
|
||||
**Why**: The validator registry is isomorphic (runs on both client and server) and serves as the single source of truth for all game validators. Adding your validator here automatically:
|
||||
- Makes it available for server-side move validation
|
||||
- Updates the `GameName` type (no manual type updates needed!)
|
||||
- Enables your game for multiplayer rooms
|
||||
|
||||
#### 7b. Register Game Definition (Client-Side)
|
||||
|
||||
Add to `src/lib/arcade/game-registry.ts`:
|
||||
|
||||
```typescript
|
||||
import { myGame } from '@/arcade-games/my-game'
|
||||
|
||||
registerGame(myGame)
|
||||
```
|
||||
|
||||
**Why**: The game registry is client-only and connects your game's UI components (Provider, GameComponent) with the arcade system. Registration happens on client init and verifies that your validator is also registered server-side.
|
||||
|
||||
**Verification**: When you register a game, the registry will warn you if:
|
||||
- ⚠️ The validator is missing from `validators.ts`
|
||||
- ⚠️ The validator instance doesn't match (different imports)
|
||||
|
||||
**Important**: Both steps are required for a working game. The validator registry handles server logic, while the game registry handles client UI.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/arcade-games/my-game/
|
||||
├── index.ts # Game definition and export
|
||||
├── Validator.ts # Server-side move validation
|
||||
├── Provider.tsx # Client state management
|
||||
├── GameComponent.tsx # Main UI wrapper
|
||||
├── types.ts # TypeScript type definitions
|
||||
└── components/
|
||||
├── SetupPhase.tsx # Setup/lobby UI
|
||||
├── PlayingPhase.tsx # Main gameplay UI
|
||||
└── ResultsPhase.tsx # End game/scores UI
|
||||
```
|
||||
|
||||
### File Responsibilities
|
||||
|
||||
| File | Purpose | Runs On |
|
||||
|------|---------|---------|
|
||||
| `index.ts` | Game registration | Both |
|
||||
| `Validator.ts` | Move validation, game logic | **Server only** |
|
||||
| `Provider.tsx` | State management, API calls | Client only |
|
||||
| `GameComponent.tsx` | Navigation, phase routing | Client only |
|
||||
| `types.ts` | Shared type definitions | Both |
|
||||
| `components/*` | UI for each game phase | Client only |
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Number Guesser (Turn-Based)
|
||||
|
||||
See `src/arcade-games/number-guesser/` for a complete example of:
|
||||
- Turn-based gameplay (chooser → guessers)
|
||||
- Player rotation logic
|
||||
- Round management
|
||||
- Score tracking
|
||||
- Hot/cold feedback system
|
||||
- Error handling and user feedback
|
||||
|
||||
**Key Patterns:**
|
||||
- Setting `currentPlayerId` for turn indicators
|
||||
- Rotating turns in validator
|
||||
- Handling round vs. game completion
|
||||
- Type coercion for JSON-serialized numbers
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Player Ordering Consistency
|
||||
|
||||
**Problem**: Sets don't guarantee order, causing mismatch between UI and game logic.
|
||||
|
||||
**Solution**: Use `Array.from(activePlayerIds)` without sorting in both UI and game logic.
|
||||
|
||||
```typescript
|
||||
// In Provider
|
||||
const activePlayers = Array.from(activePlayerIds) // NO .sort()
|
||||
|
||||
// In Validator
|
||||
const newState = {
|
||||
...state,
|
||||
currentPlayer: activePlayers[0], // First in Set order = first in UI
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Type Coercion for Numbers
|
||||
|
||||
**Problem**: WebSocket JSON serialization converts numbers to strings.
|
||||
|
||||
**Solution**: Explicitly coerce in validator:
|
||||
|
||||
```typescript
|
||||
validateMove(state: MyGameState, move: MyGameMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'MAKE_GUESS':
|
||||
return this.validateGuess(state, Number(move.data.guess)) // Coerce!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Error Feedback
|
||||
|
||||
**Problem**: Users don't see why their moves were rejected.
|
||||
|
||||
**Solution**: Use `lastError` from `useArcadeSession`:
|
||||
|
||||
```typescript
|
||||
const { state, lastError, clearError } = useArcadeSession(...)
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
// Show in UI
|
||||
{lastError && (
|
||||
<div className="error-banner">
|
||||
<div>⚠️ Move Rejected</div>
|
||||
<div>{lastError}</div>
|
||||
<button onClick={clearError}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 4. Turn Indicators
|
||||
|
||||
**Problem**: Players don't know whose turn it is.
|
||||
|
||||
**Solution**: Pass `currentPlayerId` to `PageWithNav`:
|
||||
|
||||
```typescript
|
||||
<PageWithNav
|
||||
currentPlayerId={state.currentPlayer}
|
||||
playerScores={state.scores}
|
||||
>
|
||||
```
|
||||
|
||||
### 5. Server-Only Logic
|
||||
|
||||
**Problem**: Client can cheat by modifying local state.
|
||||
|
||||
**Solution**: All game logic in validator, client uses `applyMove: (state) => state`:
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Client calculates winner
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
applyMove: (state, move) => {
|
||||
if (move.type === 'SCORE') {
|
||||
return { ...state, winner: calculateWinner(state) } // Cheatable!
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ✅ GOOD: Server calculates everything
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
applyMove: (state, move) => state // Client just waits for server
|
||||
})
|
||||
```
|
||||
|
||||
### 6. Phase Management
|
||||
|
||||
Use discriminated union for type-safe phase rendering:
|
||||
|
||||
```typescript
|
||||
type GamePhase = 'setup' | 'playing' | 'results'
|
||||
|
||||
interface MyGameState {
|
||||
gamePhase: GamePhase
|
||||
// ...
|
||||
}
|
||||
|
||||
// In GameComponent
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Player not found" errors
|
||||
|
||||
**Cause**: Player IDs from `useGameMode()` don't match server state.
|
||||
|
||||
**Fix**: Always use `buildPlayerMetadata()` helper:
|
||||
|
||||
```typescript
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId)
|
||||
```
|
||||
|
||||
### Turn indicator not showing
|
||||
|
||||
**Cause**: `currentPlayerId` not passed or doesn't match player IDs in UI.
|
||||
|
||||
**Fix**: Verify player order matches between game state and `activePlayerIds`:
|
||||
|
||||
```typescript
|
||||
// Both should use same source without sorting
|
||||
const activePlayers = Array.from(activePlayerIds) // Provider
|
||||
const activePlayerList = Array.from(activePlayers) // PageWithNav
|
||||
```
|
||||
|
||||
### Moves rejected with type errors
|
||||
|
||||
**Cause**: JSON serialization converts numbers to strings.
|
||||
|
||||
**Fix**: Add `Number()` coercion in validator:
|
||||
|
||||
```typescript
|
||||
case 'SET_VALUE':
|
||||
return this.validateValue(state, Number(move.data.value))
|
||||
```
|
||||
|
||||
### State not syncing across clients
|
||||
|
||||
**Cause**: Not using `useArcadeSession` correctly.
|
||||
|
||||
**Fix**: Ensure `roomId` is passed:
|
||||
|
||||
```typescript
|
||||
const { state, sendMove } = useArcadeSession({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Required for room sync!
|
||||
initialState,
|
||||
applyMove: (state) => state,
|
||||
})
|
||||
```
|
||||
|
||||
### Game not appearing in selector
|
||||
|
||||
**Cause**: Not registered or `available: false`.
|
||||
|
||||
**Fix**:
|
||||
1. Add to `game-registry.ts`: `registerGame(myGame)`
|
||||
2. Set `available: true` in manifest
|
||||
3. Verify no console errors on import
|
||||
|
||||
### Config changes not taking effect
|
||||
|
||||
**Cause**: State sync timing - validator uses old state while config is being updated.
|
||||
|
||||
**Context**: When you change game config (e.g., min/max numbers), there's a brief window where:
|
||||
1. Client updates config in database
|
||||
2. Config change hasn't propagated to server state yet
|
||||
3. Moves are validated against old state
|
||||
|
||||
**Fix**: Ensure config changes trigger state reset or are applied atomically:
|
||||
|
||||
```typescript
|
||||
// When changing config, also update initialState
|
||||
const setConfig = useCallback((field, value) => {
|
||||
sendMove({ type: 'SET_CONFIG', data: { field, value } })
|
||||
|
||||
// Persist to database for next session
|
||||
if (roomData?.id) {
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...roomData.gameConfig,
|
||||
'my-game': { ...currentConfig, [field]: value }
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [sendMove, updateGameConfig, roomData])
|
||||
```
|
||||
|
||||
**Best Practice**: Make config changes only during setup phase, before game starts.
|
||||
|
||||
### Debugging validation errors
|
||||
|
||||
**Problem**: Moves rejected but unclear why (especially type-related issues).
|
||||
|
||||
**Solution**: Add debug logging in validator:
|
||||
|
||||
```typescript
|
||||
private validateGuess(state: State, guess: number): ValidationResult {
|
||||
// Debug logging
|
||||
console.log('[MyGame] Validating guess:', {
|
||||
guess,
|
||||
guessType: typeof guess, // Check if it's a string!
|
||||
min: state.minNumber,
|
||||
minType: typeof state.minNumber,
|
||||
max: state.maxNumber,
|
||||
maxType: typeof state.maxNumber,
|
||||
})
|
||||
|
||||
if (guess < state.minNumber || guess > state.maxNumber) {
|
||||
return { valid: false, error: `Guess must be between ${state.minNumber} and ${state.maxNumber}` }
|
||||
}
|
||||
|
||||
// ... rest of validation
|
||||
}
|
||||
```
|
||||
|
||||
**What to check:**
|
||||
1. **Browser console**: Look for `[ArcadeSession] Move rejected by server:` messages
|
||||
2. **Server logs**: Check validator console.log output for types and values
|
||||
3. **Type mismatches**: Numbers becoming strings is the #1 issue
|
||||
4. **State sync**: Is the validator using the state you expect?
|
||||
|
||||
**Common debugging workflow:**
|
||||
1. Move rejected → Check browser console for error message
|
||||
2. Error unclear → Add console.log to validator
|
||||
3. Restart server → See debug output when move is made
|
||||
4. Compare expected vs. actual values/types
|
||||
5. Add `Number()` coercion if types don't match
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Game SDK**: `src/lib/arcade/game-sdk/`
|
||||
- **Registry**: `src/lib/arcade/game-registry.ts`
|
||||
- **Example Game**: `src/arcade-games/number-guesser/`
|
||||
- **Validation Types**: `src/lib/arcade/validation/types.ts`
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I use external libraries in my game?**
|
||||
A: Yes, but install them in the workspace package.json. Games should be self-contained.
|
||||
|
||||
**Q: How do I add game configuration that persists?**
|
||||
A: Use `useUpdateGameConfig()` to save to room:
|
||||
|
||||
```typescript
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...roomData.gameConfig,
|
||||
'my-game': { difficulty: 5 }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Q: Can I have asymmetric player roles?**
|
||||
A: Yes! See Number Guesser's chooser/guesser pattern.
|
||||
|
||||
**Q: How do I handle real-time timers?**
|
||||
A: Store `startTime` in state, use client-side countdown, server validates elapsed time.
|
||||
|
||||
**Q: What's the difference between `playerId` and `userId`?**
|
||||
A: `userId` is the user account, `playerId` is the avatar/character in the game. One user can control multiple players.
|
||||
198
apps/web/src/arcade-games/math-sprint/Provider.tsx
Normal file
198
apps/web/src/arcade-games/math-sprint/Provider.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Math Sprint Provider
|
||||
*
|
||||
* Context provider for Math Sprint game state management.
|
||||
* Demonstrates free-for-all gameplay with TEAM_MOVE pattern.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
|
||||
import type { Difficulty, MathSprintState } from './types'
|
||||
|
||||
/**
|
||||
* Context value provided to child components
|
||||
*/
|
||||
interface MathSprintContextValue {
|
||||
state: MathSprintState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
submitAnswer: (answer: number) => void
|
||||
nextQuestion: () => void
|
||||
resetGame: () => void
|
||||
setConfig: (field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const MathSprintContext = createContext<MathSprintContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Math Sprint context
|
||||
*/
|
||||
export function useMathSprint() {
|
||||
const context = useContext(MathSprintContext)
|
||||
if (!context) {
|
||||
throw new Error('useMathSprint must be used within MathSprintProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Math Sprint Provider Component
|
||||
*/
|
||||
export function MathSprintProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array (keep Set iteration order)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room with defaults
|
||||
const gameConfig = useMemo(() => {
|
||||
const allGameConfigs = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = allGameConfigs?.['math-sprint'] as Record<string, unknown> | undefined
|
||||
return {
|
||||
difficulty: (savedConfig?.difficulty as Difficulty) || 'medium',
|
||||
questionsPerRound: (savedConfig?.questionsPerRound as number) || 10,
|
||||
timePerQuestion: (savedConfig?.timePerQuestion as number) || 30,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Initial state with merged config
|
||||
const initialState = useMemo<MathSprintState>(
|
||||
() => ({
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
difficulty: gameConfig.difficulty,
|
||||
questionsPerRound: gameConfig.questionsPerRound,
|
||||
timePerQuestion: gameConfig.timePerQuestion,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [],
|
||||
scores: {},
|
||||
correctAnswersCount: {},
|
||||
answers: [],
|
||||
questionStartTime: 0,
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}),
|
||||
[gameConfig]
|
||||
)
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<MathSprintState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: (state) => state, // Server handles all state updates
|
||||
})
|
||||
|
||||
// Action: Start game
|
||||
const startGame = useCallback(() => {
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: TEAM_MOVE, // Free-for-all: no specific turn owner
|
||||
userId: viewerId || '',
|
||||
data: { activePlayers, playerMetadata },
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
// Action: Submit answer
|
||||
const submitAnswer = useCallback(
|
||||
(answer: number) => {
|
||||
// Find this user's player ID from game state
|
||||
const myPlayerId = state.activePlayers.find((pid) => {
|
||||
return state.playerMetadata[pid]?.userId === viewerId
|
||||
})
|
||||
|
||||
if (!myPlayerId) {
|
||||
console.error('[MathSprint] No player found for current user')
|
||||
return
|
||||
}
|
||||
|
||||
sendMove({
|
||||
type: 'SUBMIT_ANSWER',
|
||||
playerId: myPlayerId, // Specific player answering
|
||||
userId: viewerId || '',
|
||||
data: { answer },
|
||||
})
|
||||
},
|
||||
[state.activePlayers, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
// Action: Next question
|
||||
const nextQuestion = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_QUESTION',
|
||||
playerId: TEAM_MOVE, // Any player can advance
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
// Action: Reset game
|
||||
const resetGame = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'RESET_GAME',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
// Action: Set config
|
||||
const setConfig = useCallback(
|
||||
(field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database for next session
|
||||
if (roomData?.id) {
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...roomData.gameConfig,
|
||||
'math-sprint': {
|
||||
...(roomData.gameConfig?.['math-sprint'] || {}),
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove, updateGameConfig, roomData]
|
||||
)
|
||||
|
||||
const contextValue: MathSprintContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
submitAnswer,
|
||||
nextQuestion,
|
||||
resetGame,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return <MathSprintContext.Provider value={contextValue}>{children}</MathSprintContext.Provider>
|
||||
}
|
||||
340
apps/web/src/arcade-games/math-sprint/Validator.ts
Normal file
340
apps/web/src/arcade-games/math-sprint/Validator.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Math Sprint Validator
|
||||
*
|
||||
* Server-side validation for Math Sprint game.
|
||||
* Generates questions, validates answers, awards points.
|
||||
*/
|
||||
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
|
||||
import type {
|
||||
Difficulty,
|
||||
MathSprintConfig,
|
||||
MathSprintMove,
|
||||
MathSprintState,
|
||||
Operation,
|
||||
Question,
|
||||
} from './types'
|
||||
|
||||
export class MathSprintValidator
|
||||
implements GameValidator<MathSprintState, MathSprintMove>
|
||||
{
|
||||
/**
|
||||
* Validate a game move
|
||||
*/
|
||||
validateMove(
|
||||
state: MathSprintState,
|
||||
move: MathSprintMove,
|
||||
context?: { userId?: string }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'SUBMIT_ANSWER':
|
||||
return this.validateSubmitAnswer(
|
||||
state,
|
||||
move.playerId,
|
||||
Number(move.data.answer),
|
||||
move.timestamp
|
||||
)
|
||||
|
||||
case 'NEXT_QUESTION':
|
||||
return this.validateNextQuestion(state)
|
||||
|
||||
case 'RESET_GAME':
|
||||
return this.validateResetGame(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game is complete
|
||||
*/
|
||||
isGameComplete(state: MathSprintState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initial state for new game
|
||||
*/
|
||||
getInitialState(config: unknown): MathSprintState {
|
||||
const { difficulty, questionsPerRound, timePerQuestion } = config as MathSprintConfig
|
||||
|
||||
return {
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
difficulty: difficulty || 'medium',
|
||||
questionsPerRound: questionsPerRound || 10,
|
||||
timePerQuestion: timePerQuestion || 30,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [],
|
||||
scores: {},
|
||||
correctAnswersCount: {},
|
||||
answers: [],
|
||||
questionStartTime: 0,
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Methods
|
||||
// ============================================================================
|
||||
|
||||
private validateStartGame(
|
||||
state: MathSprintState,
|
||||
activePlayers: string[],
|
||||
playerMetadata: Record<string, any>
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Game already started' }
|
||||
}
|
||||
|
||||
if (activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
// Generate questions
|
||||
const questions = this.generateQuestions(state.difficulty, state.questionsPerRound)
|
||||
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
questions,
|
||||
currentQuestionIndex: 0,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
correctAnswersCount: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
answers: [],
|
||||
questionStartTime: Date.now(),
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSubmitAnswer(
|
||||
state: MathSprintState,
|
||||
playerId: string,
|
||||
answer: number,
|
||||
timestamp: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in progress' }
|
||||
}
|
||||
|
||||
if (!state.activePlayers.includes(playerId)) {
|
||||
return { valid: false, error: 'Player not in game' }
|
||||
}
|
||||
|
||||
if (state.questionAnswered) {
|
||||
return { valid: false, error: 'Question already answered correctly' }
|
||||
}
|
||||
|
||||
// Check if player already answered this question
|
||||
const alreadyAnswered = state.answers.some((a) => a.playerId === playerId)
|
||||
if (alreadyAnswered) {
|
||||
return { valid: false, error: 'You already answered this question' }
|
||||
}
|
||||
|
||||
const currentQuestion = state.questions[state.currentQuestionIndex]
|
||||
const correct = answer === currentQuestion.correctAnswer
|
||||
|
||||
const answerRecord = {
|
||||
playerId,
|
||||
answer,
|
||||
timestamp,
|
||||
correct,
|
||||
}
|
||||
|
||||
const newAnswers = [...state.answers, answerRecord]
|
||||
let newState = { ...state, answers: newAnswers }
|
||||
|
||||
// If correct, award points and mark question as answered
|
||||
if (correct) {
|
||||
newState = {
|
||||
...newState,
|
||||
questionAnswered: true,
|
||||
winnerId: playerId,
|
||||
scores: {
|
||||
...state.scores,
|
||||
[playerId]: state.scores[playerId] + 10,
|
||||
},
|
||||
correctAnswersCount: {
|
||||
...state.correctAnswersCount,
|
||||
[playerId]: state.correctAnswersCount[playerId] + 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateNextQuestion(state: MathSprintState): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in progress' }
|
||||
}
|
||||
|
||||
if (!state.questionAnswered) {
|
||||
return { valid: false, error: 'Current question not answered yet' }
|
||||
}
|
||||
|
||||
const isLastQuestion = state.currentQuestionIndex >= state.questions.length - 1
|
||||
|
||||
if (isLastQuestion) {
|
||||
// Game complete, go to results
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
}
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Move to next question
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
currentQuestionIndex: state.currentQuestionIndex + 1,
|
||||
answers: [],
|
||||
questionStartTime: Date.now(),
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateResetGame(state: MathSprintState): ValidationResult {
|
||||
const newState = this.getInitialState({
|
||||
difficulty: state.difficulty,
|
||||
questionsPerRound: state.questionsPerRound,
|
||||
timePerQuestion: state.timePerQuestion,
|
||||
})
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: MathSprintState,
|
||||
field: string,
|
||||
value: any
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Cannot change config during game' }
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Question Generation
|
||||
// ============================================================================
|
||||
|
||||
private generateQuestions(difficulty: Difficulty, count: number): Question[] {
|
||||
const questions: Question[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const operation = this.randomOperation()
|
||||
const question = this.generateQuestion(difficulty, operation, `q-${i}`)
|
||||
questions.push(question)
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
private generateQuestion(
|
||||
difficulty: Difficulty,
|
||||
operation: Operation,
|
||||
id: string
|
||||
): Question {
|
||||
let operand1: number
|
||||
let operand2: number
|
||||
let correctAnswer: number
|
||||
|
||||
switch (difficulty) {
|
||||
case 'easy':
|
||||
operand1 = this.randomInt(1, 10)
|
||||
operand2 = this.randomInt(1, 10)
|
||||
break
|
||||
case 'medium':
|
||||
operand1 = this.randomInt(10, 50)
|
||||
operand2 = this.randomInt(1, 20)
|
||||
break
|
||||
case 'hard':
|
||||
operand1 = this.randomInt(10, 100)
|
||||
operand2 = this.randomInt(10, 50)
|
||||
break
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'addition':
|
||||
correctAnswer = operand1 + operand2
|
||||
break
|
||||
case 'subtraction':
|
||||
// Ensure positive result
|
||||
if (operand1 < operand2) {
|
||||
;[operand1, operand2] = [operand2, operand1]
|
||||
}
|
||||
correctAnswer = operand1 - operand2
|
||||
break
|
||||
case 'multiplication':
|
||||
// Smaller numbers for multiplication
|
||||
if (difficulty === 'hard') {
|
||||
operand1 = this.randomInt(2, 20)
|
||||
operand2 = this.randomInt(2, 12)
|
||||
} else {
|
||||
operand1 = this.randomInt(2, 10)
|
||||
operand2 = this.randomInt(2, 10)
|
||||
}
|
||||
correctAnswer = operand1 * operand2
|
||||
break
|
||||
}
|
||||
|
||||
const operationSymbol = this.getOperationSymbol(operation)
|
||||
const displayText = `${operand1} ${operationSymbol} ${operand2} = ?`
|
||||
|
||||
return {
|
||||
id,
|
||||
operand1,
|
||||
operand2,
|
||||
operation,
|
||||
correctAnswer,
|
||||
displayText,
|
||||
}
|
||||
}
|
||||
|
||||
private randomOperation(): Operation {
|
||||
const operations: Operation[] = ['addition', 'subtraction', 'multiplication']
|
||||
return operations[Math.floor(Math.random() * operations.length)]
|
||||
}
|
||||
|
||||
private randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
private getOperationSymbol(operation: Operation): string {
|
||||
switch (operation) {
|
||||
case 'addition':
|
||||
return '+'
|
||||
case 'subtraction':
|
||||
return '−'
|
||||
case 'multiplication':
|
||||
return '×'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mathSprintValidator = new MathSprintValidator()
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Math Sprint - Game Component
|
||||
*
|
||||
* Main wrapper component with navigation and phase routing.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useMathSprint } from '../Provider'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useMathSprint()
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Math Sprint"
|
||||
navEmoji="🧮"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
// No currentPlayerId - free-for-all game, everyone can act simultaneously
|
||||
playerScores={state.scores}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
resetGame()
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Math Sprint - Playing Phase
|
||||
*
|
||||
* Main gameplay: show question, accept answers, show feedback.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
|
||||
export function PlayingPhase() {
|
||||
const { state, submitAnswer, nextQuestion, lastError, clearError } = useMathSprint()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const currentQuestion = state.questions[state.currentQuestionIndex]
|
||||
const progress = `${state.currentQuestionIndex + 1} / ${state.questions.length}`
|
||||
|
||||
// Find if current user answered
|
||||
const myPlayerId = Object.keys(state.playerMetadata).find(
|
||||
(pid) => state.playerMetadata[pid]?.userId === viewerId
|
||||
)
|
||||
const myAnswer = state.answers.find((a) => a.playerId === myPlayerId)
|
||||
|
||||
// Auto-clear error after 3 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
// Clear input after question changes
|
||||
useEffect(() => {
|
||||
setInputValue('')
|
||||
}, [state.currentQuestionIndex])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const answer = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(answer)) return
|
||||
|
||||
submitAnswer(answer)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '700px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>Question {progress}</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
background: 'gray.200',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(90deg, #a78bfa, #8b5cf6)',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.3s',
|
||||
})}
|
||||
style={{
|
||||
width: `${((state.currentQuestionIndex + 1) / state.questions.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span>⚠️</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'red.700' })}>{lastError}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
padding: '4px 8px',
|
||||
background: 'red.100',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'red.200' },
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Display */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.300',
|
||||
borderRadius: '16px',
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{currentQuestion.displayText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer Input */}
|
||||
{!state.questionAnswered && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: myAnswer ? 'gray.300' : 'purple.500',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
{myAnswer ? (
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Your answer: <strong>{myAnswer.answer}</strong>
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
|
||||
Waiting for others or correct answer...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Your Answer
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '12px' })}>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your answer..."
|
||||
autoFocus
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'purple.500',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: inputValue ? 'purple.600' : 'gray.400',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: inputValue ? 'pointer' : 'not-allowed',
|
||||
_hover: {
|
||||
background: inputValue ? 'purple.700' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Winner Display */}
|
||||
{state.questionAnswered && state.winnerId && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
|
||||
border: '2px solid',
|
||||
borderColor: 'green.400',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '3xl', marginBottom: '8px' })}>🎉</div>
|
||||
<div className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'green.700' })}>
|
||||
{state.playerMetadata[state.winnerId]?.name || 'Someone'} got it right!
|
||||
</div>
|
||||
<div className={css({ fontSize: 'md', color: 'green.600', marginTop: '4px' })}>
|
||||
Answer: {currentQuestion.correctAnswer}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextQuestion}
|
||||
className={css({
|
||||
marginTop: '16px',
|
||||
padding: '12px 32px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: 'green.600',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'green.700' },
|
||||
})}
|
||||
>
|
||||
Next Question →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Scores
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
|
||||
{Object.entries(state.scores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([playerId, score]) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
background: 'gray.50',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{player?.emoji}</span>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
|
||||
{player?.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}>
|
||||
{score} pts
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Math Sprint - Results Phase
|
||||
*
|
||||
* Show final scores and winner.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, resetGame } = useMathSprint()
|
||||
|
||||
// Sort players by score
|
||||
const sortedPlayers = Object.entries(state.scores)
|
||||
.map(([playerId, score]) => ({
|
||||
playerId,
|
||||
score,
|
||||
correct: state.correctAnswersCount[playerId] || 0,
|
||||
player: state.playerMetadata[playerId],
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
const winner = sortedPlayers[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Winner Announcement */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
border: '2px solid',
|
||||
borderColor: 'yellow.400',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '4xl', marginBottom: '12px' })}>🏆</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'yellow.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{winner.player?.name} Wins!
|
||||
</h2>
|
||||
<div className={css({ fontSize: 'lg', color: 'yellow.700' })}>
|
||||
{winner.score} points • {winner.correct} correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Scores */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Final Scores
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{sortedPlayers.map((item, index) => (
|
||||
<div
|
||||
key={item.playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
background: index === 0 ? 'linear-gradient(135deg, #fef3c7, #fde68a)' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: index === 0 ? 'yellow.300' : 'gray.200',
|
||||
borderRadius: '12px',
|
||||
})}
|
||||
>
|
||||
{/* Rank */}
|
||||
<div
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: index === 0 ? 'yellow.500' : 'gray.300',
|
||||
color: index === 0 ? 'white' : 'gray.700',
|
||||
borderRadius: '50%',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Player Info */}
|
||||
<div className={css({ flex: 1 })}>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{item.player?.emoji}</span>
|
||||
<span className={css({ fontSize: 'md', fontWeight: 'semibold' })}>
|
||||
{item.player?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.600', marginTop: '2px' })}>
|
||||
{item.correct} / {state.questions.length} correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: index === 0 ? 'yellow.700' : 'purple.600',
|
||||
})}
|
||||
>
|
||||
{item.score}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
|
||||
border: '1px solid',
|
||||
borderColor: 'purple.300',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
|
||||
{state.questions.length}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Questions</div>
|
||||
</div>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
|
||||
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Difficulty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play Again Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetGame}
|
||||
className={css({
|
||||
padding: '14px 28px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: 'purple.600',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
_hover: {
|
||||
background: 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
apps/web/src/arcade-games/math-sprint/components/SetupPhase.tsx
Normal file
196
apps/web/src/arcade-games/math-sprint/components/SetupPhase.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Math Sprint - Setup Phase
|
||||
*
|
||||
* Configure game settings before starting.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
import type { Difficulty } from '../types'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, startGame, setConfig } = useMathSprint()
|
||||
|
||||
const handleDifficultyChange = (difficulty: Difficulty) => {
|
||||
setConfig('difficulty', difficulty)
|
||||
}
|
||||
|
||||
const handleQuestionsChange = (questions: number) => {
|
||||
setConfig('questionsPerRound', questions)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Game Title */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
🧮 Math Sprint
|
||||
</h1>
|
||||
<p className={css({ color: 'gray.600' })}>
|
||||
Race to solve math problems! First correct answer wins points.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings Card */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Game Settings
|
||||
</h2>
|
||||
|
||||
{/* Difficulty */}
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Difficulty
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '8px' })}>
|
||||
{(['easy', 'medium', 'hard'] as Difficulty[]).map((diff) => (
|
||||
<button
|
||||
key={diff}
|
||||
type="button"
|
||||
onClick={() => handleDifficultyChange(diff)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: state.difficulty === diff ? 'purple.500' : 'gray.300',
|
||||
background: state.difficulty === diff ? 'purple.50' : 'white',
|
||||
color: state.difficulty === diff ? 'purple.700' : 'gray.700',
|
||||
fontWeight: state.difficulty === diff ? 'semibold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'purple.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{diff.charAt(0).toUpperCase() + diff.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.500', marginTop: '4px' })}>
|
||||
{state.difficulty === 'easy' && 'Numbers 1-10, simple operations'}
|
||||
{state.difficulty === 'medium' && 'Numbers 1-50, varied operations'}
|
||||
{state.difficulty === 'hard' && 'Numbers 1-100, harder calculations'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Questions Per Round */}
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Questions: {state.questionsPerRound}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="20"
|
||||
step="5"
|
||||
value={state.questionsPerRound}
|
||||
onChange={(e) => handleQuestionsChange(Number(e.target.value))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
})}
|
||||
/>
|
||||
<div className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
<span>15</span>
|
||||
<span>20</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.300',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3 className={css({ fontSize: 'sm', fontWeight: 'semibold', marginBottom: '8px' })}>
|
||||
How to Play
|
||||
</h3>
|
||||
<ul className={css({ fontSize: 'sm', color: 'gray.700', paddingLeft: '20px' })}>
|
||||
<li>Solve math problems as fast as you can</li>
|
||||
<li>First correct answer earns 10 points</li>
|
||||
<li>Everyone can answer at the same time</li>
|
||||
<li>Most points wins!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Start Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
disabled={state.activePlayers.length < 2}
|
||||
className={css({
|
||||
padding: '14px 28px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.600',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: state.activePlayers.length < 2 ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.length < 2
|
||||
? `Need ${2 - state.activePlayers.length} more player(s)`
|
||||
: 'Start Game'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
apps/web/src/arcade-games/math-sprint/game.yaml
Normal file
16
apps/web/src/arcade-games/math-sprint/game.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
name: math-sprint
|
||||
displayName: Math Sprint
|
||||
icon: 🧮
|
||||
description: Fast-paced math racing game
|
||||
longDescription: Race against other players to solve math problems! Answer questions quickly to earn points. First person to answer correctly wins the round. Features multiple difficulty levels and customizable question counts.
|
||||
maxPlayers: 6
|
||||
difficulty: Beginner
|
||||
chips:
|
||||
- 👥 Multiplayer
|
||||
- ⚡ Free-for-All
|
||||
- 🧮 Math Skills
|
||||
- 🏃 Speed
|
||||
color: purple
|
||||
gradient: linear-gradient(135deg, #ddd6fe, #c4b5fd)
|
||||
borderColor: purple.200
|
||||
available: true
|
||||
43
apps/web/src/arcade-games/math-sprint/index.ts
Normal file
43
apps/web/src/arcade-games/math-sprint/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Math Sprint Game Definition
|
||||
*
|
||||
* A free-for-all math game demonstrating the TEAM_MOVE pattern.
|
||||
* Players race to solve math problems - first correct answer wins points.
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { MathSprintProvider } from './Provider'
|
||||
import type { MathSprintConfig, MathSprintMove, MathSprintState } from './types'
|
||||
import { mathSprintValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'math-sprint',
|
||||
displayName: 'Math Sprint',
|
||||
icon: '🧮',
|
||||
description: 'Race to solve math problems!',
|
||||
longDescription:
|
||||
'A fast-paced free-for-all game where players compete to solve math problems. First correct answer earns points. Choose your difficulty and test your mental math skills!',
|
||||
maxPlayers: 8,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '⚡ Fast-Paced', '🧠 Mental Math'],
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #ddd6fe, #c4b5fd)',
|
||||
borderColor: 'purple.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: MathSprintConfig = {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
}
|
||||
|
||||
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
|
||||
manifest,
|
||||
Provider: MathSprintProvider,
|
||||
GameComponent,
|
||||
validator: mathSprintValidator,
|
||||
defaultConfig,
|
||||
})
|
||||
120
apps/web/src/arcade-games/math-sprint/types.ts
Normal file
120
apps/web/src/arcade-games/math-sprint/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Math Sprint Game Types
|
||||
*
|
||||
* A free-for-all game where players race to solve math problems.
|
||||
* Demonstrates the TEAM_MOVE pattern (no specific turn owner).
|
||||
*/
|
||||
|
||||
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
|
||||
|
||||
/**
|
||||
* Difficulty levels for math problems
|
||||
*/
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||
|
||||
/**
|
||||
* Math operation types
|
||||
*/
|
||||
export type Operation = 'addition' | 'subtraction' | 'multiplication'
|
||||
|
||||
/**
|
||||
* Game configuration (persisted to database)
|
||||
*/
|
||||
export interface MathSprintConfig extends GameConfig {
|
||||
difficulty: Difficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number // seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* A math question
|
||||
*/
|
||||
export interface Question {
|
||||
id: string
|
||||
operand1: number
|
||||
operand2: number
|
||||
operation: Operation
|
||||
correctAnswer: number
|
||||
displayText: string // e.g., "5 + 3 = ?"
|
||||
}
|
||||
|
||||
/**
|
||||
* Player answer submission
|
||||
*/
|
||||
export interface Answer {
|
||||
playerId: string
|
||||
answer: number
|
||||
timestamp: number
|
||||
correct: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Game state (synchronized across all clients)
|
||||
*/
|
||||
export interface MathSprintState extends GameState {
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
|
||||
|
||||
// Configuration
|
||||
difficulty: Difficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number
|
||||
|
||||
// Game progress
|
||||
currentQuestionIndex: number
|
||||
questions: Question[]
|
||||
|
||||
// Scoring
|
||||
scores: Record<string, number> // playerId -> score
|
||||
correctAnswersCount: Record<string, number> // playerId -> count
|
||||
|
||||
// Current question state
|
||||
answers: Answer[] // All answers for current question
|
||||
questionStartTime: number // Timestamp when question was shown
|
||||
questionAnswered: boolean // True if someone got it right
|
||||
winnerId: string | null // Winner of current question (first correct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move types for Math Sprint
|
||||
*/
|
||||
export type MathSprintMove =
|
||||
| StartGameMove
|
||||
| SubmitAnswerMove
|
||||
| NextQuestionMove
|
||||
| ResetGameMove
|
||||
| SetConfigMove
|
||||
|
||||
export interface StartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmitAnswerMove extends GameMove {
|
||||
type: 'SUBMIT_ANSWER'
|
||||
data: {
|
||||
answer: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextQuestionMove extends GameMove {
|
||||
type: 'NEXT_QUESTION'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface ResetGameMove extends GameMove {
|
||||
type: 'RESET_GAME'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion'
|
||||
value: Difficulty | number
|
||||
}
|
||||
}
|
||||
215
apps/web/src/arcade-games/number-guesser/Provider.tsx
Normal file
215
apps/web/src/arcade-games/number-guesser/Provider.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Number Guesser Provider
|
||||
* Manages game state using the Arcade SDK
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserState } from './types'
|
||||
|
||||
/**
|
||||
* Context value interface
|
||||
*/
|
||||
interface NumberGuesserContextValue {
|
||||
state: NumberGuesserState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
chooseNumber: (number: number) => void
|
||||
makeGuess: (guess: number) => void
|
||||
nextRound: () => void
|
||||
goToSetup: () => void
|
||||
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const NumberGuesserContext = createContext<NumberGuesserContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Number Guesser context
|
||||
*/
|
||||
export function useNumberGuesser() {
|
||||
const context = useContext(NumberGuesserContext)
|
||||
if (!context) {
|
||||
throw new Error('useNumberGuesser must be used within NumberGuesserProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application
|
||||
*/
|
||||
function applyMoveOptimistically(state: NumberGuesserState, move: GameMove): NumberGuesserState {
|
||||
// For simplicity, just return current state
|
||||
// Server will send back the validated new state
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Number Guesser Provider Component
|
||||
*/
|
||||
export function NumberGuesserProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array (keep Set iteration order to match UI display)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room
|
||||
const initialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = gameConfig?.['number-guesser'] as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
minNumber: (savedConfig?.minNumber as number) || 1,
|
||||
maxNumber: (savedConfig?.maxNumber as number) || 100,
|
||||
roundsToWin: (savedConfig?.roundsToWin as number) || 3,
|
||||
gamePhase: 'setup' as const,
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<NumberGuesserState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (activePlayers.length < 2) {
|
||||
console.error('Need at least 2 players to start')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: activePlayers[0],
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const chooseNumber = useCallback(
|
||||
(secretNumber: number) => {
|
||||
sendMove({
|
||||
type: 'CHOOSE_NUMBER',
|
||||
playerId: state.chooser,
|
||||
userId: viewerId || '',
|
||||
data: { secretNumber },
|
||||
})
|
||||
},
|
||||
[state.chooser, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const makeGuess = useCallback(
|
||||
(guess: number) => {
|
||||
const playerName = state.playerMetadata[state.currentGuesser]?.name || 'Unknown'
|
||||
|
||||
sendMove({
|
||||
type: 'MAKE_GUESS',
|
||||
playerId: state.currentGuesser,
|
||||
userId: viewerId || '',
|
||||
data: { guess, playerName },
|
||||
})
|
||||
},
|
||||
[state.currentGuesser, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const nextRound = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_ROUND',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, viewerId, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: activePlayers[0] || state.chooser || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.chooser, viewerId, sendMove])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
|
||||
const currentNumberGuesserConfig =
|
||||
(currentGameConfig['number-guesser'] as Record<string, unknown>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'number-guesser': {
|
||||
...currentNumberGuesserConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
}
|
||||
},
|
||||
[activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const contextValue: NumberGuesserContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
chooseNumber,
|
||||
makeGuess,
|
||||
nextRound,
|
||||
goToSetup,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
|
||||
)
|
||||
}
|
||||
315
apps/web/src/arcade-games/number-guesser/Validator.ts
Normal file
315
apps/web/src/arcade-games/number-guesser/Validator.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Server-side validator for Number Guesser game
|
||||
*/
|
||||
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
|
||||
|
||||
export class NumberGuesserValidator
|
||||
implements GameValidator<NumberGuesserState, NumberGuesserMove>
|
||||
{
|
||||
validateMove(state: NumberGuesserState, move: NumberGuesserMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'CHOOSE_NUMBER':
|
||||
// Ensure secretNumber is a number (JSON deserialization can make it a string)
|
||||
return this.validateChooseNumber(state, Number(move.data.secretNumber), move.playerId)
|
||||
|
||||
case 'MAKE_GUESS':
|
||||
// Ensure guess is a number (JSON deserialization can make it a string)
|
||||
return this.validateMakeGuess(
|
||||
state,
|
||||
Number(move.data.guess),
|
||||
move.playerId,
|
||||
move.data.playerName
|
||||
)
|
||||
|
||||
case 'NEXT_ROUND':
|
||||
return this.validateNextRound(state)
|
||||
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
// Ensure value is a number (JSON deserialization can make it a string)
|
||||
return this.validateSetConfig(state, move.data.field, Number(move.data.value))
|
||||
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${(move as { type: string }).type}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(
|
||||
state: NumberGuesserState,
|
||||
activePlayers: string[],
|
||||
playerMetadata: Record<string, unknown>
|
||||
): ValidationResult {
|
||||
if (!activePlayers || activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'choosing',
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata as typeof state.playerMetadata,
|
||||
chooser: activePlayers[0],
|
||||
currentGuesser: '',
|
||||
secretNumber: null,
|
||||
guesses: [],
|
||||
roundNumber: 1,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateChooseNumber(
|
||||
state: NumberGuesserState,
|
||||
secretNumber: number,
|
||||
playerId: string
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'choosing') {
|
||||
return { valid: false, error: 'Not in choosing phase' }
|
||||
}
|
||||
|
||||
if (playerId !== state.chooser) {
|
||||
return { valid: false, error: 'Not your turn to choose' }
|
||||
}
|
||||
|
||||
if (
|
||||
secretNumber < state.minNumber ||
|
||||
secretNumber > state.maxNumber ||
|
||||
!Number.isInteger(secretNumber)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Number must be between ${state.minNumber} and ${state.maxNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[NumberGuesser] Setting secret number:', {
|
||||
secretNumber,
|
||||
secretNumberType: typeof secretNumber,
|
||||
})
|
||||
|
||||
// First guesser is the next player after chooser
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const firstGuesserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
const firstGuesser = state.activePlayers[firstGuesserIndex]
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'guessing',
|
||||
secretNumber,
|
||||
currentGuesser: firstGuesser,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateMakeGuess(
|
||||
state: NumberGuesserState,
|
||||
guess: number,
|
||||
playerId: string,
|
||||
playerName: string
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing') {
|
||||
return { valid: false, error: 'Not in guessing phase' }
|
||||
}
|
||||
|
||||
if (playerId !== state.currentGuesser) {
|
||||
return { valid: false, error: 'Not your turn to guess' }
|
||||
}
|
||||
|
||||
if (guess < state.minNumber || guess > state.maxNumber || !Number.isInteger(guess)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Guess must be between ${state.minNumber} and ${state.maxNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.secretNumber) {
|
||||
return { valid: false, error: 'No secret number set' }
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[NumberGuesser] Validating guess:', {
|
||||
guess,
|
||||
guessType: typeof guess,
|
||||
secretNumber: state.secretNumber,
|
||||
secretNumberType: typeof state.secretNumber,
|
||||
})
|
||||
|
||||
const distance = Math.abs(guess - state.secretNumber)
|
||||
|
||||
console.log('[NumberGuesser] Calculated distance:', distance)
|
||||
const newGuess = {
|
||||
playerId,
|
||||
playerName,
|
||||
guess,
|
||||
distance,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const guesses = [...state.guesses, newGuess]
|
||||
|
||||
// Check if guess is correct
|
||||
if (distance === 0) {
|
||||
// Correct guess! Award point and end round
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[playerId]: (state.scores[playerId] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if player won
|
||||
const winner = newScores[playerId] >= state.roundsToWin ? playerId : null
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
guesses,
|
||||
scores: newScores,
|
||||
gamePhase: winner ? 'results' : 'guessing',
|
||||
gameEndTime: winner ? Date.now() : null,
|
||||
winner,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Incorrect guess, move to next guesser
|
||||
const guesserIndex = state.activePlayers.indexOf(state.currentGuesser)
|
||||
let nextGuesserIndex = (guesserIndex + 1) % state.activePlayers.length
|
||||
|
||||
// Skip the chooser
|
||||
if (state.activePlayers[nextGuesserIndex] === state.chooser) {
|
||||
nextGuesserIndex = (nextGuesserIndex + 1) % state.activePlayers.length
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
guesses,
|
||||
currentGuesser: state.activePlayers[nextGuesserIndex],
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateNextRound(state: NumberGuesserState): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing') {
|
||||
return { valid: false, error: 'Not in guessing phase' }
|
||||
}
|
||||
|
||||
// Check if the round is complete (someone guessed correctly)
|
||||
const roundComplete =
|
||||
state.guesses.length > 0 && state.guesses[state.guesses.length - 1].distance === 0
|
||||
|
||||
if (!roundComplete) {
|
||||
return { valid: false, error: 'Round not complete yet - no one has guessed the number' }
|
||||
}
|
||||
|
||||
// Rotate chooser to next player
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const nextChooserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
const nextChooser = state.activePlayers[nextChooserIndex]
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'choosing',
|
||||
chooser: nextChooser,
|
||||
currentGuesser: '',
|
||||
secretNumber: null,
|
||||
guesses: [],
|
||||
roundNumber: state.roundNumber + 1,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: NumberGuesserState): ValidationResult {
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: NumberGuesserState,
|
||||
field: 'minNumber' | 'maxNumber' | 'roundsToWin',
|
||||
value: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change config in setup' }
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
return { valid: false, error: 'Value must be a positive integer' }
|
||||
}
|
||||
|
||||
if (field === 'minNumber' && value >= state.maxNumber) {
|
||||
return { valid: false, error: 'Min must be less than max' }
|
||||
}
|
||||
|
||||
if (field === 'maxNumber' && value <= state.minNumber) {
|
||||
return { valid: false, error: 'Max must be greater than min' }
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
isGameComplete(state: NumberGuesserState): boolean {
|
||||
return state.gamePhase === 'results' && state.winner !== null
|
||||
}
|
||||
|
||||
getInitialState(config: unknown): NumberGuesserState {
|
||||
const { minNumber, maxNumber, roundsToWin } = config as NumberGuesserConfig
|
||||
|
||||
return {
|
||||
minNumber: minNumber || 1,
|
||||
maxNumber: maxNumber || 100,
|
||||
roundsToWin: roundsToWin || 3,
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const numberGuesserValidator = new NumberGuesserValidator()
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Choosing Phase - Chooser picks a secret number
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function ChoosingPhase() {
|
||||
const { state, chooseNumber } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const chooserMetadata = state.playerMetadata[state.chooser]
|
||||
const isChooser = chooserMetadata?.userId === viewerId
|
||||
|
||||
const handleSubmit = () => {
|
||||
const number = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(number)) return
|
||||
|
||||
chooseNumber(number)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{chooserMetadata?.emoji || '🤔'}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{isChooser ? "You're choosing!" : `${chooserMetadata?.name || 'Someone'} is choosing...`}
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Round {state.roundNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isChooser ? (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a secret number ({state.minNumber} - {state.maxNumber})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
min={state.minNumber}
|
||||
max={state.maxNumber}
|
||||
placeholder={`${state.minNumber} - ${state.maxNumber}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'xl',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Confirm Choice
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Waiting for {chooserMetadata?.name || 'player'} to choose a number...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '32px',
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Scores
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.map((playerId) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
background: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{player?.emoji} {player?.name}: {state.scores[playerId] || 0}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Number Guesser Game Component
|
||||
* Main component that switches between game phases
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
import { ChoosingPhase } from './ChoosingPhase'
|
||||
import { GuessingPhase } from './GuessingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, goToSetup } = useNumberGuesser()
|
||||
|
||||
// Determine whose turn it is based on game phase
|
||||
const currentPlayerId =
|
||||
state.gamePhase === 'choosing'
|
||||
? state.chooser
|
||||
: state.gamePhase === 'guessing'
|
||||
? state.currentGuesser
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Number Guesser"
|
||||
navEmoji="🎯"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={state.scores}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
goToSetup?.()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #fff7ed, #ffedd5)',
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'choosing' && <ChoosingPhase />}
|
||||
{state.gamePhase === 'guessing' && <GuessingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* Guessing Phase - Players take turns guessing the secret number
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function GuessingPhase() {
|
||||
const { state, makeGuess, nextRound, lastError, clearError } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const currentGuesserMetadata = state.playerMetadata[state.currentGuesser]
|
||||
const isCurrentGuesser = currentGuesserMetadata?.userId === viewerId
|
||||
|
||||
// Check if someone just won the round
|
||||
const lastGuess = state.guesses[state.guesses.length - 1]
|
||||
const roundJustEnded = lastGuess?.distance === 0
|
||||
|
||||
// Auto-clear error after 5 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const guess = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(guess)) return
|
||||
|
||||
makeGuess(guess)
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
const getHotColdMessage = (distance: number) => {
|
||||
if (distance === 0) return '🎯 Correct!'
|
||||
if (distance <= 5) return '🔥 Very Hot!'
|
||||
if (distance <= 10) return '🌡️ Hot'
|
||||
if (distance <= 20) return '😊 Warm'
|
||||
if (distance <= 30) return '😐 Cool'
|
||||
if (distance <= 50) return '❄️ Cold'
|
||||
return '🧊 Very Cold'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{roundJustEnded ? '🎉' : currentGuesserMetadata?.emoji || '🤔'}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{roundJustEnded
|
||||
? `${lastGuess.playerName} guessed it!`
|
||||
: isCurrentGuesser
|
||||
? 'Your turn to guess!'
|
||||
: `${currentGuesserMetadata?.name || 'Someone'} is guessing...`}
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Round {state.roundNumber} • Range: {state.minNumber} - {state.maxNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
animation: 'slideIn 0.3s ease',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
})}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Move Rejected
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'red.600',
|
||||
})}
|
||||
>
|
||||
{lastError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
color: 'red.700',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'red.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Round ended - show next round button */}
|
||||
{roundJustEnded && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'green.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
🎯
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
The secret number was <strong>{state.secretNumber}</strong>!
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextRound}
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Next Round
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guessing input (only if round not ended) */}
|
||||
{!roundJustEnded && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
{isCurrentGuesser ? (
|
||||
<>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Make your guess ({state.minNumber} - {state.maxNumber})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && inputValue) {
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
min={state.minNumber}
|
||||
max={state.maxNumber}
|
||||
placeholder={`${state.minNumber} - ${state.maxNumber}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'xl',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Submit Guess
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Waiting for {currentGuesserMetadata?.name || 'player'} to guess...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guess history */}
|
||||
{state.guesses.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Guess History
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
{state.guesses.map((guess, index) => {
|
||||
const player = state.playerMetadata[guess.playerId]
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={css({
|
||||
padding: '12px',
|
||||
background: guess.distance === 0 ? 'green.50' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<span>{player?.emoji || '🎮'}</span>
|
||||
<span className={css({ fontWeight: '600' })}>{guess.playerName}</span>
|
||||
<span className={css({ color: 'gray.600' })}>guessed</span>
|
||||
<span className={css({ fontWeight: 'bold', fontSize: 'lg' })}>
|
||||
{guess.guess}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: guess.distance === 0 ? 'green.700' : 'orange.700',
|
||||
})}
|
||||
>
|
||||
{getHotColdMessage(guess.distance)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Scores (First to {state.roundsToWin} wins!)
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.map((playerId) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
const score = state.scores[playerId] || 0
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
background: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{player?.emoji} {player?.name}: {score}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Results Phase - Shows winner and final scores
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, goToSetup } = useNumberGuesser()
|
||||
|
||||
const winnerMetadata = state.winner ? state.playerMetadata[state.winner] : null
|
||||
const winnerScore = state.winner ? state.scores[state.winner] : 0
|
||||
|
||||
// Sort players by score
|
||||
const sortedPlayers = [...state.activePlayers].sort((a, b) => {
|
||||
const scoreA = state.scores[a] || 0
|
||||
const scoreB = state.scores[b] || 0
|
||||
return scoreB - scoreA
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Winner Celebration */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '96px',
|
||||
marginBottom: '16px',
|
||||
animation: 'bounce 1s ease-in-out infinite',
|
||||
})}
|
||||
>
|
||||
{winnerMetadata?.emoji || '🏆'}
|
||||
</div>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
{winnerMetadata?.name || 'Someone'} Wins!
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
with {winnerScore} {winnerScore === 1 ? 'round' : 'rounds'} won
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Final Standings */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Final Standings
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
{sortedPlayers.map((playerId, index) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
const score = state.scores[playerId] || 0
|
||||
const isWinner = playerId === state.winner
|
||||
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px',
|
||||
background: isWinner ? 'linear-gradient(135deg, #fed7aa, #fdba74)' : 'gray.100',
|
||||
borderRadius: '8px',
|
||||
border: isWinner ? '2px solid' : 'none',
|
||||
borderColor: isWinner ? 'orange.300' : undefined,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.400',
|
||||
width: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={css({ fontSize: '32px' })}>{player?.emoji || '🎮'}</span>
|
||||
<span className={css({ fontSize: 'lg', fontWeight: '600' })}>
|
||||
{player?.name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: isWinner ? 'orange.700' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
{score} {isWinner && '🏆'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Stats */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Game Stats
|
||||
</h3>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
|
||||
{state.roundNumber} {state.roundNumber === 1 ? 'round' : 'rounds'} played
|
||||
</p>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
|
||||
{state.guesses.length} {state.guesses.length === 1 ? 'guess' : 'guesses'} made
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Setup Phase - Game configuration
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, startGame, setConfig } = useNumberGuesser()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
🎯 Number Guesser Setup
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Game Rules
|
||||
</h3>
|
||||
<ul
|
||||
className={css({
|
||||
listStyle: 'disc',
|
||||
paddingLeft: '24px',
|
||||
lineHeight: '1.6',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<li>One player chooses a secret number</li>
|
||||
<li>Other players take turns guessing</li>
|
||||
<li>Get feedback on how close your guess is</li>
|
||||
<li>First to guess correctly wins the round!</li>
|
||||
<li>First to {state.roundsToWin} rounds wins the game!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Configuration
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Minimum Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.minNumber ?? 1}
|
||||
onChange={(e) => setConfig('minNumber', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Maximum Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.maxNumber ?? 100}
|
||||
onChange={(e) => setConfig('maxNumber', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Rounds to Win
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.roundsToWin ?? 3}
|
||||
onChange={(e) => setConfig('roundsToWin', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={startGame}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
apps/web/src/arcade-games/number-guesser/game.yaml
Normal file
15
apps/web/src/arcade-games/number-guesser/game.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
name: number-guesser
|
||||
displayName: Number Guesser
|
||||
icon: 🎯
|
||||
description: Classic turn-based number guessing game
|
||||
longDescription: One player thinks of a number, others take turns guessing. Get hot/cold feedback as you try to find the secret number. Perfect for testing your deduction skills!
|
||||
maxPlayers: 4
|
||||
difficulty: Beginner
|
||||
chips:
|
||||
- 👥 Multiplayer
|
||||
- 🎲 Turn-Based
|
||||
- 🧠 Logic Puzzle
|
||||
color: orange
|
||||
gradient: linear-gradient(135deg, #fed7aa, #fdba74)
|
||||
borderColor: orange.200
|
||||
available: true
|
||||
48
apps/web/src/arcade-games/number-guesser/index.ts
Normal file
48
apps/web/src/arcade-games/number-guesser/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Number Guesser Game Definition
|
||||
* Exports the complete game using the Arcade SDK
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { NumberGuesserProvider } from './Provider'
|
||||
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
|
||||
import { numberGuesserValidator } from './Validator'
|
||||
|
||||
// Game manifest (matches game.yaml)
|
||||
const manifest: GameManifest = {
|
||||
name: 'number-guesser',
|
||||
displayName: 'Number Guesser',
|
||||
icon: '🎯',
|
||||
description: 'Classic turn-based number guessing game',
|
||||
longDescription:
|
||||
'One player thinks of a number, others take turns guessing. Get hot/cold feedback to narrow down your guesses. First to guess wins the round!',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '🎲 Turn-Based', '🧠 Logic Puzzle'],
|
||||
color: 'orange',
|
||||
gradient: 'linear-gradient(135deg, #fed7aa, #fdba74)',
|
||||
borderColor: 'orange.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
const defaultConfig: NumberGuesserConfig = {
|
||||
minNumber: 1,
|
||||
maxNumber: 100,
|
||||
roundsToWin: 3,
|
||||
}
|
||||
|
||||
// Export game definition
|
||||
export const numberGuesserGame = defineGame<
|
||||
NumberGuesserConfig,
|
||||
NumberGuesserState,
|
||||
NumberGuesserMove
|
||||
>({
|
||||
manifest,
|
||||
Provider: NumberGuesserProvider,
|
||||
GameComponent,
|
||||
validator: numberGuesserValidator,
|
||||
defaultConfig,
|
||||
})
|
||||
116
apps/web/src/arcade-games/number-guesser/types.ts
Normal file
116
apps/web/src/arcade-games/number-guesser/types.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Type definitions for Number Guesser game
|
||||
*/
|
||||
|
||||
import type { GameMove } from '@/lib/arcade/game-sdk'
|
||||
|
||||
/**
|
||||
* Game configuration
|
||||
*/
|
||||
export type NumberGuesserConfig = {
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single guess attempt
|
||||
*/
|
||||
export interface Guess {
|
||||
playerId: string
|
||||
playerName: string
|
||||
guess: number
|
||||
distance: number // How far from the secret number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Game phases
|
||||
*/
|
||||
export type GamePhase = 'setup' | 'choosing' | 'guessing' | 'results'
|
||||
|
||||
/**
|
||||
* Game state
|
||||
*/
|
||||
export type NumberGuesserState = {
|
||||
// Configuration
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
|
||||
// Game phase
|
||||
gamePhase: GamePhase
|
||||
|
||||
// Players
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
|
||||
|
||||
// Current round
|
||||
secretNumber: number | null
|
||||
chooser: string // Player ID who chose the number
|
||||
currentGuesser: string // Player ID whose turn it is to guess
|
||||
|
||||
// Round history
|
||||
guesses: Guess[]
|
||||
roundNumber: number
|
||||
|
||||
// Scores
|
||||
scores: Record<string, number>
|
||||
|
||||
// Game state
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
winner: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Game moves
|
||||
*/
|
||||
export interface StartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChooseNumberMove extends GameMove {
|
||||
type: 'CHOOSE_NUMBER'
|
||||
data: {
|
||||
secretNumber: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MakeGuessMove extends GameMove {
|
||||
type: 'MAKE_GUESS'
|
||||
data: {
|
||||
guess: number
|
||||
playerName: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextRoundMove extends GameMove {
|
||||
type: 'NEXT_ROUND'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface GoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'minNumber' | 'maxNumber' | 'roundsToWin'
|
||||
value: number
|
||||
}
|
||||
}
|
||||
|
||||
export type NumberGuesserMove =
|
||||
| StartGameMove
|
||||
| ChooseNumberMove
|
||||
| MakeGuessMove
|
||||
| NextRoundMove
|
||||
| GoToSetupMove
|
||||
| SetConfigMove
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { getAllGames } from '../lib/arcade/game-registry'
|
||||
import { GameCard } from './GameCard'
|
||||
|
||||
// Game configuration defining player limits
|
||||
@@ -70,7 +72,39 @@ export const GAMES_CONFIG = {
|
||||
},
|
||||
} as const
|
||||
|
||||
export type GameType = keyof typeof GAMES_CONFIG
|
||||
export type GameType = keyof typeof GAMES_CONFIG | string
|
||||
|
||||
/**
|
||||
* Get all games from both legacy config and new registry
|
||||
*/
|
||||
function getAllGameConfigs() {
|
||||
const legacyGames = Object.entries(GAMES_CONFIG).map(([gameType, config]) => ({
|
||||
gameType,
|
||||
config,
|
||||
}))
|
||||
|
||||
// Get games from registry and transform to legacy format
|
||||
const registryGames = getAllGames().map((gameDef) => ({
|
||||
gameType: gameDef.manifest.name,
|
||||
config: {
|
||||
name: gameDef.manifest.displayName,
|
||||
fullName: gameDef.manifest.displayName,
|
||||
maxPlayers: gameDef.manifest.maxPlayers,
|
||||
description: gameDef.manifest.description,
|
||||
longDescription: gameDef.manifest.longDescription,
|
||||
url: `/arcade/room?game=${gameDef.manifest.name}`, // Registry games load in room
|
||||
icon: gameDef.manifest.icon,
|
||||
chips: gameDef.manifest.chips,
|
||||
color: gameDef.manifest.color,
|
||||
gradient: gameDef.manifest.gradient,
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
difficulty: gameDef.manifest.difficulty,
|
||||
available: gameDef.manifest.available,
|
||||
},
|
||||
}))
|
||||
|
||||
return [...legacyGames, ...registryGames]
|
||||
}
|
||||
|
||||
interface GameSelectorProps {
|
||||
variant?: 'compact' | 'detailed'
|
||||
@@ -87,17 +121,17 @@ export function GameSelector({
|
||||
}: GameSelectorProps) {
|
||||
const { activePlayerCount } = useGameMode()
|
||||
|
||||
// Memoize the combined games list
|
||||
const allGames = useMemo(() => getAllGameConfigs(), [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css(
|
||||
{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
className
|
||||
)}
|
||||
className={`${css({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
})} ${className || ''}`}
|
||||
>
|
||||
{showHeader && (
|
||||
<h3
|
||||
@@ -125,7 +159,7 @@ export function GameSelector({
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
|
||||
{allGames.map(({ gameType, config }) => (
|
||||
<GameCard
|
||||
key={gameType}
|
||||
gameType={gameType as GameType}
|
||||
|
||||
@@ -26,7 +26,7 @@ interface AddPlayerButtonProps {
|
||||
// Context-aware: show different content based on room state
|
||||
isInRoom?: boolean
|
||||
// Game info for room creation
|
||||
gameName?: 'matching' | 'memory-quiz' | 'complement-race'
|
||||
gameName?: string | null
|
||||
}
|
||||
|
||||
export function AddPlayerButton({
|
||||
@@ -38,7 +38,7 @@ export function AddPlayerButton({
|
||||
activeTab: activeTabProp,
|
||||
setActiveTab: setActiveTabProp,
|
||||
isInRoom = false,
|
||||
gameName = 'Arcade',
|
||||
gameName = null,
|
||||
}: AddPlayerButtonProps) {
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
@@ -28,7 +28,7 @@ interface NetworkPlayer {
|
||||
interface ArcadeRoomInfo {
|
||||
roomId?: string
|
||||
roomName?: string
|
||||
gameName: string
|
||||
gameName: string | null
|
||||
playerCount: number
|
||||
joinCode?: string
|
||||
}
|
||||
@@ -200,6 +200,7 @@ export function GameContextNav({
|
||||
onSetup={onSetup}
|
||||
onNewGame={onNewGame}
|
||||
onQuit={onExitSession}
|
||||
showMenu={true}
|
||||
/>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<GameModeIndicator
|
||||
@@ -287,7 +288,7 @@ export function GameContextNav({
|
||||
playerScores={playerScores}
|
||||
playerStreaks={playerStreaks}
|
||||
roomId={roomInfo?.roomId}
|
||||
currentUserId={currentUserId}
|
||||
currentUserId={currentUserId ?? undefined}
|
||||
isCurrentUserHost={isCurrentUserHost}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import type { schema } from '@/db'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import type { RoomData } from '@/hooks/useRoomData'
|
||||
import { useGetRoomByCode, useJoinRoom } from '@/hooks/useRoomData'
|
||||
|
||||
export interface JoinRoomModalProps {
|
||||
/**
|
||||
@@ -25,12 +25,13 @@ export interface JoinRoomModalProps {
|
||||
* Modal for joining a room by entering a 6-character code
|
||||
*/
|
||||
export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps) {
|
||||
const { getRoomByCode, joinRoom } = useRoomData()
|
||||
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
|
||||
const { mutateAsync: joinRoom } = useJoinRoom()
|
||||
const [code, setCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [roomInfo, setRoomInfo] = useState<schema.ArcadeRoom | null>(null)
|
||||
const [roomInfo, setRoomInfo] = useState<RoomData | null>(null)
|
||||
const [needsPassword, setNeedsPassword] = useState(false)
|
||||
const [needsApproval, setNeedsApproval] = useState(false)
|
||||
const [approvalRequested, setApprovalRequested] = useState(false)
|
||||
@@ -101,7 +102,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
}
|
||||
|
||||
// Join the room (with password if needed)
|
||||
await joinRoom(room.id, password || undefined)
|
||||
await joinRoom({ roomId: room.id, password: password || undefined })
|
||||
|
||||
// Success! Close modal
|
||||
handleClose()
|
||||
@@ -174,7 +175,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
|
||||
if (data.roomId === roomInfo.id) {
|
||||
console.log('[JoinRoomModal] Joining room automatically...')
|
||||
try {
|
||||
await joinRoom(roomInfo.id)
|
||||
await joinRoom({ roomId: roomInfo.id })
|
||||
handleClose()
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import type { RoomBan, RoomMember, RoomReport } from '@/db/schema'
|
||||
import type { RoomBan, RoomReport } from '@/db/schema'
|
||||
import type { RoomMember } from '@/hooks/useRoomData'
|
||||
|
||||
export interface RoomPlayer {
|
||||
id: string
|
||||
|
||||
@@ -52,7 +52,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
const handleGenerateNewName = () => {
|
||||
const allPlayers = Array.from(players.values())
|
||||
const existingNames = allPlayers.filter((p) => p.id !== playerId).map((p) => p.name)
|
||||
const newName = generateUniquePlayerName(existingNames)
|
||||
const newName = generateUniquePlayerName(existingNames, player.emoji)
|
||||
|
||||
setLocalName(newName)
|
||||
updatePlayer(playerId, { name: newName })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
interface RecentRoom {
|
||||
code: string
|
||||
name: string | null
|
||||
gameName: string
|
||||
gameName: string | null
|
||||
joinedAt: number
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
|
||||
{getRoomDisplayWithEmoji({
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
gameName: room.gameName,
|
||||
gameName: room.gameName ?? undefined,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
|
||||
export function addToRecentRooms(room: {
|
||||
code: string
|
||||
name: string | null
|
||||
gameName: string
|
||||
gameName: string | null
|
||||
}): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/hooks/useUserPlayers'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getNextPlayerColor } from '../types/player'
|
||||
import { generateUniquePlayerName, generateUniquePlayerNames } from '../utils/playerNames'
|
||||
import { generateUniquePlayerName } from '../utils/playerNames'
|
||||
|
||||
// Client-side Player type (compatible with old type)
|
||||
export interface Player {
|
||||
@@ -141,8 +141,13 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isInitialized) {
|
||||
if (dbPlayers.length === 0) {
|
||||
// Generate unique names for default players
|
||||
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
|
||||
// Generate unique names for default players, themed by their emoji
|
||||
const existingNames: string[] = []
|
||||
const generatedNames = DEFAULT_PLAYER_CONFIGS.map((config) => {
|
||||
const name = generateUniquePlayerName(existingNames, config.emoji)
|
||||
existingNames.push(name)
|
||||
return name
|
||||
})
|
||||
|
||||
// Create default players with generated names
|
||||
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
|
||||
@@ -167,10 +172,11 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const addPlayer = (playerData?: Partial<Player>) => {
|
||||
const playerList = Array.from(players.values())
|
||||
const existingNames = playerList.map((p) => p.name)
|
||||
const emoji = playerData?.emoji ?? '🎮'
|
||||
|
||||
const newPlayer = {
|
||||
name: playerData?.name ?? generateUniquePlayerName(existingNames),
|
||||
emoji: playerData?.emoji ?? '🎮',
|
||||
name: playerData?.name ?? generateUniquePlayerName(existingNames, emoji),
|
||||
emoji,
|
||||
color: playerData?.color ?? getNextPlayerColor(playerList),
|
||||
isActive: playerData?.isActive ?? false,
|
||||
}
|
||||
@@ -254,8 +260,13 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
deletePlayer(player.id)
|
||||
})
|
||||
|
||||
// Generate unique names for default players
|
||||
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
|
||||
// Generate unique names for default players, themed by their emoji
|
||||
const existingNames: string[] = []
|
||||
const generatedNames = DEFAULT_PLAYER_CONFIGS.map((config) => {
|
||||
const name = generateUniquePlayerName(existingNames, config.emoji)
|
||||
existingNames.push(name)
|
||||
return name
|
||||
})
|
||||
|
||||
// Create default players with generated names
|
||||
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
|
||||
|
||||
@@ -33,9 +33,8 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
|
||||
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
|
||||
|
||||
// Game configuration (nullable to support game selection in room)
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}),
|
||||
// Accepts any string - validation happens at runtime against validator registry
|
||||
gameName: text('game_name'),
|
||||
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)
|
||||
|
||||
// Current state
|
||||
|
||||
@@ -16,9 +16,8 @@ export const arcadeSessions = sqliteTable('arcade_sessions', {
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Session metadata
|
||||
currentGame: text('current_game', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
// Accepts any string - validation happens at runtime against validator registry
|
||||
currentGame: text('current_game').notNull(),
|
||||
|
||||
gameUrl: text('game_url').notNull(), // e.g., '/arcade/matching'
|
||||
|
||||
|
||||
@@ -15,5 +15,6 @@ export * from './room-invitations'
|
||||
export * from './room-reports'
|
||||
export * from './room-bans'
|
||||
export * from './room-join-requests'
|
||||
export * from './room-game-configs'
|
||||
export * from './user-stats'
|
||||
export * from './users'
|
||||
|
||||
49
apps/web/src/db/schema/room-game-configs.ts
Normal file
49
apps/web/src/db/schema/room-game-configs.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||
import { arcadeRooms } from './arcade-rooms'
|
||||
|
||||
/**
|
||||
* Game-specific configuration settings for arcade rooms
|
||||
* Each row represents one game's settings for one room
|
||||
*/
|
||||
export const roomGameConfigs = sqliteTable(
|
||||
'room_game_configs',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
|
||||
// Room reference
|
||||
roomId: text('room_id')
|
||||
.notNull()
|
||||
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Game identifier
|
||||
// Accepts any string - validation happens at runtime against validator registry
|
||||
gameName: text('game_name').notNull(),
|
||||
|
||||
// Game-specific configuration JSON
|
||||
// Structure depends on gameName:
|
||||
// - matching: { gameType, difficulty, turnTimer }
|
||||
// - memory-quiz: { selectedCount, displayTime, selectedDifficulty, playMode }
|
||||
// - complement-race: TBD
|
||||
// - number-guesser: { minNumber, maxNumber, roundsToWin }
|
||||
// - math-sprint: { difficulty, questionsPerRound, timePerQuestion }
|
||||
config: text('config', { mode: 'json' }).notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: integer('created_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
// Ensure only one config per game per room
|
||||
uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName),
|
||||
})
|
||||
)
|
||||
|
||||
export type RoomGameConfig = typeof roomGameConfigs.$inferSelect
|
||||
export type NewRoomGameConfig = typeof roomGameConfigs.$inferInsert
|
||||
@@ -46,6 +46,11 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
*/
|
||||
hasPendingMoves: boolean
|
||||
|
||||
/**
|
||||
* Last error from server (move rejection)
|
||||
*/
|
||||
lastError: string | null
|
||||
|
||||
/**
|
||||
* Send a game move (applies optimistically and sends to server)
|
||||
* Note: playerId must be provided by caller (not omitted)
|
||||
@@ -57,6 +62,11 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
*/
|
||||
exitSession: () => void
|
||||
|
||||
/**
|
||||
* Clear the last error
|
||||
*/
|
||||
clearError: () => void
|
||||
|
||||
/**
|
||||
* Manually sync with server (useful after reconnect)
|
||||
*/
|
||||
@@ -172,8 +182,10 @@ export function useArcadeSession<TState>(
|
||||
version: optimistic.version,
|
||||
connected,
|
||||
hasPendingMoves: optimistic.hasPendingMoves,
|
||||
lastError: optimistic.lastError,
|
||||
sendMove,
|
||||
exitSession,
|
||||
clearError: optimistic.clearError,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export interface UseOptimisticGameStateReturn<TState> {
|
||||
*/
|
||||
hasPendingMoves: boolean
|
||||
|
||||
/**
|
||||
* Last error from server (move rejection)
|
||||
*/
|
||||
lastError: string | null
|
||||
|
||||
/**
|
||||
* Apply a move optimistically and send to server
|
||||
*/
|
||||
@@ -66,6 +71,11 @@ export interface UseOptimisticGameStateReturn<TState> {
|
||||
*/
|
||||
syncWithServer: (serverState: TState, serverVersion: number) => void
|
||||
|
||||
/**
|
||||
* Clear the last error
|
||||
*/
|
||||
clearError: () => void
|
||||
|
||||
/**
|
||||
* Reset to initial state
|
||||
*/
|
||||
@@ -94,6 +104,9 @@ export function useOptimisticGameState<TState>(
|
||||
// Pending moves that haven't been confirmed by server yet
|
||||
const [pendingMoves, setPendingMoves] = useState<PendingMove<TState>[]>([])
|
||||
|
||||
// Last error from move rejection
|
||||
const [lastError, setLastError] = useState<string | null>(null)
|
||||
|
||||
// Ref for callbacks to avoid stale closures
|
||||
const callbacksRef = useRef({ onMoveAccepted, onMoveRejected })
|
||||
useEffect(() => {
|
||||
@@ -152,6 +165,9 @@ export function useOptimisticGameState<TState>(
|
||||
)
|
||||
|
||||
const handleMoveRejected = useCallback((error: string, rejectedMove: GameMove) => {
|
||||
// Set the error for UI display
|
||||
setLastError(error)
|
||||
|
||||
// Remove the rejected move and all subsequent moves from pending queue
|
||||
setPendingMoves((prev) => {
|
||||
const index = prev.findIndex(
|
||||
@@ -176,20 +192,27 @@ export function useOptimisticGameState<TState>(
|
||||
setPendingMoves([])
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setLastError(null)
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setServerState(initialState)
|
||||
setServerVersion(1)
|
||||
setPendingMoves([])
|
||||
setLastError(null)
|
||||
}, [initialState])
|
||||
|
||||
return {
|
||||
state: currentState,
|
||||
version: serverVersion,
|
||||
hasPendingMoves: pendingMoves.length > 0,
|
||||
lastError,
|
||||
applyOptimisticMove,
|
||||
handleMoveAccepted,
|
||||
handleMoveRejected,
|
||||
syncWithServer,
|
||||
clearError,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +449,7 @@ export function useRoomData() {
|
||||
const handleRoomGameChanged = (data: {
|
||||
roomId: string
|
||||
gameName: string | null
|
||||
gameConfig: Record<string, unknown>
|
||||
gameConfig?: Record<string, unknown>
|
||||
}) => {
|
||||
console.log('[useRoomData] Room game changed:', data)
|
||||
if (data.roomId === roomData?.id) {
|
||||
@@ -458,6 +458,8 @@ export function useRoomData() {
|
||||
return {
|
||||
...prev,
|
||||
gameName: data.gameName,
|
||||
// Only update gameConfig if it was provided in the broadcast
|
||||
...(data.gameConfig !== undefined ? { gameConfig: data.gameConfig } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -585,13 +587,20 @@ async function setRoomGameApi(params: {
|
||||
gameName: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
// Only include gameConfig in the request if it was explicitly provided
|
||||
// Otherwise, we preserve the existing gameConfig in the database
|
||||
const body: { gameName: string; gameConfig?: Record<string, unknown> } = {
|
||||
gameName: params.gameName,
|
||||
}
|
||||
|
||||
if (params.gameConfig !== undefined) {
|
||||
body.gameConfig = params.gameConfig
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameName: params.gameName,
|
||||
gameConfig: params.gameConfig || {},
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -625,6 +634,8 @@ export function useSetRoomGame() {
|
||||
|
||||
/**
|
||||
* Clear/reset game for a room (host only)
|
||||
* This only clears gameName (returns to game selection) but preserves gameConfig
|
||||
* so settings persist when the user selects a game again.
|
||||
*/
|
||||
async function clearRoomGameApi(roomId: string): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
|
||||
@@ -632,7 +643,7 @@ async function clearRoomGameApi(roomId: string): Promise<void> {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameName: null,
|
||||
gameConfig: null,
|
||||
// DO NOT send gameConfig: null - we want to preserve settings!
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -672,6 +683,18 @@ async function updateGameConfigApi(params: {
|
||||
roomId: string
|
||||
gameConfig: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
console.log(
|
||||
'[updateGameConfigApi] Sending PATCH to server:',
|
||||
JSON.stringify(
|
||||
{
|
||||
url: `/api/arcade/rooms/${params.roomId}/settings`,
|
||||
gameConfig: params.gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -682,8 +705,11 @@ async function updateGameConfigApi(params: {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error('[updateGameConfigApi] Server error:', JSON.stringify(errorData, null, 2))
|
||||
throw new Error(errorData.error || 'Failed to update game config')
|
||||
}
|
||||
|
||||
console.log('[updateGameConfigApi] Server responded OK')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -704,7 +730,10 @@ export function useUpdateGameConfig() {
|
||||
gameConfig: variables.gameConfig,
|
||||
}
|
||||
})
|
||||
console.log('[useUpdateGameConfig] Updated cache with new gameConfig:', variables.gameConfig)
|
||||
console.log(
|
||||
'[useUpdateGameConfig] Updated cache with new gameConfig:',
|
||||
JSON.stringify(variables.gameConfig, null, 2)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
236
apps/web/src/lib/arcade/game-config-helpers.ts
Normal file
236
apps/web/src/lib/arcade/game-config-helpers.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Game configuration helpers
|
||||
*
|
||||
* Centralized functions for reading and writing game configs from the database.
|
||||
* Uses the room_game_configs table (one row per game per room).
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { db, schema } from '@/db'
|
||||
import type { GameName } from './validators'
|
||||
import type { GameConfigByName } from './game-configs'
|
||||
import {
|
||||
DEFAULT_MATCHING_CONFIG,
|
||||
DEFAULT_MEMORY_QUIZ_CONFIG,
|
||||
DEFAULT_COMPLEMENT_RACE_CONFIG,
|
||||
DEFAULT_NUMBER_GUESSER_CONFIG,
|
||||
DEFAULT_MATH_SPRINT_CONFIG,
|
||||
} from './game-configs'
|
||||
|
||||
/**
|
||||
* Extended game name type that includes both registered validators and legacy games
|
||||
* TODO: Remove 'complement-race' once migrated to the new modular system
|
||||
*/
|
||||
type ExtendedGameName = GameName | 'complement-race'
|
||||
|
||||
/**
|
||||
* Get default config for a game
|
||||
*/
|
||||
function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[ExtendedGameName] {
|
||||
switch (gameName) {
|
||||
case 'matching':
|
||||
return DEFAULT_MATCHING_CONFIG
|
||||
case 'memory-quiz':
|
||||
return DEFAULT_MEMORY_QUIZ_CONFIG
|
||||
case 'complement-race':
|
||||
return DEFAULT_COMPLEMENT_RACE_CONFIG
|
||||
case 'number-guesser':
|
||||
return DEFAULT_NUMBER_GUESSER_CONFIG
|
||||
case 'math-sprint':
|
||||
return DEFAULT_MATH_SPRINT_CONFIG
|
||||
default:
|
||||
throw new Error(`Unknown game: ${gameName}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get game-specific config from database with defaults
|
||||
* Type-safe: returns the correct config type based on gameName
|
||||
*/
|
||||
export async function getGameConfig<T extends ExtendedGameName>(
|
||||
roomId: string,
|
||||
gameName: T
|
||||
): Promise<GameConfigByName[T]> {
|
||||
// Query the room_game_configs table for this specific room+game
|
||||
const configRow = await db.query.roomGameConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.roomGameConfigs.roomId, roomId),
|
||||
eq(schema.roomGameConfigs.gameName, gameName)
|
||||
),
|
||||
})
|
||||
|
||||
// If no config exists, return defaults
|
||||
if (!configRow) {
|
||||
return getDefaultGameConfig(gameName) as GameConfigByName[T]
|
||||
}
|
||||
|
||||
// Merge saved config with defaults to handle missing fields
|
||||
const defaults = getDefaultGameConfig(gameName)
|
||||
return { ...defaults, ...(configRow.config as object) } as GameConfigByName[T]
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (upsert) a game's config in the database
|
||||
* Creates a new row if it doesn't exist, updates if it does
|
||||
*/
|
||||
export async function setGameConfig<T extends ExtendedGameName>(
|
||||
roomId: string,
|
||||
gameName: T,
|
||||
config: Partial<GameConfigByName[T]>
|
||||
): Promise<void> {
|
||||
const now = new Date()
|
||||
|
||||
// Check if config already exists
|
||||
const existing = await db.query.roomGameConfigs.findFirst({
|
||||
where: and(
|
||||
eq(schema.roomGameConfigs.roomId, roomId),
|
||||
eq(schema.roomGameConfigs.gameName, gameName)
|
||||
),
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
// Update existing config (merge with existing values)
|
||||
const mergedConfig = { ...(existing.config as object), ...config }
|
||||
await db
|
||||
.update(schema.roomGameConfigs)
|
||||
.set({
|
||||
config: mergedConfig as any,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.roomGameConfigs.id, existing.id))
|
||||
} else {
|
||||
// Insert new config (merge with defaults)
|
||||
const defaults = getDefaultGameConfig(gameName)
|
||||
const mergedConfig = { ...defaults, ...config }
|
||||
|
||||
await db.insert(schema.roomGameConfigs).values({
|
||||
id: createId(),
|
||||
roomId,
|
||||
gameName,
|
||||
config: mergedConfig as any,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[GameConfig] Updated ${gameName} config for room ${roomId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific field in a game's config
|
||||
* Convenience wrapper around setGameConfig
|
||||
*/
|
||||
export async function updateGameConfigField<
|
||||
T extends ExtendedGameName,
|
||||
K extends keyof GameConfigByName[T],
|
||||
>(roomId: string, gameName: T, field: K, value: GameConfigByName[T][K]): Promise<void> {
|
||||
// Create a partial config with just the field being updated
|
||||
const partialConfig: Partial<GameConfigByName[T]> = {} as any
|
||||
;(partialConfig as any)[field] = value
|
||||
await setGameConfig(roomId, gameName, partialConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a game's config from the database
|
||||
* Useful when clearing game selection or cleaning up
|
||||
*/
|
||||
export async function deleteGameConfig(roomId: string, gameName: ExtendedGameName): Promise<void> {
|
||||
await db
|
||||
.delete(schema.roomGameConfigs)
|
||||
.where(
|
||||
and(eq(schema.roomGameConfigs.roomId, roomId), eq(schema.roomGameConfigs.gameName, gameName))
|
||||
)
|
||||
|
||||
console.log(`[GameConfig] Deleted ${gameName} config for room ${roomId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all game configs for a room (all games)
|
||||
* Returns a map of gameName -> config
|
||||
*/
|
||||
export async function getAllGameConfigs(
|
||||
roomId: string
|
||||
): Promise<Partial<Record<ExtendedGameName, unknown>>> {
|
||||
const configs = await db.query.roomGameConfigs.findMany({
|
||||
where: eq(schema.roomGameConfigs.roomId, roomId),
|
||||
})
|
||||
|
||||
const result: Partial<Record<ExtendedGameName, unknown>> = {}
|
||||
for (const config of configs) {
|
||||
result[config.gameName as ExtendedGameName] = config.config
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all game configs for a room
|
||||
* Called when deleting a room (cascade should handle this, but useful for explicit cleanup)
|
||||
*/
|
||||
export async function deleteAllGameConfigs(roomId: string): Promise<void> {
|
||||
await db.delete(schema.roomGameConfigs).where(eq(schema.roomGameConfigs.roomId, roomId))
|
||||
console.log(`[GameConfig] Deleted all configs for room ${roomId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a game config at runtime
|
||||
* Returns true if the config is valid for the given game
|
||||
*/
|
||||
export function validateGameConfig(gameName: ExtendedGameName, config: any): boolean {
|
||||
switch (gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
['abacus-numeral', 'complement-pairs'].includes(config.gameType) &&
|
||||
typeof config.difficulty === 'number' &&
|
||||
[6, 8, 12, 15].includes(config.difficulty) &&
|
||||
typeof config.turnTimer === 'number' &&
|
||||
config.turnTimer >= 5 &&
|
||||
config.turnTimer <= 300
|
||||
)
|
||||
|
||||
case 'memory-quiz':
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
[2, 5, 8, 12, 15].includes(config.selectedCount) &&
|
||||
typeof config.displayTime === 'number' &&
|
||||
config.displayTime > 0 &&
|
||||
['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) &&
|
||||
['cooperative', 'competitive'].includes(config.playMode)
|
||||
)
|
||||
|
||||
case 'complement-race':
|
||||
// TODO: Add validation when complement-race settings are defined
|
||||
return typeof config === 'object' && config !== null
|
||||
|
||||
case 'number-guesser':
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
typeof config.minNumber === 'number' &&
|
||||
typeof config.maxNumber === 'number' &&
|
||||
typeof config.roundsToWin === 'number' &&
|
||||
config.minNumber >= 1 &&
|
||||
config.maxNumber > config.minNumber &&
|
||||
config.roundsToWin >= 1
|
||||
)
|
||||
|
||||
case 'math-sprint':
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
['easy', 'medium', 'hard'].includes(config.difficulty) &&
|
||||
typeof config.questionsPerRound === 'number' &&
|
||||
typeof config.timePerQuestion === 'number' &&
|
||||
config.questionsPerRound >= 5 &&
|
||||
config.questionsPerRound <= 20 &&
|
||||
config.timePerQuestion >= 10
|
||||
)
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
115
apps/web/src/lib/arcade/game-configs.ts
Normal file
115
apps/web/src/lib/arcade/game-configs.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Shared game configuration types
|
||||
*
|
||||
* This is the single source of truth for all game settings.
|
||||
* These types are used across:
|
||||
* - Database storage (room_game_configs table)
|
||||
* - Validators (getInitialState method signatures)
|
||||
* - Client providers (settings UI and state management)
|
||||
* - Helper functions (reading/writing configs)
|
||||
*/
|
||||
|
||||
import type { DifficultyLevel } from '@/app/arcade/memory-quiz/types'
|
||||
import type { Difficulty, GameType } from '@/app/games/matching/context/types'
|
||||
import type { Difficulty as MathSprintDifficulty } from '@/arcade-games/math-sprint/types'
|
||||
|
||||
/**
|
||||
* Configuration for matching (memory pairs) game
|
||||
*/
|
||||
export interface MatchingGameConfig {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for memory-quiz (soroban lightning) game
|
||||
*/
|
||||
export interface MemoryQuizGameConfig {
|
||||
selectedCount: 2 | 5 | 8 | 12 | 15
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
playMode: 'cooperative' | 'competitive'
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for complement-race game
|
||||
* TODO: Define when implementing complement-race settings
|
||||
*/
|
||||
export interface ComplementRaceGameConfig {
|
||||
// Future settings will go here
|
||||
placeholder?: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for number-guesser game
|
||||
*/
|
||||
export interface NumberGuesserGameConfig {
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for math-sprint game
|
||||
*/
|
||||
export interface MathSprintGameConfig {
|
||||
difficulty: MathSprintDifficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all game configs for type-safe access
|
||||
*/
|
||||
export type GameConfigByName = {
|
||||
matching: MatchingGameConfig
|
||||
'memory-quiz': MemoryQuizGameConfig
|
||||
'complement-race': ComplementRaceGameConfig
|
||||
'number-guesser': NumberGuesserGameConfig
|
||||
'math-sprint': MathSprintGameConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Room's game configuration object (nested by game name)
|
||||
* This matches the structure stored in room_game_configs table
|
||||
*/
|
||||
export interface RoomGameConfig {
|
||||
matching?: MatchingGameConfig
|
||||
'memory-quiz'?: MemoryQuizGameConfig
|
||||
'complement-race'?: ComplementRaceGameConfig
|
||||
'number-guesser'?: NumberGuesserGameConfig
|
||||
'math-sprint'?: MathSprintGameConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configurations for each game
|
||||
*/
|
||||
export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = {
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
|
||||
selectedCount: 5,
|
||||
displayTime: 2.0,
|
||||
selectedDifficulty: 'easy',
|
||||
playMode: 'cooperative',
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
|
||||
// Future defaults will go here
|
||||
}
|
||||
|
||||
export const DEFAULT_NUMBER_GUESSER_CONFIG: NumberGuesserGameConfig = {
|
||||
minNumber: 1,
|
||||
maxNumber: 100,
|
||||
roundsToWin: 3,
|
||||
}
|
||||
|
||||
export const DEFAULT_MATH_SPRINT_CONFIG: MathSprintGameConfig = {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
}
|
||||
113
apps/web/src/lib/arcade/game-registry.ts
Normal file
113
apps/web/src/lib/arcade/game-registry.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Game Registry
|
||||
*
|
||||
* Central registry for all arcade games.
|
||||
* Games are explicitly registered here after being defined.
|
||||
*/
|
||||
|
||||
import type { GameConfig, GameDefinition, GameMove, GameState } from './game-sdk/types'
|
||||
|
||||
/**
|
||||
* Global game registry
|
||||
* Maps game name to game definition
|
||||
* Using `any` for generics to allow different game types
|
||||
*/
|
||||
const registry = new Map<string, GameDefinition<any, any, any>>()
|
||||
|
||||
/**
|
||||
* Register a game in the registry
|
||||
*
|
||||
* @param game - Game definition to register
|
||||
* @throws Error if game with same name already registered
|
||||
*/
|
||||
export function registerGame<
|
||||
TConfig extends GameConfig,
|
||||
TState extends GameState,
|
||||
TMove extends GameMove,
|
||||
>(game: GameDefinition<TConfig, TState, TMove>): void {
|
||||
const { name } = game.manifest
|
||||
|
||||
if (registry.has(name)) {
|
||||
throw new Error(`Game "${name}" is already registered`)
|
||||
}
|
||||
|
||||
// Verify validator is also registered server-side
|
||||
try {
|
||||
const { hasValidator, getValidator } = require('./validators')
|
||||
if (!hasValidator(name)) {
|
||||
console.error(
|
||||
`⚠️ Game "${name}" registered but validator not found in server registry!` +
|
||||
`\n Add to src/lib/arcade/validators.ts to enable multiplayer.`
|
||||
)
|
||||
} else {
|
||||
const serverValidator = getValidator(name)
|
||||
if (serverValidator !== game.validator) {
|
||||
console.warn(
|
||||
`⚠️ Game "${name}" has different validator instances (client vs server).` +
|
||||
`\n This may cause issues. Ensure both use the same import.`
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If validators.ts can't be imported (e.g., in browser), skip check
|
||||
// This is expected - validator registry is isomorphic but check only runs server-side
|
||||
}
|
||||
|
||||
registry.set(name, game)
|
||||
console.log(`✅ Registered game: ${name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a game from the registry
|
||||
*
|
||||
* @param gameName - Internal game identifier
|
||||
* @returns Game definition or undefined if not found
|
||||
*/
|
||||
export function getGame(gameName: string): GameDefinition<any, any, any> | undefined {
|
||||
return registry.get(gameName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered games
|
||||
*
|
||||
* @returns Array of all game definitions
|
||||
*/
|
||||
export function getAllGames(): GameDefinition<any, any, any>[] {
|
||||
return Array.from(registry.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available games (where available: true)
|
||||
*
|
||||
* @returns Array of available game definitions
|
||||
*/
|
||||
export function getAvailableGames(): GameDefinition<any, any, any>[] {
|
||||
return getAllGames().filter((game) => game.manifest.available)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a game is registered
|
||||
*
|
||||
* @param gameName - Internal game identifier
|
||||
* @returns true if game is registered
|
||||
*/
|
||||
export function hasGame(gameName: string): boolean {
|
||||
return registry.has(gameName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all games from registry (used for testing)
|
||||
*/
|
||||
export function clearRegistry(): void {
|
||||
registry.clear()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game Registrations
|
||||
// ============================================================================
|
||||
|
||||
import { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
import { mathSprintGame } from '@/arcade-games/math-sprint'
|
||||
|
||||
registerGame(numberGuesserGame)
|
||||
registerGame(mathSprintGame)
|
||||
124
apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx
Normal file
124
apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Error Boundary for Arcade Games
|
||||
*
|
||||
* Catches errors in game components and displays a friendly error message
|
||||
* instead of crashing the entire app.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
gameName?: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class GameErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: unknown) {
|
||||
console.error('Game error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
minHeight: '400px',
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '64px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
color: '#dc2626',
|
||||
}}
|
||||
>
|
||||
Game Error
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '12px',
|
||||
maxWidth: '500px',
|
||||
}}
|
||||
>
|
||||
{this.props.gameName
|
||||
? `There was an error loading the game "${this.props.gameName}".`
|
||||
: 'There was an error loading the game.'}
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<pre
|
||||
style={{
|
||||
background: '#f9fafb',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginTop: '12px',
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto',
|
||||
textAlign: 'left',
|
||||
fontSize: '12px',
|
||||
color: '#374151',
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '12px 24px',
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
80
apps/web/src/lib/arcade/game-sdk/define-game.ts
Normal file
80
apps/web/src/lib/arcade/game-sdk/define-game.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Game definition helper
|
||||
* Provides type-safe game registration
|
||||
*/
|
||||
|
||||
import type {
|
||||
GameComponent,
|
||||
GameConfig,
|
||||
GameDefinition,
|
||||
GameMove,
|
||||
GameProviderComponent,
|
||||
GameState,
|
||||
GameValidator,
|
||||
} from './types'
|
||||
import type { GameManifest } from '../manifest-schema'
|
||||
|
||||
/**
|
||||
* Options for defining a game
|
||||
*/
|
||||
export interface DefineGameOptions<
|
||||
TConfig extends GameConfig,
|
||||
TState extends GameState,
|
||||
TMove extends GameMove,
|
||||
> {
|
||||
/** Game manifest (loaded from game.yaml) */
|
||||
manifest: GameManifest
|
||||
|
||||
/** React provider component */
|
||||
Provider: GameProviderComponent
|
||||
|
||||
/** Main game UI component */
|
||||
GameComponent: GameComponent
|
||||
|
||||
/** Server-side validator */
|
||||
validator: GameValidator<TState, TMove>
|
||||
|
||||
/** Default configuration for the game */
|
||||
defaultConfig: TConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a game with full type safety
|
||||
*
|
||||
* This helper ensures all required parts of a game are provided
|
||||
* and returns a properly typed GameDefinition.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const myGame = defineGame({
|
||||
* manifest: loadManifest('./game.yaml'),
|
||||
* Provider: MyGameProvider,
|
||||
* GameComponent: MyGameComponent,
|
||||
* validator: myGameValidator,
|
||||
* defaultConfig: {
|
||||
* difficulty: 'easy',
|
||||
* maxTime: 60
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function defineGame<
|
||||
TConfig extends GameConfig,
|
||||
TState extends GameState,
|
||||
TMove extends GameMove,
|
||||
>(options: DefineGameOptions<TConfig, TState, TMove>): GameDefinition<TConfig, TState, TMove> {
|
||||
const { manifest, Provider, GameComponent, validator, defaultConfig } = options
|
||||
|
||||
// Validate that manifest.name matches the game identifier
|
||||
if (!manifest.name) {
|
||||
throw new Error('Game manifest must have a "name" field')
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
Provider,
|
||||
GameComponent,
|
||||
validator,
|
||||
defaultConfig,
|
||||
}
|
||||
}
|
||||
92
apps/web/src/lib/arcade/game-sdk/index.ts
Normal file
92
apps/web/src/lib/arcade/game-sdk/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Arcade Game SDK - Stable API Surface
|
||||
*
|
||||
* This is the ONLY module that games are allowed to import from.
|
||||
* All game code must use this SDK - no direct imports from /src/
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import {
|
||||
* defineGame,
|
||||
* useArcadeSession,
|
||||
* useRoomData,
|
||||
* type GameDefinition
|
||||
* } from '@/lib/arcade/game-sdk'
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Core Types
|
||||
// ============================================================================
|
||||
export type {
|
||||
GameDefinition,
|
||||
GameProviderComponent,
|
||||
GameComponent,
|
||||
GameValidator,
|
||||
GameConfig,
|
||||
GameState,
|
||||
GameMove,
|
||||
ValidationContext,
|
||||
ValidationResult,
|
||||
TeamMoveSentinel,
|
||||
} from './types'
|
||||
|
||||
export { TEAM_MOVE } from './types'
|
||||
|
||||
export type { GameManifest } from '../manifest-schema'
|
||||
|
||||
// ============================================================================
|
||||
// React Hooks (Controlled API)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Arcade session management hook
|
||||
* Handles state synchronization, move validation, and multiplayer sync
|
||||
*/
|
||||
export { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
|
||||
/**
|
||||
* Room data hook - access current room information
|
||||
*/
|
||||
export { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
|
||||
/**
|
||||
* Game mode context - access players and game mode
|
||||
*/
|
||||
export { useGameMode } from '@/contexts/GameModeContext'
|
||||
|
||||
/**
|
||||
* Viewer ID hook - get current user's ID
|
||||
*/
|
||||
export { useViewerId } from '@/hooks/useViewerId'
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Player ownership and metadata utilities
|
||||
*/
|
||||
export {
|
||||
buildPlayerMetadata,
|
||||
buildPlayerOwnershipFromRoomData,
|
||||
} from '@/lib/arcade/player-ownership.client'
|
||||
|
||||
/**
|
||||
* Helper for loading and validating game manifests
|
||||
*/
|
||||
export { loadManifest } from './load-manifest'
|
||||
|
||||
/**
|
||||
* Game definition helper
|
||||
*/
|
||||
export { defineGame } from './define-game'
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports for convenience
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common types from contexts
|
||||
*/
|
||||
export type { Player } from '@/contexts/GameModeContext'
|
||||
39
apps/web/src/lib/arcade/game-sdk/load-manifest.ts
Normal file
39
apps/web/src/lib/arcade/game-sdk/load-manifest.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Manifest loading and validation utilities
|
||||
*/
|
||||
|
||||
import yaml from 'js-yaml'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { validateManifest, type GameManifest } from '../manifest-schema'
|
||||
|
||||
/**
|
||||
* Load and validate a game manifest from a YAML file
|
||||
*
|
||||
* @param manifestPath - Absolute path to game.yaml file
|
||||
* @returns Validated GameManifest object
|
||||
* @throws Error if manifest is invalid or file doesn't exist
|
||||
*/
|
||||
export function loadManifest(manifestPath: string): GameManifest {
|
||||
try {
|
||||
const fileContents = readFileSync(manifestPath, 'utf8')
|
||||
const data = yaml.load(fileContents)
|
||||
return validateManifest(data)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to load manifest from ${manifestPath}: ${error.message}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load manifest from a game directory
|
||||
*
|
||||
* @param gameDir - Absolute path to game directory
|
||||
* @returns Validated GameManifest object
|
||||
*/
|
||||
export function loadManifestFromDir(gameDir: string): GameManifest {
|
||||
const manifestPath = join(gameDir, 'game.yaml')
|
||||
return loadManifest(manifestPath)
|
||||
}
|
||||
80
apps/web/src/lib/arcade/game-sdk/types.ts
Normal file
80
apps/web/src/lib/arcade/game-sdk/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Type definitions for the Arcade Game SDK
|
||||
* These types define the contract that all games must implement
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { GameManifest } from '../manifest-schema'
|
||||
import type {
|
||||
GameMove as BaseGameMove,
|
||||
GameValidator as BaseGameValidator,
|
||||
ValidationContext,
|
||||
ValidationResult,
|
||||
} from '../validation/types'
|
||||
|
||||
/**
|
||||
* Re-export base validation types from arcade system
|
||||
*/
|
||||
export type { GameMove, ValidationContext, ValidationResult } from '../validation/types'
|
||||
export { TEAM_MOVE } from '../validation/types'
|
||||
export type { TeamMoveSentinel } from '../validation/types'
|
||||
|
||||
/**
|
||||
* Generic game configuration
|
||||
* Each game defines its own specific config type
|
||||
*/
|
||||
export type GameConfig = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Generic game state
|
||||
* Each game defines its own specific state type
|
||||
*/
|
||||
export type GameState = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Game validator interface
|
||||
* Games must implement this to validate moves server-side
|
||||
*/
|
||||
export interface GameValidator<TState = GameState, TMove extends BaseGameMove = BaseGameMove>
|
||||
extends BaseGameValidator<TState, TMove> {
|
||||
validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult
|
||||
isGameComplete(state: TState): boolean
|
||||
getInitialState(config: unknown): TState
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component interface
|
||||
* Each game provides a React context provider that wraps the game UI
|
||||
*/
|
||||
export type GameProviderComponent = (props: { children: ReactNode }) => JSX.Element
|
||||
|
||||
/**
|
||||
* Main game component interface
|
||||
* The root component that renders the game UI
|
||||
*/
|
||||
export type GameComponent = () => JSX.Element
|
||||
|
||||
/**
|
||||
* Complete game definition
|
||||
* This is what games export after using defineGame()
|
||||
*/
|
||||
export interface GameDefinition<
|
||||
TConfig extends GameConfig = GameConfig,
|
||||
TState extends GameState = GameState,
|
||||
TMove extends BaseGameMove = BaseGameMove,
|
||||
> {
|
||||
/** Parsed and validated manifest */
|
||||
manifest: GameManifest
|
||||
|
||||
/** React provider component */
|
||||
Provider: GameProviderComponent
|
||||
|
||||
/** Main game UI component */
|
||||
GameComponent: GameComponent
|
||||
|
||||
/** Server-side validator */
|
||||
validator: GameValidator<TState, TMove>
|
||||
|
||||
/** Default configuration */
|
||||
defaultConfig: TConfig
|
||||
}
|
||||
38
apps/web/src/lib/arcade/manifest-schema.ts
Normal file
38
apps/web/src/lib/arcade/manifest-schema.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Game manifest schema validation
|
||||
* Validates game.yaml files using Zod
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Schema for game manifest (game.yaml)
|
||||
*/
|
||||
export const GameManifestSchema = z.object({
|
||||
name: z.string().min(1).describe('Internal game identifier (e.g., "matching")'),
|
||||
displayName: z.string().min(1).describe('Display name shown to users'),
|
||||
icon: z.string().min(1).describe('Emoji icon for the game'),
|
||||
description: z.string().min(1).describe('Short description'),
|
||||
longDescription: z.string().min(1).describe('Detailed description'),
|
||||
maxPlayers: z.number().int().min(1).max(10).describe('Maximum number of players'),
|
||||
difficulty: z
|
||||
.enum(['Beginner', 'Intermediate', 'Advanced', 'Expert'])
|
||||
.describe('Difficulty level'),
|
||||
chips: z.array(z.string()).describe('Feature chips displayed on game card'),
|
||||
color: z.string().min(1).describe('Color theme (e.g., "purple")'),
|
||||
gradient: z.string().min(1).describe('CSS gradient for card background'),
|
||||
borderColor: z.string().min(1).describe('Border color (e.g., "purple.200")'),
|
||||
available: z.boolean().describe('Whether game is available to play'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Inferred TypeScript type from schema
|
||||
*/
|
||||
export type GameManifest = z.infer<typeof GameManifestSchema>
|
||||
|
||||
/**
|
||||
* Validate a parsed manifest object
|
||||
*/
|
||||
export function validateManifest(data: unknown): GameManifest {
|
||||
return GameManifestSchema.parse(data)
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db, schema } from '@/db'
|
||||
import { buildPlayerOwnershipMap, type PlayerOwnershipMap } from './player-ownership'
|
||||
import { type GameMove, type GameName, getValidator } from './validation'
|
||||
import { getValidator, type GameName } from './validators'
|
||||
import type { GameMove } from './validation/types'
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
userId: string // User who owns/created the session (typically room creator)
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
* Validates all game moves and state transitions
|
||||
*/
|
||||
|
||||
import type {
|
||||
Difficulty,
|
||||
GameCard,
|
||||
GameType,
|
||||
MemoryPairsState,
|
||||
Player,
|
||||
} from '@/app/games/matching/context/types'
|
||||
import type { GameCard, MemoryPairsState, Player } from '@/app/games/matching/context/types'
|
||||
import { generateGameCards } from '@/app/games/matching/utils/cardGeneration'
|
||||
import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchValidation'
|
||||
import type { MatchingGameConfig } from '@/lib/arcade/game-configs'
|
||||
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
|
||||
|
||||
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
|
||||
@@ -536,11 +531,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
|
||||
}
|
||||
|
||||
getInitialState(config: {
|
||||
difficulty: Difficulty
|
||||
gameType: GameType
|
||||
turnTimer: number
|
||||
}): MemoryPairsState {
|
||||
getInitialState(config: MatchingGameConfig): MemoryPairsState {
|
||||
return {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Validates all game moves and state transitions
|
||||
*/
|
||||
|
||||
import type { DifficultyLevel, SorobanQuizState } from '@/app/arcade/memory-quiz/types'
|
||||
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
|
||||
import type { MemoryQuizGameConfig } from '@/lib/arcade/game-configs'
|
||||
import type {
|
||||
GameValidator,
|
||||
MemoryQuizGameMove,
|
||||
@@ -180,12 +181,6 @@ export class MemoryQuizGameValidator
|
||||
}
|
||||
|
||||
// Number must be in correct answers
|
||||
console.log('[MemoryQuizValidator] Checking number:', {
|
||||
number,
|
||||
correctAnswers: state.correctAnswers,
|
||||
includes: state.correctAnswers.includes(number),
|
||||
})
|
||||
|
||||
if (!state.correctAnswers.includes(number)) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -401,11 +396,7 @@ export class MemoryQuizGameValidator
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
getInitialState(config: {
|
||||
selectedCount: number
|
||||
displayTime: number
|
||||
selectedDifficulty: DifficultyLevel
|
||||
}): SorobanQuizState {
|
||||
getInitialState(config: MemoryQuizGameConfig): SorobanQuizState {
|
||||
return {
|
||||
cards: [],
|
||||
quizCards: [],
|
||||
@@ -422,7 +413,7 @@ export class MemoryQuizGameValidator
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
playerScores: {},
|
||||
playMode: 'cooperative',
|
||||
playMode: config.playMode || 'cooperative',
|
||||
numberFoundBy: {},
|
||||
// UI state
|
||||
gamePhase: 'setup',
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
/**
|
||||
* Game validator registry
|
||||
* Maps game names to their validators
|
||||
* @deprecated This file now re-exports from the unified registry
|
||||
* New code should import from '@/lib/arcade/validators' instead
|
||||
*/
|
||||
|
||||
import { matchingGameValidator } from './MatchingGameValidator'
|
||||
import { memoryQuizGameValidator } from './MemoryQuizGameValidator'
|
||||
import type { GameName, GameValidator } from './types'
|
||||
// Re-export everything from unified registry
|
||||
export {
|
||||
getValidator,
|
||||
hasValidator,
|
||||
getRegisteredGameNames,
|
||||
validatorRegistry,
|
||||
matchingGameValidator,
|
||||
memoryQuizGameValidator,
|
||||
numberGuesserValidator,
|
||||
} from '../validators'
|
||||
|
||||
const validators = new Map<GameName, GameValidator>([
|
||||
['matching', matchingGameValidator],
|
||||
['memory-quiz', memoryQuizGameValidator],
|
||||
// Add other game validators here as they're implemented
|
||||
])
|
||||
|
||||
export function getValidator(gameName: GameName): GameValidator {
|
||||
const validator = validators.get(gameName)
|
||||
if (!validator) {
|
||||
throw new Error(`No validator found for game: ${gameName}`)
|
||||
}
|
||||
return validator
|
||||
}
|
||||
|
||||
export { matchingGameValidator } from './MatchingGameValidator'
|
||||
export { memoryQuizGameValidator } from './MemoryQuizGameValidator'
|
||||
export type { GameName } from '../validators'
|
||||
export * from './types'
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
import type { MemoryPairsState } from '@/app/games/matching/context/types'
|
||||
import type { SorobanQuizState } from '@/app/arcade/memory-quiz/types'
|
||||
|
||||
export type GameName = 'matching' | 'memory-quiz' | 'complement-race'
|
||||
/**
|
||||
* Game name type - auto-derived from validator registry
|
||||
* @deprecated Import from '@/lib/arcade/validators' instead
|
||||
*/
|
||||
export type { GameName } from '../validators'
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
|
||||
100
apps/web/src/lib/arcade/validators.ts
Normal file
100
apps/web/src/lib/arcade/validators.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Unified Validator Registry (Isomorphic - runs on client AND server)
|
||||
*
|
||||
* This is the single source of truth for game validators.
|
||||
* Both client and server import validators from here.
|
||||
*
|
||||
* To add a new game:
|
||||
* 1. Import the validator
|
||||
* 2. Add to validatorRegistry Map
|
||||
* 3. GameName type will auto-update
|
||||
*/
|
||||
|
||||
import { matchingGameValidator } from './validation/MatchingGameValidator'
|
||||
import { memoryQuizGameValidator } from './validation/MemoryQuizGameValidator'
|
||||
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
|
||||
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
|
||||
import type { GameValidator } from './validation/types'
|
||||
|
||||
/**
|
||||
* Central registry of all game validators
|
||||
* Key: game name (matches manifest.name)
|
||||
* Value: validator instance
|
||||
*/
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
'math-sprint': mathSprintValidator,
|
||||
// Add new games here - GameName type will auto-update
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Auto-derived game name type from registry
|
||||
* No need to manually update this!
|
||||
*/
|
||||
export type GameName = keyof typeof validatorRegistry
|
||||
|
||||
/**
|
||||
* Get validator for a game
|
||||
* @throws Error if game not found (fail fast)
|
||||
*/
|
||||
export function getValidator(gameName: string): GameValidator {
|
||||
const validator = validatorRegistry[gameName as GameName]
|
||||
if (!validator) {
|
||||
throw new Error(
|
||||
`No validator found for game: ${gameName}. ` +
|
||||
`Available games: ${Object.keys(validatorRegistry).join(', ')}`
|
||||
)
|
||||
}
|
||||
return validator
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a game has a registered validator
|
||||
*/
|
||||
export function hasValidator(gameName: string): gameName is GameName {
|
||||
return gameName in validatorRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered game names
|
||||
*/
|
||||
export function getRegisteredGameNames(): GameName[] {
|
||||
return Object.keys(validatorRegistry) as GameName[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a game name at runtime
|
||||
* Use this instead of TypeScript enums to check if a game is valid
|
||||
*
|
||||
* @param gameName - Game name to validate
|
||||
* @returns true if game has a registered validator
|
||||
*/
|
||||
export function isValidGameName(gameName: unknown): gameName is GameName {
|
||||
return typeof gameName === 'string' && hasValidator(gameName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a game name is valid, throw if not
|
||||
*
|
||||
* @param gameName - Game name to validate
|
||||
* @throws Error if game name is invalid
|
||||
*/
|
||||
export function assertValidGameName(gameName: unknown): asserts gameName is GameName {
|
||||
if (!isValidGameName(gameName)) {
|
||||
throw new Error(
|
||||
`Invalid game name: ${gameName}. Must be one of: ${getRegisteredGameNames().join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export validators for backwards compatibility
|
||||
*/
|
||||
export {
|
||||
matchingGameValidator,
|
||||
memoryQuizGameValidator,
|
||||
numberGuesserValidator,
|
||||
mathSprintValidator,
|
||||
}
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
import { createRoom, getRoomById } from './lib/arcade/room-manager'
|
||||
import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager'
|
||||
import type { GameMove, GameName } from './lib/arcade/validation'
|
||||
import { getValidator } from './lib/arcade/validation'
|
||||
import { getValidator, type GameName } from './lib/arcade/validators'
|
||||
import type { GameMove } from './lib/arcade/validation/types'
|
||||
import { getGameConfig } from './lib/arcade/game-config-helpers'
|
||||
|
||||
// Use globalThis to store socket.io instance to avoid module isolation issues
|
||||
// This ensures the same instance is accessible across dynamic imports
|
||||
@@ -81,24 +82,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
const validator = getValidator(room.gameName as GameName)
|
||||
console.log('[join-arcade-session] Got validator for:', room.gameName)
|
||||
|
||||
// Different games have different initial configs
|
||||
let initialState: any
|
||||
if (room.gameName === 'matching') {
|
||||
initialState = validator.getInitialState({
|
||||
difficulty: (room.gameConfig as any)?.difficulty || 6,
|
||||
gameType: (room.gameConfig as any)?.gameType || 'abacus-numeral',
|
||||
turnTimer: (room.gameConfig as any)?.turnTimer || 30,
|
||||
})
|
||||
} else if (room.gameName === 'memory-quiz') {
|
||||
initialState = validator.getInitialState({
|
||||
selectedCount: (room.gameConfig as any)?.selectedCount || 5,
|
||||
displayTime: (room.gameConfig as any)?.displayTime || 2.0,
|
||||
selectedDifficulty: (room.gameConfig as any)?.selectedDifficulty || 'easy',
|
||||
})
|
||||
} else {
|
||||
// Fallback for other games
|
||||
initialState = validator.getInitialState(room.gameConfig || {})
|
||||
}
|
||||
// Get game-specific config from database (type-safe)
|
||||
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
|
||||
const initialState = validator.getInitialState(gameConfig)
|
||||
|
||||
session = await createArcadeSession({
|
||||
userId,
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('playerNames', () => {
|
||||
it('should append number if all combinations are exhausted', () => {
|
||||
// Create a mock with limited attempts
|
||||
const existingNames = ['Swift Ninja']
|
||||
const name = generateUniquePlayerName(existingNames, 1)
|
||||
const name = generateUniquePlayerName(existingNames, undefined, 1)
|
||||
|
||||
// Should either be unique or have a number appended
|
||||
expect(name).toBeTruthy()
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* Fun automatic player name generation system
|
||||
* Generates creative names by combining adjectives with nouns/roles
|
||||
*
|
||||
* Supports avatar-specific theming! Each emoji can have its own personality-matched words.
|
||||
* Falls back gracefully: emoji-specific → category → generic abacus theme
|
||||
*/
|
||||
|
||||
import { EMOJI_SPECIFIC_WORDS, EMOJI_TO_THEME, THEMED_WORD_LISTS } from './themedWords'
|
||||
|
||||
// Generic abacus-themed words (used as ultimate fallback)
|
||||
const ADJECTIVES = [
|
||||
// Abacus-themed adjectives
|
||||
'Ancient',
|
||||
@@ -114,32 +120,96 @@ const NOUNS = [
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate a random player name by combining an adjective and noun
|
||||
* Select a word list tier using weighted random selection
|
||||
* Balanced mix: emoji-specific (50%), category (25%), global abacus (25%)
|
||||
*/
|
||||
export function generatePlayerName(): string {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
function selectWordListTier(emoji: string, wordType: 'adjectives' | 'nouns'): string[] {
|
||||
// Collect available tiers
|
||||
const availableTiers: Array<{ weight: number; words: string[] }> = []
|
||||
|
||||
// Emoji-specific tier (50% preference)
|
||||
const emojiSpecific = EMOJI_SPECIFIC_WORDS[emoji]
|
||||
if (emojiSpecific) {
|
||||
availableTiers.push({ weight: 50, words: emojiSpecific[wordType] })
|
||||
}
|
||||
|
||||
// Category tier (25% preference)
|
||||
const category = EMOJI_TO_THEME[emoji]
|
||||
if (category) {
|
||||
const categoryTheme = THEMED_WORD_LISTS[category]
|
||||
if (categoryTheme) {
|
||||
availableTiers.push({ weight: 25, words: categoryTheme[wordType] })
|
||||
}
|
||||
}
|
||||
|
||||
// Global abacus tier (25% preference)
|
||||
availableTiers.push({ weight: 25, words: wordType === 'adjectives' ? ADJECTIVES : NOUNS })
|
||||
|
||||
// Weighted random selection
|
||||
const totalWeight = availableTiers.reduce((sum, tier) => sum + tier.weight, 0)
|
||||
let random = Math.random() * totalWeight
|
||||
|
||||
for (const tier of availableTiers) {
|
||||
random -= tier.weight
|
||||
if (random <= 0) {
|
||||
return tier.words
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (should never reach here)
|
||||
return wordType === 'adjectives' ? ADJECTIVES : NOUNS
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random player name by combining an adjective and noun
|
||||
* Optionally themed based on avatar emoji for ultra-personalized names!
|
||||
* Uses per-word-type probabilistic tier selection for natural variety
|
||||
*
|
||||
* @param emoji - Optional emoji avatar to theme the name around
|
||||
* @returns A creative player name like "Grinning Calculator" or "Lightning Smiler"
|
||||
*/
|
||||
export function generatePlayerName(emoji?: string): string {
|
||||
if (!emoji) {
|
||||
// No emoji provided, use pure abacus theme
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
return `${adjective} ${noun}`
|
||||
}
|
||||
|
||||
// Select tier independently for each word type
|
||||
// This creates natural mixing: adjective might be emoji-specific while noun is global
|
||||
const adjectiveList = selectWordListTier(emoji, 'adjectives')
|
||||
const nounList = selectWordListTier(emoji, 'nouns')
|
||||
|
||||
const adjective = adjectiveList[Math.floor(Math.random() * adjectiveList.length)]
|
||||
const noun = nounList[Math.floor(Math.random() * nounList.length)]
|
||||
|
||||
return `${adjective} ${noun}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique player name that doesn't conflict with existing players
|
||||
* @param existingNames - Array of names already in use
|
||||
* @param emoji - Optional emoji avatar to theme the name around
|
||||
* @param maxAttempts - Maximum attempts to find a unique name (default: 50)
|
||||
* @returns A unique player name
|
||||
*/
|
||||
export function generateUniquePlayerName(existingNames: string[], maxAttempts = 50): string {
|
||||
export function generateUniquePlayerName(
|
||||
existingNames: string[],
|
||||
emoji?: string,
|
||||
maxAttempts = 50
|
||||
): string {
|
||||
const existingNamesSet = new Set(existingNames.map((name) => name.toLowerCase()))
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const name = generatePlayerName()
|
||||
const name = generatePlayerName(emoji)
|
||||
if (!existingNamesSet.has(name.toLowerCase())) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we can't find a unique name, append a number
|
||||
const baseName = generatePlayerName()
|
||||
const baseName = generatePlayerName(emoji)
|
||||
let counter = 1
|
||||
while (existingNamesSet.has(`${baseName} ${counter}`.toLowerCase())) {
|
||||
counter++
|
||||
@@ -150,12 +220,13 @@ export function generateUniquePlayerName(existingNames: string[], maxAttempts =
|
||||
/**
|
||||
* Generate a batch of unique player names
|
||||
* @param count - Number of names to generate
|
||||
* @param emoji - Optional emoji avatar to theme the names around
|
||||
* @returns Array of unique player names
|
||||
*/
|
||||
export function generateUniquePlayerNames(count: number): string[] {
|
||||
export function generateUniquePlayerNames(count: number, emoji?: string): string[] {
|
||||
const names: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = generateUniquePlayerName(names)
|
||||
const name = generateUniquePlayerName(names, emoji)
|
||||
names.push(name)
|
||||
}
|
||||
return names
|
||||
|
||||
6664
apps/web/src/utils/themedWords.ts
Normal file
6664
apps/web/src/utils/themedWords.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,24 @@
|
||||
"src/db/schema/**/*.ts",
|
||||
"src/db/migrate.ts",
|
||||
"src/lib/arcade/**/*.ts",
|
||||
"src/arcade-games/**/Validator.ts",
|
||||
"src/arcade-games/**/types.ts",
|
||||
"src/app/games/matching/context/types.ts",
|
||||
"src/app/games/matching/utils/cardGeneration.ts",
|
||||
"src/app/games/matching/utils/matchValidation.ts",
|
||||
"src/app/arcade/memory-quiz/types.ts",
|
||||
"src/socket-server.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"src/components/**/*",
|
||||
"src/contexts/**/*",
|
||||
"src/hooks/**/*",
|
||||
"src/stories/**/*",
|
||||
"src/utils/**/*",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.17.3",
|
||||
"version": "4.0.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
8
packages/core/client/browser/node_modules/.bin/tsc
generated
vendored
8
packages/core/client/browser/node_modules/.bin/tsc
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/browser/node_modules/.bin/tsserver
generated
vendored
8
packages/core/client/browser/node_modules/.bin/tsserver
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/browser/node_modules/.bin/vite
generated
vendored
8
packages/core/client/browser/node_modules/.bin/vite
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/bin/vite.js" "$@"
|
||||
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite/bin/vite.js" "$@"
|
||||
exec node "$basedir/../vite/bin/vite.js" "$@"
|
||||
fi
|
||||
|
||||
2
packages/core/client/browser/node_modules/vite
generated
vendored
2
packages/core/client/browser/node_modules/vite
generated
vendored
@@ -1 +1 @@
|
||||
../../../../../node_modules/.pnpm/vite@5.0.0_@types+node@20.0.0/node_modules/vite
|
||||
../../../../../node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite
|
||||
8
packages/core/client/node/node_modules/.bin/tsc
generated
vendored
8
packages/core/client/node/node_modules/.bin/tsc
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/node/node_modules/.bin/tsserver
generated
vendored
8
packages/core/client/node/node_modules/.bin/tsserver
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/node/node_modules/.bin/tsup
generated
vendored
8
packages/core/client/node/node_modules/.bin/tsup
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-default.js" "$@"
|
||||
exec "$basedir/node" "$basedir/../tsup/dist/cli-default.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-default.js" "$@"
|
||||
exec node "$basedir/../tsup/dist/cli-default.js" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/node/node_modules/.bin/tsup-node
generated
vendored
8
packages/core/client/node/node_modules/.bin/tsup-node
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-node.js" "$@"
|
||||
exec "$basedir/node" "$basedir/../tsup/dist/cli-node.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-node.js" "$@"
|
||||
exec node "$basedir/../tsup/dist/cli-node.js" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/node/node_modules/.bin/vitest
generated
vendored
8
packages/core/client/node/node_modules/.bin/vitest
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest/vitest.mjs" "$@"
|
||||
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest/vitest.mjs" "$@"
|
||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
||||
fi
|
||||
|
||||
2
packages/core/client/node/node_modules/tsup
generated
vendored
2
packages/core/client/node/node_modules/tsup
generated
vendored
@@ -1 +1 @@
|
||||
../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup
|
||||
../../../../../node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup
|
||||
2
packages/core/client/node/node_modules/vitest
generated
vendored
2
packages/core/client/node/node_modules/vitest
generated
vendored
@@ -1 +1 @@
|
||||
../../../../../node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest
|
||||
../../../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest
|
||||
8
packages/core/client/typescript/node_modules/.bin/tsc
generated
vendored
8
packages/core/client/typescript/node_modules/.bin/tsc
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsc" "$@"
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/typescript/node_modules/.bin/tsserver
generated
vendored
8
packages/core/client/typescript/node_modules/.bin/tsserver
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/typescript@5.0.2/node_modules/typescript/bin/tsserver" "$@"
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/typescript/node_modules/.bin/tsup
generated
vendored
8
packages/core/client/typescript/node_modules/.bin/tsup
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-default.js" "$@"
|
||||
exec "$basedir/node" "$basedir/../tsup/dist/cli-default.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-default.js" "$@"
|
||||
exec node "$basedir/../tsup/dist/cli-default.js" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/typescript/node_modules/.bin/tsup-node
generated
vendored
8
packages/core/client/typescript/node_modules/.bin/tsup-node
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-node.js" "$@"
|
||||
exec "$basedir/node" "$basedir/../tsup/dist/cli-node.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup/dist/cli-node.js" "$@"
|
||||
exec node "$basedir/../tsup/dist/cli-node.js" "$@"
|
||||
fi
|
||||
|
||||
8
packages/core/client/typescript/node_modules/.bin/vitest
generated
vendored
8
packages/core/client/typescript/node_modules/.bin/vitest
generated
vendored
@@ -6,12 +6,12 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../../node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest/vitest.mjs" "$@"
|
||||
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../../node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest/vitest.mjs" "$@"
|
||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
||||
fi
|
||||
|
||||
2
packages/core/client/typescript/node_modules/tsup
generated
vendored
2
packages/core/client/typescript/node_modules/tsup
generated
vendored
@@ -1 +1 @@
|
||||
../../../../../node_modules/.pnpm/tsup@7.0.0_typescript@5.0.2/node_modules/tsup
|
||||
../../../../../node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup
|
||||
2
packages/core/client/typescript/node_modules/vitest
generated
vendored
2
packages/core/client/typescript/node_modules/vitest
generated
vendored
@@ -1 +1 @@
|
||||
../../../../../node_modules/.pnpm/vitest@1.0.0_@types+node@20.0.0_@vitest+ui@3.2.4_jsdom@27.0.0/node_modules/vitest
|
||||
../../../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -152,6 +152,9 @@ importers:
|
||||
jose:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
js-yaml:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
lucide-react:
|
||||
specifier: ^0.294.0
|
||||
version: 0.294.0(react@18.3.1)
|
||||
@@ -185,6 +188,9 @@ importers:
|
||||
socket.io-client:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
zod:
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.55.1
|
||||
@@ -210,6 +216,9 @@ importers:
|
||||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.13
|
||||
version: 7.6.13
|
||||
'@types/js-yaml':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.19
|
||||
@@ -3668,6 +3677,9 @@ packages:
|
||||
'@types/istanbul-reports@3.0.4':
|
||||
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
||||
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
'@types/jsdom@21.1.7':
|
||||
resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
|
||||
|
||||
@@ -9370,6 +9382,9 @@ packages:
|
||||
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
||||
zod@4.1.12:
|
||||
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@adobe/css-tools@4.4.4': {}
|
||||
@@ -13150,6 +13165,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/istanbul-lib-report': 3.0.3
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/jsdom@21.1.7':
|
||||
dependencies:
|
||||
'@types/node': 20.19.19
|
||||
@@ -19382,3 +19399,5 @@ snapshots:
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yocto-queue@1.2.1: {}
|
||||
|
||||
zod@4.1.12: {}
|
||||
|
||||
Reference in New Issue
Block a user