Compare commits

...

19 Commits

Author SHA1 Message Date
semantic-release-bot
61196ccbff chore(release): 3.22.2 [skip ci]
## [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](f775fc55e5)), closes [#1](https://github.com/antialias/soroban-abacus-flashcards/issues/1)
2025-10-16 01:39:05 +00:00
Thomas Hallock
f775fc55e5 refactor(arcade): create unified validator registry to fix dual registration
**Problem**: Games required dual registration - once in client registry
(game-registry.ts) and again in server validator map (validation/index.ts).
This broke the modular architecture goal.

**Solution**: Created unified isomorphic validator registry that serves
both client and server needs.

## Changes

### New File
- `src/lib/arcade/validators.ts` - Unified validator registry
  - Single source of truth for all game validators
  - Auto-derives GameName type from registry keys
  - Isomorphic (runs on both client and server)

### Updated Files
- `validation/index.ts` - Converted to re-export from unified registry
  - Maintains backwards compatibility
  - Marked as deprecated
- `validation/types.ts` - GameName now re-exported from validators
  - No longer hard-coded union type
  - Auto-updates when new games added
- `game-registry.ts` - Added runtime validation
  - Checks if validator registered server-side
  - Warns on registration mismatch
- `session-manager.ts` - Import from unified registry
- `socket-server.ts` - Import from unified registry
- `route.ts` (rooms API) - Use hasValidator() instead of hard-coded array
- `game-config-helpers.ts` - Handle ExtendedGameName for legacy games
  - Supports both registered validators and legacy 'complement-race'
  - TODO comment for migration

## Benefits
 Single registration point for new games
 Auto-derived GameName type (no manual updates)
 Type-safe validator access
 Backwards compatible with existing code
 Clear migration path for old games

## Migration Status
-  number-guesser: Uses new system
- 🔄 matching, memory-quiz: Use new validator registry, not migrated to arcade-games/ yet
-  complement-race: Legacy game, handled via ExtendedGameName

Addresses critical Issue #1 from AUDIT_MODULAR_GAME_SYSTEM.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 20:38:15 -05:00
semantic-release-bot
3cef4fcbac chore(release): 3.22.1 [skip ci]
## [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](7d1a351ed6))
* **nav:** update types for registry games with nullable gameName ([a51e539](a51e539d02))
2025-10-16 00:16:26 +00:00
Thomas Hallock
a51e539d02 fix(nav): update types for registry games with nullable gameName
Fixes TypeScript errors introduced by registry game system:
- Allow gameName to be string | null in nav component types
- Update RecentRoom, AddPlayerButton, GameContextNav, ArcadeRoomInfo interfaces
- Convert null to undefined where needed for legacy function calls
- Fix GameTitleMenu showMenu prop
- Update JoinRoomModal to use separate useGetRoomByCode and useJoinRoom hooks

These changes allow rooms to exist without a selected game (gameName = null).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:15:32 -05:00
Thomas Hallock
7d1a351ed6 fix(arcade): add Number Guesser to game config helpers
Fixes server-side session creation for Number Guesser:
- Import DEFAULT_NUMBER_GUESSER_CONFIG
- Add case for 'number-guesser' in getDefaultGameConfig()
- Add validation for number-guesser config
- Include arcade-games validators in server TypeScript build

This resolves the "Unknown game: number-guesser" error when creating sessions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:15:32 -05:00
semantic-release-bot
3e81c1f480 chore(release): 3.22.0 [skip ci]
## [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](0e3c058707))
2025-10-16 00:08:18 +00:00
Thomas Hallock
0e3c058707 feat(arcade): add Number Guesser demo game with plugin architecture
Implements the first registry-based game to demonstrate the modular plugin system:

Game Features:
- Turn-based number guessing with hot/cold feedback
- 2-4 players with configurable settings
- Round-based scoring system
- Complete game phases: Setup → Choosing → Guessing → Results

Technical Implementation:
- Created Number Guesser game in src/arcade-games/number-guesser/
- Registered NumberGuesserValidator in validation system
- Added 'number-guesser' to database schema enums (arcade_rooms, arcade_sessions, room_game_configs)
- Created NumberGuesserGameConfig type with default configuration
- Integrated game into GameSelector and room page
- Added PageWithNav wrapper for consistent navigation
- Fixed controlled input warnings with fallback values

Registry Integration:
- Game automatically appears in /arcade/room selection UI
- Settings persist to room_game_configs table
- Validator creates and manages server-side game state
- Provider syncs client state via arcade session

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:07:20 -05:00
semantic-release-bot
0e76bcd79a chore(release): 3.21.0 [skip ci]
## [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](de30bec479))
2025-10-15 23:10:06 +00:00
Thomas Hallock
de30bec479 feat(arcade): add modular game SDK and registry system
Create foundation for modular arcade game architecture:

**Game SDK** (`/src/lib/arcade/game-sdk/`):
- Stable API surface that games can safely import
- Type-safe game definition with `defineGame()` helper
- Controlled hook exports (useArcadeSession, useRoomData, etc.)
- Player ownership and metadata utilities
- Error boundary component for game crashes

**Manifest System**:
- YAML-based game manifests with Zod validation
- Game metadata (name, icon, description, difficulty, etc.)
- Type-safe manifest loading with `loadManifest()`

**Game Registry**:
- Central registry for all arcade games
- Explicit registration pattern via `registerGame()`
- Helper functions to query available games

**Type Safety**:
- Full TypeScript contracts for games
- GameValidator, GameState, GameMove, GameConfig types
- Compile-time validation of game implementations

This establishes the plugin system for drop-in arcade games.
Next: Create demo games to exercise the system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 18:09:17 -05:00
semantic-release-bot
0eed26966c chore(release): 3.20.0 [skip ci]
## [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](49219e34cd))

### Code Refactoring

* use per-word-type tier selection for name generation ([499ee52](499ee525a8))
2025-10-15 19:10:15 +00:00
Thomas Hallock
49219e34cd feat: adjust tier probabilities for more abacus flavor
Change weighted selection from 70/20/10 to 50/25/25:
- Emoji-specific: 70% → 50%
- Category-specific: 20% → 25%
- Global abacus: 10% → 25%

This increases abacus-themed words by 2.5x, ensuring stronger
presence of core abacus vocabulary (Calculator, Abacist, Counter)
while still maintaining emoji personality theming.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:09:25 -05:00
Thomas Hallock
499ee525a8 refactor: use per-word-type tier selection for name generation
Changed from tier-then-mix approach to per-word-type selection:
- Before: Pick one tier, then optionally mix with abacus words
- After: Pick tier independently for adjective and noun

Benefits:
- Simpler, cleaner code
- More natural variety in name combinations
- Adjective and noun can come from different tiers naturally
- Examples: "Grinning Calculator" (emoji + global), "Ancient Smiler" (global + emoji)

Each word still uses weighted selection:
- 70% emoji-specific
- 20% category-specific
- 10% global abacus

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:03:21 -05:00
semantic-release-bot
843b45b14e chore(release): 3.19.0 [skip ci]
## [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](76a8472f12))
2025-10-15 19:01:15 +00:00
Thomas Hallock
76a8472f12 feat: implement avatar-themed name generation with probabilistic mixing
Add comprehensive emoji-themed player name generation system:
- 150+ emoji-specific word lists (10 adjectives + 10 nouns each)
- 45+ category-themed word lists as fallback
- Generic abacus-themed words as ultimate fallback

Probabilistic tier selection for variety:
- 70% emoji-specific (e.g., "Grinning Grinner" for 😀)
- 20% category-specific (e.g., "Cheerful Optimist" for happy faces)
- 10% global abacus theme (e.g., "Lightning Calculator")

Cross-tier mixing for abacus flavor infusion:
- 60% pure themed words
- 30% themed adjective + abacus noun
- 10% abacus adjective + themed noun

Updated all name generation call sites to pass player emoji:
- PlayerConfigDialog.tsx: Pass emoji when generating name
- GameModeContext.tsx: Theme names for default players and new players

This creates ultra-personalized, variety-rich names while maintaining
abacus theme presence across all generated names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 14:00:18 -05:00
semantic-release-bot
bf02bc14fd chore(release): 3.18.1 [skip ci]
## [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](ffb626f403))
2025-10-15 18:55:55 +00:00
Thomas Hallock
ffb626f403 fix(arcade): prevent empty update in settings API when only gameConfig changes
When only gameConfig is updated (without accessMode, password, or gameName),
the updateData object remained empty, causing Drizzle to throw "No values to set"
error when attempting to update arcade_rooms table.

Now only updates arcade_rooms if there are actual fields to update, preventing
the 500 error while still allowing gameConfig-only updates to room_game_configs table.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:54:47 -05:00
semantic-release-bot
860fd607be chore(release): 3.18.0 [skip ci]
## [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](3bae00b9a9))

### Documentation

* document manual migration of room_game_configs table ([ff79140](ff791409cf))
2025-10-15 18:41:45 +00:00
Thomas Hallock
3bae00b9a9 feat: add drizzle migration for room_game_configs table
Creates migration 0011 to:
- Create room_game_configs table with proper schema
- Add unique index on (room_id, game_name)
- Migrate existing game_config data from arcade_rooms table

Migration is idempotent and safe to run on any database state:
- Uses IF NOT EXISTS for table and index creation
- Uses INSERT OR IGNORE to avoid duplicate data
- Will work on both fresh databases and existing production

This ensures production will automatically get the new table structure
when the migration runs on deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:40:40 -05:00
Thomas Hallock
ff791409cf docs: document manual migration of room_game_configs table
Manual migration applied on 2025-10-15:
- Created room_game_configs table via sqlite3 CLI
- Migrated 6000 existing configs from arcade_rooms.game_config
- 5991 matching configs + 9 memory-quiz configs
- Table created directly instead of through drizzle migration system

The manually created drizzle migration SQL file has been removed since
the migration was applied directly to the database. See
.claude/MANUAL_MIGRATION_0011.md for complete details on the migration
process, verification steps, and rollback plan.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 13:31:55 -05:00
50 changed files with 9666 additions and 170 deletions

View File

@@ -1,3 +1,70 @@
## [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)

View File

@@ -370,22 +370,28 @@ Manual test procedure:
**Old Schema:**
- Settings stored in `arcade_rooms.game_config` JSON column
- Structure: `{ matching: {...}, memory-quiz: {...} }`
- 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
- Old column preserved temporarily for safety
- Unique constraint on (room_id, game_name)
- Configs persist when switching between games
**Migration SQL:** `drizzle/0011_add_room_game_configs.sql`
- Extracts matching configs from old column
- Extracts memory-quiz configs from old column
- Uses `json_extract()` to parse nested structure
**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 `gameConfig` column still exists
- Can revert to reading from it if needed
- Will be dropped in future release after validation
- 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

View 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

View File

@@ -70,7 +70,12 @@
"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)\"\"')"
],
"deny": [],
"ask": []

View File

@@ -1,6 +1,8 @@
-- Create room_game_configs table for per-game settings storage
-- This replaces the monolithic gameConfig JSON column with a normalized table
CREATE TABLE `room_game_configs` (
-- 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,
@@ -10,30 +12,22 @@ CREATE TABLE `room_game_configs` (
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `room_game_idx` ON `room_game_configs` (`room_id`, `game_name`);
-- Create unique index
CREATE UNIQUE INDEX IF NOT EXISTS `room_game_idx` ON `room_game_configs` (`room_id`,`game_name`);
--> statement-breakpoint
-- Migrate existing 'matching' configs from arcade_rooms.game_config
INSERT INTO `room_game_configs` (`id`, `room_id`, `game_name`, `config`, `created_at`, `updated_at`)
-- 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))),
`id` as room_id,
'matching' as game_name,
json_extract(`game_config`, '$.matching') as config,
`created_at`,
`last_activity` as updated_at
FROM `arcade_rooms`
WHERE json_extract(`game_config`, '$.matching') IS NOT NULL;
--> statement-breakpoint
-- Migrate existing 'memory-quiz' configs from arcade_rooms.game_config
INSERT INTO `room_game_configs` (`id`, `room_id`, `game_name`, `config`, `created_at`, `updated_at`)
SELECT
lower(hex(randomblob(16))),
`id` as room_id,
'memory-quiz' as game_name,
json_extract(`game_config`, '$."memory-quiz"') as config,
`created_at`,
`last_activity` as updated_at
FROM `arcade_rooms`
WHERE json_extract(`game_config`, '$."memory-quiz"') IS NOT NULL;
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');

View File

@@ -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
}
]
}

View File

@@ -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",
@@ -80,6 +81,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",

View File

@@ -20,7 +20,7 @@ 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?: 'matching' | 'memory-quiz' | 'complement-race' | 'number-guesser' | null (select game for room)
* - gameConfig?: object (game-specific settings)
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
@@ -94,7 +94,8 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
// Validate gameName if provided
if (body.gameName !== undefined && body.gameName !== null) {
const validGames = ['matching', 'memory-quiz', 'complement-race']
// Legacy games + registry games (TODO: make this dynamic when we refactor to lazy-load registry)
const validGames = ['matching', 'memory-quiz', 'complement-race', 'number-guesser']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
@@ -148,12 +149,15 @@ 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)

View File

@@ -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 })
}
}

View File

@@ -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 (

View File

@@ -0,0 +1,210 @@
/**
* 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
startGame: () => void
chooseNumber: (number: number) => void
makeGuess: (guess: number) => void
nextRound: () => void
goToSetup: () => void
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => 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
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 } = 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,
startGame,
chooseNumber,
makeGuess,
nextRound,
goToSetup,
setConfig,
exitSession,
}
return (
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
)
}

View File

@@ -0,0 +1,283 @@
/**
* 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':
return this.validateChooseNumber(state, move.data.secretNumber, move.playerId)
case 'MAKE_GUESS':
return this.validateMakeGuess(state, 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':
return this.validateSetConfig(state, move.data.field, 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}`,
}
}
// 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' }
}
const distance = Math.abs(guess - state.secretNumber)
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' || !state.winner) {
return { valid: false, error: 'Cannot start next round yet' }
}
// 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()

View File

@@ -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>
)
}

View File

@@ -0,0 +1,50 @@
/**
* 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()
return (
<PageWithNav
navTitle="Number Guesser"
navEmoji="🎯"
emphasizePlayerSelection={state.gamePhase === 'setup'}
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>
)
}

View File

@@ -0,0 +1,362 @@
/**
* Guessing Phase - Players take turns guessing the 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 GuessingPhase() {
const { state, makeGuess, nextRound } = 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
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>
{/* 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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

View 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,
})

View 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

View File

@@ -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}

View File

@@ -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()

View File

@@ -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}
/>
))}

View File

@@ -2,7 +2,7 @@ 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 { useGetRoomByCode, useJoinRoom } from '@/hooks/useRoomData'
export interface JoinRoomModalProps {
/**
@@ -25,7 +25,8 @@ 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 { mutate: joinRoom } = useJoinRoom()
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')

View File

@@ -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 })

View File

@@ -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)

View File

@@ -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) => {

View File

@@ -34,7 +34,7 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
// Game configuration (nullable to support game selection in room)
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
enum: ['matching', 'memory-quiz', 'complement-race', 'number-guesser'],
}),
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)

View File

@@ -17,7 +17,7 @@ export const arcadeSessions = sqliteTable('arcade_sessions', {
// Session metadata
currentGame: text('current_game', {
enum: ['matching', 'memory-quiz', 'complement-race'],
enum: ['matching', 'memory-quiz', 'complement-race', 'number-guesser'],
}).notNull(),
gameUrl: text('game_url').notNull(), // e.g., '/arcade/matching'

View File

@@ -20,7 +20,7 @@ export const roomGameConfigs = sqliteTable(
// Game identifier
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
enum: ['matching', 'memory-quiz', 'complement-race', 'number-guesser'],
}).notNull(),
// Game-specific configuration JSON

View File

@@ -8,18 +8,25 @@
import { and, eq } from 'drizzle-orm'
import { createId } from '@paralleldrive/cuid2'
import { db, schema } from '@/db'
import type { GameName } from './validation'
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,
} 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: GameName): GameConfigByName[GameName] {
function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[ExtendedGameName] {
switch (gameName) {
case 'matching':
return DEFAULT_MATCHING_CONFIG
@@ -27,6 +34,8 @@ function getDefaultGameConfig(gameName: GameName): GameConfigByName[GameName] {
return DEFAULT_MEMORY_QUIZ_CONFIG
case 'complement-race':
return DEFAULT_COMPLEMENT_RACE_CONFIG
case 'number-guesser':
return DEFAULT_NUMBER_GUESSER_CONFIG
default:
throw new Error(`Unknown game: ${gameName}`)
}
@@ -36,7 +45,7 @@ function getDefaultGameConfig(gameName: GameName): GameConfigByName[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 GameName>(
export async function getGameConfig<T extends ExtendedGameName>(
roomId: string,
gameName: T
): Promise<GameConfigByName[T]> {
@@ -62,7 +71,7 @@ export async function getGameConfig<T extends GameName>(
* 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 GameName>(
export async function setGameConfig<T extends ExtendedGameName>(
roomId: string,
gameName: T,
config: Partial<GameConfigByName[T]>
@@ -110,7 +119,7 @@ export async function setGameConfig<T extends GameName>(
* Convenience wrapper around setGameConfig
*/
export async function updateGameConfigField<
T extends GameName,
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
@@ -123,7 +132,7 @@ export async function updateGameConfigField<
* Delete a game's config from the database
* Useful when clearing game selection or cleaning up
*/
export async function deleteGameConfig(roomId: string, gameName: GameName): Promise<void> {
export async function deleteGameConfig(roomId: string, gameName: ExtendedGameName): Promise<void> {
await db
.delete(schema.roomGameConfigs)
.where(
@@ -139,14 +148,14 @@ export async function deleteGameConfig(roomId: string, gameName: GameName): Prom
*/
export async function getAllGameConfigs(
roomId: string
): Promise<Partial<Record<GameName, unknown>>> {
): Promise<Partial<Record<ExtendedGameName, unknown>>> {
const configs = await db.query.roomGameConfigs.findMany({
where: eq(schema.roomGameConfigs.roomId, roomId),
})
const result: Partial<Record<GameName, unknown>> = {}
const result: Partial<Record<ExtendedGameName, unknown>> = {}
for (const config of configs) {
result[config.gameName as GameName] = config.config
result[config.gameName as ExtendedGameName] = config.config
}
return result
@@ -165,7 +174,7 @@ export async function deleteAllGameConfigs(roomId: string): Promise<void> {
* Validate a game config at runtime
* Returns true if the config is valid for the given game
*/
export function validateGameConfig(gameName: GameName, config: any): boolean {
export function validateGameConfig(gameName: ExtendedGameName, config: any): boolean {
switch (gameName) {
case 'matching':
return (
@@ -194,6 +203,18 @@ export function validateGameConfig(gameName: GameName, config: any): boolean {
// 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
)
default:
return false
}

View File

@@ -40,6 +40,15 @@ export interface ComplementRaceGameConfig {
placeholder?: never
}
/**
* Configuration for number-guesser game
*/
export interface NumberGuesserGameConfig {
minNumber: number
maxNumber: number
roundsToWin: number
}
/**
* Union type of all game configs for type-safe access
*/
@@ -47,6 +56,7 @@ export type GameConfigByName = {
matching: MatchingGameConfig
'memory-quiz': MemoryQuizGameConfig
'complement-race': ComplementRaceGameConfig
'number-guesser': NumberGuesserGameConfig
}
/**
@@ -57,6 +67,7 @@ export interface RoomGameConfig {
matching?: MatchingGameConfig
'memory-quiz'?: MemoryQuizGameConfig
'complement-race'?: ComplementRaceGameConfig
'number-guesser'?: NumberGuesserGameConfig
}
/**
@@ -78,3 +89,9 @@ export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
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,
}

View File

@@ -0,0 +1,111 @@
/**
* 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'
registerGame(numberGuesserGame)

View 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
}
}

View 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,
}
}

View 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'

View 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)
}

View 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
}

View 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)
}

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View File

@@ -0,0 +1,68 @@
/**
* 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 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,
// 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[]
}
/**
* Re-export validators for backwards compatibility
*/
export { matchingGameValidator, memoryQuizGameValidator, numberGuesserValidator }

View File

@@ -13,8 +13,8 @@ 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

View File

@@ -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()

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "3.17.14",
"version": "3.22.2",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [

11
pnpm-lock.yaml generated
View File

@@ -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)
@@ -210,6 +213,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 +3674,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==}
@@ -13150,6 +13159,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