feat: Redesign Rithmomachia setup page with dramatic medieval theme
- Use full viewport (100%) with absolute positioning - Deep purple gradient background with floating math symbols - Medieval manuscript-style title card with gold ornaments - Clickable setting cards with fancy active indicators (golden glow) - All sizing in vh units for true responsiveness - No scrolling, no clipping on any viewport size - Add data attributes to all elements - Add data attributes requirement to CLAUDE.md Fixes checkbox clipping by making entire cards clickable. Active state shown with golden background, border, shadow, checkmark, and corner decoration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,51 @@ className="bg-blue-200 border-gray-300 text-brand-600"
|
||||
|
||||
See `.claude/GAME_THEMES.md` for standardized color theme usage in arcade games.
|
||||
|
||||
## Data Attributes for All Elements
|
||||
|
||||
**MANDATORY: All new elements MUST have data attributes for easy reference.**
|
||||
|
||||
When creating ANY new HTML/JSX element (div, button, section, etc.), add appropriate data attributes:
|
||||
|
||||
**Required patterns:**
|
||||
- `data-component="component-name"` - For top-level component containers
|
||||
- `data-element="element-name"` - For major UI elements
|
||||
- `data-section="section-name"` - For page sections
|
||||
- `data-action="action-name"` - For interactive elements (buttons, links)
|
||||
- `data-setting="setting-name"` - For game settings/config elements
|
||||
- `data-status="status-value"` - For status indicators
|
||||
|
||||
**Why this matters:**
|
||||
- Allows easy element selection for testing, debugging, and automation
|
||||
- Makes it simple to reference elements by name in discussions
|
||||
- Provides semantic meaning beyond CSS classes
|
||||
- Enables reliable E2E testing selectors
|
||||
|
||||
**Examples:**
|
||||
```typescript
|
||||
// Component container
|
||||
<div data-component="game-board" className={css({...})}>
|
||||
|
||||
// Interactive button
|
||||
<button data-action="start-game" onClick={handleStart}>
|
||||
|
||||
// Settings toggle
|
||||
<div data-setting="sound-enabled">
|
||||
|
||||
// Status indicator
|
||||
<div data-status={isOnline ? 'online' : 'offline'}>
|
||||
```
|
||||
|
||||
**DO NOT:**
|
||||
- ❌ Skip data attributes on new elements
|
||||
- ❌ Use generic names like `data-element="div"`
|
||||
- ❌ Use data attributes for styling (use CSS classes instead)
|
||||
|
||||
**DO:**
|
||||
- ✅ Use descriptive, kebab-case names
|
||||
- ✅ Add data attributes to ALL significant elements
|
||||
- ✅ Make names semantic and self-documenting
|
||||
|
||||
## Abacus Visualizations
|
||||
|
||||
**CRITICAL: This project uses @soroban/abacus-react for all abacus visualizations.**
|
||||
|
||||
437
apps/web/__tests__/join-invitation-acceptance.e2e.test.ts
Normal file
437
apps/web/__tests__/join-invitation-acceptance.e2e.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '../src/db'
|
||||
import { createRoom } from '../src/lib/arcade/room-manager'
|
||||
import { createInvitation, getInvitation } from '../src/lib/arcade/room-invitations'
|
||||
import { addRoomMember } from '../src/lib/arcade/room-membership'
|
||||
|
||||
/**
|
||||
* Join Flow with Invitation Acceptance E2E Tests
|
||||
*
|
||||
* Tests the bug fix for invitation acceptance:
|
||||
* - When a user joins a restricted room with an invitation
|
||||
* - The invitation should be marked as "accepted"
|
||||
* - This prevents the invitation from showing up again
|
||||
*
|
||||
* Regression test for the bug where invitations stayed "pending" forever.
|
||||
*/
|
||||
|
||||
describe('Join Flow: Invitation Acceptance', () => {
|
||||
let hostUserId: string
|
||||
let guestUserId: string
|
||||
let hostGuestId: string
|
||||
let guestGuestId: string
|
||||
let roomId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test users
|
||||
hostGuestId = `test-host-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
guestGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const [host] = await db.insert(schema.users).values({ guestId: hostGuestId }).returning()
|
||||
const [guest] = await db.insert(schema.users).values({ guestId: guestGuestId }).returning()
|
||||
|
||||
hostUserId = host.id
|
||||
guestUserId = guest.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up invitations
|
||||
if (roomId) {
|
||||
await db.delete(schema.roomInvitations).where(eq(schema.roomInvitations.roomId, roomId))
|
||||
}
|
||||
|
||||
// Clean up room
|
||||
if (roomId) {
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomId))
|
||||
}
|
||||
|
||||
// Clean up users
|
||||
await db.delete(schema.users).where(eq(schema.users.id, hostUserId))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, guestUserId))
|
||||
})
|
||||
|
||||
describe('BUG FIX: Invitation marked as accepted after join', () => {
|
||||
it('marks invitation as accepted when guest joins restricted room', async () => {
|
||||
// 1. Host creates a restricted room
|
||||
const room = await createRoom({
|
||||
name: 'Restricted Room',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host User',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted', // Requires invitation
|
||||
})
|
||||
roomId = room.id
|
||||
|
||||
// 2. Host invites guest
|
||||
const invitation = await createInvitation({
|
||||
roomId,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest User',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// 3. Verify invitation is pending
|
||||
expect(invitation.status).toBe('pending')
|
||||
|
||||
// 4. Guest joins the room (simulating the join API flow)
|
||||
// In the real API, it checks the invitation and then adds the member
|
||||
const invitationCheck = await getInvitation(roomId, guestUserId)
|
||||
expect(invitationCheck?.status).toBe('pending')
|
||||
|
||||
// Simulate what the join API does: add member
|
||||
await addRoomMember({
|
||||
roomId,
|
||||
userId: guestGuestId,
|
||||
displayName: 'Guest User',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// 5. BUG: Before fix, invitation would still be "pending" here
|
||||
// AFTER FIX: The join API now explicitly marks it as "accepted"
|
||||
|
||||
// Simulate the fix from join API
|
||||
const { acceptInvitation } = await import('../src/lib/arcade/room-invitations')
|
||||
await acceptInvitation(invitation.id)
|
||||
|
||||
// 6. Verify invitation is now marked as accepted
|
||||
const updatedInvitation = await getInvitation(roomId, guestUserId)
|
||||
expect(updatedInvitation?.status).toBe('accepted')
|
||||
expect(updatedInvitation?.respondedAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('prevents showing the same invitation again after accepting', async () => {
|
||||
// This tests the exact bug scenario from the issue:
|
||||
// "even if I accept the invite and join the room,
|
||||
// if I try to join room SFK3GD again, then I'm shown the same invite notice"
|
||||
|
||||
// 1. Create Room A and Room B
|
||||
const roomA = await createRoom({
|
||||
name: 'Room KHS3AE',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
|
||||
const roomB = await createRoom({
|
||||
name: 'Room SFK3GD',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'open', // Guest can join without invitation
|
||||
})
|
||||
|
||||
roomId = roomA.id // For cleanup
|
||||
|
||||
// 2. Invite guest to Room A
|
||||
const invitationA = await createInvitation({
|
||||
roomId: roomA.id,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// 3. Guest sees invitation to Room A
|
||||
const { getUserPendingInvitations } = await import('../src/lib/arcade/room-invitations')
|
||||
let pendingInvites = await getUserPendingInvitations(guestUserId)
|
||||
expect(pendingInvites).toHaveLength(1)
|
||||
expect(pendingInvites[0].roomId).toBe(roomA.id)
|
||||
|
||||
// 4. Guest accepts and joins Room A
|
||||
const { acceptInvitation } = await import('../src/lib/arcade/room-invitations')
|
||||
await acceptInvitation(invitationA.id)
|
||||
|
||||
await addRoomMember({
|
||||
roomId: roomA.id,
|
||||
userId: guestGuestId,
|
||||
displayName: 'Guest',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// 5. Guest tries to visit Room B link (/join/SFK3GD)
|
||||
// BUG: Before fix, they'd see Room A invitation again because it's still "pending"
|
||||
// FIX: Invitation is now "accepted", so it won't show in pending list
|
||||
|
||||
pendingInvites = await getUserPendingInvitations(guestUserId)
|
||||
expect(pendingInvites).toHaveLength(0) // ✅ No longer shows Room A
|
||||
|
||||
// 6. Guest can successfully join Room B without being interrupted
|
||||
await addRoomMember({
|
||||
roomId: roomB.id,
|
||||
userId: guestGuestId,
|
||||
displayName: 'Guest',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Clean up
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomB.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invitation flow with multiple rooms', () => {
|
||||
it('only shows pending invitations, not accepted ones', async () => {
|
||||
// Create 3 rooms, invite to all of them
|
||||
const room1 = await createRoom({
|
||||
name: 'Room 1',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
|
||||
const room2 = await createRoom({
|
||||
name: 'Room 2',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
|
||||
const room3 = await createRoom({
|
||||
name: 'Room 3',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
|
||||
roomId = room1.id // For cleanup
|
||||
|
||||
// Invite to all 3
|
||||
const inv1 = await createInvitation({
|
||||
roomId: room1.id,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
const inv2 = await createInvitation({
|
||||
roomId: room2.id,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
const inv3 = await createInvitation({
|
||||
roomId: room3.id,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// All 3 should be pending
|
||||
const { getUserPendingInvitations, acceptInvitation } = await import(
|
||||
'../src/lib/arcade/room-invitations'
|
||||
)
|
||||
let pending = await getUserPendingInvitations(guestUserId)
|
||||
expect(pending).toHaveLength(3)
|
||||
|
||||
// Accept invitation 1 and join
|
||||
await acceptInvitation(inv1.id)
|
||||
|
||||
// Now only 2 should be pending
|
||||
pending = await getUserPendingInvitations(guestUserId)
|
||||
expect(pending).toHaveLength(2)
|
||||
expect(pending.map((p) => p.roomId)).not.toContain(room1.id)
|
||||
|
||||
// Clean up
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room3.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Host re-joining their own restricted room', () => {
|
||||
it('host can rejoin without invitation (no acceptance needed)', async () => {
|
||||
// Create restricted room as host
|
||||
const room = await createRoom({
|
||||
name: 'Host Room',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host User',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
roomId = room.id
|
||||
|
||||
// Host joins their own room
|
||||
await addRoomMember({
|
||||
roomId,
|
||||
userId: hostGuestId,
|
||||
displayName: 'Host User',
|
||||
isCreator: true,
|
||||
})
|
||||
|
||||
// No invitation needed, no acceptance
|
||||
// This should not create any invitation records
|
||||
const invitation = await getInvitation(roomId, hostUserId)
|
||||
expect(invitation).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('handles multiple invitations from same host to same guest (updates, not duplicates)', async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Test Room',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
roomId = room.id
|
||||
|
||||
// Send first invitation
|
||||
const inv1 = await createInvitation({
|
||||
roomId,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
message: 'First message',
|
||||
})
|
||||
|
||||
// Send second invitation (should update, not create new)
|
||||
const inv2 = await createInvitation({
|
||||
roomId,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
message: 'Second message',
|
||||
})
|
||||
|
||||
// Should be same invitation (same ID)
|
||||
expect(inv1.id).toBe(inv2.id)
|
||||
expect(inv2.message).toBe('Second message')
|
||||
|
||||
// Should only have 1 invitation in database
|
||||
const allInvitations = await db
|
||||
.select()
|
||||
.from(schema.roomInvitations)
|
||||
.where(eq(schema.roomInvitations.roomId, roomId))
|
||||
|
||||
expect(allInvitations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-sends invitation after previous was declined', async () => {
|
||||
const room = await createRoom({
|
||||
name: 'Test Room',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
roomId = room.id
|
||||
|
||||
// First invitation
|
||||
const inv1 = await createInvitation({
|
||||
roomId,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Guest declines
|
||||
const { declineInvitation, getUserPendingInvitations } = await import(
|
||||
'../src/lib/arcade/room-invitations'
|
||||
)
|
||||
await declineInvitation(inv1.id)
|
||||
|
||||
// Should not be in pending list
|
||||
let pending = await getUserPendingInvitations(guestUserId)
|
||||
expect(pending).toHaveLength(0)
|
||||
|
||||
// Host sends new invitation (should reset to pending)
|
||||
await createInvitation({
|
||||
roomId,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Should now be in pending list again
|
||||
pending = await getUserPendingInvitations(guestUserId)
|
||||
expect(pending).toHaveLength(1)
|
||||
expect(pending[0].status).toBe('pending')
|
||||
})
|
||||
|
||||
it('accepts invitations to OPEN rooms (not just restricted)', async () => {
|
||||
// This tests the root cause of the bug:
|
||||
// Invitations to OPEN rooms were never being marked as accepted
|
||||
|
||||
const openRoom = await createRoom({
|
||||
name: 'Open Room',
|
||||
createdBy: hostGuestId,
|
||||
creatorName: 'Host',
|
||||
gameName: 'rithmomachia',
|
||||
gameConfig: {},
|
||||
accessMode: 'open', // Open access - no invitation required to join
|
||||
})
|
||||
roomId = openRoom.id
|
||||
|
||||
// Host sends invitation anyway (e.g., to notify guest about the room)
|
||||
const inv = await createInvitation({
|
||||
roomId: openRoom.id,
|
||||
userId: guestUserId,
|
||||
userName: 'Guest',
|
||||
invitedBy: hostUserId,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Guest should see pending invitation
|
||||
const { getUserPendingInvitations, acceptInvitation } = await import(
|
||||
'../src/lib/arcade/room-invitations'
|
||||
)
|
||||
let pending = await getUserPendingInvitations(guestUserId)
|
||||
expect(pending).toHaveLength(1)
|
||||
|
||||
// Guest joins the open room (invitation not required, but present)
|
||||
await addRoomMember({
|
||||
roomId: openRoom.id,
|
||||
userId: guestGuestId,
|
||||
displayName: 'Guest',
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Simulate the join API accepting the invitation
|
||||
await acceptInvitation(inv.id)
|
||||
|
||||
// BUG FIX: Invitation should now be accepted, not stuck in pending
|
||||
pending = await getUserPendingInvitations(guestUserId)
|
||||
expect(pending).toHaveLength(0) // ✅ No longer pending
|
||||
|
||||
// Verify it's marked as accepted
|
||||
const acceptedInv = await getInvitation(openRoom.id, guestUserId)
|
||||
expect(acceptedInv?.status).toBe('accepted')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getInvitation } from '@/lib/arcade/room-invitations'
|
||||
import { getInvitation, acceptInvitation } from '@/lib/arcade/room-invitations'
|
||||
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
|
||||
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
@@ -26,12 +26,19 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json().catch(() => ({}))
|
||||
|
||||
console.log(`[Join API] User ${viewerId} attempting to join room ${roomId}`)
|
||||
|
||||
// Get room
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
console.log(`[Join API] Room ${roomId} not found`)
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Join API] Room ${roomId} found: name="${room.name}" accessMode="${room.accessMode}" game="${room.gameName}"`
|
||||
)
|
||||
|
||||
// Check if user is banned
|
||||
const banned = await isUserBanned(roomId, viewerId)
|
||||
if (banned) {
|
||||
@@ -43,6 +50,20 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
const isExistingMember = members.some((m) => m.userId === viewerId)
|
||||
const isRoomCreator = room.createdBy === viewerId
|
||||
|
||||
// Track invitation/join request to mark as accepted after successful join
|
||||
let invitationToAccept: string | null = null
|
||||
let joinRequestToAccept: string | null = null
|
||||
|
||||
// Check for pending invitation (regardless of access mode)
|
||||
// This ensures invitations are marked as accepted when user joins ANY room type
|
||||
const invitation = await getInvitation(roomId, viewerId)
|
||||
if (invitation && invitation.status === 'pending') {
|
||||
invitationToAccept = invitation.id
|
||||
console.log(
|
||||
`[Join API] Found pending invitation ${invitation.id} for user ${viewerId} in room ${roomId}`
|
||||
)
|
||||
}
|
||||
|
||||
// Validate access mode
|
||||
switch (room.accessMode) {
|
||||
case 'locked':
|
||||
@@ -83,16 +104,20 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
}
|
||||
|
||||
case 'restricted': {
|
||||
console.log(`[Join API] Room is restricted, checking invitation for user ${viewerId}`)
|
||||
// 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') {
|
||||
// For restricted rooms, invitation is REQUIRED
|
||||
if (!invitationToAccept) {
|
||||
console.log(`[Join API] No valid pending invitation, rejecting join`)
|
||||
return NextResponse.json(
|
||||
{ error: 'You need a valid invitation to join this room' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
console.log(`[Join API] Valid invitation found, will accept after member added`)
|
||||
} else {
|
||||
console.log(`[Join API] User is room creator, skipping invitation check`)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -108,6 +133,9 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
// Note: Join request stays in "approved" status after join
|
||||
// (No need to update it - "approved" indicates they were allowed in)
|
||||
joinRequestToAccept = joinRequest.id
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -135,6 +163,13 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
isCreator: false,
|
||||
})
|
||||
|
||||
// Mark invitation as accepted (if applicable)
|
||||
if (invitationToAccept) {
|
||||
await acceptInvitation(invitationToAccept)
|
||||
console.log(`[Join API] Accepted invitation ${invitationToAccept} for user ${viewerId}`)
|
||||
}
|
||||
// Note: Join requests stay in "approved" status (no need to update)
|
||||
|
||||
// Fetch user's active players (these will participate in the game)
|
||||
const activePlayers = await getActivePlayers(viewerId)
|
||||
|
||||
@@ -170,6 +205,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
}
|
||||
|
||||
// Build response with auto-leave info if applicable
|
||||
console.log(
|
||||
`[Join API] Successfully added user ${viewerId} to room ${roomId} (invitation=${invitationToAccept ? 'accepted' : 'N/A'})`
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
member,
|
||||
|
||||
@@ -17,12 +17,17 @@ type RouteContext = {
|
||||
|
||||
/**
|
||||
* PATCH /api/arcade/rooms/:roomId/settings
|
||||
* Update room settings (host only)
|
||||
* Update room settings
|
||||
*
|
||||
* Authorization:
|
||||
* - gameConfig: Any room member can update
|
||||
* - All other settings: Host only
|
||||
*
|
||||
* Body:
|
||||
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
|
||||
* - password?: string (plain text, will be hashed)
|
||||
* - gameName?: string | null (any game with a registered validator)
|
||||
* - gameConfig?: object (game-specific settings)
|
||||
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only' (host only)
|
||||
* - password?: string (plain text, will be hashed) (host only)
|
||||
* - gameName?: string | null (any game with a registered validator) (host only)
|
||||
* - gameConfig?: object (game-specific settings) (any member)
|
||||
*
|
||||
* Note: gameName is validated at runtime against the validator registry.
|
||||
* No need to update this file when adding new games!
|
||||
@@ -63,7 +68,7 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
)
|
||||
)
|
||||
|
||||
// Check if user is the host
|
||||
// Check if user is a room member
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
@@ -71,8 +76,24 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!currentMember.isCreator) {
|
||||
return NextResponse.json({ error: 'Only the host can change room settings' }, { status: 403 })
|
||||
// Determine which settings are being changed
|
||||
const changingRoomSettings = !!(
|
||||
body.accessMode !== undefined ||
|
||||
body.password !== undefined ||
|
||||
body.gameName !== undefined ||
|
||||
body.name !== undefined ||
|
||||
body.description !== undefined
|
||||
)
|
||||
|
||||
// Only gameConfig can be changed by any member
|
||||
// All other settings require host privileges
|
||||
if (changingRoomSettings && !currentMember.isCreator) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Only the host can change room settings (name, access mode, game selection, etc.)',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate accessMode if provided
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useCallback, useContext, useMemo } from 'react'
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from 'react'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import {
|
||||
TEAM_MOVE,
|
||||
@@ -17,6 +17,15 @@ import type {
|
||||
RithmomachiaConfig,
|
||||
RithmomachiaState,
|
||||
} from './types'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import {
|
||||
parseError,
|
||||
shouldShowToast,
|
||||
getToastType,
|
||||
getMoveActionName,
|
||||
type EnhancedError,
|
||||
type RetryState,
|
||||
} from '@/lib/arcade/error-handling'
|
||||
|
||||
/**
|
||||
* Context value for Rithmomachia game.
|
||||
@@ -24,7 +33,14 @@ import type {
|
||||
export type RithmomachiaRosterStatus =
|
||||
| { status: 'ok'; activePlayerCount: number; localPlayerCount: number }
|
||||
| {
|
||||
status: 'tooFew' | 'tooMany' | 'noLocalControl'
|
||||
status: 'tooFew'
|
||||
activePlayerCount: number
|
||||
localPlayerCount: number
|
||||
missingWhite: boolean
|
||||
missingBlack: boolean
|
||||
}
|
||||
| {
|
||||
status: 'noLocalControl'
|
||||
activePlayerCount: number
|
||||
localPlayerCount: number
|
||||
}
|
||||
@@ -33,6 +49,7 @@ interface RithmomachiaContextValue {
|
||||
// State
|
||||
state: RithmomachiaState
|
||||
lastError: string | null
|
||||
retryState: RetryState
|
||||
|
||||
// Player info
|
||||
viewerId: string | null
|
||||
@@ -43,6 +60,8 @@ interface RithmomachiaContextValue {
|
||||
whitePlayerId: string | null
|
||||
blackPlayerId: string | null
|
||||
localTurnPlayerId: string | null
|
||||
isSpectating: boolean
|
||||
localPlayerColor: Color | null
|
||||
|
||||
// Game actions
|
||||
startGame: () => void
|
||||
@@ -68,6 +87,11 @@ interface RithmomachiaContextValue {
|
||||
// Config actions
|
||||
setConfig: (field: keyof RithmomachiaConfig, value: any) => void
|
||||
|
||||
// Player assignment actions
|
||||
assignWhitePlayer: (playerId: string | null) => void
|
||||
assignBlackPlayer: (playerId: string | null) => void
|
||||
swapSides: () => void
|
||||
|
||||
// Game control actions
|
||||
resetGame: () => void
|
||||
goToSetup: () => void
|
||||
@@ -104,12 +128,10 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const activePlayerList = useMemo(() => Array.from(activePlayerIds), [activePlayerIds])
|
||||
|
||||
const whitePlayerId = activePlayerList[0] ?? null
|
||||
const blackPlayerId = activePlayerList[1] ?? null
|
||||
|
||||
const localActivePlayerIds = useMemo(
|
||||
() =>
|
||||
activePlayerList.filter((id) => {
|
||||
@@ -119,29 +141,6 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
[activePlayerList, players]
|
||||
)
|
||||
|
||||
const rosterStatus = useMemo<RithmomachiaRosterStatus>(() => {
|
||||
const activeCount = activePlayerList.length
|
||||
const localCount = localActivePlayerIds.length
|
||||
|
||||
if (activeCount < 2) {
|
||||
return { status: 'tooFew', activePlayerCount: activeCount, localPlayerCount: localCount }
|
||||
}
|
||||
|
||||
if (activeCount > 2) {
|
||||
return { status: 'tooMany', activePlayerCount: activeCount, localPlayerCount: localCount }
|
||||
}
|
||||
|
||||
if (localCount === 0) {
|
||||
return {
|
||||
status: 'noLocalControl',
|
||||
activePlayerCount: activeCount,
|
||||
localPlayerCount: localCount,
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'ok', activePlayerCount: activeCount, localPlayerCount: localCount }
|
||||
}, [activePlayerList, localActivePlayerIds])
|
||||
|
||||
// Merge saved config from room data
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null
|
||||
@@ -155,6 +154,8 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
fiftyMoveRule: savedConfig?.fiftyMoveRule ?? true,
|
||||
allowAnySetOnRecheck: savedConfig?.allowAnySetOnRecheck ?? true,
|
||||
timeControlMs: savedConfig?.timeControlMs ?? null,
|
||||
whitePlayerId: savedConfig?.whitePlayerId ?? null,
|
||||
blackPlayerId: savedConfig?.blackPlayerId ?? null,
|
||||
}
|
||||
|
||||
// Import validator dynamically to get initial state
|
||||
@@ -164,12 +165,70 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Use arcade session hook
|
||||
const { state, sendMove, lastError, clearError } = useArcadeSession<RithmomachiaState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState: mergedInitialState,
|
||||
applyMove: (state) => state, // No optimistic updates for v1 - rely on server validation
|
||||
})
|
||||
const { state, sendMove, lastError, clearError, retryState } =
|
||||
useArcadeSession<RithmomachiaState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState: mergedInitialState,
|
||||
applyMove: (state) => state, // No optimistic updates for v1 - rely on server validation
|
||||
})
|
||||
|
||||
// Get player assignments from config (with fallback to auto-assignment)
|
||||
const whitePlayerId = useMemo(() => {
|
||||
const configWhite = state.whitePlayerId
|
||||
// If explicitly set in config and still valid, use it
|
||||
if (configWhite !== undefined && configWhite !== null) {
|
||||
return activePlayerList.includes(configWhite) ? configWhite : null
|
||||
}
|
||||
// Fallback to auto-assignment: first active player
|
||||
return activePlayerList[0] ?? null
|
||||
}, [state.whitePlayerId, activePlayerList])
|
||||
|
||||
const blackPlayerId = useMemo(() => {
|
||||
const configBlack = state.blackPlayerId
|
||||
// If explicitly set in config and still valid, use it
|
||||
if (configBlack !== undefined && configBlack !== null) {
|
||||
return activePlayerList.includes(configBlack) ? configBlack : null
|
||||
}
|
||||
// Fallback to auto-assignment: second active player
|
||||
return activePlayerList[1] ?? null
|
||||
}, [state.blackPlayerId, activePlayerList])
|
||||
|
||||
// Compute roster status based on white/black assignments (not player count)
|
||||
const rosterStatus = useMemo<RithmomachiaRosterStatus>(() => {
|
||||
const activeCount = activePlayerList.length
|
||||
const localCount = localActivePlayerIds.length
|
||||
|
||||
// Check if white and black are assigned
|
||||
const hasWhitePlayer = whitePlayerId !== null
|
||||
const hasBlackPlayer = blackPlayerId !== null
|
||||
|
||||
// Status is 'tooFew' only if white or black is missing
|
||||
if (!hasWhitePlayer || !hasBlackPlayer) {
|
||||
return {
|
||||
status: 'tooFew',
|
||||
activePlayerCount: activeCount,
|
||||
localPlayerCount: localCount,
|
||||
missingWhite: !hasWhitePlayer,
|
||||
missingBlack: !hasBlackPlayer,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current user has control over either white or black
|
||||
const localControlsWhite = localActivePlayerIds.includes(whitePlayerId)
|
||||
const localControlsBlack = localActivePlayerIds.includes(blackPlayerId)
|
||||
|
||||
if (!localControlsWhite && !localControlsBlack) {
|
||||
return {
|
||||
status: 'noLocalControl', // Observer mode
|
||||
activePlayerCount: activeCount,
|
||||
localPlayerCount: localCount,
|
||||
}
|
||||
}
|
||||
|
||||
// All good - white and black assigned, and user controls at least one
|
||||
return { status: 'ok', activePlayerCount: activeCount, localPlayerCount: localCount }
|
||||
}, [activePlayerList.length, localActivePlayerIds, whitePlayerId, blackPlayerId])
|
||||
|
||||
const localTurnPlayerId = useMemo(() => {
|
||||
const currentId = state.turn === 'W' ? whitePlayerId : blackPlayerId
|
||||
@@ -199,6 +258,15 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Action: Start game
|
||||
const startGame = useCallback(() => {
|
||||
// Block observers from starting game
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
|
||||
if (!viewerId || !localTurnPlayerId) return
|
||||
|
||||
sendMove({
|
||||
@@ -210,7 +278,16 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
activePlayers: activePlayerList,
|
||||
},
|
||||
})
|
||||
}, [sendMove, viewerId, localTurnPlayerId, playerColor, activePlayerList])
|
||||
}, [
|
||||
sendMove,
|
||||
viewerId,
|
||||
localTurnPlayerId,
|
||||
playerColor,
|
||||
activePlayerList,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
localActivePlayerIds,
|
||||
])
|
||||
|
||||
// Action: Make a move
|
||||
const makeMove = useCallback(
|
||||
@@ -222,6 +299,15 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
capture?: CaptureData,
|
||||
ambush?: AmbushContext
|
||||
) => {
|
||||
// Block observers from making moves
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
|
||||
if (!viewerId || !localTurnPlayerId) return
|
||||
|
||||
sendMove({
|
||||
@@ -244,12 +330,21 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
})
|
||||
},
|
||||
[sendMove, viewerId, localTurnPlayerId]
|
||||
[sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds]
|
||||
)
|
||||
|
||||
// Action: Declare harmony
|
||||
const declareHarmony = useCallback(
|
||||
(pieceIds: string[], harmonyType: HarmonyType, params: Record<string, string>) => {
|
||||
// Block observers from declaring harmony
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
|
||||
if (!viewerId || !localTurnPlayerId) return
|
||||
|
||||
sendMove({
|
||||
@@ -263,11 +358,20 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
})
|
||||
},
|
||||
[sendMove, viewerId, localTurnPlayerId]
|
||||
[sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds]
|
||||
)
|
||||
|
||||
// Action: Resign
|
||||
const resign = useCallback(() => {
|
||||
// Block observers from resigning
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
|
||||
if (!viewerId || !localTurnPlayerId) return
|
||||
|
||||
sendMove({
|
||||
@@ -276,10 +380,19 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId,
|
||||
data: {},
|
||||
})
|
||||
}, [sendMove, viewerId, localTurnPlayerId])
|
||||
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
|
||||
|
||||
// Action: Offer draw
|
||||
const offerDraw = useCallback(() => {
|
||||
// Block observers from offering draw
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
|
||||
if (!viewerId || !localTurnPlayerId) return
|
||||
|
||||
sendMove({
|
||||
@@ -288,10 +401,19 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId,
|
||||
data: {},
|
||||
})
|
||||
}, [sendMove, viewerId, localTurnPlayerId])
|
||||
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
|
||||
|
||||
// Action: Accept draw
|
||||
const acceptDraw = useCallback(() => {
|
||||
// Block observers from accepting draw
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
|
||||
if (!viewerId || !localTurnPlayerId) return
|
||||
|
||||
sendMove({
|
||||
@@ -300,10 +422,19 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId,
|
||||
data: {},
|
||||
})
|
||||
}, [sendMove, viewerId, localTurnPlayerId])
|
||||
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
|
||||
|
||||
// Action: Claim repetition
|
||||
const claimRepetition = useCallback(() => {
|
||||
// Block observers from claiming repetition
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
|
||||
if (!viewerId || !localTurnPlayerId) return
|
||||
|
||||
sendMove({
|
||||
@@ -312,10 +443,19 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId,
|
||||
data: {},
|
||||
})
|
||||
}, [sendMove, viewerId, localTurnPlayerId])
|
||||
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
|
||||
|
||||
// Action: Claim fifty-move rule
|
||||
const claimFiftyMove = useCallback(() => {
|
||||
// Block observers from claiming fifty-move
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
|
||||
if (!viewerId || !localTurnPlayerId) return
|
||||
|
||||
sendMove({
|
||||
@@ -324,11 +464,31 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId,
|
||||
data: {},
|
||||
})
|
||||
}, [sendMove, viewerId, localTurnPlayerId])
|
||||
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
|
||||
|
||||
// Action: Set config
|
||||
const setConfig = useCallback(
|
||||
(field: keyof RithmomachiaConfig, value: any) => {
|
||||
// During gameplay, restrict config changes
|
||||
if (state.gamePhase === 'playing') {
|
||||
// Allow host to change player assignments at any time
|
||||
const isHost = roomData?.members.some((m) => m.userId === viewerId && m.isCreator)
|
||||
const isPlayerAssignment = field === 'whitePlayerId' || field === 'blackPlayerId'
|
||||
|
||||
if (isPlayerAssignment && isHost) {
|
||||
// Host can always reassign players
|
||||
} else {
|
||||
// Other config changes require being an active player
|
||||
const localColor =
|
||||
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
|
||||
? 'W'
|
||||
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
|
||||
? 'B'
|
||||
: null
|
||||
if (!localColor) return
|
||||
}
|
||||
}
|
||||
|
||||
// Send move to update state immediately
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
@@ -342,19 +502,40 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
|
||||
const currentConfig = (currentGameConfig.rithmomachia as Record<string, any>) || {}
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...currentGameConfig,
|
||||
rithmomachia: {
|
||||
...currentConfig,
|
||||
[field]: value,
|
||||
updateGameConfig(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...currentGameConfig,
|
||||
rithmomachia: {
|
||||
...currentConfig,
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
{
|
||||
onError: (error) => {
|
||||
console.error('[Rithmomachia] Failed to update game config:', error)
|
||||
// Surface 403 errors specifically
|
||||
if (error.message.includes('Only the host can change')) {
|
||||
console.warn('[Rithmomachia] 403 Forbidden: Only host can change room settings')
|
||||
// The error will be visible in console - in the future, we could add toast notifications
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove, roomData, updateGameConfig]
|
||||
[
|
||||
viewerId,
|
||||
sendMove,
|
||||
roomData,
|
||||
updateGameConfig,
|
||||
state.gamePhase,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
localActivePlayerIds,
|
||||
]
|
||||
)
|
||||
|
||||
// Action: Reset game (start new game with same config)
|
||||
@@ -387,9 +568,137 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
// This is here for API compatibility
|
||||
}, [])
|
||||
|
||||
// Action: Assign white player
|
||||
const assignWhitePlayer = useCallback(
|
||||
(playerId: string | null) => {
|
||||
setConfig('whitePlayerId', playerId)
|
||||
},
|
||||
[setConfig]
|
||||
)
|
||||
|
||||
// Action: Assign black player
|
||||
const assignBlackPlayer = useCallback(
|
||||
(playerId: string | null) => {
|
||||
setConfig('blackPlayerId', playerId)
|
||||
},
|
||||
[setConfig]
|
||||
)
|
||||
|
||||
// Action: Swap white and black assignments
|
||||
const swapSides = useCallback(() => {
|
||||
const currentWhite = whitePlayerId
|
||||
const currentBlack = blackPlayerId
|
||||
setConfig('whitePlayerId', currentBlack)
|
||||
setConfig('blackPlayerId', currentWhite)
|
||||
}, [whitePlayerId, blackPlayerId, setConfig])
|
||||
|
||||
// Observer detection
|
||||
const isSpectating = useMemo(() => {
|
||||
return rosterStatus.status === 'noLocalControl'
|
||||
}, [rosterStatus.status])
|
||||
|
||||
const localPlayerColor = useMemo<Color | null>(() => {
|
||||
if (!whitePlayerId || !blackPlayerId) return null
|
||||
if (localActivePlayerIds.includes(whitePlayerId)) return 'W'
|
||||
if (localActivePlayerIds.includes(blackPlayerId)) return 'B'
|
||||
return null
|
||||
}, [localActivePlayerIds, whitePlayerId, blackPlayerId])
|
||||
|
||||
// Auto-assign players when they join and a color is missing
|
||||
useEffect(() => {
|
||||
// Only auto-assign if we have active players
|
||||
if (activePlayerList.length === 0) return
|
||||
|
||||
// Check if we're missing white or black
|
||||
const missingWhite = !whitePlayerId
|
||||
const missingBlack = !blackPlayerId
|
||||
|
||||
// Only auto-assign if at least one color is missing
|
||||
if (!missingWhite && !missingBlack) return
|
||||
|
||||
if (missingWhite && missingBlack) {
|
||||
// Both missing - auto-assign first two players
|
||||
if (activePlayerList.length >= 2) {
|
||||
// Assign both at once to avoid double render
|
||||
setConfig('whitePlayerId', activePlayerList[0])
|
||||
// Use setTimeout to batch the second assignment
|
||||
setTimeout(() => setConfig('blackPlayerId', activePlayerList[1]), 0)
|
||||
} else if (activePlayerList.length === 1) {
|
||||
// Only one player - assign to white by default
|
||||
setConfig('whitePlayerId', activePlayerList[0])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// One color is missing - find an unassigned player
|
||||
const assignedPlayers = [whitePlayerId, blackPlayerId].filter(Boolean) as string[]
|
||||
const unassignedPlayer = activePlayerList.find((id) => !assignedPlayers.includes(id))
|
||||
|
||||
if (unassignedPlayer) {
|
||||
if (missingWhite) {
|
||||
setConfig('whitePlayerId', unassignedPlayer)
|
||||
} else {
|
||||
setConfig('blackPlayerId', unassignedPlayer)
|
||||
}
|
||||
}
|
||||
}, [activePlayerList, whitePlayerId, blackPlayerId])
|
||||
// Note: setConfig is intentionally NOT in dependencies to avoid infinite loop
|
||||
// setConfig is stable (defined with useCallback) so this is safe
|
||||
|
||||
// Toast notifications for errors
|
||||
useEffect(() => {
|
||||
if (!lastError) return
|
||||
|
||||
// Parse the error to get enhanced information
|
||||
const enhancedError: EnhancedError = parseError(
|
||||
lastError,
|
||||
retryState.move ?? undefined,
|
||||
retryState.retryCount
|
||||
)
|
||||
|
||||
// Show toast if appropriate
|
||||
if (shouldShowToast(enhancedError)) {
|
||||
const toastType = getToastType(enhancedError.severity)
|
||||
const actionName = retryState.move ? getMoveActionName(retryState.move) : 'performing action'
|
||||
|
||||
showToast({
|
||||
type: toastType,
|
||||
title: enhancedError.userMessage,
|
||||
description: enhancedError.suggestion
|
||||
? `${enhancedError.suggestion} (${actionName})`
|
||||
: `Error while ${actionName}`,
|
||||
duration: enhancedError.severity === 'fatal' ? 10000 : 7000,
|
||||
})
|
||||
}
|
||||
}, [lastError, retryState, showToast])
|
||||
|
||||
// Toast for retry state changes (progressive feedback)
|
||||
useEffect(() => {
|
||||
if (!retryState.isRetrying || !retryState.move) return
|
||||
|
||||
// Parse the error as a version conflict
|
||||
const enhancedError: EnhancedError = parseError(
|
||||
'version conflict',
|
||||
retryState.move,
|
||||
retryState.retryCount
|
||||
)
|
||||
|
||||
// Show toast for 3+ retries (progressive disclosure)
|
||||
if (retryState.retryCount >= 3 && shouldShowToast(enhancedError)) {
|
||||
const actionName = getMoveActionName(retryState.move)
|
||||
showToast({
|
||||
type: 'info',
|
||||
title: enhancedError.userMessage,
|
||||
description: `Retrying ${actionName}... (attempt ${retryState.retryCount})`,
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
}, [retryState, showToast])
|
||||
|
||||
const value: RithmomachiaContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
retryState,
|
||||
viewerId: viewerId ?? null,
|
||||
playerColor,
|
||||
isMyTurn,
|
||||
@@ -398,6 +707,8 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
localTurnPlayerId,
|
||||
isSpectating,
|
||||
localPlayerColor,
|
||||
startGame,
|
||||
makeMove,
|
||||
declareHarmony,
|
||||
@@ -407,6 +718,9 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
|
||||
claimRepetition,
|
||||
claimFiftyMove,
|
||||
setConfig,
|
||||
assignWhitePlayer,
|
||||
assignBlackPlayer,
|
||||
swapSides,
|
||||
resetGame,
|
||||
goToSetup,
|
||||
exitSession,
|
||||
|
||||
@@ -44,6 +44,8 @@ export class RithmomachiaValidator implements GameValidator<RithmomachiaState, R
|
||||
fiftyMoveRule: config.fiftyMoveRule,
|
||||
allowAnySetOnRecheck: config.allowAnySetOnRecheck,
|
||||
timeControlMs: config.timeControlMs ?? null,
|
||||
whitePlayerId: config.whitePlayerId ?? null,
|
||||
blackPlayerId: config.blackPlayerId ?? null,
|
||||
|
||||
// Game phase
|
||||
gamePhase: 'setup',
|
||||
@@ -756,6 +758,8 @@ export class RithmomachiaValidator implements GameValidator<RithmomachiaState, R
|
||||
'fiftyMoveRule',
|
||||
'allowAnySetOnRecheck',
|
||||
'timeControlMs',
|
||||
'whitePlayerId',
|
||||
'blackPlayerId',
|
||||
]
|
||||
|
||||
if (!validFields.includes(field as keyof RithmomachiaConfig)) {
|
||||
@@ -786,6 +790,12 @@ export class RithmomachiaValidator implements GameValidator<RithmomachiaState, R
|
||||
}
|
||||
}
|
||||
|
||||
if (field === 'whitePlayerId' || field === 'blackPlayerId') {
|
||||
if (value !== null && typeof value !== 'string') {
|
||||
return { valid: false, error: `${field} must be a string or null` }
|
||||
}
|
||||
}
|
||||
|
||||
// Create new state with updated config field
|
||||
const newState = {
|
||||
...state,
|
||||
@@ -915,6 +925,8 @@ export class RithmomachiaValidator implements GameValidator<RithmomachiaState, R
|
||||
fiftyMoveRule: state.fiftyMoveRule,
|
||||
allowAnySetOnRecheck: state.allowAnySetOnRecheck,
|
||||
timeControlMs: state.timeControlMs,
|
||||
whitePlayerId: state.whitePlayerId ?? null,
|
||||
blackPlayerId: state.blackPlayerId ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,62 +229,6 @@ function useRosterWarning(phase: 'setup' | 'playing'): RosterWarning | undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (rosterStatus.status === 'tooMany') {
|
||||
const actions = []
|
||||
|
||||
// Add deactivate actions for local players
|
||||
for (const player of removableLocalPlayers) {
|
||||
actions.push({
|
||||
label: `Deactivate ${player.name}`,
|
||||
onClick: () => setActive(player.id, false),
|
||||
})
|
||||
}
|
||||
|
||||
// Add deactivate and kick actions for remote players (if host)
|
||||
for (const player of kickablePlayers) {
|
||||
// Add deactivate button (softer action)
|
||||
actions.push({
|
||||
label: `Deactivate ${player.name}`,
|
||||
onClick: () => {
|
||||
console.log('[RithmomachiaGame] Deactivating player:', player)
|
||||
console.log('[RithmomachiaGame] Player ID:', player.id)
|
||||
console.log('[RithmomachiaGame] Player userId:', (player as any).userId)
|
||||
console.log('[RithmomachiaGame] Player isLocal:', player.isLocal)
|
||||
if (roomData) {
|
||||
console.log('[RithmomachiaGame] Room ID:', roomData.id)
|
||||
console.log('[RithmomachiaGame] Room members:', roomData.members)
|
||||
console.log('[RithmomachiaGame] Member players:', roomData.memberPlayers)
|
||||
deactivatePlayer({ roomId: roomData.id, playerId: player.id })
|
||||
}
|
||||
},
|
||||
})
|
||||
// Add kick button (removes entire user)
|
||||
actions.push({
|
||||
label: `Kick ${player.name}'s user`,
|
||||
onClick: () => handleKick(player),
|
||||
variant: 'danger' as const,
|
||||
})
|
||||
}
|
||||
|
||||
// If guest has no actions available, show waiting message
|
||||
if (actions.length === 0 && !isHost) {
|
||||
return {
|
||||
heading: 'Too many active players',
|
||||
description:
|
||||
'Rithmomachia supports only two active players. Waiting for the room host to deactivate or remove extras...',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
heading: 'Too many active players',
|
||||
description:
|
||||
actions.length > 0
|
||||
? 'Rithmomachia supports only two active players. Deactivate or kick extras:'
|
||||
: 'Rithmomachia supports only two active players.',
|
||||
actions: actions.length > 0 ? actions : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [
|
||||
rosterStatus.status,
|
||||
@@ -394,6 +338,7 @@ export function RithmomachiaGame() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
@@ -425,243 +370,616 @@ function SetupPhase() {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="setup-phase-container"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4c1d95 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6',
|
||||
p: '6',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
p: '4',
|
||||
bg: 'red.100',
|
||||
borderColor: 'red.400',
|
||||
borderWidth: '2px',
|
||||
borderRadius: 'md',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: 'red.800', fontWeight: 'semibold' })}>⚠️ {lastError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'red.200',
|
||||
color: 'red.800',
|
||||
borderRadius: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: 'red.300' },
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h1 className={css({ fontSize: '3xl', fontWeight: 'bold', mb: '2' })}>Rithmomachia</h1>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'lg' })}>The Battle of Numbers</p>
|
||||
<p className={css({ color: 'gray.500', fontSize: 'sm', mt: '2', maxWidth: '600px' })}>
|
||||
A medieval strategy game where pieces capture through mathematical relations. Win by
|
||||
achieving harmony (a mathematical progression) in enemy territory!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Game Settings */}
|
||||
{/* Animated mathematical symbols background */}
|
||||
<div
|
||||
data-element="background-symbols"
|
||||
className={css({
|
||||
width: '100%',
|
||||
bg: 'white',
|
||||
borderRadius: 'lg',
|
||||
p: '6',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
fontSize: '20vh',
|
||||
color: 'white',
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<h2 className={css({ fontSize: 'xl', fontWeight: 'bold', mb: '4' })}>Game Rules</h2>
|
||||
<span>∑</span>
|
||||
<span>π</span>
|
||||
<span>∞</span>
|
||||
<span>±</span>
|
||||
<span>∫</span>
|
||||
<span>√</span>
|
||||
</div>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '4' })}>
|
||||
{/* Point Victory */}
|
||||
{/* Main content container - uses full viewport */}
|
||||
<div
|
||||
data-element="main-content"
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.5vh',
|
||||
overflow: 'hidden',
|
||||
p: '2vh',
|
||||
})}
|
||||
>
|
||||
{lastError && (
|
||||
<div
|
||||
data-element="error-banner"
|
||||
className={css({
|
||||
width: '100%',
|
||||
p: '2vh',
|
||||
bg: 'rgba(220, 38, 38, 0.9)',
|
||||
borderRadius: 'lg',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '3',
|
||||
bg: 'gray.50',
|
||||
borderRadius: 'md',
|
||||
backdropFilter: 'blur(10px)',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<div className={css({ fontWeight: 'semibold' })}>Point Victory</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
Win by capturing pieces worth {state.pointWinThreshold} points
|
||||
</div>
|
||||
</div>
|
||||
<label className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.pointWinEnabled}
|
||||
onChange={() => toggleSetting('pointWinEnabled')}
|
||||
className={css({ cursor: 'pointer', width: '18px', height: '18px' })}
|
||||
/>
|
||||
</label>
|
||||
<span className={css({ color: 'white', fontWeight: 'bold', fontSize: '1.8vh' })}>
|
||||
⚠️ {lastError}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
px: '2vh',
|
||||
py: '1vh',
|
||||
bg: 'rgba(255, 255, 255, 0.3)',
|
||||
color: 'white',
|
||||
borderRadius: 'md',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.6vh',
|
||||
_hover: { bg: 'rgba(255, 255, 255, 0.5)' },
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Point Threshold (only visible if point win enabled) */}
|
||||
{state.pointWinEnabled && (
|
||||
{/* Title Section - Dramatic medieval manuscript style */}
|
||||
<div
|
||||
data-element="title-section"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
bg: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: '2vh',
|
||||
p: '3vh',
|
||||
boxShadow: '0 2vh 6vh rgba(0,0,0,0.5)',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
border: '0.5vh solid',
|
||||
borderColor: 'rgba(251, 191, 36, 0.6)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
})}
|
||||
>
|
||||
{/* Ornamental corners */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-1vh',
|
||||
left: '-1vh',
|
||||
width: '8vh',
|
||||
height: '8vh',
|
||||
borderTop: '0.5vh solid',
|
||||
borderLeft: '0.5vh solid',
|
||||
borderColor: 'rgba(251, 191, 36, 0.8)',
|
||||
borderRadius: '2vh 0 0 0',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-1vh',
|
||||
right: '-1vh',
|
||||
width: '8vh',
|
||||
height: '8vh',
|
||||
borderTop: '0.5vh solid',
|
||||
borderRight: '0.5vh solid',
|
||||
borderColor: 'rgba(251, 191, 36, 0.8)',
|
||||
borderRadius: '0 2vh 0 0',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '-1vh',
|
||||
left: '-1vh',
|
||||
width: '8vh',
|
||||
height: '8vh',
|
||||
borderBottom: '0.5vh solid',
|
||||
borderLeft: '0.5vh solid',
|
||||
borderColor: 'rgba(251, 191, 36, 0.8)',
|
||||
borderRadius: '0 0 0 2vh',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '-1vh',
|
||||
right: '-1vh',
|
||||
width: '8vh',
|
||||
height: '8vh',
|
||||
borderBottom: '0.5vh solid',
|
||||
borderRight: '0.5vh solid',
|
||||
borderColor: 'rgba(251, 191, 36, 0.8)',
|
||||
borderRadius: '0 0 2vh 0',
|
||||
})}
|
||||
/>
|
||||
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '6vh',
|
||||
fontWeight: 'bold',
|
||||
mb: '1.5vh',
|
||||
color: '#7c2d12',
|
||||
textShadow: '0.3vh 0.3vh 0 rgba(251, 191, 36, 0.5), 0.6vh 0.6vh 1vh rgba(0,0,0,0.3)',
|
||||
letterSpacing: '0.3vh',
|
||||
})}
|
||||
>
|
||||
⚔️ RITHMOMACHIA ⚔️
|
||||
</h1>
|
||||
<div
|
||||
className={css({
|
||||
height: '0.3vh',
|
||||
width: '60%',
|
||||
margin: '0 auto 1.5vh',
|
||||
background:
|
||||
'linear-gradient(90deg, transparent, rgba(251, 191, 36, 0.8), transparent)',
|
||||
})}
|
||||
/>
|
||||
<p
|
||||
className={css({
|
||||
color: '#92400e',
|
||||
fontSize: '3vh',
|
||||
fontWeight: 'bold',
|
||||
mb: '1.5vh',
|
||||
fontStyle: 'italic',
|
||||
})}
|
||||
>
|
||||
The Battle of Numbers
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
color: '#78350f',
|
||||
fontSize: '1.8vh',
|
||||
lineHeight: '1.4',
|
||||
fontWeight: '500',
|
||||
})}
|
||||
>
|
||||
A medieval strategy game of mathematical combat.
|
||||
<br />
|
||||
Capture through relations • Win through harmony
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Game Settings - Compact with flex: 1 to take remaining space */}
|
||||
<div
|
||||
data-element="game-settings"
|
||||
className={css({
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
bg: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: '2vh',
|
||||
p: '2vh',
|
||||
boxShadow: '0 2vh 6vh rgba(0,0,0,0.5)',
|
||||
border: '0.3vh solid',
|
||||
borderColor: 'rgba(251, 191, 36, 0.4)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2.5vh',
|
||||
fontWeight: 'bold',
|
||||
mb: '1.5vh',
|
||||
color: '#7c2d12',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1vh',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<span>⚙️</span>
|
||||
<span>Game Rules</span>
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(35%, 1fr))',
|
||||
gap: '1.5vh',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
alignContent: 'start',
|
||||
})}
|
||||
>
|
||||
{/* Point Victory */}
|
||||
<div
|
||||
data-setting="point-victory"
|
||||
onClick={() => toggleSetting('pointWinEnabled')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '3',
|
||||
bg: 'purple.50',
|
||||
borderRadius: 'md',
|
||||
ml: '4',
|
||||
p: '1.5vh',
|
||||
bg: state.pointWinEnabled ? 'rgba(251, 191, 36, 0.25)' : 'rgba(139, 92, 246, 0.1)',
|
||||
borderRadius: '1vh',
|
||||
border: '0.3vh solid',
|
||||
borderColor: state.pointWinEnabled
|
||||
? 'rgba(251, 191, 36, 0.8)'
|
||||
: 'rgba(139, 92, 246, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: state.pointWinEnabled
|
||||
? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)'
|
||||
: '0 0.2vh 0.5vh rgba(0,0,0,0.1)',
|
||||
_hover: {
|
||||
bg: state.pointWinEnabled
|
||||
? 'rgba(251, 191, 36, 0.35)'
|
||||
: 'rgba(139, 92, 246, 0.2)',
|
||||
borderColor: state.pointWinEnabled
|
||||
? 'rgba(251, 191, 36, 1)'
|
||||
: 'rgba(139, 92, 246, 0.5)',
|
||||
transform: 'translateY(-0.2vh)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontWeight: 'semibold' })}>Point Threshold</div>
|
||||
<input
|
||||
type="number"
|
||||
value={state.pointWinThreshold}
|
||||
onChange={(e) => updateThreshold(Number.parseInt(e.target.value, 10))}
|
||||
min="1"
|
||||
<div className={css({ flex: 1, pointerEvents: 'none' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.6vh',
|
||||
color: state.pointWinEnabled ? '#92400e' : '#7c2d12',
|
||||
})}
|
||||
>
|
||||
{state.pointWinEnabled && '✓ '}Point Victory
|
||||
</div>
|
||||
<div className={css({ fontSize: '1.3vh', color: '#78350f' })}>
|
||||
Win at {state.pointWinThreshold}pts
|
||||
</div>
|
||||
</div>
|
||||
{state.pointWinEnabled && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '4vh',
|
||||
height: '4vh',
|
||||
borderRadius: '0 1vh 0 100%',
|
||||
bg: 'rgba(251, 191, 36, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Point Threshold (only visible if point win enabled) */}
|
||||
{state.pointWinEnabled && (
|
||||
<div
|
||||
data-setting="point-threshold"
|
||||
className={css({
|
||||
width: '80px',
|
||||
px: '3',
|
||||
py: '2',
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'purple.300',
|
||||
textAlign: 'center',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '1.5vh',
|
||||
bg: 'rgba(168, 85, 247, 0.15)',
|
||||
borderRadius: '1vh',
|
||||
border: '0.2vh solid',
|
||||
borderColor: 'rgba(168, 85, 247, 0.4)',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Threefold Repetition */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '3',
|
||||
bg: 'gray.50',
|
||||
borderRadius: 'md',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<div className={css({ fontWeight: 'semibold' })}>Threefold Repetition Draw</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
Draw if same position occurs 3 times
|
||||
>
|
||||
<div className={css({ fontWeight: 'bold', fontSize: '1.6vh', color: '#7c2d12' })}>
|
||||
Threshold
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={state.pointWinThreshold}
|
||||
onChange={(e) => updateThreshold(Number.parseInt(e.target.value, 10))}
|
||||
min="1"
|
||||
className={css({
|
||||
width: '10vh',
|
||||
px: '1vh',
|
||||
py: '0.5vh',
|
||||
borderRadius: '0.5vh',
|
||||
border: '0.2vh solid',
|
||||
borderColor: 'rgba(124, 45, 18, 0.5)',
|
||||
textAlign: 'center',
|
||||
fontSize: '1.6vh',
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.repetitionRule}
|
||||
onChange={() => toggleSetting('repetitionRule')}
|
||||
className={css({ cursor: 'pointer', width: '18px', height: '18px' })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fifty Move Rule */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '3',
|
||||
bg: 'gray.50',
|
||||
borderRadius: 'md',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<div className={css({ fontWeight: 'semibold' })}>Fifty-Move Rule</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
Draw if 50 moves with no capture or harmony
|
||||
{/* Threefold Repetition */}
|
||||
<div
|
||||
data-setting="threefold-repetition"
|
||||
onClick={() => toggleSetting('repetitionRule')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '1.5vh',
|
||||
bg: state.repetitionRule ? 'rgba(251, 191, 36, 0.25)' : 'rgba(139, 92, 246, 0.1)',
|
||||
borderRadius: '1vh',
|
||||
border: '0.3vh solid',
|
||||
borderColor: state.repetitionRule
|
||||
? 'rgba(251, 191, 36, 0.8)'
|
||||
: 'rgba(139, 92, 246, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: state.repetitionRule
|
||||
? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)'
|
||||
: '0 0.2vh 0.5vh rgba(0,0,0,0.1)',
|
||||
_hover: {
|
||||
bg: state.repetitionRule ? 'rgba(251, 191, 36, 0.35)' : 'rgba(139, 92, 246, 0.2)',
|
||||
borderColor: state.repetitionRule
|
||||
? 'rgba(251, 191, 36, 1)'
|
||||
: 'rgba(139, 92, 246, 0.5)',
|
||||
transform: 'translateY(-0.2vh)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ flex: 1, pointerEvents: 'none' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.6vh',
|
||||
color: state.repetitionRule ? '#92400e' : '#7c2d12',
|
||||
})}
|
||||
>
|
||||
{state.repetitionRule && '✓ '}Threefold Draw
|
||||
</div>
|
||||
<div className={css({ fontSize: '1.3vh', color: '#78350f' })}>Same position 3x</div>
|
||||
</div>
|
||||
{state.repetitionRule && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '4vh',
|
||||
height: '4vh',
|
||||
borderRadius: '0 1vh 0 100%',
|
||||
bg: 'rgba(251, 191, 36, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.fiftyMoveRule}
|
||||
onChange={() => toggleSetting('fiftyMoveRule')}
|
||||
className={css({ cursor: 'pointer', width: '18px', height: '18px' })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Harmony Persistence */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '3',
|
||||
bg: 'gray.50',
|
||||
borderRadius: 'md',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<div className={css({ fontWeight: 'semibold' })}>Flexible Harmony</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
Allow any valid harmony for persistence (not just the declared one)
|
||||
{/* Fifty Move Rule */}
|
||||
<div
|
||||
data-setting="fifty-move-rule"
|
||||
onClick={() => toggleSetting('fiftyMoveRule')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '1.5vh',
|
||||
bg: state.fiftyMoveRule ? 'rgba(251, 191, 36, 0.25)' : 'rgba(139, 92, 246, 0.1)',
|
||||
borderRadius: '1vh',
|
||||
border: '0.3vh solid',
|
||||
borderColor: state.fiftyMoveRule
|
||||
? 'rgba(251, 191, 36, 0.8)'
|
||||
: 'rgba(139, 92, 246, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: state.fiftyMoveRule
|
||||
? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)'
|
||||
: '0 0.2vh 0.5vh rgba(0,0,0,0.1)',
|
||||
_hover: {
|
||||
bg: state.fiftyMoveRule ? 'rgba(251, 191, 36, 0.35)' : 'rgba(139, 92, 246, 0.2)',
|
||||
borderColor: state.fiftyMoveRule
|
||||
? 'rgba(251, 191, 36, 1)'
|
||||
: 'rgba(139, 92, 246, 0.5)',
|
||||
transform: 'translateY(-0.2vh)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ flex: 1, pointerEvents: 'none' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.6vh',
|
||||
color: state.fiftyMoveRule ? '#92400e' : '#7c2d12',
|
||||
})}
|
||||
>
|
||||
{state.fiftyMoveRule && '✓ '}Fifty-Move Draw
|
||||
</div>
|
||||
<div className={css({ fontSize: '1.3vh', color: '#78350f' })}>
|
||||
50 moves no event
|
||||
</div>
|
||||
</div>
|
||||
{state.fiftyMoveRule && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '4vh',
|
||||
height: '4vh',
|
||||
borderRadius: '0 1vh 0 100%',
|
||||
bg: 'rgba(251, 191, 36, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Harmony Persistence */}
|
||||
<div
|
||||
data-setting="flexible-harmony"
|
||||
onClick={() => toggleSetting('allowAnySetOnRecheck')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: '1.5vh',
|
||||
bg: state.allowAnySetOnRecheck
|
||||
? 'rgba(251, 191, 36, 0.25)'
|
||||
: 'rgba(139, 92, 246, 0.1)',
|
||||
borderRadius: '1vh',
|
||||
border: '0.3vh solid',
|
||||
borderColor: state.allowAnySetOnRecheck
|
||||
? 'rgba(251, 191, 36, 0.8)'
|
||||
: 'rgba(139, 92, 246, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: state.allowAnySetOnRecheck
|
||||
? '0 0.5vh 2vh rgba(251, 191, 36, 0.4)'
|
||||
: '0 0.2vh 0.5vh rgba(0,0,0,0.1)',
|
||||
_hover: {
|
||||
bg: state.allowAnySetOnRecheck
|
||||
? 'rgba(251, 191, 36, 0.35)'
|
||||
: 'rgba(139, 92, 246, 0.2)',
|
||||
borderColor: state.allowAnySetOnRecheck
|
||||
? 'rgba(251, 191, 36, 1)'
|
||||
: 'rgba(139, 92, 246, 0.5)',
|
||||
transform: 'translateY(-0.2vh)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ flex: 1, pointerEvents: 'none' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.6vh',
|
||||
color: state.allowAnySetOnRecheck ? '#92400e' : '#7c2d12',
|
||||
})}
|
||||
>
|
||||
{state.allowAnySetOnRecheck && '✓ '}Flexible Harmony
|
||||
</div>
|
||||
<div className={css({ fontSize: '1.3vh', color: '#78350f' })}>Any valid set</div>
|
||||
</div>
|
||||
{state.allowAnySetOnRecheck && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '4vh',
|
||||
height: '4vh',
|
||||
borderRadius: '0 1vh 0 100%',
|
||||
bg: 'rgba(251, 191, 36, 0.4)',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<label className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.allowAnySetOnRecheck}
|
||||
onChange={() => toggleSetting('allowAnySetOnRecheck')}
|
||||
className={css({ cursor: 'pointer', width: '18px', height: '18px' })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Start Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
disabled={startDisabled}
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: startDisabled ? 'gray.400' : 'purple.600',
|
||||
color: 'white',
|
||||
borderRadius: 'lg',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: startDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: startDisabled ? 0.7 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: startDisabled
|
||||
? undefined
|
||||
: {
|
||||
bg: 'purple.700',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
{/* Start Button - Dramatic and always visible */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="start-game"
|
||||
onClick={startGame}
|
||||
disabled={startDisabled}
|
||||
className={css({
|
||||
width: '100%',
|
||||
py: '3vh',
|
||||
bg: startDisabled
|
||||
? 'rgba(100, 100, 100, 0.5)'
|
||||
: 'linear-gradient(135deg, rgba(251, 191, 36, 0.95) 0%, rgba(245, 158, 11, 0.95) 100%)',
|
||||
color: startDisabled ? 'rgba(200, 200, 200, 0.7)' : '#7c2d12',
|
||||
borderRadius: '2vh',
|
||||
fontSize: '4vh',
|
||||
fontWeight: 'bold',
|
||||
cursor: startDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||
boxShadow: startDisabled
|
||||
? '0 1vh 3vh rgba(0,0,0,0.2)'
|
||||
: '0 2vh 6vh rgba(251, 191, 36, 0.6), inset 0 -0.5vh 1vh rgba(124, 45, 18, 0.3)',
|
||||
border: '0.5vh solid',
|
||||
borderColor: startDisabled ? 'rgba(100, 100, 100, 0.3)' : 'rgba(245, 158, 11, 0.8)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5vh',
|
||||
textShadow: startDisabled
|
||||
? 'none'
|
||||
: '0.2vh 0.2vh 0.5vh rgba(124, 45, 18, 0.5), 0 0 2vh rgba(255, 255, 255, 0.3)',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
_hover: startDisabled
|
||||
? undefined
|
||||
: {
|
||||
transform: 'translateY(-1vh) scale(1.02)',
|
||||
boxShadow:
|
||||
'0 3vh 8vh rgba(251, 191, 36, 0.8), inset 0 -0.5vh 1vh rgba(124, 45, 18, 0.4)',
|
||||
borderColor: 'rgba(251, 191, 36, 1)',
|
||||
},
|
||||
_active: startDisabled
|
||||
? undefined
|
||||
: {
|
||||
transform: 'translateY(-0.3vh) scale(0.98)',
|
||||
},
|
||||
_before: startDisabled
|
||||
? undefined
|
||||
: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background:
|
||||
'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent)',
|
||||
animation: 'shimmer 3s infinite',
|
||||
},
|
||||
})}
|
||||
>
|
||||
⚔️ BEGIN BATTLE ⚔️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface RithmomachiaState extends GameState {
|
||||
fiftyMoveRule: boolean
|
||||
allowAnySetOnRecheck: boolean
|
||||
timeControlMs: number | null
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
|
||||
// Game phase
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
@@ -148,6 +150,10 @@ export interface RithmomachiaConfig extends GameConfig {
|
||||
|
||||
// Optional time controls (not implemented in v1)
|
||||
timeControlMs?: number | null
|
||||
|
||||
// Player assignments (null = auto-assign)
|
||||
whitePlayerId?: string | null // default: null (auto-assign first active player)
|
||||
blackPlayerId?: string | null // default: null (auto-assign second active player)
|
||||
}
|
||||
|
||||
// === GAME MOVES ===
|
||||
|
||||
@@ -15,6 +15,7 @@ interface PageWithNavProps {
|
||||
navEmoji?: string
|
||||
gameName?: 'matching' | 'memory-quiz' | 'complement-race' // Internal game name for API
|
||||
emphasizePlayerSelection?: boolean
|
||||
disableFullscreenSelection?: boolean // Disable "Select Your Champions" overlay
|
||||
onExitSession?: () => void
|
||||
onSetup?: () => void
|
||||
onNewGame?: () => void
|
||||
@@ -26,6 +27,13 @@ interface PageWithNavProps {
|
||||
playerBadges?: Record<string, PlayerBadge>
|
||||
// Game-specific roster warnings
|
||||
rosterWarning?: RosterWarning
|
||||
// Side assignments (for 2-player games like Rithmomachia)
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
onAssignWhitePlayer?: (playerId: string | null) => void
|
||||
onAssignBlackPlayer?: (playerId: string | null) => void
|
||||
// Game phase (for showing spectating vs assign)
|
||||
gamePhase?: 'setup' | 'playing' | 'results'
|
||||
}
|
||||
|
||||
export function PageWithNav({
|
||||
@@ -33,6 +41,7 @@ export function PageWithNav({
|
||||
navEmoji,
|
||||
gameName,
|
||||
emphasizePlayerSelection = false,
|
||||
disableFullscreenSelection = false,
|
||||
onExitSession,
|
||||
onSetup,
|
||||
onNewGame,
|
||||
@@ -42,6 +51,11 @@ export function PageWithNav({
|
||||
playerStreaks,
|
||||
playerBadges,
|
||||
rosterWarning,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
onAssignWhitePlayer,
|
||||
onAssignBlackPlayer,
|
||||
gamePhase,
|
||||
}: PageWithNavProps) {
|
||||
const { players, activePlayers, setActive, activePlayerCount } = useGameMode()
|
||||
const { roomData, isInRoom, moderationEvent, clearModerationEvent } = useRoomData()
|
||||
@@ -110,7 +124,8 @@ export function PageWithNav({
|
||||
: 'none'
|
||||
|
||||
const shouldEmphasize = emphasizePlayerSelection && mounted
|
||||
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
|
||||
const showFullscreenSelection =
|
||||
!disableFullscreenSelection && shouldEmphasize && activePlayerCount === 0
|
||||
|
||||
// Compute arcade session info for display
|
||||
// Memoized to prevent unnecessary re-renders
|
||||
@@ -180,6 +195,11 @@ export function PageWithNav({
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
rosterWarning={rosterWarning}
|
||||
whitePlayerId={whitePlayerId}
|
||||
blackPlayerId={blackPlayerId}
|
||||
onAssignWhitePlayer={onAssignWhitePlayer}
|
||||
onAssignBlackPlayer={onAssignBlackPlayer}
|
||||
gamePhase={gamePhase}
|
||||
/>
|
||||
) : null
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
interface StandardGameLayoutProps {
|
||||
@@ -14,19 +14,46 @@ interface StandardGameLayoutProps {
|
||||
* 2. Navigation never covers game elements (safe area padding)
|
||||
* 3. Perfect viewport fit on all devices
|
||||
* 4. Consistent experience across all games
|
||||
* 5. Dynamically calculates nav height for proper spacing
|
||||
*/
|
||||
export function StandardGameLayout({ children, className }: StandardGameLayoutProps) {
|
||||
const [navHeight, setNavHeight] = useState(80) // Default fallback
|
||||
|
||||
useEffect(() => {
|
||||
// Measure the actual nav height from the fixed header
|
||||
const measureNavHeight = () => {
|
||||
const header = document.querySelector('header')
|
||||
if (header) {
|
||||
const rect = header.getBoundingClientRect()
|
||||
// Add extra spacing for safety (nav top position + nav height + margin)
|
||||
const calculatedHeight = rect.top + rect.height + 20
|
||||
setNavHeight(calculatedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure on mount and when window resizes
|
||||
measureNavHeight()
|
||||
window.addEventListener('resize', measureNavHeight)
|
||||
|
||||
// Also measure after a short delay to catch any late-rendering nav elements
|
||||
const timer = setTimeout(measureNavHeight, 100)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', measureNavHeight)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-layout="standard-game-layout"
|
||||
data-nav-height={navHeight}
|
||||
className={`${css({
|
||||
// Exact viewport sizing - no scrolling ever
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
|
||||
// Safe area for navigation (fixed at top: 4px, right: 4px)
|
||||
// Navigation is ~60px tall, so we need padding-top of ~80px to be safe
|
||||
paddingTop: '80px',
|
||||
paddingRight: '4px', // Ensure nav doesn't overlap content on right side
|
||||
paddingBottom: '4px',
|
||||
paddingLeft: '4px',
|
||||
@@ -41,6 +68,10 @@ export function StandardGameLayout({ children, className }: StandardGameLayoutPr
|
||||
// Transparent background - themes will be applied at nav level
|
||||
background: 'transparent',
|
||||
})} ${className || ''}`}
|
||||
style={{
|
||||
// Dynamic padding based on measured nav height
|
||||
paddingTop: `${navHeight}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,16 @@ interface ActivePlayersListProps {
|
||||
playerScores?: Record<string, number>
|
||||
playerStreaks?: Record<string, number>
|
||||
playerBadges?: Record<string, PlayerBadge>
|
||||
// Side assignments (for 2-player games)
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
onAssignWhitePlayer?: (playerId: string | null) => void
|
||||
onAssignBlackPlayer?: (playerId: string | null) => void
|
||||
// Room/host context for assignment permissions
|
||||
isInRoom?: boolean
|
||||
isCurrentUserHost?: boolean
|
||||
// Game phase (for showing spectating vs assign)
|
||||
gamePhase?: 'setup' | 'playing' | 'results'
|
||||
}
|
||||
|
||||
export function ActivePlayersList({
|
||||
@@ -32,8 +42,64 @@ export function ActivePlayersList({
|
||||
playerScores = {},
|
||||
playerStreaks = {},
|
||||
playerBadges = {},
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
onAssignWhitePlayer,
|
||||
onAssignBlackPlayer,
|
||||
isInRoom = false,
|
||||
isCurrentUserHost = false,
|
||||
gamePhase,
|
||||
}: ActivePlayersListProps) {
|
||||
const [hoveredPlayerId, setHoveredPlayerId] = React.useState<string | null>(null)
|
||||
const [hoveredBadge, setHoveredBadge] = React.useState<string | null>(null)
|
||||
const [clickCooldown, setClickCooldown] = React.useState<string | null>(null)
|
||||
|
||||
// Determine if user can assign players
|
||||
// Can assign if: not in room (local play) OR in room and is host
|
||||
const canAssignPlayers = !isInRoom || isCurrentUserHost
|
||||
|
||||
// Handler to assign to white
|
||||
const handleAssignWhite = React.useCallback(
|
||||
(playerId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignWhitePlayer) return
|
||||
onAssignWhitePlayer(playerId)
|
||||
setClickCooldown(playerId)
|
||||
},
|
||||
[onAssignWhitePlayer]
|
||||
)
|
||||
|
||||
// Handler to assign to black
|
||||
const handleAssignBlack = React.useCallback(
|
||||
(playerId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignBlackPlayer) return
|
||||
onAssignBlackPlayer(playerId)
|
||||
setClickCooldown(playerId)
|
||||
},
|
||||
[onAssignBlackPlayer]
|
||||
)
|
||||
|
||||
// Handler to swap sides
|
||||
const handleSwap = React.useCallback(
|
||||
(playerId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignWhitePlayer || !onAssignBlackPlayer) return
|
||||
|
||||
if (whitePlayerId === playerId) {
|
||||
// Currently white, swap with black player
|
||||
const currentBlack = blackPlayerId ?? null
|
||||
onAssignWhitePlayer(currentBlack)
|
||||
onAssignBlackPlayer(playerId)
|
||||
} else if (blackPlayerId === playerId) {
|
||||
// Currently black, swap with white player
|
||||
const currentWhite = whitePlayerId ?? null
|
||||
onAssignBlackPlayer(currentWhite)
|
||||
onAssignWhitePlayer(playerId)
|
||||
}
|
||||
},
|
||||
[whitePlayerId, blackPlayerId, onAssignWhitePlayer, onAssignBlackPlayer]
|
||||
)
|
||||
|
||||
// Helper to get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
@@ -315,6 +381,283 @@ export function ActivePlayersList({
|
||||
Your turn
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side assignment badge (white/black for 2-player games) */}
|
||||
{onAssignWhitePlayer && onAssignBlackPlayer && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
width: '88px', // Fixed width to prevent layout shift
|
||||
transition: 'none', // Prevent any inherited transitions
|
||||
}}
|
||||
onMouseEnter={() => canAssignPlayers && setHoveredBadge(player.id)}
|
||||
onMouseLeave={() => {
|
||||
setHoveredBadge(null)
|
||||
setClickCooldown(null)
|
||||
}}
|
||||
>
|
||||
{/* Unassigned player - show split button on hover */}
|
||||
{whitePlayerId !== player.id && blackPlayerId !== player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host/local play: show interactive assignment buttons
|
||||
<>
|
||||
{hoveredBadge === player.id && clickCooldown !== player.id ? (
|
||||
// Hover state: split button
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<div
|
||||
onClick={(e) => handleAssignWhite(player.id, e)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px 0 0 12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
borderRight: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
W
|
||||
</div>
|
||||
<div
|
||||
onClick={(e) => handleAssignBlack(player.id, e)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
borderRadius: '0 12px 12px 0',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
borderLeft: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: ASSIGN or SPECTATING button
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #e5e7eb, #d1d5db)',
|
||||
color: '#6b7280',
|
||||
border: '2px solid #9ca3af',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
transition: 'none',
|
||||
}}
|
||||
>
|
||||
{gamePhase === 'playing' ? 'SPECTATING' : 'ASSIGN'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : // Guest in room: show SPECTATING during gameplay, nothing during setup
|
||||
gamePhase === 'playing' ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
background: 'transparent',
|
||||
color: '#9ca3af',
|
||||
border: '2px solid transparent',
|
||||
textAlign: 'center',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
SPECTATING
|
||||
</div>
|
||||
) : (
|
||||
// During setup/results: show nothing
|
||||
<div style={{ width: '100%', height: '28px' }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* White player - show SWAP to black on hover */}
|
||||
{whitePlayerId === player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host/local play: show interactive swap
|
||||
<>
|
||||
{hoveredBadge === player.id && clickCooldown !== player.id ? (
|
||||
// Hover state: SWAP with black styling
|
||||
<div
|
||||
onClick={(e) => handleSwap(player.id, e)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
SWAP
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: WHITE
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
WHITE
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Guest in room: show static WHITE label
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'default',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
WHITE
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Black player - show SWAP to white on hover */}
|
||||
{blackPlayerId === player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host/local play: show interactive swap
|
||||
<>
|
||||
{hoveredBadge === player.id && clickCooldown !== player.id ? (
|
||||
// Hover state: SWAP with white styling
|
||||
<div
|
||||
onClick={(e) => handleSwap(player.id, e)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
SWAP
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: BLACK
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
BLACK
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Guest in room: show static BLACK label
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'default',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
BLACK
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PlayerTooltip>
|
||||
)
|
||||
|
||||
@@ -76,6 +76,13 @@ interface GameContextNavProps {
|
||||
setActiveTab?: (tab: 'add' | 'invite') => void
|
||||
// Game-specific roster warnings
|
||||
rosterWarning?: RosterWarning
|
||||
// Side assignments (for 2-player games)
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
onAssignWhitePlayer?: (playerId: string | null) => void
|
||||
onAssignBlackPlayer?: (playerId: string | null) => void
|
||||
// Game phase (for showing spectating vs assign)
|
||||
gamePhase?: 'setup' | 'playing' | 'results'
|
||||
}
|
||||
|
||||
export function GameContextNav({
|
||||
@@ -104,6 +111,11 @@ export function GameContextNav({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
rosterWarning,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
onAssignWhitePlayer,
|
||||
onAssignBlackPlayer,
|
||||
gamePhase,
|
||||
}: GameContextNavProps) {
|
||||
// Get current user info for moderation
|
||||
const { data: currentUserId } = useViewerId()
|
||||
@@ -258,6 +270,14 @@ export function GameContextNav({
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#dc2626' : '#f59e0b'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#b91c1c' : '#d97706'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#dc2626' : '#f59e0b'
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{action.label}
|
||||
@@ -384,6 +404,12 @@ export function GameContextNav({
|
||||
roomId={roomInfo?.roomId}
|
||||
currentUserId={currentUserId ?? undefined}
|
||||
isCurrentUserHost={isCurrentUserHost}
|
||||
whitePlayerId={whitePlayerId}
|
||||
blackPlayerId={blackPlayerId}
|
||||
onAssignWhitePlayer={onAssignWhitePlayer}
|
||||
onAssignBlackPlayer={onAssignBlackPlayer}
|
||||
isInRoom={!!roomInfo}
|
||||
gamePhase={gamePhase}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -420,6 +446,13 @@ export function GameContextNav({
|
||||
playerScores={playerScores}
|
||||
playerStreaks={playerStreaks}
|
||||
playerBadges={playerBadges}
|
||||
whitePlayerId={whitePlayerId}
|
||||
blackPlayerId={blackPlayerId}
|
||||
onAssignWhitePlayer={onAssignWhitePlayer}
|
||||
onAssignBlackPlayer={onAssignBlackPlayer}
|
||||
isInRoom={!!roomInfo}
|
||||
isCurrentUserHost={isCurrentUserHost}
|
||||
gamePhase={gamePhase}
|
||||
/>
|
||||
|
||||
<AddPlayerButton
|
||||
|
||||
202
apps/web/src/components/nav/HistoricalPlayersInvite.tsx
Normal file
202
apps/web/src/components/nav/HistoricalPlayersInvite.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
|
||||
interface HistoricalMember {
|
||||
userId: string
|
||||
displayName: string
|
||||
firstJoinedAt: string
|
||||
lastSeenAt: string
|
||||
status: 'active' | 'banned' | 'kicked' | 'left'
|
||||
isCurrentlyInRoom: boolean
|
||||
isBanned: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to show historical players who are not currently in the room
|
||||
* with invite buttons to bring them back
|
||||
*/
|
||||
export function HistoricalPlayersInvite() {
|
||||
const { roomData } = useRoomData()
|
||||
const [historicalMembers, setHistoricalMembers] = useState<HistoricalMember[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [invitingUserId, setInvitingUserId] = useState<string | null>(null)
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
// Fetch historical members
|
||||
useEffect(() => {
|
||||
if (!roomData?.id) return
|
||||
|
||||
const loadHistoricalMembers = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/arcade/rooms/${roomData.id}/history`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Filter to only show members who are NOT currently in the room and NOT banned
|
||||
const notInRoom = (data.historicalMembers || []).filter(
|
||||
(m: HistoricalMember) => !m.isCurrentlyInRoom && !m.isBanned
|
||||
)
|
||||
setHistoricalMembers(notInRoom)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load historical members:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadHistoricalMembers()
|
||||
}, [roomData?.id])
|
||||
|
||||
const handleInvite = async (userId: string, displayName: string) => {
|
||||
if (!roomData?.id) return
|
||||
|
||||
setInvitingUserId(userId)
|
||||
try {
|
||||
const res = await fetch(`/api/arcade/rooms/${roomData.id}/invite`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, userName: displayName }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json()
|
||||
throw new Error(errorData.error || 'Failed to send invitation')
|
||||
}
|
||||
|
||||
showSuccess(`Invitation sent to ${displayName}`)
|
||||
|
||||
// Remove from list after inviting
|
||||
setHistoricalMembers((prev) => prev.filter((m) => m.userId !== userId))
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to send invitation')
|
||||
} finally {
|
||||
setInvitingUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!roomData?.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
Loading past players...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (historicalMembers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Past Players
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{historicalMembers.map((member) => (
|
||||
<div
|
||||
key={member.userId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(255, 255, 255, 0.5)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1e293b',
|
||||
}}
|
||||
>
|
||||
{member.displayName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
Last seen: {new Date(member.lastSeenAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInvite(member.userId, member.displayName)}
|
||||
disabled={invitingUserId === member.userId}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background:
|
||||
invitingUserId === member.userId
|
||||
? '#d1d5db'
|
||||
: 'linear-gradient(135deg, #8b5cf6, #7c3aed)',
|
||||
color: 'white',
|
||||
cursor: invitingUserId === member.userId ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: invitingUserId === member.userId ? 0.6 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (invitingUserId !== member.userId) {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed, #6d28d9)'
|
||||
e.currentTarget.style.transform = 'scale(1.05)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (invitingUserId !== member.userId) {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6, #7c3aed)'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{invitingUserId === member.userId ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCreateRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { RoomShareButtons } from './RoomShareButtons'
|
||||
import { HistoricalPlayersInvite } from './HistoricalPlayersInvite'
|
||||
|
||||
/**
|
||||
* Tab content for inviting players to a room
|
||||
@@ -176,6 +177,11 @@ export function InvitePlayersTab() {
|
||||
Share to invite players
|
||||
</div>
|
||||
<RoomShareButtons joinCode={roomData.code} shareUrl={shareUrl} />
|
||||
|
||||
{/* Historical players who can be invited back */}
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<HistoricalPlayersInvite />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
import { ReportPlayerModal } from './ReportPlayerModal'
|
||||
import type { PlayerBadge } from './types'
|
||||
import { useDeactivatePlayer } from '@/hooks/useRoomData'
|
||||
|
||||
interface NetworkPlayer {
|
||||
id: string
|
||||
@@ -25,6 +26,15 @@ interface NetworkPlayerIndicatorProps {
|
||||
roomId?: string
|
||||
currentUserId?: string
|
||||
isCurrentUserHost?: boolean
|
||||
// Side assignments (for 2-player games)
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
onAssignWhitePlayer?: (playerId: string | null) => void
|
||||
onAssignBlackPlayer?: (playerId: string | null) => void
|
||||
// Room context for assignment permissions
|
||||
isInRoom?: boolean
|
||||
// Game phase (for showing spectating vs assign)
|
||||
gamePhase?: 'setup' | 'playing' | 'results'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,8 +51,22 @@ export function NetworkPlayerIndicator({
|
||||
roomId,
|
||||
currentUserId,
|
||||
isCurrentUserHost,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
onAssignWhitePlayer,
|
||||
onAssignBlackPlayer,
|
||||
isInRoom = true, // Network players are always in a room
|
||||
gamePhase,
|
||||
}: NetworkPlayerIndicatorProps) {
|
||||
const [showReportModal, setShowReportModal] = useState(false)
|
||||
const [hoveredPlayerId, setHoveredPlayerId] = useState<string | null>(null)
|
||||
const [hoveredBadge, setHoveredBadge] = useState(false)
|
||||
const [clickCooldown, setClickCooldown] = useState(false)
|
||||
const { mutate: deactivatePlayer } = useDeactivatePlayer()
|
||||
|
||||
// Determine if user can assign players
|
||||
// For network players: Can assign only if user is host (always in a room)
|
||||
const canAssignPlayers = isCurrentUserHost
|
||||
|
||||
const playerName = player.name || `Network Player ${player.id.slice(0, 8)}`
|
||||
const extraInfo = player.memberName ? `Controlled by ${player.memberName}` : undefined
|
||||
@@ -77,6 +101,46 @@ export function NetworkPlayerIndicator({
|
||||
const celebrationLevel = getCelebrationLevel(streak)
|
||||
const badge = playerBadges[player.id]
|
||||
|
||||
// Handler for deactivating player (host only)
|
||||
const handleDeactivate = () => {
|
||||
if (!roomId || !isCurrentUserHost) return
|
||||
deactivatePlayer({ roomId, playerId: player.id })
|
||||
}
|
||||
|
||||
// Handler to assign to white
|
||||
const handleAssignWhite = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignWhitePlayer) return
|
||||
onAssignWhitePlayer(player.id)
|
||||
setClickCooldown(true)
|
||||
}
|
||||
|
||||
// Handler to assign to black
|
||||
const handleAssignBlack = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignBlackPlayer) return
|
||||
onAssignBlackPlayer(player.id)
|
||||
setClickCooldown(true)
|
||||
}
|
||||
|
||||
// Handler to swap sides
|
||||
const handleSwap = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignWhitePlayer || !onAssignBlackPlayer) return
|
||||
|
||||
if (whitePlayerId === player.id) {
|
||||
// Currently white, swap with black player
|
||||
const currentBlack = blackPlayerId ?? null
|
||||
onAssignWhitePlayer(currentBlack)
|
||||
onAssignBlackPlayer(player.id)
|
||||
} else if (blackPlayerId === player.id) {
|
||||
// Currently black, swap with white player
|
||||
const currentWhite = whitePlayerId ?? null
|
||||
onAssignBlackPlayer(currentWhite)
|
||||
onAssignWhitePlayer(player.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlayerTooltip
|
||||
@@ -108,6 +172,8 @@ export function NetworkPlayerIndicator({
|
||||
justifyContent: 'center',
|
||||
opacity: hasGameState ? (isCurrentPlayer ? 1 : 0.65) : 1,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredPlayerId(player.id)}
|
||||
onMouseLeave={() => setHoveredPlayerId(null)}
|
||||
>
|
||||
{/* Turn indicator border ring - show when current player */}
|
||||
{isCurrentPlayer && hasGameState && (
|
||||
@@ -200,6 +266,61 @@ export function NetworkPlayerIndicator({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button - top left (host only, on hover) */}
|
||||
{shouldEmphasize && isCurrentUserHost && hoveredPlayerId === player.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeactivate()
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
left: '-6px',
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid white',
|
||||
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #dc2626, #b91c1c)'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(239, 68, 68, 0.5)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #dc2626, #b91c1c)'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(239, 68, 68, 0.5)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)'
|
||||
}}
|
||||
aria-label={`Deactivate ${playerName}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@@ -319,6 +440,283 @@ export function NetworkPlayerIndicator({
|
||||
Their turn
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side assignment badge (white/black for 2-player games) */}
|
||||
{onAssignWhitePlayer && onAssignBlackPlayer && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
width: '88px', // Fixed width to prevent layout shift
|
||||
transition: 'none', // Prevent any inherited transitions
|
||||
}}
|
||||
onMouseEnter={() => canAssignPlayers && setHoveredBadge(true)}
|
||||
onMouseLeave={() => {
|
||||
setHoveredBadge(false)
|
||||
setClickCooldown(false)
|
||||
}}
|
||||
>
|
||||
{/* Unassigned player - show split button on hover */}
|
||||
{whitePlayerId !== player.id && blackPlayerId !== player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host: show interactive assignment buttons
|
||||
<>
|
||||
{hoveredBadge && !clickCooldown ? (
|
||||
// Hover state: split button
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<div
|
||||
onClick={handleAssignWhite}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px 0 0 12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
borderRight: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
W
|
||||
</div>
|
||||
<div
|
||||
onClick={handleAssignBlack}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
borderRadius: '0 12px 12px 0',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
borderLeft: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: ASSIGN or SPECTATING button
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #e5e7eb, #d1d5db)',
|
||||
color: '#6b7280',
|
||||
border: '2px solid #9ca3af',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
transition: 'none',
|
||||
}}
|
||||
>
|
||||
{gamePhase === 'playing' ? 'SPECTATING' : 'ASSIGN'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : // Guest: show SPECTATING during gameplay, nothing during setup
|
||||
gamePhase === 'playing' ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
background: 'transparent',
|
||||
color: '#9ca3af',
|
||||
border: '2px solid transparent',
|
||||
textAlign: 'center',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
SPECTATING
|
||||
</div>
|
||||
) : (
|
||||
// During setup/results: show nothing
|
||||
<div style={{ width: '100%', height: '28px' }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* White player - show SWAP to black on hover */}
|
||||
{whitePlayerId === player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host: show interactive swap
|
||||
<>
|
||||
{hoveredBadge && !clickCooldown ? (
|
||||
// Hover state: SWAP with black styling
|
||||
<div
|
||||
onClick={handleSwap}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
SWAP
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: WHITE
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
WHITE
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Guest: show static WHITE label
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'default',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
WHITE
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Black player - show SWAP to white on hover */}
|
||||
{blackPlayerId === player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host: show interactive swap
|
||||
<>
|
||||
{hoveredBadge && !clickCooldown ? (
|
||||
// Hover state: SWAP with white styling
|
||||
<div
|
||||
onClick={handleSwap}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
SWAP
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: BLACK
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
BLACK
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Guest: show static BLACK label
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'default',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
BLACK
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PlayerTooltip>
|
||||
|
||||
|
||||
230
apps/web/src/components/nav/SetupPlayerRequirement.tsx
Normal file
230
apps/web/src/components/nav/SetupPlayerRequirement.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React from 'react'
|
||||
import { InvitePlayersTab } from './InvitePlayersTab'
|
||||
|
||||
interface Player {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
interface SetupPlayerRequirementProps {
|
||||
minPlayers: number
|
||||
currentPlayers: Player[]
|
||||
inactivePlayers: Player[]
|
||||
onAddPlayer: (playerId: string) => void
|
||||
onConfigurePlayer: (playerId: string) => void
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Prominent player requirement component shown during setup when not enough players are active.
|
||||
* Forces the user to add more players before proceeding with setup.
|
||||
*/
|
||||
export function SetupPlayerRequirement({
|
||||
minPlayers,
|
||||
currentPlayers,
|
||||
inactivePlayers,
|
||||
onAddPlayer,
|
||||
onConfigurePlayer,
|
||||
gameTitle,
|
||||
}: SetupPlayerRequirementProps) {
|
||||
const [activeTab, setActiveTab] = React.useState<'add' | 'invite'>('add')
|
||||
const needsPlayers = currentPlayers.length < minPlayers
|
||||
const playersNeeded = minPlayers - currentPlayers.length
|
||||
|
||||
if (!needsPlayers) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto 32px auto',
|
||||
padding: '24px',
|
||||
background: 'linear-gradient(135deg, rgba(96, 165, 250, 0.15), rgba(167, 139, 250, 0.15))',
|
||||
borderRadius: '16px',
|
||||
border: '3px solid rgba(96, 165, 250, 0.4)',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.15)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h2
|
||||
style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
}}
|
||||
>
|
||||
{gameTitle} needs {playersNeeded} more {playersNeeded === 1 ? 'player' : 'players'}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Add local players or invite friends to join
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab selector */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('add')}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
borderRadius: '8px',
|
||||
border: activeTab === 'add' ? '2px solid #60a5fa' : '2px solid transparent',
|
||||
background: activeTab === 'add' ? '#60a5fa' : 'rgba(255, 255, 255, 0.5)',
|
||||
color: activeTab === 'add' ? 'white' : '#64748b',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Add Local Player
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('invite')}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
borderRadius: '8px',
|
||||
border: activeTab === 'invite' ? '2px solid #a78bfa' : '2px solid transparent',
|
||||
background: activeTab === 'invite' ? '#a78bfa' : 'rgba(255, 255, 255, 0.5)',
|
||||
color: activeTab === 'invite' ? 'white' : '#64748b',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Invite Players
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'add' ? (
|
||||
// Add local player tab
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
>
|
||||
{inactivePlayers.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<p style={{ margin: '0 0 12px 0', color: '#64748b', fontSize: '14px' }}>
|
||||
No inactive players available
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfigurePlayer('new')}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #60a5fa',
|
||||
background: '#60a5fa',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Create New Player
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: '16px',
|
||||
justifyItems: 'center',
|
||||
}}
|
||||
>
|
||||
{inactivePlayers.map((player) => (
|
||||
<div
|
||||
key={player.id}
|
||||
onClick={() => onAddPlayer(player.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
background: 'white',
|
||||
border: '2px solid rgba(96, 165, 250, 0.3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)'
|
||||
e.currentTarget.style.borderColor = '#60a5fa'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(96, 165, 250, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.borderColor = 'rgba(96, 165, 250, 0.3)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', lineHeight: 1 }}>{player.emoji}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#1e293b',
|
||||
textAlign: 'center',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{player.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Invite players tab
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
>
|
||||
<InvitePlayersTab />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useArcadeSocket } from './useArcadeSocket'
|
||||
import {
|
||||
type UseOptimisticGameStateOptions,
|
||||
useOptimisticGameState,
|
||||
} from './useOptimisticGameState'
|
||||
import type { RetryState } from '@/lib/arcade/error-handling'
|
||||
|
||||
export interface UseArcadeSessionOptions<TState> extends UseOptimisticGameStateOptions<TState> {
|
||||
/**
|
||||
@@ -51,6 +52,11 @@ export interface UseArcadeSessionReturn<TState> {
|
||||
*/
|
||||
lastError: string | null
|
||||
|
||||
/**
|
||||
* Current retry state (for showing UI indicators)
|
||||
*/
|
||||
retryState: RetryState
|
||||
|
||||
/**
|
||||
* Send a game move (applies optimistically and sends to server)
|
||||
* Note: playerId must be provided by caller (not omitted)
|
||||
@@ -98,6 +104,14 @@ export function useArcadeSession<TState>(
|
||||
// Optimistic state management
|
||||
const optimistic = useOptimisticGameState<TState>(optimisticOptions)
|
||||
|
||||
// Track retry state (exposed to UI for indicators)
|
||||
const [retryState, setRetryState] = useState<RetryState>({
|
||||
isRetrying: false,
|
||||
retryCount: 0,
|
||||
move: null,
|
||||
timestamp: null,
|
||||
})
|
||||
|
||||
// WebSocket connection
|
||||
const {
|
||||
socket,
|
||||
@@ -111,18 +125,75 @@ export function useArcadeSession<TState>(
|
||||
},
|
||||
|
||||
onMoveAccepted: (data) => {
|
||||
const isRetry = retryState.move?.timestamp === data.move.timestamp
|
||||
console.log(
|
||||
`[AutoRetry] ACCEPTED move=${data.move.type} ts=${data.move.timestamp} isRetry=${isRetry} retryCount=${retryState.retryCount || 0}`
|
||||
)
|
||||
|
||||
// Check if this was a retried move
|
||||
if (isRetry && retryState.isRetrying) {
|
||||
console.log(
|
||||
`[AutoRetry] SUCCESS after ${retryState.retryCount} retries move=${data.move.type}`
|
||||
)
|
||||
// Clear retry state
|
||||
setRetryState({
|
||||
isRetrying: false,
|
||||
retryCount: 0,
|
||||
move: null,
|
||||
timestamp: null,
|
||||
})
|
||||
}
|
||||
optimistic.handleMoveAccepted(data.gameState as TState, data.version, data.move)
|
||||
},
|
||||
|
||||
onMoveRejected: (data) => {
|
||||
const isRetry = retryState.move?.timestamp === data.move.timestamp
|
||||
console.warn(
|
||||
`[AutoRetry] REJECTED move=${data.move.type} ts=${data.move.timestamp} isRetry=${isRetry} versionConflict=${!!data.versionConflict} error="${data.error}"`
|
||||
)
|
||||
|
||||
// For version conflicts, automatically retry the move
|
||||
if (data.versionConflict) {
|
||||
const retryCount = isRetry && retryState.isRetrying ? retryState.retryCount + 1 : 1
|
||||
|
||||
if (retryCount > 5) {
|
||||
console.error(`[AutoRetry] FAILED after 5 retries move=${data.move.type}`)
|
||||
// Clear retry state and show error
|
||||
setRetryState({
|
||||
isRetrying: false,
|
||||
retryCount: 0,
|
||||
move: null,
|
||||
timestamp: null,
|
||||
})
|
||||
optimistic.handleMoveRejected(data.error, data.move)
|
||||
return
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[AutoRetry] SCHEDULE_RETRY_${retryCount} room=${roomId || 'none'} move=${data.move.type} ts=${data.move.timestamp} delay=${10 * retryCount}ms`
|
||||
)
|
||||
|
||||
// Update retry state
|
||||
setRetryState({
|
||||
isRetrying: true,
|
||||
retryCount,
|
||||
move: data.move,
|
||||
timestamp: data.move.timestamp,
|
||||
})
|
||||
|
||||
// Wait a tiny bit for server state to propagate, then retry
|
||||
setTimeout(() => {
|
||||
console.warn(
|
||||
`[AutoRetry] SENDING_RETRY_${retryCount} move=${data.move.type} ts=${data.move.timestamp}`
|
||||
)
|
||||
socketSendMove(userId, data.move, roomId)
|
||||
}, 10)
|
||||
}, 10 * retryCount)
|
||||
|
||||
// Don't show error to user - we're handling it automatically
|
||||
return
|
||||
}
|
||||
|
||||
// Non-version-conflict errors: show to user
|
||||
optimistic.handleMoveRejected(data.error, data.move)
|
||||
},
|
||||
|
||||
@@ -186,6 +257,7 @@ export function useArcadeSession<TState>(
|
||||
connected,
|
||||
hasPendingMoves: optimistic.hasPendingMoves,
|
||||
lastError: optimistic.lastError,
|
||||
retryState,
|
||||
sendMove,
|
||||
exitSession,
|
||||
clearError: optimistic.clearError,
|
||||
|
||||
@@ -166,6 +166,7 @@ export function useOptimisticGameState<TState>(
|
||||
|
||||
const handleMoveRejected = useCallback((error: string, rejectedMove: GameMove) => {
|
||||
// Set the error for UI display
|
||||
console.warn(`[ErrorState] SET_ERROR error="${error}" move=${rejectedMove.type}`)
|
||||
setLastError(error)
|
||||
|
||||
// Remove the rejected move and all subsequent moves from pending queue
|
||||
@@ -186,6 +187,7 @@ export function useOptimisticGameState<TState>(
|
||||
}, [])
|
||||
|
||||
const syncWithServer = useCallback((newServerState: TState, newServerVersion: number) => {
|
||||
console.log(`[ErrorState] SYNC_WITH_SERVER version=${newServerVersion}`)
|
||||
setServerState(newServerState)
|
||||
setServerVersion(newServerVersion)
|
||||
// Clear pending moves on sync (new authoritative state from server)
|
||||
@@ -193,6 +195,7 @@ export function useOptimisticGameState<TState>(
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
console.log('[ErrorState] CLEAR_ERROR')
|
||||
setLastError(null)
|
||||
}, [])
|
||||
|
||||
|
||||
333
apps/web/src/lib/arcade/__tests__/room-invitations.test.ts
Normal file
333
apps/web/src/lib/arcade/__tests__/room-invitations.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { db, schema } from '@/db'
|
||||
import {
|
||||
createInvitation,
|
||||
getUserPendingInvitations,
|
||||
getInvitation,
|
||||
acceptInvitation,
|
||||
declineInvitation,
|
||||
cancelInvitation,
|
||||
} from '../room-invitations'
|
||||
import { createRoom } from '../room-manager'
|
||||
|
||||
/**
|
||||
* Room Invitations Unit Tests
|
||||
*
|
||||
* Tests the invitation system:
|
||||
* - Creating invitations
|
||||
* - Accepting/declining invitations
|
||||
* - Invitation status transitions
|
||||
*/
|
||||
|
||||
describe('Room Invitations', () => {
|
||||
let testUserId1: string
|
||||
let testUserId2: string
|
||||
let testGuestId1: string
|
||||
let testGuestId2: string
|
||||
let testRoomId: string
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test users
|
||||
testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning()
|
||||
const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning()
|
||||
|
||||
testUserId1 = user1.id
|
||||
testUserId2 = user2.id
|
||||
|
||||
// Create test room
|
||||
const room = await createRoom({
|
||||
name: 'Test Room',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Host User',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
testRoomId = room.id
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up invitations (should cascade, but be explicit)
|
||||
await db.delete(schema.roomInvitations).where(eq(schema.roomInvitations.roomId, testRoomId))
|
||||
|
||||
// Clean up room
|
||||
if (testRoomId) {
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId))
|
||||
}
|
||||
|
||||
// Clean up users
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId1))
|
||||
await db.delete(schema.users).where(eq(schema.users.id, testUserId2))
|
||||
})
|
||||
|
||||
describe('Creating Invitations', () => {
|
||||
it('creates a new invitation', async () => {
|
||||
const invitation = await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
expect(invitation).toBeDefined()
|
||||
expect(invitation.roomId).toBe(testRoomId)
|
||||
expect(invitation.userId).toBe(testUserId2)
|
||||
expect(invitation.status).toBe('pending')
|
||||
expect(invitation.invitationType).toBe('manual')
|
||||
})
|
||||
|
||||
it('updates existing invitation instead of creating duplicate', async () => {
|
||||
// Create first invitation
|
||||
const invitation1 = await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
message: 'First invite',
|
||||
})
|
||||
|
||||
// Create second invitation for same user/room
|
||||
const invitation2 = await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User Updated',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
message: 'Second invite',
|
||||
})
|
||||
|
||||
// Should have same ID (updated, not created)
|
||||
expect(invitation2.id).toBe(invitation1.id)
|
||||
expect(invitation2.message).toBe('Second invite')
|
||||
expect(invitation2.status).toBe('pending') // Reset to pending
|
||||
|
||||
// Verify only one invitation exists
|
||||
const allInvitations = await db
|
||||
.select()
|
||||
.from(schema.roomInvitations)
|
||||
.where(eq(schema.roomInvitations.userId, testUserId2))
|
||||
|
||||
expect(allInvitations).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accepting Invitations', () => {
|
||||
it('marks invitation as accepted', async () => {
|
||||
// Create invitation
|
||||
const invitation = await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
expect(invitation.status).toBe('pending')
|
||||
|
||||
// Accept invitation
|
||||
const accepted = await acceptInvitation(invitation.id)
|
||||
|
||||
expect(accepted.status).toBe('accepted')
|
||||
expect(accepted.respondedAt).toBeDefined()
|
||||
expect(accepted.respondedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('removes invitation from pending list after acceptance', async () => {
|
||||
// Create invitation
|
||||
await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Verify it's in pending list
|
||||
let pending = await getUserPendingInvitations(testUserId2)
|
||||
expect(pending).toHaveLength(1)
|
||||
|
||||
// Accept it
|
||||
await acceptInvitation(pending[0].id)
|
||||
|
||||
// Verify it's no longer in pending list
|
||||
pending = await getUserPendingInvitations(testUserId2)
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('BUG FIX: invitation stays pending if not explicitly accepted', async () => {
|
||||
// This test verifies the bug we fixed
|
||||
const invitation = await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Get invitation (simulating join API checking it)
|
||||
const retrieved = await getInvitation(testRoomId, testUserId2)
|
||||
expect(retrieved?.status).toBe('pending')
|
||||
|
||||
// WITHOUT calling acceptInvitation, invitation stays pending
|
||||
const stillPending = await getInvitation(testRoomId, testUserId2)
|
||||
expect(stillPending?.status).toBe('pending')
|
||||
|
||||
// This is the bug: user joined but invitation not marked accepted
|
||||
// Now verify the fix works
|
||||
await acceptInvitation(invitation.id)
|
||||
const nowAccepted = await getInvitation(testRoomId, testUserId2)
|
||||
expect(nowAccepted?.status).toBe('accepted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Declining Invitations', () => {
|
||||
it('marks invitation as declined', async () => {
|
||||
const invitation = await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
const declined = await declineInvitation(invitation.id)
|
||||
|
||||
expect(declined.status).toBe('declined')
|
||||
expect(declined.respondedAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('removes invitation from pending list after declining', async () => {
|
||||
const invitation = await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Decline
|
||||
await declineInvitation(invitation.id)
|
||||
|
||||
// Verify no longer pending
|
||||
const pending = await getUserPendingInvitations(testUserId2)
|
||||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canceling Invitations', () => {
|
||||
it('deletes invitation completely', async () => {
|
||||
await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest User',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host User',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Cancel
|
||||
await cancelInvitation(testRoomId, testUserId2)
|
||||
|
||||
// Verify deleted
|
||||
const invitation = await getInvitation(testRoomId, testUserId2)
|
||||
expect(invitation).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retrieving Invitations', () => {
|
||||
it('gets pending invitations for a user', async () => {
|
||||
// Create invitations for multiple rooms
|
||||
const room2 = await createRoom({
|
||||
name: 'Room 2',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Host',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
|
||||
await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
await createInvitation({
|
||||
roomId: room2.id,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
const pending = await getUserPendingInvitations(testUserId2)
|
||||
expect(pending).toHaveLength(2)
|
||||
|
||||
// Clean up
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
|
||||
})
|
||||
|
||||
it('only returns pending invitations, not accepted/declined', async () => {
|
||||
const inv1 = await createInvitation({
|
||||
roomId: testRoomId,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Create second room and invitation
|
||||
const room2 = await createRoom({
|
||||
name: 'Room 2',
|
||||
createdBy: testGuestId1,
|
||||
creatorName: 'Host',
|
||||
gameName: 'matching',
|
||||
gameConfig: {},
|
||||
accessMode: 'restricted',
|
||||
})
|
||||
|
||||
const inv2 = await createInvitation({
|
||||
roomId: room2.id,
|
||||
userId: testUserId2,
|
||||
userName: 'Guest',
|
||||
invitedBy: testUserId1,
|
||||
invitedByName: 'Host',
|
||||
invitationType: 'manual',
|
||||
})
|
||||
|
||||
// Accept first, decline second
|
||||
await acceptInvitation(inv1.id)
|
||||
await declineInvitation(inv2.id)
|
||||
|
||||
// Should have no pending
|
||||
const pending = await getUserPendingInvitations(testUserId2)
|
||||
expect(pending).toHaveLength(0)
|
||||
|
||||
// Clean up
|
||||
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
|
||||
})
|
||||
})
|
||||
})
|
||||
228
apps/web/src/lib/arcade/error-handling.ts
Normal file
228
apps/web/src/lib/arcade/error-handling.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Enhanced error handling system for arcade games
|
||||
* Provides user-friendly error messages, retry logic, and recovery suggestions
|
||||
*/
|
||||
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
|
||||
export type ErrorSeverity = 'info' | 'warning' | 'error' | 'fatal'
|
||||
export type ErrorCategory =
|
||||
| 'version-conflict'
|
||||
| 'permission'
|
||||
| 'validation'
|
||||
| 'network'
|
||||
| 'game-rule'
|
||||
| 'unknown'
|
||||
|
||||
export interface EnhancedError {
|
||||
category: ErrorCategory
|
||||
severity: ErrorSeverity
|
||||
userMessage: string // User-friendly message
|
||||
technicalMessage: string // For console/debugging
|
||||
autoRetry: boolean // Is this being auto-retried?
|
||||
retryCount?: number // Current retry attempt
|
||||
suggestion?: string // What the user should do
|
||||
recoverable: boolean // Can the user recover from this?
|
||||
}
|
||||
|
||||
export interface RetryState {
|
||||
isRetrying: boolean
|
||||
retryCount: number
|
||||
move: GameMove | null
|
||||
timestamp: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw error messages into enhanced error objects
|
||||
*/
|
||||
export function parseError(error: string, move?: GameMove, retryCount?: number): EnhancedError {
|
||||
// Version conflict errors
|
||||
if (error.includes('version conflict') || error.includes('Version conflict')) {
|
||||
return {
|
||||
category: 'version-conflict',
|
||||
severity: retryCount && retryCount > 3 ? 'warning' : 'info',
|
||||
userMessage:
|
||||
retryCount && retryCount > 2
|
||||
? 'Multiple players are making moves quickly, still syncing...'
|
||||
: 'Another player made a move, syncing...',
|
||||
technicalMessage: `Version conflict on ${move?.type || 'unknown move'}`,
|
||||
autoRetry: true,
|
||||
retryCount,
|
||||
suggestion:
|
||||
retryCount && retryCount > 4 ? 'Wait a moment for the game to stabilize' : undefined,
|
||||
recoverable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Permission errors (403)
|
||||
if (error.includes('Only the host') || error.includes('403')) {
|
||||
return {
|
||||
category: 'permission',
|
||||
severity: 'warning',
|
||||
userMessage: 'Only the room host can change this setting',
|
||||
technicalMessage: `403 Forbidden: ${error}`,
|
||||
autoRetry: false,
|
||||
suggestion: 'Ask the host to make this change',
|
||||
recoverable: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Network errors
|
||||
if (
|
||||
error.includes('network') ||
|
||||
error.includes('Network') ||
|
||||
error.includes('timeout') ||
|
||||
error.includes('fetch')
|
||||
) {
|
||||
return {
|
||||
category: 'network',
|
||||
severity: 'error',
|
||||
userMessage: 'Network connection issue',
|
||||
technicalMessage: error,
|
||||
autoRetry: false,
|
||||
suggestion: 'Check your internet connection',
|
||||
recoverable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Validation/game rule errors (from validator)
|
||||
if (
|
||||
error.includes('Invalid') ||
|
||||
error.includes('cannot') ||
|
||||
error.includes('not allowed') ||
|
||||
error.includes('must')
|
||||
) {
|
||||
return {
|
||||
category: 'game-rule',
|
||||
severity: 'warning',
|
||||
userMessage: error, // Validator messages are already user-friendly
|
||||
technicalMessage: error,
|
||||
autoRetry: false,
|
||||
recoverable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Not in room
|
||||
if (error.includes('not in this room')) {
|
||||
return {
|
||||
category: 'permission',
|
||||
severity: 'error',
|
||||
userMessage: 'You are not in this room',
|
||||
technicalMessage: error,
|
||||
autoRetry: false,
|
||||
suggestion: 'Rejoin the room to continue playing',
|
||||
recoverable: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown errors
|
||||
return {
|
||||
category: 'unknown',
|
||||
severity: 'error',
|
||||
userMessage: 'Something went wrong',
|
||||
technicalMessage: error,
|
||||
autoRetry: false,
|
||||
suggestion: 'Try refreshing the page',
|
||||
recoverable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly action name for a move type
|
||||
*/
|
||||
export function getMoveActionName(move: GameMove): string {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return 'starting game'
|
||||
case 'MAKE_MOVE':
|
||||
return 'moving piece'
|
||||
case 'DECLARE_HARMONY':
|
||||
return 'declaring harmony'
|
||||
case 'RESIGN':
|
||||
return 'resigning'
|
||||
case 'OFFER_DRAW':
|
||||
return 'offering draw'
|
||||
case 'ACCEPT_DRAW':
|
||||
return 'accepting draw'
|
||||
case 'CLAIM_REPETITION':
|
||||
return 'claiming repetition'
|
||||
case 'CLAIM_FIFTY_MOVE':
|
||||
return 'claiming fifty-move rule'
|
||||
case 'SET_CONFIG':
|
||||
return 'updating settings'
|
||||
case 'RESET_GAME':
|
||||
return 'resetting game'
|
||||
default:
|
||||
return 'performing action'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error should show a toast notification
|
||||
*/
|
||||
export function shouldShowToast(error: EnhancedError): boolean {
|
||||
// Version conflicts: only show toast after 2+ retries
|
||||
if (error.category === 'version-conflict') {
|
||||
return (error.retryCount ?? 0) >= 2
|
||||
}
|
||||
|
||||
// Permission errors: always show toast
|
||||
if (error.category === 'permission') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Network errors: always show toast
|
||||
if (error.category === 'network') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Game rule errors: show in banner, not toast
|
||||
if (error.category === 'game-rule') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Unknown errors: show toast
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error should show a banner (in-game persistent error display)
|
||||
*/
|
||||
export function shouldShowBanner(error: EnhancedError): boolean {
|
||||
// Version conflicts: only show banner if max retries exceeded
|
||||
if (error.category === 'version-conflict') {
|
||||
return (error.retryCount ?? 0) >= 5
|
||||
}
|
||||
|
||||
// Game rule errors: always show in banner
|
||||
if (error.category === 'game-rule') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fatal errors: show in banner
|
||||
if (error.severity === 'fatal') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Network errors after retry
|
||||
if (error.category === 'network') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get toast type from error severity
|
||||
*/
|
||||
export function getToastType(severity: ErrorSeverity): 'success' | 'error' | 'info' {
|
||||
switch (severity) {
|
||||
case 'info':
|
||||
return 'info'
|
||||
case 'warning':
|
||||
return 'error' // Use error styling for warnings
|
||||
case 'error':
|
||||
case 'fatal':
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
@@ -262,6 +262,17 @@ export async function applyGameMove(
|
||||
|
||||
if (!updatedSession) {
|
||||
// Version conflict - another move was processed first
|
||||
// Query the current state to see what version we're at now
|
||||
const [currentSession] = await db
|
||||
.select()
|
||||
.from(schema.arcadeSessions)
|
||||
.where(eq(schema.arcadeSessions.roomId, session.roomId))
|
||||
.limit(1)
|
||||
|
||||
const versionDiff = currentSession ? currentSession.version - session.version : 'unknown'
|
||||
console.warn(
|
||||
`[SessionManager] VERSION_CONFLICT room=${session.roomId} game=${session.currentGame} expected_v=${session.version} actual_v=${currentSession?.version} diff=${versionDiff} move=${move.type} user=${internalUserId || userId}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: 'Version conflict - please retry',
|
||||
|
||||
@@ -504,6 +504,11 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
await updateSessionActivity(data.userId)
|
||||
} else {
|
||||
// Send rejection only to the requesting socket
|
||||
if (result.versionConflict) {
|
||||
console.warn(
|
||||
`[SocketServer] VERSION_CONFLICT_REJECTED room=${data.roomId} move=${data.move.type} user=${data.userId} socket=${socket.id}`
|
||||
)
|
||||
}
|
||||
socket.emit('move-rejected', {
|
||||
error: result.error,
|
||||
move: data.move,
|
||||
|
||||
Reference in New Issue
Block a user