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:
@@ -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':
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user