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:
Thomas Hallock
2025-10-30 16:45:10 -05:00
parent 7c1c2d7beb
commit 6ae4d13dc7
22 changed files with 3432 additions and 325 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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)
}, [])

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

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

View File

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

View File

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