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