Compare commits

...

7 Commits

Author SHA1 Message Date
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
13 changed files with 6991 additions and 62 deletions

View File

@@ -1,3 +1,29 @@
## [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,10 @@
"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:*)"
],
"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

@@ -148,12 +148,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

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

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

@@ -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,19 @@
/**
* 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,
type ThemedWordList,
} from './themedWords'
// Generic abacus-themed words (used as ultimate fallback)
const ADJECTIVES = [
// Abacus-themed adjectives
'Ancient',
@@ -114,32 +125,115 @@ const NOUNS = [
]
/**
* Generate a random player name by combining an adjective and noun
* Get themed word list for an emoji avatar using probabilistic tier selection
* Strongly prefers emoji-specific (70%), then category (20%), then global (10%)
* This adds variety while maintaining personality-matched theming
*/
export function generatePlayerName(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
function getThemedWordsForEmoji(emoji: string): ThemedWordList {
// Collect available tiers
const availableTiers: Array<{ weight: number; words: ThemedWordList }> = []
// Emoji-specific tier (70% preference)
const emojiSpecific = EMOJI_SPECIFIC_WORDS[emoji]
if (emojiSpecific) {
availableTiers.push({ weight: 70, words: emojiSpecific })
}
// Category tier (20% preference)
const category = EMOJI_TO_THEME[emoji]
if (category) {
const categoryTheme = THEMED_WORD_LISTS[category]
if (categoryTheme) {
availableTiers.push({ weight: 20, words: categoryTheme })
}
}
// Global abacus tier (10% preference)
availableTiers.push({ weight: 10, words: { adjectives: ADJECTIVES, nouns: 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 { adjectives: ADJECTIVES, nouns: NOUNS }
}
/**
* Generate a random player name by combining an adjective and noun
* Optionally themed based on avatar emoji for ultra-personalized names!
* Uses probabilistic tier selection and cross-tier mixing for abacus flavor
*
* @param emoji - Optional emoji avatar to theme the name around
* @returns A creative player name like "Grinning Calculator" or "Lightning Abacist"
*/
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}`
}
// Get themed words using probabilistic tier selection
const themedWords = getThemedWordsForEmoji(emoji)
const abacusWords = { adjectives: ADJECTIVES, nouns: NOUNS }
// 60% chance: Use themed words for both adjective and noun
// 30% chance: Mix themed adjective with abacus noun (infuses abacus flavor)
// 10% chance: Mix abacus adjective with themed noun (infuses abacus flavor)
const mixStrategy = Math.random()
let adjective: string
let noun: string
if (mixStrategy < 0.6) {
// Pure themed
adjective = themedWords.adjectives[Math.floor(Math.random() * themedWords.adjectives.length)]
noun = themedWords.nouns[Math.floor(Math.random() * themedWords.nouns.length)]
} else if (mixStrategy < 0.9) {
// Themed adjective + abacus noun
adjective = themedWords.adjectives[Math.floor(Math.random() * themedWords.adjectives.length)]
noun = abacusWords.nouns[Math.floor(Math.random() * abacusWords.nouns.length)]
} else {
// Abacus adjective + themed noun
adjective = abacusWords.adjectives[Math.floor(Math.random() * abacusWords.adjectives.length)]
noun = themedWords.nouns[Math.floor(Math.random() * themedWords.nouns.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 +244,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

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