Improve Rithmomachia player roster handling

This commit is contained in:
Thomas Hallock 2025-10-29 17:03:30 -05:00
parent 8af3c4d7aa
commit e7e67a023d
2 changed files with 466 additions and 50 deletions

View File

@ -21,6 +21,14 @@ import type {
/**
* Context value for Rithmomachia game.
*/
export type RithmomachiaRosterStatus =
| { status: 'ok'; activePlayerCount: number; localPlayerCount: number }
| {
status: 'tooFew' | 'tooMany' | 'noLocalControl'
activePlayerCount: number
localPlayerCount: number
}
interface RithmomachiaContextValue {
// State
state: RithmomachiaState
@ -30,6 +38,11 @@ interface RithmomachiaContextValue {
viewerId: string | null
playerColor: Color | null
isMyTurn: boolean
rosterStatus: RithmomachiaRosterStatus
localActivePlayerIds: string[]
whitePlayerId: string | null
blackPlayerId: string | null
localTurnPlayerId: string | null
// Game actions
startGame: () => void
@ -92,13 +105,38 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get local player ID
const localPlayerId = useMemo(() => {
return Array.from(activePlayerIds).find((id) => {
const player = players.get(id)
return player?.isLocal !== false
})
}, [activePlayerIds, players])
const activePlayerList = useMemo(() => Array.from(activePlayerIds), [activePlayerIds])
const whitePlayerId = activePlayerList[0] ?? null
const blackPlayerId = activePlayerList[1] ?? null
const localActivePlayerIds = useMemo(
() =>
activePlayerList.filter((id) => {
const player = players.get(id)
return player?.isLocal !== false
}),
[activePlayerList, players]
)
const rosterStatus = useMemo<RithmomachiaRosterStatus>(() => {
const activeCount = activePlayerList.length
const localCount = localActivePlayerIds.length
if (activeCount < 2) {
return { status: 'tooFew', activePlayerCount: activeCount, localPlayerCount: localCount }
}
if (activeCount > 2) {
return { status: 'tooMany', activePlayerCount: activeCount, localPlayerCount: localCount }
}
if (localCount === 0) {
return { status: 'noLocalControl', activePlayerCount: activeCount, localPlayerCount: localCount }
}
return { status: 'ok', activePlayerCount: activeCount, localPlayerCount: localCount }
}, [activePlayerList, localActivePlayerIds])
// Merge saved config from room data
const mergedInitialState = useMemo(() => {
@ -129,33 +167,46 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
applyMove: (state) => state, // No optimistic updates for v1 - rely on server validation
})
// Determine player color (simplified: first player is White, second is Black)
const localTurnPlayerId = useMemo(() => {
const currentId = state.turn === 'W' ? whitePlayerId : blackPlayerId
if (!currentId) return null
return localActivePlayerIds.includes(currentId) ? currentId : null
}, [state.turn, whitePlayerId, blackPlayerId, localActivePlayerIds])
const playerColor = useMemo((): Color | null => {
if (!localPlayerId) return null
const playerIndex = Array.from(activePlayerIds).indexOf(localPlayerId)
return playerIndex === 0 ? 'W' : 'B'
}, [localPlayerId, activePlayerIds])
if (localTurnPlayerId) {
return state.turn
}
if (localActivePlayerIds.length === 1) {
const soleLocalId = localActivePlayerIds[0]
if (soleLocalId === whitePlayerId) return 'W'
if (soleLocalId === blackPlayerId) return 'B'
}
return null
}, [localTurnPlayerId, localActivePlayerIds, whitePlayerId, blackPlayerId, state.turn])
// Check if it's my turn
const isMyTurn = useMemo(() => {
if (!playerColor) return false
return state.turn === playerColor
}, [state.turn, playerColor])
if (rosterStatus.status !== 'ok') return false
return localTurnPlayerId !== null
}, [rosterStatus.status, localTurnPlayerId])
// Action: Start game
const startGame = useCallback(() => {
if (!viewerId || !localPlayerId) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'START_GAME',
playerId: localPlayerId,
playerId: localTurnPlayerId,
userId: viewerId,
data: {
playerColor: playerColor || 'W',
activePlayers: Array.from(activePlayerIds),
activePlayers: activePlayerList,
},
})
}, [sendMove, viewerId, localPlayerId, playerColor, activePlayerIds])
}, [sendMove, viewerId, localTurnPlayerId, playerColor, activePlayerList])
// Action: Make a move
const makeMove = useCallback(
@ -167,11 +218,11 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
capture?: CaptureData,
ambush?: AmbushContext
) => {
if (!viewerId || !localPlayerId) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'MOVE',
playerId: localPlayerId,
playerId: localTurnPlayerId,
userId: viewerId,
data: {
from,
@ -189,17 +240,17 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
},
})
},
[sendMove, viewerId, localPlayerId]
[sendMove, viewerId, localTurnPlayerId]
)
// Action: Declare harmony
const declareHarmony = useCallback(
(pieceIds: string[], harmonyType: HarmonyType, params: Record<string, string>) => {
if (!viewerId || !localPlayerId) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'DECLARE_HARMONY',
playerId: localPlayerId,
playerId: localTurnPlayerId,
userId: viewerId,
data: {
pieceIds,
@ -208,68 +259,68 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
},
})
},
[sendMove, viewerId, localPlayerId]
[sendMove, viewerId, localTurnPlayerId]
)
// Action: Resign
const resign = useCallback(() => {
if (!viewerId || !localPlayerId) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'RESIGN',
playerId: localPlayerId,
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
}, [sendMove, viewerId, localTurnPlayerId])
// Action: Offer draw
const offerDraw = useCallback(() => {
if (!viewerId || !localPlayerId) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'OFFER_DRAW',
playerId: localPlayerId,
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
}, [sendMove, viewerId, localTurnPlayerId])
// Action: Accept draw
const acceptDraw = useCallback(() => {
if (!viewerId || !localPlayerId) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'ACCEPT_DRAW',
playerId: localPlayerId,
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
}, [sendMove, viewerId, localTurnPlayerId])
// Action: Claim repetition
const claimRepetition = useCallback(() => {
if (!viewerId || !localPlayerId) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'CLAIM_REPETITION',
playerId: localPlayerId,
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
}, [sendMove, viewerId, localTurnPlayerId])
// Action: Claim fifty-move rule
const claimFiftyMove = useCallback(() => {
if (!viewerId || !localPlayerId) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'CLAIM_FIFTY_MOVE',
playerId: localPlayerId,
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localPlayerId])
}, [sendMove, viewerId, localTurnPlayerId])
// Action: Set config
const setConfig = useCallback(
@ -338,6 +389,11 @@ export function RithmomachiaProvider({ children }: { children: ReactNode }) {
viewerId: viewerId ?? null,
playerColor,
isMyTurn,
rosterStatus,
localActivePlayerIds,
whitePlayerId,
blackPlayerId,
localTurnPlayerId,
startGame,
makeMove,
declareHarmony,

View File

@ -3,10 +3,12 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { animated, to, useSpring } from '@react-spring/web'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { StandardGameLayout } from '@/components/StandardGameLayout'
import { Z_INDEX } from '@/constants/zIndex'
import { useGameMode } from '@/contexts/GameModeContext'
import type { Player } from '@/contexts/GameModeContext'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { css } from '../../../../styled-system/css'
import { useRithmomachia } from '../Provider'
@ -194,11 +196,346 @@ export function RithmomachiaGame() {
)
}
function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) {
const { rosterStatus, whitePlayerId, blackPlayerId } = useRithmomachia()
const { players: playerMap, activePlayers: activePlayerIds, addPlayer, setActive } = useGameMode()
const [showManager, setShowManager] = useState(false)
const playersArray = useMemo(() => {
const list = Array.from(playerMap.values())
return list.sort((a, b) => {
const aTime =
typeof a.createdAt === 'number'
? a.createdAt
: a.createdAt instanceof Date
? a.createdAt.getTime()
: 0
const bTime =
typeof b.createdAt === 'number'
? b.createdAt
: b.createdAt instanceof Date
? b.createdAt.getTime()
: 0
return aTime - bTime
})
}, [playerMap])
const activePlayerNames = useMemo(
() =>
playersArray
.filter((player) => activePlayerIds.has(player.id))
.map((player) =>
`${player.emoji} ${player.name}${player.isLocal === false ? ' (remote)' : ''}`
),
[playersArray, activePlayerIds]
)
const inactiveLocalPlayer = useMemo(
() =>
playersArray.find(
(player) => player.isLocal !== false && !activePlayerIds.has(player.id)
) || null,
[playersArray, activePlayerIds]
)
const removableLocalPlayer = useMemo(
() =>
playersArray.find(
(player) =>
player.isLocal !== false &&
activePlayerIds.has(player.id) &&
player.id !== whitePlayerId &&
player.id !== blackPlayerId
) || null,
[playersArray, activePlayerIds, whitePlayerId, blackPlayerId]
)
const quickFix = useMemo(() => {
if (rosterStatus.status === 'tooFew') {
if (inactiveLocalPlayer) {
return {
label: `Activate ${inactiveLocalPlayer.name}`,
action: () => setActive(inactiveLocalPlayer.id, true),
}
}
return {
label: 'Create local player',
action: () => addPlayer({ isActive: true }),
}
}
if (rosterStatus.status === 'noLocalControl') {
if (inactiveLocalPlayer) {
return {
label: `Activate ${inactiveLocalPlayer.name}`,
action: () => setActive(inactiveLocalPlayer.id, true),
}
}
return null
}
if (rosterStatus.status === 'tooMany' && removableLocalPlayer) {
return {
label: `Deactivate ${removableLocalPlayer.name}`,
action: () => setActive(removableLocalPlayer.id, false),
}
}
return null
}, [rosterStatus.status, inactiveLocalPlayer, removableLocalPlayer, addPlayer, setActive])
const heading = useMemo(() => {
switch (rosterStatus.status) {
case 'tooFew':
return 'Need two active players'
case 'tooMany':
return 'Too many active players'
case 'noLocalControl':
return 'Join the roster from this device'
default:
return ''
}
}, [rosterStatus.status])
const description = useMemo(() => {
switch (rosterStatus.status) {
case 'tooFew':
return phase === 'setup'
? 'Rithmomachia needs exactly two active players before the match can begin. Activate or add another player to continue.'
: 'Gameplay is paused until two players are active. Activate or add another player to resume the match.'
case 'tooMany':
return 'Rithmomachia supports only two active players. Deactivate extra players so each color has exactly one seat.'
case 'noLocalControl':
return phase === 'setup'
? 'All active seats belong to other devices. Activate a local player to control a side before starting.'
: 'All active seats belong to other devices. Activate a local player if you want to make moves from this computer.'
default:
return ''
}
}, [phase, rosterStatus.status])
if (rosterStatus.status === 'ok') {
return null
}
const details: string[] = []
if (activePlayerNames.length > 0) {
details.push(`Active seats: ${activePlayerNames.join(', ')}`)
} else {
details.push('No players are currently marked active.')
}
return (
<>
<div
className={css({
width: '100%',
borderWidth: '2px',
borderColor: 'amber.400',
backgroundColor: 'amber.50',
color: 'amber.900',
p: '4',
borderRadius: 'md',
display: 'flex',
flexDirection: 'column',
gap: '3',
})}
>
<div>
<h3 className={css({ fontWeight: 'bold', fontSize: 'lg' })}>{heading}</h3>
<p className={css({ fontSize: 'sm', lineHeight: '1.5', mt: '1' })}>{description}</p>
{details.map((detail) => (
<p key={detail} className={css({ fontSize: 'sm', color: 'amber.800', mt: '2' })}>
{detail}
</p>
))}
</div>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '2',
})}
>
{quickFix && (
<button
type="button"
onClick={quickFix.action}
className={css({
px: '3',
py: '2',
bg: 'amber.500',
color: 'white',
borderRadius: 'md',
fontWeight: 'semibold',
fontSize: 'sm',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: { bg: 'amber.600' },
})}
>
{quickFix.label}
</button>
)}
<button
type="button"
onClick={() => setShowManager((prev) => !prev)}
className={css({
px: '3',
py: '2',
bg: 'white',
color: 'amber.900',
borderRadius: 'md',
fontWeight: 'semibold',
fontSize: 'sm',
borderWidth: '1px',
borderColor: 'amber.300',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: { bg: 'amber.100' },
})}
>
{showManager ? 'Hide player manager' : 'Manage players'}
</button>
</div>
</div>
{showManager && (
<InlinePlayerManager
players={playersArray}
activePlayerIds={activePlayerIds}
onToggleActive={setActive}
onAddPlayer={() => addPlayer({ isActive: true })}
/>
)}
</>
)
}
function InlinePlayerManager({
players,
activePlayerIds,
onToggleActive,
onAddPlayer,
}: {
players: Player[]
activePlayerIds: Set<string>
onToggleActive: (playerId: string, active: boolean) => void
onAddPlayer: () => void
}) {
return (
<div
className={css({
width: '100%',
borderWidth: '1px',
borderColor: 'gray.200',
borderRadius: 'md',
backgroundColor: 'white',
boxShadow: '0 6px 16px rgba(15, 23, 42, 0.08)',
p: '4',
display: 'flex',
flexDirection: 'column',
gap: '3',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<h4 className={css({ fontWeight: 'bold', fontSize: 'sm', color: 'gray.800' })}>
Player seats
</h4>
<button
type="button"
onClick={onAddPlayer}
className={css({
px: '3',
py: '1',
bg: 'purple.600',
color: 'white',
borderRadius: 'md',
fontWeight: 'semibold',
fontSize: 'sm',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: { bg: 'purple.700' },
})}
>
Add local player
</button>
</div>
<ul
className={css({
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: '2',
})}
>
{players.map((player) => {
const isActive = activePlayerIds.has(player.id)
const isLocal = player.isLocal !== false
return (
<li
key={player.id}
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '3',
p: '3',
borderRadius: 'md',
borderWidth: '1px',
borderColor: isActive ? 'green.300' : 'gray.200',
backgroundColor: isActive ? 'green.50' : 'gray.50',
})}
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1' })}>
<span className={css({ fontWeight: 'semibold', color: 'gray.900' })}>
{player.emoji} {player.name}
</span>
<span className={css({ fontSize: 'xs', color: 'gray.600' })}>
{isLocal ? 'Local player' : 'Remote player'}
</span>
</div>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
fontSize: 'sm',
color: isLocal ? 'gray.800' : 'gray.500',
})}
>
<span>{isActive ? 'Active' : 'Inactive'}</span>
<input
type="checkbox"
checked={isActive}
disabled={!isLocal}
onChange={(event) => onToggleActive(player.id, event.target.checked)}
/>
</label>
</li>
)
})}
</ul>
</div>
)
}
/**
* Setup phase: game configuration and start button.
*/
function SetupPhase() {
const { state, startGame, setConfig, lastError, clearError } = useRithmomachia()
const { state, startGame, setConfig, lastError, clearError, rosterStatus } = useRithmomachia()
const startDisabled = rosterStatus.status !== 'ok'
const toggleSetting = (key: keyof typeof state) => {
if (typeof state[key] === 'boolean') {
@ -265,6 +602,8 @@ function SetupPhase() {
</p>
</div>
<RosterStatusNotice phase="setup" />
{/* Game Settings */}
<div
className={css({
@ -426,21 +765,25 @@ function SetupPhase() {
<button
type="button"
onClick={startGame}
disabled={startDisabled}
className={css({
px: '8',
py: '4',
bg: 'purple.600',
bg: startDisabled ? 'gray.400' : 'purple.600',
color: 'white',
borderRadius: 'lg',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
cursor: startDisabled ? 'not-allowed' : 'pointer',
opacity: startDisabled ? 0.7 : 1,
transition: 'all 0.2s ease',
_hover: {
bg: 'purple.700',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.4)',
},
_hover: startDisabled
? undefined
: {
bg: 'purple.700',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.4)',
},
})}
>
Start Game
@ -453,7 +796,7 @@ function SetupPhase() {
* Playing phase: main game board and controls.
*/
function PlayingPhase() {
const { state, isMyTurn, lastError, clearError } = useRithmomachia()
const { state, isMyTurn, lastError, clearError, rosterStatus } = useRithmomachia()
return (
<div
@ -498,6 +841,8 @@ function PlayingPhase() {
</div>
)}
<RosterStatusNotice phase="playing" />
<div
className={css({
display: 'flex',
@ -529,6 +874,21 @@ function PlayingPhase() {
Your Turn
</div>
)}
{!isMyTurn && rosterStatus.status === 'ok' && (
<div
className={css({
px: '3',
py: '1',
bg: 'gray.200',
color: 'gray.700',
borderRadius: 'md',
fontSize: 'sm',
fontWeight: 'semibold',
})}
>
Waiting for {state.turn === 'W' ? 'White' : 'Black'}
</div>
)}
</div>
<BoardDisplay />