Merge pull request #2 from antialias/codex/propose-solutions-for-player-turn-handling

This commit is contained in:
Thomas Hallock
2025-10-29 17:17:29 -05:00
committed by GitHub
2 changed files with 290 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,11 @@
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 { useFullscreen } from '@/contexts/FullscreenContext'
import { css } from '../../../../styled-system/css'
import { useRithmomachia } from '../Provider'
@@ -194,11 +195,171 @@ export function RithmomachiaGame() {
)
}
function RosterStatusNotice({ phase }: { phase: 'setup' | 'playing' }) {
const { rosterStatus, whitePlayerId, blackPlayerId } = useRithmomachia()
const { players: playerMap, activePlayers: activePlayerIds, addPlayer, setActive } = useGameMode()
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 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. Use the roster controls in the game nav to activate or add another player.'
: 'Gameplay is paused until two players are active. Use the roster controls in the game nav to activate or add another player and resume the match.'
case 'tooMany':
return 'Rithmomachia supports only two active players. Use the game nav roster to deactivate extras so each color has exactly one seat.'
case 'noLocalControl':
return phase === 'setup'
? 'All active seats belong to other devices. Activate a local player from the game nav if you want to start from this computer.'
: 'All active seats belong to other devices. Activate a local player in the game nav if you want to make moves from this computer.'
default:
return ''
}
}, [phase, rosterStatus.status])
if (rosterStatus.status === 'ok') {
return null
}
return (
<div
className={css({
width: '100%',
borderWidth: '2px',
borderColor: 'amber.400',
backgroundColor: 'amber.50',
color: 'amber.900',
p: '4',
borderRadius: 'md',
display: 'flex',
flexDirection: { base: 'column', md: 'row' },
gap: '3',
justifyContent: 'space-between',
alignItems: { base: 'flex-start', md: 'center' },
})}
>
<div>
<h3 className={css({ fontWeight: 'bold', fontSize: 'lg' })}>{heading}</h3>
<p className={css({ fontSize: 'sm', lineHeight: '1.5', mt: '1' })}>{description}</p>
</div>
{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' },
flexShrink: 0,
})}
>
{quickFix.label}
</button>
)}
</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 +426,8 @@ function SetupPhase() {
</p>
</div>
<RosterStatusNotice phase="setup" />
{/* Game Settings */}
<div
className={css({
@@ -426,21 +589,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 +620,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 +665,8 @@ function PlayingPhase() {
</div>
)}
<RosterStatusNotice phase="playing" />
<div
className={css({
display: 'flex',
@@ -529,6 +698,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 />