refactor(complement-race): move AI opponents from server-side to client-side

Migrates AI opponent system from server-side Validator to client-side Provider to align with original single-player implementation and enable sophisticated features.

Changes:
- Remove AI generation/updates from Validator (now returns empty aiOpponents array)
- Add clientAIRacers state to Provider (similar to clientMomentum/clientPosition)
- Initialize AI racers when game starts based on config.enableAI
- Handle UPDATE_AI_POSITIONS dispatch to update client-side AI state
- Map clientAIRacers to compatibleState.aiRacers for components to consume

This enables the existing useAIRacers hook to work properly with:
- Time-based position updates (200ms interval)
- Rubber-banding mechanic (AI speeds up 2x when >10 units behind)
- AI commentary system with personality-based messages
- Context detection and sound effects

AI wins are now detected client-side via useAIRacers hook instead of server-side Validator.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-18 08:07:22 -05:00
parent 0541c115c5
commit 09e21fa493
2 changed files with 82 additions and 106 deletions

View File

@@ -317,6 +317,19 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
const [clientPosition, setClientPosition] = useState(0)
const [clientPressure, setClientPressure] = useState(0)
const [clientAIRacers, setClientAIRacers] = useState<
Array<{
id: string
name: string
position: number
speed: number
personality: 'competitive' | 'analytical'
icon: string
lastComment: number
commentCooldown: number
previousPosition: number
}>
>([])
const lastUpdateRef = useRef(Date.now())
const gameStartTimeRef = useRef(0)
@@ -388,17 +401,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
raceGoal: multiplayerState.config.raceGoal,
timeLimit: multiplayerState.config.timeLimit ?? null,
speedMultiplier: 1.0,
aiRacers: multiplayerState.aiOpponents.map((ai) => ({
id: ai.id,
name: ai.name,
position: ai.position,
speed: ai.speed,
personality: ai.personality,
icon: ai.personality === 'competitive' ? '🏃‍♂️' : '🏃',
lastComment: ai.lastCommentTime,
commentCooldown: 0,
previousPosition: ai.position,
})),
aiRacers: clientAIRacers, // Use client-side AI state
// Sprint mode specific (all client-side for smooth movement)
momentum: clientMomentum, // Client-only state with continuous decay
@@ -425,7 +428,15 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
adaptiveFeedback: localUIState.adaptiveFeedback,
difficultyTracker: localUIState.difficultyTracker,
}
}, [multiplayerState, localPlayerId, localUIState, clientPosition, clientPressure])
}, [
multiplayerState,
localPlayerId,
localUIState,
clientPosition,
clientPressure,
clientMomentum,
clientAIRacers,
])
// Initialize game start time when game becomes active
useEffect(() => {
@@ -444,6 +455,41 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
}
}, [compatibleState.isGameActive, compatibleState.style])
// Initialize AI racers when game starts
useEffect(() => {
if (compatibleState.isGameActive && multiplayerState.config.enableAI) {
const count = multiplayerState.config.aiOpponentCount
if (count > 0 && clientAIRacers.length === 0) {
const aiNames = ['Robo-Racer', 'Calculator', 'Speed Demon', 'Brain Bot']
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
const newAI = []
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
newAI.push({
id: `ai-${i}`,
name: aiNames[i],
personality: personalities[i % personalities.length] as 'competitive' | 'analytical',
position: 0,
speed: 0.8 + Math.random() * 0.4, // Speed multiplier 0.8-1.2
icon: personalities[i % personalities.length] === 'competitive' ? '🏃‍♂️' : '🏃',
lastComment: 0,
commentCooldown: 0,
previousPosition: 0,
})
}
setClientAIRacers(newAI)
}
} else if (!compatibleState.isGameActive) {
// Clear AI when game ends
setClientAIRacers([])
}
}, [
compatibleState.isGameActive,
multiplayerState.config.enableAI,
multiplayerState.config.aiOpponentCount,
clientAIRacers.length,
])
// Main client-side game loop: momentum decay and position calculation
useEffect(() => {
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint') return
@@ -757,8 +803,27 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
})
break
}
case 'UPDATE_AI_POSITIONS': {
// Update client-side AI positions
if (action.positions && Array.isArray(action.positions)) {
setClientAIRacers((prevRacers) =>
prevRacers.map((racer) => {
const update = action.positions.find(
(p: { id: string; position: number }) => p.id === racer.id
)
return update
? {
...racer,
previousPosition: racer.position,
position: update.position,
}
: racer
})
)
}
break
}
// Other local actions that don't affect UI (can be ignored for now)
case 'UPDATE_AI_POSITIONS':
case 'UPDATE_MOMENTUM':
case 'UPDATE_TRAIN_POSITION':
case 'UPDATE_STEAM_JOURNEY':

View File

@@ -205,12 +205,6 @@ export class ComplementRaceValidator
}
}
// Generate AI opponents if enabled
const aiOpponents =
state.config.enableAI && state.config.aiOpponentCount > 0
? this.generateAIOpponents(state.config.aiOpponentCount)
: []
const newState: ComplementRaceState = {
...state,
config: updatedConfig,
@@ -224,7 +218,7 @@ export class ComplementRaceValidator
routeStartTime: state.config.style === 'sprint' ? Date.now() : null,
raceStartTime: Date.now(), // Race starts immediately
gameStartTime: Date.now(),
aiOpponents,
aiOpponents: [], // AI handled client-side
}
return { valid: true, newState }
@@ -363,15 +357,6 @@ export class ComplementRaceValidator
},
}
// Update AI opponents (make them progress at their speed)
if (state.config.enableAI && state.aiOpponents.length > 0) {
newState.aiOpponents = this.updateAIOpponents(
state.aiOpponents,
state.config.style,
state.config.raceGoal
)
}
// Check win conditions
const winner = this.checkWinCondition(newState)
if (winner) {
@@ -779,66 +764,6 @@ export class ComplementRaceValidator
return passengers
}
private generateAIOpponents(count: number): Array<{
id: string
name: string
personality: 'competitive' | 'analytical'
position: number
speed: number
lastComment: string | null
lastCommentTime: number
}> {
const aiNames = ['Robo-Racer', 'Calculator', 'Speed Demon', 'Brain Bot']
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
const opponents = []
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
opponents.push({
id: `ai-${i}`,
name: aiNames[i],
personality: personalities[i % personalities.length],
position: 0,
speed: 0.8 + Math.random() * 0.4, // Speed multiplier 0.8-1.2
lastComment: null,
lastCommentTime: 0,
})
}
return opponents
}
private updateAIOpponents(
opponents: Array<{
id: string
name: string
personality: 'competitive' | 'analytical'
position: number
speed: number
lastComment: string | null
lastCommentTime: number
}>,
gameStyle: 'practice' | 'sprint' | 'survival',
raceGoal: number
) {
return opponents.map((opponent) => {
let newPosition = opponent.position
if (gameStyle === 'practice') {
// AI moves forward based on their speed (simulates answering questions)
newPosition = Math.min(100, opponent.position + (100 / raceGoal) * opponent.speed)
} else if (gameStyle === 'survival') {
// AI always moves forward
newPosition = opponent.position + 4 * opponent.speed
}
// Sprint mode: AI doesn't participate (train journey is single-player focused)
return {
...opponent,
position: newPosition,
}
})
}
/**
* Calculate the maximum number of passengers that will be on the train
* concurrently at any given moment during the route
@@ -895,18 +820,12 @@ export class ComplementRaceValidator
// Practice mode: First to reach goal
if (config.style === 'practice') {
// Check human players
for (const [playerId, player] of Object.entries(players)) {
if (player.correctAnswers >= config.raceGoal) {
return playerId
}
}
// Check AI opponents
for (const ai of state.aiOpponents) {
if (ai.position >= 100) {
return ai.id
}
}
// AI wins handled client-side via useAIRacers hook
}
// Sprint mode: Check route-based, score-based, or time-based win conditions
@@ -955,25 +874,17 @@ export class ComplementRaceValidator
if (config.style === 'survival' && config.timeLimit) {
const elapsed = state.raceStartTime ? (Date.now() - state.raceStartTime) / 1000 : 0
if (elapsed >= config.timeLimit) {
// Find player or AI with highest position (most laps)
// Find player with highest position (most laps)
let maxPosition = 0
let winner: string | null = null
// Check human players
for (const [playerId, player] of Object.entries(players)) {
if (player.position > maxPosition) {
maxPosition = player.position
winner = playerId
}
}
// Check AI opponents
for (const ai of state.aiOpponents) {
if (ai.position > maxPosition) {
maxPosition = ai.position
winner = ai.id
}
}
// AI wins handled client-side via useAIRacers hook
return winner
}