feat(know-your-world): sync letter confirmation across multiplayer sessions

Add shared state for learning mode name confirmation:
- Add nameConfirmationProgress to KnowYourWorldState
- Add CONFIRM_LETTER move type for validating letter entry
- Implement validateConfirmLetter in Validator (looks up region name from ID)
- Add confirmLetter action to Provider context
- Update GameInfoPanel to use shared state with optimistic updates

Fixes race condition when typing fast by using an optimistic ref that
updates immediately before server responds, then syncs with server state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-30 11:45:41 -06:00
parent 285b128bb8
commit 655660f7cf
4 changed files with 162 additions and 12 deletions

View File

@ -13,6 +13,7 @@ import { buildPlayerOwnershipFromRoomData } from '@/lib/arcade/player-ownership.
import type { KnowYourWorldState, AssistanceLevel } from './types'
import type { RegionSize } from './maps'
import type { FeedbackType } from './utils/hotColdPhrases'
import { MusicProvider } from './music'
// Controls state for GameInfoPanel (set by MapRenderer, read by GameInfoPanel)
export interface ControlsState {
@ -98,6 +99,7 @@ interface KnowYourWorldContextValue {
giveUp: () => void
requestHint: () => void
returnToSetup: () => void
confirmLetter: (letter: string, letterIndex: number) => void
// Setup actions
setMap: (map: 'world' | 'usa') => void
@ -234,6 +236,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
giveUpVotes: [],
hintsUsed: 0,
hintActive: null,
nameConfirmationProgress: 0,
}
}, [roomData])
@ -377,6 +380,19 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
})
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
// Action: Confirm Letter (for learning mode name confirmation)
const confirmLetter = useCallback(
(letter: string, letterIndex: number) => {
sendMove({
type: 'CONFIRM_LETTER',
playerId: state.currentPlayer || activePlayers[0] || '',
userId: viewerId || '',
data: { letter, letterIndex },
})
},
[viewerId, sendMove, state.currentPlayer, activePlayers]
)
// Setup Action: Set Map
const setMap = useCallback(
(selectedMap: 'world' | 'usa') => {
@ -545,6 +561,12 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
>
}, [roomData?.memberPlayers])
// Music is active when game is in playing phase
const isMusicActive = state.gamePhase === 'playing'
// Pass celebration state to music provider (with type only)
const musicCelebration = celebration ? { type: celebration.type } : null
return (
<KnowYourWorldContext.Provider
value={{
@ -558,6 +580,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
endGame,
giveUp,
requestHint,
confirmLetter,
returnToSetup,
setMap,
setMode,
@ -577,7 +600,15 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
sharedContainerRef,
}}
>
{children}
<MusicProvider
isGameActive={isMusicActive}
currentRegionId={state.currentPrompt}
mapType={state.selectedMap}
hotColdFeedback={controlsState.hotColdFeedbackType}
celebration={musicCelebration}
>
{children}
</MusicProvider>
</KnowYourWorldContext.Provider>
)
}

View File

@ -51,6 +51,8 @@ export class KnowYourWorldValidator
return await this.validateGiveUp(state, move.playerId, move.userId)
case 'REQUEST_HINT':
return this.validateRequestHint(state, move.playerId, move.timestamp)
case 'CONFIRM_LETTER':
return await this.validateConfirmLetter(state, move.playerId, move.userId, move.data)
default:
return { valid: false, error: 'Unknown move type' }
}
@ -115,6 +117,7 @@ export class KnowYourWorldValidator
giveUpVotes: [],
hintsUsed: 0,
hintActive: null,
nameConfirmationProgress: 0, // Reset for new prompt
}
return { valid: true, newState }
@ -217,6 +220,7 @@ export class KnowYourWorldValidator
giveUpVotes: [], // Clear votes when moving to next region
hintActive: null, // Clear hint when moving to next region
activeUserIds,
nameConfirmationProgress: 0, // Reset for new prompt
}
return { valid: true, newState }
@ -301,6 +305,7 @@ export class KnowYourWorldValidator
giveUpVotes: [],
hintsUsed: 0,
hintActive: null,
nameConfirmationProgress: 0, // Reset for new round
}
return { valid: true, newState }
@ -423,6 +428,7 @@ export class KnowYourWorldValidator
startTime: 0,
endTime: undefined,
giveUpReveal: null,
nameConfirmationProgress: 0,
}
return { valid: true, newState }
@ -551,6 +557,7 @@ export class KnowYourWorldValidator
giveUpVotes: [], // Clear votes after give up is executed
hintActive: null, // Clear hint when moving to next region
activeUserIds,
nameConfirmationProgress: 0, // Reset for new prompt
}
return { valid: true, newState }
@ -590,6 +597,71 @@ export class KnowYourWorldValidator
return { valid: true, newState }
}
private async validateConfirmLetter(
state: KnowYourWorldState,
playerId: string,
userId: string,
data: { letter: string; letterIndex: number }
): Promise<ValidationResult> {
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only confirm letters during playing phase',
}
}
if (!state.currentPrompt) {
return { valid: false, error: 'No region to confirm' }
}
// For turn-based: check if it's this player's turn
if (state.gameMode === 'turn-based' && state.currentPlayer !== playerId) {
return { valid: false, error: 'Not your turn' }
}
const { letter, letterIndex } = data
// Check that letterIndex matches current progress
const currentProgress = state.nameConfirmationProgress ?? 0
if (letterIndex !== currentProgress) {
return {
valid: false,
error: `Expected letter index ${currentProgress}, got ${letterIndex}`,
}
}
// currentPrompt is actually the region ID (e.g., "ru"), not the name
// We need to look up the actual region name from the map data
const mapData = await getFilteredMapDataBySizesLazy(
state.selectedMap,
state.selectedContinent,
state.includeSizes
)
const region = mapData.regions.find((r) => r.id === state.currentPrompt)
if (!region) {
return { valid: false, error: 'Region not found in map data' }
}
const regionName = region.name
// Check if the letter matches
const expectedLetter = regionName[letterIndex]?.toLowerCase()
if (letter.toLowerCase() !== expectedLetter) {
// Wrong letter - don't advance progress (but move is still valid, just ignored)
return { valid: true, newState: state }
}
// Correct letter - advance progress
const activeUserIds = this.addUserIdIfNew(state.activeUserIds, userId)
const newState: KnowYourWorldState = {
...state,
nameConfirmationProgress: currentProgress + 1,
activeUserIds,
}
return { valid: true, newState }
}
isGameComplete(state: KnowYourWorldState): boolean {
return state.gamePhase === 'results'
}
@ -642,6 +714,7 @@ export class KnowYourWorldValidator
giveUpVotes: [],
hintsUsed: 0,
hintActive: null,
nameConfirmationProgress: 0,
}
}

View File

@ -81,7 +81,7 @@ export function GameInfoPanel({
}: GameInfoPanelProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { state, lastError, clearError, giveUp, controlsState, setIsInTakeover } =
const { state, lastError, clearError, giveUp, confirmLetter, controlsState, setIsInTakeover } =
useKnowYourWorld()
// Destructure controls state from context
@ -170,10 +170,14 @@ export function GameInfoPanel({
// Track if we're in the "attention grab" phase for the name display
const [isAttentionPhase, setIsAttentionPhase] = useState(false)
// Track name confirmation via keypress (count of confirmed letters)
const [confirmedLetterCount, setConfirmedLetterCount] = useState(0)
// Name confirmation progress from shared state (synced across sessions)
const confirmedLetterCount = state.nameConfirmationProgress ?? 0
const [nameConfirmed, setNameConfirmed] = useState(false)
// Optimistic letter count ref - prevents race conditions when typing fast
// This is updated immediately on keypress, before server responds
const optimisticLetterCountRef = useRef(confirmedLetterCount)
// Get assistance level config
const assistanceConfig = useMemo(() => {
return getAssistanceLevel(state.assistanceLevel)
@ -225,8 +229,15 @@ export function GameInfoPanel({
// During give up animation, suppress takeover (progress = 1 means no takeover)
if (isGiveUpAnimating) return 1
if (!isLearningMode || requiresNameConfirmation === 0) return 1
return Math.min(1, confirmedLetterCount / requiresNameConfirmation)
}, [isLearningMode, requiresNameConfirmation, confirmedLetterCount, isGiveUpAnimating])
const progress = Math.min(1, confirmedLetterCount / requiresNameConfirmation)
console.log('[GameInfoPanel] takeoverProgress:', {
confirmedLetterCount,
requiresNameConfirmation,
progress,
stateProgress: state.nameConfirmationProgress,
})
return progress
}, [isLearningMode, requiresNameConfirmation, confirmedLetterCount, isGiveUpAnimating, state.nameConfirmationProgress])
// Spring animation for scale only - position is handled by CSS centering
const takeoverSpring = useSpring({
@ -243,10 +254,10 @@ export function GameInfoPanel({
setIsInTakeover(isInTakeoverLocal)
}, [isInTakeoverLocal, setIsInTakeover])
// Reset name confirmation when region changes
// Reset local UI state when region changes
// Note: nameConfirmationProgress is reset on the server when prompt changes
useEffect(() => {
if (currentRegionId) {
setConfirmedLetterCount(0)
setNameConfirmed(false)
setIsAttentionPhase(true)
@ -271,7 +282,19 @@ export function GameInfoPanel({
}
}, [confirmedLetterCount, requiresNameConfirmation, nameConfirmed, onHintsUnlock])
// Sync optimistic ref with server state when it arrives
// This ensures we stay in sync if server rejects a move
useEffect(() => {
optimisticLetterCountRef.current = confirmedLetterCount
}, [confirmedLetterCount])
// Reset optimistic ref when region changes
useEffect(() => {
optimisticLetterCountRef.current = 0
}, [currentRegionName])
// Listen for keypresses to confirm letters (only when name confirmation is required)
// Dispatches to shared state so all multiplayer sessions see the same progress
useEffect(() => {
if (requiresNameConfirmation === 0 || nameConfirmed || !currentRegionName) {
return
@ -283,8 +306,8 @@ export function GameInfoPanel({
return
}
// Get the next expected letter
const nextLetterIndex = confirmedLetterCount
// Use optimistic count to prevent race conditions when typing fast
const nextLetterIndex = optimisticLetterCountRef.current
if (nextLetterIndex >= requiresNameConfirmation) {
return // Already confirmed all required letters
}
@ -295,7 +318,10 @@ export function GameInfoPanel({
// Only accept single character keys (letters and space)
if (pressedLetter.length === 1 && /[a-z ]/i.test(pressedLetter)) {
if (pressedLetter === expectedLetter) {
setConfirmedLetterCount((prev) => prev + 1)
// Optimistically advance count before server responds
optimisticLetterCountRef.current = nextLetterIndex + 1
// Dispatch to shared state - server validates and broadcasts to all sessions
confirmLetter(pressedLetter, nextLetterIndex)
}
// Ignore wrong characters silently (no feedback, no backspace needed)
}
@ -303,7 +329,12 @@ export function GameInfoPanel({
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [requiresNameConfirmation, nameConfirmed, currentRegionName, confirmedLetterCount])
}, [
requiresNameConfirmation,
nameConfirmed,
currentRegionName,
confirmLetter,
])
// Check if animation is in progress based on timestamp
useEffect(() => {

View File

@ -101,6 +101,11 @@ export interface KnowYourWorldState extends GameState {
regionId: string
timestamp: number // For animation timing
} | null
// Name confirmation progress (learning mode - type first N letters)
// Tracks how many letters have been confirmed for the current prompt
// Resets to 0 when currentPrompt changes
nameConfirmationProgress: number
}
// Move types
@ -209,3 +214,13 @@ export type KnowYourWorldMove =
timestamp: number
data: {}
}
| {
type: 'CONFIRM_LETTER'
playerId: string
userId: string
timestamp: number
data: {
letter: string // The letter being confirmed (lowercase)
letterIndex: number // Which letter position (0-indexed)
}
}