feat(complement-race): restore AI opponents in practice and survival modes

PROBLEM:
Practice Race and Survival Circuit modes had no AI opponents visible, even
though the config had enableAI: true and aiOpponentCount: 2.

ROOT CAUSE:
The Validator's validateStartGame method (line 208) was initializing
aiOpponents as an empty array and never actually generating them, even
when config.enableAI was true.

This was likely lost during the multiplayer migration when the code was
refactored from single-player to multiplayer architecture.

FIX:
1. Added generateAIOpponents() method (lines 782-808)
   - Creates AI opponents with names like "Robo-Racer", "Calculator", etc.
   - Assigns personality types (competitive/analytical)
   - Gives each AI a random speed multiplier (0.8-1.2)

2. Call generateAIOpponents in validateStartGame (lines 209-212)
   - Only generates AI when config.enableAI is true and aiOpponentCount > 0

3. Added updateAIOpponents() method (lines 810-840)
   - Updates AI positions during the game
   - Practice mode: AI moves forward based on speed (simulates answering)
   - Survival mode: AI continuously moves forward
   - Sprint mode: AI doesn't participate (train journey is single-player)

4. Call updateAIOpponents in validateSubmitAnswer (lines 367-373)
   - AI opponents progress each time a human answers a question

5. Updated checkWinCondition (lines 904-909, 970-976)
   - Practice mode: Check if any AI reaches position 100
   - Survival mode: Check if any AI has highest position when time expires

BEHAVIOR:
- Practice Race now shows 2 AI opponents racing alongside you
- Survival Circuit now has AI competitors to beat
- AI opponents move at slightly randomized speeds for variety
- AI can win the race if they reach the goal first

🤖 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 07:58:01 -05:00
parent 03262dbf40
commit 325e07de59

View File

@@ -205,6 +205,12 @@ 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,
@@ -218,6 +224,7 @@ export class ComplementRaceValidator
routeStartTime: state.config.style === 'sprint' ? Date.now() : null,
raceStartTime: Date.now(), // Race starts immediately
gameStartTime: Date.now(),
aiOpponents,
}
return { valid: true, newState }
@@ -356,6 +363,15 @@ 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) {
@@ -763,6 +779,66 @@ 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
@@ -819,11 +895,18 @@ 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
}
}
}
// Sprint mode: Check route-based, score-based, or time-based win conditions
@@ -872,15 +955,26 @@ 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 with highest position (most laps)
// Find player or AI 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
}
}
return winner
}
}