Compare commits

...

23 Commits

Author SHA1 Message Date
semantic-release-bot
2fb6ead4f2 chore(release): 3.13.2 [skip ci]
## [3.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.1...v3.13.2) (2025-10-14)

### Bug Fixes

* **arcade:** only notify room creator of join requests ([bc571e3](bc571e3d0d))
2025-10-14 13:55:57 +00:00
Thomas Hallock
bc571e3d0d fix(arcade): only notify room creator of join requests
Fixes issue where ALL room members were seeing join request approval
toasts, but only the creator can approve them, leading to confusing
error messages when non-creators clicked approve/deny.

Changes:
- Join request notifications now sent only to room creator's user channel
- Changed from broadcasting to entire room to targeted user notification
- Uses `user:${room.createdBy}` channel instead of `room:${roomId}`

Non-host users will no longer see approval toasts they cannot act on.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:55:10 -05:00
semantic-release-bot
eed7c9b938 chore(release): 3.13.1 [skip ci]
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)

### Bug Fixes

* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](654ba19ccc))
2025-10-14 13:54:31 +00:00
Thomas Hallock
654ba19ccc fix(arcade): allow room creator to rejoin restricted/approval rooms
Fixes catch-22 where room creator who leaves their own approval-only
or restricted room cannot rejoin because:
- Approval-only: They need approval but can't approve themselves
- Restricted: They need an invitation but can't invite themselves

Changes:
- Room creator now bypasses invitation check for restricted rooms
- Room creator now bypasses approval check for approval-only rooms
- Other users still require proper authorization

This ensures hosts can always access their own rooms regardless of
access mode restrictions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:53:39 -05:00
semantic-release-bot
f5469cda0c chore(release): 3.13.0 [skip ci]
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)

### Features

* **moderation:** add inline feedback and persistent password display ([86e3d41](86e3d41996))
2025-10-14 13:53:19 +00:00
Thomas Hallock
86e3d41996 feat(moderation): add inline feedback and persistent password display
- Add success/error message UI component to ModerationPanel
- Replace all browser alert() calls with inline React-based feedback
- Add displayPassword field to arcade_rooms schema for plain text storage
- Create migration to add display_password column
- Update settings PATCH route to store both hashed and display passwords
- Update room GET route to return displayPassword only to room creator
- Update ModerationPanel to populate password field when loading settings
- Fix room-manager test to include displayPassword field

Password field now persists and displays correctly when reloading the page
for room owners in password-protected rooms.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:52:19 -05:00
semantic-release-bot
cb11bec975 chore(release): 3.12.0 [skip ci]
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)

### Features

* **moderation:** improve password input with copy button ([2580e47](2580e474d0))
2025-10-14 13:40:37 +00:00
Thomas Hallock
2580e474d0 feat(moderation): improve password input with copy button
Enhances the password-protected room settings UX:

Changes:
1. Password input now stays visible and editable
   - Plain text input (not hidden) for easy viewing
   - Focus state with orange border
   - Clear placeholder text

2. Copy button next to password input
   - 📋 Copy icon with text
   - Visual feedback: changes to "✓ Copied!" for 2 seconds
   - Disabled state when no password entered
   - Green success color after copying

3. Better labeling and hints
   - "Room Password" label above input
   - Helper text: "Share this password with guests to allow them to join"
   - More descriptive placeholder

Note: Passwords are hashed in the database for security, so existing
passwords cannot be retrieved. This UI is for setting/changing passwords
and easily copying them to share with guests.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:39:42 -05:00
semantic-release-bot
55e0be8e42 chore(release): 3.11.1 [skip ci]
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)

### Bug Fixes

* **moderation:** improve access mode settings UX ([dd9e657](dd9e657db8))
2025-10-14 13:36:29 +00:00
Thomas Hallock
dd9e657db8 fix(moderation): improve access mode settings UX
Enhances the room moderation settings UX to prevent accidental closure
with unsaved changes:

Changes:
1. "Update Access Mode" button now only appears when there are changes
   - Tracks original access mode on load
   - Compares current selection to detect changes
   - Button hidden when no changes made

2. "Close" button disabled when there are unsaved access mode changes
   - Prevents accidentally losing changes
   - Visual feedback: dimmed appearance, orange border
   - Hover state brightens orange border to draw attention

3. Tooltip on disabled "Close" button
   - Shows "Please update access mode settings before closing"
   - Helps users understand why close is disabled

This prevents the frustrating UX issue where users want to close but
are forced to update settings they didn't intend to change.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:35:36 -05:00
semantic-release-bot
51d9a37f9b chore(release): 3.11.0 [skip ci]
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)

### Features

* add name generator button and abacus emoji ([07212e4](07212e4df0))

### Code Refactoring

* make player names abacus and arithmetic themed ([97daad9](97daad9abb))
2025-10-14 13:31:03 +00:00
Thomas Hallock
07212e4df0 feat: add name generator button and abacus emoji
Adds two enhancements to player customization:

1. Name generator button in PlayerConfigDialog
   - Dice emoji (🎲) button next to name input
   - Generates new themed names on click
   - Excludes current player from collision check
   - Maintains auto-save behavior

2. Abacus emoji option
   - Added 🧮 (abacus) as first emoji choice
   - Perfect thematic fit for the application

Now players can easily try different generated names without leaving
the settings dialog.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:30:14 -05:00
Thomas Hallock
97daad9abb refactor: make player names abacus and arithmetic themed
Replaces generic fantasy/gaming words with abacus and math-themed vocabulary.

Examples of new names:
- "Ancient Abacist", "Sliding Counter", "Soroban Master"
- "Calculating Mathematician", "Rapid Solver", "Precise Adder"
- "Bamboo Scholar", "Golden Merchant", "Mental Genius"

Changes:
- 25 abacus-specific adjectives (Ancient, Wooden, Soroban, etc.)
- 25 arithmetic adjectives (Adding, Calculating, Prime, etc.)
- 25 abacus-specific nouns (Counter, Abacist, Bead, Rod, etc.)
- 25 arithmetic nouns (Mathematician, Solver, Adder, etc.)
- Still maintains 2,500 unique combinations
- All tests pass with new vocabulary

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:29:13 -05:00
semantic-release-bot
225104c3a7 chore(release): 3.10.0 [skip ci]
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)

### Features

* add fun automatic player naming system ([249257c](249257c6c7))
2025-10-14 13:26:03 +00:00
Thomas Hallock
249257c6c7 feat: add fun automatic player naming system
Implements automatic generation of creative player names combining
adjectives with nouns/roles (e.g., "Swift Ninja", "Cosmic Wizard").

Changes:
- Created playerNames utility with 50 adjectives and 50 nouns
- Generates unique names with collision detection
- Applied to default player creation and addPlayer function
- Replaces generic "Player 1", "Player 2" with fun names
- Manual override still available via PlayerConfigDialog
- Added comprehensive unit tests (10 passing tests)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:25:05 -05:00
semantic-release-bot
b37e29e53e chore(release): 3.9.2 [skip ci]
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)

### Bug Fixes

* remove duplicate ModerationNotifications causing double toasts ([c6886a0](c6886a0e59))
2025-10-14 13:19:07 +00:00
Thomas Hallock
c6886a0e59 fix: remove duplicate ModerationNotifications causing double toasts
**Root Cause:**
- ModerationNotifications was rendered in BOTH /arcade/room/page.tsx AND PageWithNav
- Both had separate useRoomData hooks, creating two socket listeners
- When join-request-submitted event fired, BOTH instances showed a toast
- Clicking "Approve" only closed one toast, leaving the other visible

**Fix:**
- Removed ModerationNotifications from room page entirely
- PageWithNav (inside MemoryPairsGame) already handles all moderation events
- Now only ONE instance listens and renders, so approvals properly dismiss

**Files Changed:**
- Removed ModerationNotifications import from room page
- Removed all 4 instances of <ModerationNotifications /> from room page
- Removed moderationEvent and clearModerationEvent from useRoomData destructuring
- Added comment explaining PageWithNav handles notifications

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:18:12 -05:00
semantic-release-bot
cb2fec1da5 chore(release): 3.9.1 [skip ci]
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)

### Bug Fixes

* reset join request toast state when moderation event cleared ([6beb58a](6beb58a7b8))
2025-10-14 13:14:31 +00:00
Thomas Hallock
6beb58a7b8 fix: reset join request toast state when moderation event cleared
**Issue:**
Toast notification was persisting after successful approval because
showJoinRequestToast state was never reset to false.

**Fix:**
- Add else clause to useEffect that resets toast state when event cleared
- Reset both showJoinRequestToast and requestError when event changes type
- Ensures toast properly dismisses after approval/denial

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:13:40 -05:00
semantic-release-bot
544b06e290 chore(release): 3.9.0 [skip ci]
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)

### Features

* prevent invitations to retired rooms ([a7c3c1f](a7c3c1f4cd))
2025-10-14 13:13:04 +00:00
Thomas Hallock
a7c3c1f4cd feat: prevent invitations to retired rooms
- Add room access mode check in invite POST endpoint
- Block invitation creation if room is retired (403 status)
- Clear error message: "Cannot send invitations to retired rooms"
- Check happens before host validation to catch retired rooms early

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:12:10 -05:00
semantic-release-bot
090d4dac2b chore(release): 3.8.1 [skip ci]
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)

### Bug Fixes

* improve kicked modal message for retired room ejections ([f865ce1](f865ce16ec))
2025-10-14 13:10:33 +00:00
Thomas Hallock
f865ce16ec fix: improve kicked modal message for retired room ejections
**Socket handler update:**
- Capture reason field from kicked-from-room socket event

**Modal UI improvements:**
- Detect when reason contains "retired"
- Show 🏁 emoji instead of ⚠️ for retired rooms
- Title changes from "Kicked from Room" to "Room Retired"
- Message explains room owner retired the room and access is closed
- Clarify only room owner can access retired rooms

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:09:41 -05:00
20 changed files with 843 additions and 196 deletions

View File

@@ -1,3 +1,85 @@
## [3.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.1...v3.13.2) (2025-10-14)
### Bug Fixes
* **arcade:** only notify room creator of join requests ([bc571e3](https://github.com/antialias/soroban-abacus-flashcards/commit/bc571e3d0d11fe4142680132d551e25ca626d950))
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)
### Bug Fixes
* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](https://github.com/antialias/soroban-abacus-flashcards/commit/654ba19ccca595d34ad205c036c18afb99a494c7))
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)
### Features
* **moderation:** add inline feedback and persistent password display ([86e3d41](https://github.com/antialias/soroban-abacus-flashcards/commit/86e3d4199628f95048b9265c9de0adfdc2934f93))
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)
### Features
* **moderation:** improve password input with copy button ([2580e47](https://github.com/antialias/soroban-abacus-flashcards/commit/2580e474d08bf91477339e998b2c70962a633f41))
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)
### Bug Fixes
* **moderation:** improve access mode settings UX ([dd9e657](https://github.com/antialias/soroban-abacus-flashcards/commit/dd9e657db85752b32ff91ae1b33a0bf7a7628e07))
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)
### Features
* add name generator button and abacus emoji ([07212e4](https://github.com/antialias/soroban-abacus-flashcards/commit/07212e4df0c7fd4b8cccf935c48b14164df6961d))
### Code Refactoring
* make player names abacus and arithmetic themed ([97daad9](https://github.com/antialias/soroban-abacus-flashcards/commit/97daad9abb40a6f4d59ca8a4d4b671822b7b0955))
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)
### Features
* add fun automatic player naming system ([249257c](https://github.com/antialias/soroban-abacus-flashcards/commit/249257c6c77d503b48479065664c96c5de36a234))
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)
### Bug Fixes
* remove duplicate ModerationNotifications causing double toasts ([c6886a0](https://github.com/antialias/soroban-abacus-flashcards/commit/c6886a0e59b3cbf051a828e0157495101cd8c823))
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)
### Bug Fixes
* reset join request toast state when moderation event cleared ([6beb58a](https://github.com/antialias/soroban-abacus-flashcards/commit/6beb58a7b8f8e1841c71729a3517ab459e924aa9))
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)
### Features
* prevent invitations to retired rooms ([a7c3c1f](https://github.com/antialias/soroban-abacus-flashcards/commit/a7c3c1f4cd802985c8f040bc1cdf3ea4482a2fce))
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)
### Bug Fixes
* improve kicked modal message for retired room ejections ([f865ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/f865ce16ecf7648e41549795c8137f4fc33e34ac))
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)

View File

@@ -57,7 +57,10 @@
"Bash(fi)",
"Bash(then echo \"TypeScript errors found\")",
"Bash(else echo \"✓ No TypeScript errors in join page\")",
"Bash(npx @biomejs/biome format:*)"
"Bash(npx @biomejs/biome format:*)",
"Bash(npx drizzle-kit generate:*)",
"Bash(ssh nas.home.network \"docker ps | grep -E ''soroban|abaci|web''\")",
"Bash(ssh:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,2 @@
-- Add display_password column to arcade_rooms for showing plain text passwords to room owners
ALTER TABLE `arcade_rooms` ADD `display_password` text(100);

View File

@@ -1,13 +1,14 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import {
createInvitation,
declineInvitation,
getInvitation,
getRoomInvitations,
} from '@/lib/arcade/room-invitations'
import { getViewerId } from '@/lib/viewer'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -35,6 +36,20 @@ export async function POST(req: NextRequest, context: RouteContext) {
)
}
// Get room to check access mode
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Cannot invite to retired rooms
if (room.accessMode === 'retired') {
return NextResponse.json(
{ error: 'Cannot send invitations to retired rooms' },
{ status: 403 }
)
}
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)

View File

@@ -88,11 +88,12 @@ export async function POST(req: NextRequest, context: RouteContext) {
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
)
// Broadcast to all members in the room (particularly the host) via socket
// Broadcast to the room host (creator) only via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`room:${roomId}`).emit('join-request-submitted', {
// Send notification only to the room creator's user channel
io.to(`user:${room.createdBy}`).emit('join-request-submitted', {
roomId,
request: {
id: request.id,
@@ -103,7 +104,7 @@ export async function POST(req: NextRequest, context: RouteContext) {
})
console.log(
`[Join Requests] Broadcasted join-request-submitted for user ${viewerId} to room ${roomId}`
`[Join Requests] Broadcasted join-request-submitted to room creator ${room.createdBy}`
)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails

View File

@@ -83,25 +83,31 @@ export async function POST(req: NextRequest, context: RouteContext) {
}
case 'restricted': {
// Check for valid pending invitation
const invitation = await getInvitation(roomId, viewerId)
if (!invitation || invitation.status !== 'pending') {
return NextResponse.json(
{ error: 'You need a valid invitation to join this room' },
{ status: 403 }
)
// Room creator can always rejoin their own room
if (!isRoomCreator) {
// Check for valid pending invitation
const invitation = await getInvitation(roomId, viewerId)
if (!invitation || invitation.status !== 'pending') {
return NextResponse.json(
{ error: 'You need a valid invitation to join this room' },
{ status: 403 }
)
}
}
break
}
case 'approval-only': {
// Check for approved join request
const joinRequest = await getJoinRequest(roomId, viewerId)
if (!joinRequest || joinRequest.status !== 'approved') {
return NextResponse.json(
{ error: 'Your join request must be approved by the host' },
{ status: 403 }
)
// Room creator can always rejoin their own room without approval
if (!isRoomCreator) {
// Check for approved join request
const joinRequest = await getJoinRequest(roomId, viewerId)
if (!joinRequest || joinRequest.status !== 'approved') {
return NextResponse.json(
{ error: 'Your join request must be approved by the host' },
{ status: 403 }
)
}
}
break
}

View File

@@ -42,8 +42,13 @@ export async function GET(_req: NextRequest, context: RouteContext) {
// Update room activity when viewing (keeps active rooms fresh)
await touchRoom(roomId)
// Prepare room data - include displayPassword only for room creator
const roomData = canModerate
? room // Creator gets full room data including displayPassword
: { ...room, displayPassword: undefined } // Others don't see displayPassword
return NextResponse.json({
room,
room: roomData,
members,
memberPlayers, // Map of userId -> active Player[] for each member
canModerate,

View File

@@ -69,9 +69,11 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
if (body.password !== undefined) {
if (body.password === null || body.password === '') {
updateData.password = null // Clear password
updateData.displayPassword = null // Also clear display password
} else {
const hashedPassword = await bcrypt.hash(body.password, 10)
updateData.password = hashedPassword
updateData.displayPassword = body.password // Store plain text for display
}
}

View File

@@ -1,7 +1,6 @@
'use client'
import { useRoomData } from '@/hooks/useRoomData'
import { ModerationNotifications } from '@/components/nav/ModerationNotifications'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
@@ -11,60 +10,57 @@ import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProv
*
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
* Instead, we show a friendly message with a link back to the Champion Arena.
*
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
* so we don't need to render it here.
*/
export default function RoomPage() {
const { roomData, isLoading, moderationEvent, clearModerationEvent } = useRoomData()
const { roomData, isLoading } = useRoomData()
// Show loading state
if (isLoading) {
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
</>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
)
}
// Show error if no room (instead of redirecting)
if (!roomData) {
return (
<>
<div
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
color: '#3b82f6',
textDecoration: 'underline',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
</>
Go to Champion Arena
</a>
</div>
)
}
@@ -72,38 +68,26 @@ export default function RoomPage() {
switch (roomData.gameName) {
case 'matching':
return (
<>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
<ModerationNotifications
moderationEvent={moderationEvent}
onClose={clearModerationEvent}
/>
</>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)
default:
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
<ModerationNotifications
moderationEvent={moderationEvent}
onClose={clearModerationEvent}
/>
</>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
)
}
}

View File

@@ -52,6 +52,10 @@ export function ModerationNotifications({
if (moderationEvent?.type === 'join-request') {
setShowJoinRequestToast(true)
setRequestError(null) // Clear any previous errors
} else {
// Reset toast state when event is cleared or changes type
setShowJoinRequestToast(false)
setRequestError(null)
}
}, [moderationEvent])
@@ -129,6 +133,8 @@ export function ModerationNotifications({
// Kicked modal
if (moderationEvent?.type === 'kicked') {
const isRetired = moderationEvent.data.reason?.includes('retired')
return (
<Modal isOpen={true} onClose={() => {}}>
<div
@@ -139,7 +145,7 @@ export function ModerationNotifications({
minWidth: '400px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{isRetired ? '🏁' : '⚠️'}</div>
<h2
style={{
fontSize: '24px',
@@ -148,7 +154,7 @@ export function ModerationNotifications({
color: 'rgba(253, 186, 116, 1)',
}}
>
Kicked from Room
{isRetired ? 'Room Retired' : 'Kicked from Room'}
</h2>
<p
style={{
@@ -157,10 +163,16 @@ export function ModerationNotifications({
marginBottom: '8px',
}}
>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
{isRetired ? (
<>The room owner has retired this room and access has been closed</>
) : (
<>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
</>
)}
</p>
<p
style={{
@@ -169,7 +181,9 @@ export function ModerationNotifications({
marginBottom: '24px',
}}
>
You can rejoin if the host sends you a new invite
{isRetired
? 'Only the room owner can access retired rooms'
: 'You can rejoin if the host sends you a new invite'}
</p>
<button

View File

@@ -88,10 +88,16 @@ export function ModerationPanel({
// Settings state
const [accessMode, setAccessMode] = useState<string>('open')
const [originalAccessMode, setOriginalAccessMode] = useState<string>('open')
const [roomPassword, setRoomPassword] = useState('')
const [showPasswordInput, setShowPasswordInput] = useState(false)
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('')
const [joinRequests, setJoinRequests] = useState<any[]>([])
const [passwordCopied, setPasswordCopied] = useState(false)
// Inline feedback state
const [successMessage, setSuccessMessage] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
// Ban modal state
const [showBanModal, setShowBanModal] = useState(false)
@@ -180,8 +186,9 @@ export function ModerationPanel({
}
// Success - member will be removed via socket update
showSuccess('Player kicked from room')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to kick player')
showError(err instanceof Error ? err.message : 'Failed to kick player')
} finally {
setActionLoading(null)
}
@@ -218,8 +225,10 @@ export function ModerationPanel({
const data = await bansRes.json()
setBans(data.bans || [])
}
showSuccess('Player banned from room')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to ban player')
showError(err instanceof Error ? err.message : 'Failed to ban player')
} finally {
setActionLoading(null)
setBanTargetUserId(null)
@@ -255,8 +264,10 @@ export function ModerationPanel({
const data = await historyRes.json()
setHistoricalMembers(data.historicalMembers || [])
}
showSuccess('Player unbanned')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to unban player')
showError(err instanceof Error ? err.message : 'Failed to unban player')
} finally {
setActionLoading(null)
}
@@ -292,9 +303,9 @@ export function ModerationPanel({
setHistoricalMembers(data.historicalMembers || [])
}
alert(`${userName} has been unbanned and invited back to the room!`)
showSuccess(`${userName} has been unbanned and invited back to the room`)
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to unban player')
showError(err instanceof Error ? err.message : 'Failed to unban player')
} finally {
setActionLoading(null)
}
@@ -322,9 +333,9 @@ export function ModerationPanel({
setHistoricalMembers(data.historicalMembers || [])
}
alert(`Invitation sent to ${userName}!`)
showSuccess(`Invitation sent to ${userName}`)
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to send invitation')
showError(err instanceof Error ? err.message : 'Failed to send invitation')
} finally {
setActionLoading(null)
}
@@ -336,11 +347,19 @@ export function ModerationPanel({
const loadSettings = async () => {
try {
// Fetch current room data to get access mode
// Fetch current room data to get access mode and password
const roomRes = await fetch(`/api/arcade/rooms/${roomId}`)
if (roomRes.ok) {
const data = await roomRes.json()
setAccessMode(data.room?.accessMode || 'open')
const currentAccessMode = data.room?.accessMode || 'open'
setAccessMode(currentAccessMode)
setOriginalAccessMode(currentAccessMode)
// Set password field if room has a password and user is the creator
if (currentAccessMode === 'password' && data.room?.displayPassword) {
setRoomPassword(data.room.displayPassword)
setShowPasswordInput(true)
}
}
// Fetch join requests if any
@@ -377,11 +396,12 @@ export function ModerationPanel({
throw new Error(errorData.error || 'Failed to update settings')
}
alert('Room settings updated successfully!')
showSuccess('Room settings updated successfully')
setOriginalAccessMode(accessMode) // Update original to current
setShowPasswordInput(false)
setRoomPassword('')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update settings')
showError(err instanceof Error ? err.message : 'Failed to update settings')
} finally {
setActionLoading(null)
}
@@ -409,10 +429,10 @@ export function ModerationPanel({
throw new Error(errorData.error || 'Failed to transfer ownership')
}
alert(`Ownership transferred to ${newOwner.displayName}!`)
onClose() // Close panel since user is no longer host
showSuccess(`Ownership transferred to ${newOwner.displayName}`)
setTimeout(() => onClose(), 2000) // Close panel after showing message
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to transfer ownership')
showError(err instanceof Error ? err.message : 'Failed to transfer ownership')
} finally {
setActionLoading(null)
}
@@ -437,9 +457,9 @@ export function ModerationPanel({
setJoinRequests(data.requests || [])
}
alert('Join request approved!')
showSuccess('Join request approved')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to approve request')
showError(err instanceof Error ? err.message : 'Failed to approve request')
} finally {
setActionLoading(null)
}
@@ -463,16 +483,47 @@ export function ModerationPanel({
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
showSuccess('Join request denied')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to deny request')
showError(err instanceof Error ? err.message : 'Failed to deny request')
} finally {
setActionLoading(null)
}
}
const handleCopyPassword = async () => {
if (!roomPassword) return
try {
await navigator.clipboard.writeText(roomPassword)
setPasswordCopied(true)
setTimeout(() => setPasswordCopied(false), 2000)
} catch (err) {
console.error('Failed to copy password:', err)
showError('Failed to copy password to clipboard')
}
}
// Utility functions for showing feedback
const showSuccess = (message: string) => {
setSuccessMessage(message)
setErrorMessage('')
setTimeout(() => setSuccessMessage(''), 5000)
}
const showError = (message: string) => {
setErrorMessage(message)
setSuccessMessage('')
setTimeout(() => setErrorMessage(''), 5000)
}
const pendingReports = reports.filter((r) => r.status === 'pending')
const otherMembers = members.filter((m) => m.userId !== currentUserId)
// Check if there are unsaved changes in settings
const hasUnsavedAccessModeChanges = accessMode !== originalAccessMode
// Group reports by reported user ID
const reportsByUser = pendingReports.reduce(
(acc, report) => {
@@ -513,6 +564,69 @@ export function ModerationPanel({
Manage members, reports, and bans
</p>
{/* Success/Error Messages */}
{(successMessage || errorMessage) && (
<div
style={{
padding: '12px 16px',
background: successMessage ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
border: successMessage
? '1px solid rgba(34, 197, 94, 0.4)'
: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '8px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
animation: 'fadeIn 0.2s ease',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flex: 1,
}}
>
<span style={{ fontSize: '16px' }}>{successMessage ? '✓' : '⚠'}</span>
<span
style={{
fontSize: '14px',
fontWeight: '600',
color: successMessage ? 'rgba(34, 197, 94, 1)' : 'rgba(239, 68, 68, 1)',
}}
>
{successMessage || errorMessage}
</span>
</div>
<button
type="button"
onClick={() => {
setSuccessMessage('')
setErrorMessage('')
}}
style={{
background: 'none',
border: 'none',
color: successMessage ? 'rgba(34, 197, 94, 0.8)' : 'rgba(239, 68, 68, 0.8)',
fontSize: '18px',
cursor: 'pointer',
padding: '0 4px',
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.8'
}}
>
</button>
</div>
)}
{/* Tabs */}
<div
style={{
@@ -1411,49 +1525,125 @@ export function ModerationPanel({
{/* Password input (conditional) */}
{(accessMode === 'password' || showPasswordInput) && (
<input
type="text"
value={roomPassword}
onChange={(e) => setRoomPassword(e.target.value)}
placeholder="Enter room password"
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '6px',
}}
>
Room Password
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={roomPassword}
onChange={(e) => setRoomPassword(e.target.value)}
placeholder="Enter password to share with guests"
style={{
flex: 1,
padding: '10px 12px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.6)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}}
/>
<button
type="button"
onClick={handleCopyPassword}
disabled={!roomPassword}
title="Copy password to clipboard"
style={{
padding: '10px 16px',
background: passwordCopied
? 'rgba(34, 197, 94, 0.2)'
: roomPassword
? 'rgba(59, 130, 246, 0.2)'
: 'rgba(75, 85, 99, 0.2)',
color: passwordCopied
? 'rgba(34, 197, 94, 1)'
: roomPassword
? 'rgba(59, 130, 246, 1)'
: 'rgba(156, 163, 175, 1)',
border: passwordCopied
? '1px solid rgba(34, 197, 94, 0.4)'
: roomPassword
? '1px solid rgba(59, 130, 246, 0.4)'
: '1px solid rgba(75, 85, 99, 0.3)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: roomPassword ? 'pointer' : 'not-allowed',
opacity: roomPassword ? 1 : 0.5,
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => {
if (roomPassword && !passwordCopied) {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.3)'
}
}}
onMouseLeave={(e) => {
if (roomPassword && !passwordCopied) {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)'
}
}}
>
{passwordCopied ? '✓ Copied!' : '📋 Copy'}
</button>
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(156, 163, 175, 1)',
marginTop: '4px',
}}
>
Share this password with guests to allow them to join
</div>
</div>
)}
{hasUnsavedAccessModeChanges && (
<button
type="button"
onClick={handleUpdateAccessMode}
disabled={actionLoading === 'update-settings'}
style={{
width: '100%',
padding: '10px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
background:
actionLoading === 'update-settings'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border:
actionLoading === 'update-settings'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(59, 130, 246, 0.6)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
marginBottom: '12px',
fontWeight: '600',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
}}
/>
>
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
</button>
)}
<button
type="button"
onClick={handleUpdateAccessMode}
disabled={actionLoading === 'update-settings'}
style={{
width: '100%',
padding: '10px',
background:
actionLoading === 'update-settings'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border:
actionLoading === 'update-settings'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(59, 130, 246, 0.6)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
}}
>
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
</button>
</div>
</div>
@@ -1663,23 +1853,44 @@ export function ModerationPanel({
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '20px' }}>
<button
type="button"
onClick={onClose}
onClick={hasUnsavedAccessModeChanges ? undefined : onClose}
disabled={hasUnsavedAccessModeChanges}
title={
hasUnsavedAccessModeChanges
? 'Please update access mode settings before closing'
: undefined
}
style={{
padding: '10px 20px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '1px solid rgba(75, 85, 99, 0.5)',
background: hasUnsavedAccessModeChanges
? 'rgba(75, 85, 99, 0.2)'
: 'rgba(75, 85, 99, 0.3)',
color: hasUnsavedAccessModeChanges
? 'rgba(156, 163, 175, 1)'
: 'rgba(209, 213, 219, 1)',
border: hasUnsavedAccessModeChanges
? '1px solid rgba(251, 146, 60, 0.4)'
: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
cursor: hasUnsavedAccessModeChanges ? 'not-allowed' : 'pointer',
opacity: hasUnsavedAccessModeChanges ? 0.6 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
if (!hasUnsavedAccessModeChanges) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
} else {
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.8)'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
if (!hasUnsavedAccessModeChanges) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
} else {
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.4)'
}
}}
>
Close

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
import { useGameMode } from '../../contexts/GameModeContext'
import { generateUniquePlayerName } from '../../utils/playerNames'
interface PlayerConfigDialogProps {
playerId: string
@@ -48,6 +49,15 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
setShowEmojiPicker(false)
}
const handleGenerateNewName = () => {
const allPlayers = Array.from(players.values())
const existingNames = allPlayers.filter((p) => p.id !== playerId).map((p) => p.name)
const newName = generateUniquePlayerName(existingNames)
setLocalName(newName)
updatePlayer(playerId, { name: newName })
}
// Get player number for UI theming (first 4 players get special colors)
const allPlayers = Array.from(players.values()).sort((a, b) => {
const aTime =
@@ -256,40 +266,79 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
>
Name
</label>
<input
type="text"
value={localName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Player Name"
maxLength={20}
<div
style={{
width: '100%',
padding: '12px 16px',
fontSize: '16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
outline: 'none',
transition: 'all 0.2s ease',
fontWeight: '500',
display: 'flex',
gap: '8px',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = gradientColor
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e5e7eb'
e.currentTarget.style.boxShadow = 'none'
}}
/>
>
<input
type="text"
value={localName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Player Name"
maxLength={20}
style={{
flex: 1,
padding: '12px 16px',
fontSize: '16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
outline: 'none',
transition: 'all 0.2s ease',
fontWeight: '500',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = gradientColor
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e5e7eb'
e.currentTarget.style.boxShadow = 'none'
}}
/>
<button
type="button"
onClick={handleGenerateNewName}
style={{
padding: '12px 16px',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
border: 'none',
borderRadius: '12px',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
transition: 'all 0.2s ease',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)'
e.currentTarget.style.boxShadow = `0 4px 12px ${gradientColor}40`
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.boxShadow = 'none'
}}
title="Generate random name"
>
🎲
</button>
</div>
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginTop: '4px',
textAlign: 'right',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
{localName.length}/20 characters
<span>Click dice to generate a random name</span>
<span>{localName.length}/20 characters</span>
</div>
</div>
</div>

View File

@@ -1,5 +1,8 @@
// Available character emojis for players
export const PLAYER_EMOJIS = [
// Abacus
'🧮',
// People & Characters
'😀',
'😃',

View File

@@ -2,15 +2,16 @@
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
import type { Player as DBPlayer } from '@/db/schema/players'
import { useRoomData } from '@/hooks/useRoomData'
import {
useCreatePlayer,
useDeletePlayer,
useUpdatePlayer,
useUserPlayers,
} from '@/hooks/useUserPlayers'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { getNextPlayerColor } from '../types/player'
import { generateUniquePlayerName, generateUniquePlayerNames } from '../utils/playerNames'
// Client-side Player type (compatible with old type)
export interface Player {
@@ -44,11 +45,12 @@ export interface GameModeContextType {
const GameModeContext = createContext<GameModeContextType | null>(null)
// Default players to create if none exist
const DEFAULT_PLAYERS = [
{ name: 'Player 1', emoji: '😀', color: '#3b82f6' },
{ name: 'Player 2', emoji: '😎', color: '#8b5cf6' },
{ name: 'Player 3', emoji: '🤠', color: '#10b981' },
{ name: 'Player 4', emoji: '🚀', color: '#f59e0b' },
// Names are generated randomly on first initialization
const DEFAULT_PLAYER_CONFIGS = [
{ emoji: '😀', color: '#3b82f6' },
{ emoji: '😎', color: '#8b5cf6' },
{ emoji: '🤠', color: '#10b981' },
{ emoji: '🚀', color: '#f59e0b' },
]
// Convert DB player to client Player type
@@ -139,14 +141,19 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!isLoading && !isInitialized) {
if (dbPlayers.length === 0) {
// Create default players
DEFAULT_PLAYERS.forEach((data, index) => {
// Generate unique names for default players
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
// Create default players with generated names
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
createPlayer({
...data,
name: generatedNames[index],
emoji: config.emoji,
color: config.color,
isActive: index === 0, // First player active by default
})
})
console.log('✅ Created default players via API')
console.log('✅ Created default players via API with auto-generated names:', generatedNames)
} else {
console.log('✅ Loaded players from API', {
playerCount: dbPlayers.length,
@@ -159,9 +166,10 @@ 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 newPlayer = {
name: playerData?.name ?? `Player ${players.size + 1}`,
name: playerData?.name ?? generateUniquePlayerName(existingNames),
emoji: playerData?.emoji ?? '🎮',
color: playerData?.color ?? getNextPlayerColor(playerList),
isActive: playerData?.isActive ?? false,
@@ -246,10 +254,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
deletePlayer(player.id)
})
// Create default players
DEFAULT_PLAYERS.forEach((data, index) => {
// Generate unique names for default players
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
// Create default players with generated names
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
createPlayer({
...data,
name: generatedNames[index],
emoji: config.emoji,
color: config.color,
isActive: index === 0,
})
})

View File

@@ -30,6 +30,7 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
.notNull()
.default('open'),
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
// Game configuration
gameName: text('game_name', {

View File

@@ -347,13 +347,14 @@ export function useRoomData() {
}
// Moderation event handlers
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string }) => {
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
console.log('[useRoomData] User was kicked from room:', data)
setModerationEvent({
type: 'kicked',
data: {
roomId: data.roomId,
kickedBy: data.kickedBy,
reason: data.reason,
},
})
// Clear room data since user was kicked

View File

@@ -62,6 +62,7 @@ describe('Room Manager', () => {
ttlMinutes: 60,
accessMode: 'open',
password: null,
displayPassword: null,
gameName: 'matching',
gameConfig: { difficulty: 6 },
status: 'lobby',

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import {
generatePlayerName,
generateUniquePlayerName,
generateUniquePlayerNames,
} from '../playerNames'
describe('playerNames', () => {
describe('generatePlayerName', () => {
it('should generate a player name with adjective and noun', () => {
const name = generatePlayerName()
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/) // e.g., "Swift Ninja"
expect(name.split(' ')).toHaveLength(2)
})
it('should generate different names on multiple calls', () => {
const names = new Set()
// Generate 50 names and expect at least some variety
for (let i = 0; i < 50; i++) {
names.add(generatePlayerName())
}
// With 50 adjectives and 50 nouns, we should get many unique combinations
expect(names.size).toBeGreaterThan(30)
})
})
describe('generateUniquePlayerName', () => {
it('should generate a unique name not in existing names', () => {
const existingNames = ['Swift Ninja', 'Cosmic Wizard', 'Radiant Dragon']
const newName = generateUniquePlayerName(existingNames)
expect(existingNames).not.toContain(newName)
})
it('should be case-insensitive when checking uniqueness', () => {
const existingNames = ['swift ninja', 'COSMIC WIZARD']
const newName = generateUniquePlayerName(existingNames)
expect(existingNames.map((n) => n.toLowerCase())).not.toContain(newName.toLowerCase())
})
it('should handle empty existing names array', () => {
const name = generateUniquePlayerName([])
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
})
it('should append number if all combinations are exhausted', () => {
// Create a mock with limited attempts
const existingNames = ['Swift Ninja']
const name = generateUniquePlayerName(existingNames, 1)
// Should either be unique or have a number appended
expect(name).toBeTruthy()
expect(name).not.toBe('Swift Ninja')
})
})
describe('generateUniquePlayerNames', () => {
it('should generate the requested number of unique names', () => {
const names = generateUniquePlayerNames(4)
expect(names).toHaveLength(4)
// All names should be unique
const uniqueNames = new Set(names)
expect(uniqueNames.size).toBe(4)
})
it('should generate unique names across all entries', () => {
const names = generateUniquePlayerNames(10)
expect(names).toHaveLength(10)
// Check uniqueness (case-insensitive)
const uniqueNames = new Set(names.map((n) => n.toLowerCase()))
expect(uniqueNames.size).toBe(10)
})
it('should handle generating zero names', () => {
const names = generateUniquePlayerNames(0)
expect(names).toHaveLength(0)
expect(names).toEqual([])
})
it('should generate names with expected format', () => {
const names = generateUniquePlayerNames(5)
for (const name of names) {
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+( \d+)?$/)
expect(name.split(' ').length).toBeGreaterThanOrEqual(2)
}
})
})
})

View File

@@ -0,0 +1,162 @@
/**
* Fun automatic player name generation system
* Generates creative names by combining adjectives with nouns/roles
*/
const ADJECTIVES = [
// Abacus-themed adjectives
'Ancient',
'Wooden',
'Sliding',
'Decimal',
'Binary',
'Counting',
'Soroban',
'Chinese',
'Japanese',
'Nimble',
'Clicking',
'Beaded',
'Columnar',
'Vertical',
'Horizontal',
'Upper',
'Lower',
'Heaven',
'Earth',
'Golden',
'Jade',
'Bamboo',
'Polished',
'Skilled',
'Master',
// Arithmetic/calculation adjectives
'Adding',
'Subtracting',
'Multiplying',
'Dividing',
'Calculating',
'Computing',
'Estimating',
'Rounding',
'Summing',
'Tallying',
'Decimal',
'Fractional',
'Exponential',
'Algebraic',
'Geometric',
'Prime',
'Composite',
'Rational',
'Digital',
'Numeric',
'Precise',
'Accurate',
'Lightning',
'Rapid',
'Mental',
]
const NOUNS = [
// Abacus-themed nouns
'Counter',
'Abacist',
'Calculator',
'Bead',
'Rod',
'Frame',
'Slider',
'Merchant',
'Trader',
'Accountant',
'Bookkeeper',
'Clerk',
'Scribe',
'Master',
'Apprentice',
'Scholar',
'Student',
'Teacher',
'Sensei',
'Guru',
'Expert',
'Virtuoso',
'Prodigy',
'Wizard',
'Sage',
// Arithmetic/calculation nouns
'Adder',
'Multiplier',
'Divider',
'Solver',
'Mathematician',
'Arithmetician',
'Analyst',
'Computer',
'Estimator',
'Logician',
'Statistician',
'Numerologist',
'Quantifier',
'Tallier',
'Sumner',
'Keeper',
'Reckoner',
'Cipher',
'Digit',
'Figure',
'Number',
'Brain',
'Thinker',
'Genius',
'Whiz',
]
/**
* Generate a random player name by combining an adjective and noun
*/
export function generatePlayerName(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
const noun = NOUNS[Math.floor(Math.random() * 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 maxAttempts - Maximum attempts to find a unique name (default: 50)
* @returns A unique player name
*/
export function generateUniquePlayerName(existingNames: string[], maxAttempts = 50): string {
const existingNamesSet = new Set(existingNames.map((name) => name.toLowerCase()))
for (let i = 0; i < maxAttempts; i++) {
const name = generatePlayerName()
if (!existingNamesSet.has(name.toLowerCase())) {
return name
}
}
// Fallback: if we can't find a unique name, append a number
const baseName = generatePlayerName()
let counter = 1
while (existingNamesSet.has(`${baseName} ${counter}`.toLowerCase())) {
counter++
}
return `${baseName} ${counter}`
}
/**
* Generate a batch of unique player names
* @param count - Number of names to generate
* @returns Array of unique player names
*/
export function generateUniquePlayerNames(count: number): string[] {
const names: string[] = []
for (let i = 0; i < count; i++) {
const name = generateUniquePlayerName(names)
names.push(name)
}
return names
}

View File

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