Compare commits

..

4 Commits

Author SHA1 Message Date
semantic-release-bot
45ff01e1fe chore(release): 4.6.2 [skip ci]
## [4.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.1...v4.6.2) (2025-10-18)

### Bug Fixes

* **build:** resolve Docker build failures preventing deployment ([7801dbb](7801dbb25f))
2025-10-18 13:13:31 +00:00
Thomas Hallock
7801dbb25f fix(build): resolve Docker build failures preventing deployment
Fixed two critical issues blocking deployment:

1. **TypeScript build failure**: Added @types/minimatch dependency and created
   proper tsconfig.json files for @soroban/core and @soroban/client packages.
   The DTS build was failing because TypeScript couldn't find the minimatch
   type definitions.

2. **Next.js prerendering error**: Fixed complement-race pages importing from
   wrong provider. Pages were using ./context/ComplementRaceContext but game
   components were using @/arcade-games/complement-race/Provider, causing
   "useComplementRace must be used within ComplementRaceProvider" errors
   during static page generation.

Deployment was blocked for 2 days. Container on NAS is from Oct 16th while
latest commits are from Oct 18th.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 08:12:41 -05:00
semantic-release-bot
10eb4df09c chore(release): 4.6.1 [skip ci]
## [4.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.0...v4.6.1) (2025-10-18)

### Code Refactoring

* **complement-race:** move AI opponents from server-side to client-side ([09e21fa](09e21fa493))
2025-10-18 13:08:37 +00:00
Thomas Hallock
09e21fa493 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>
2025-10-18 08:07:38 -05:00
13 changed files with 131 additions and 111 deletions

View File

@@ -1,3 +1,17 @@
## [4.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.1...v4.6.2) (2025-10-18)
### Bug Fixes
* **build:** resolve Docker build failures preventing deployment ([7801dbb](https://github.com/antialias/soroban-abacus-flashcards/commit/7801dbb25fb0a33429c70f11294264f7238ce7a4))
## [4.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.0...v4.6.1) (2025-10-18)
### Code Refactoring
* **complement-race:** move AI opponents from server-side to client-side ([09e21fa](https://github.com/antialias/soroban-abacus-flashcards/commit/09e21fa4934c634d0ce46381ef7e40238fc134c3))
## [4.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.5.0...v4.6.0) (2025-10-18)

View File

@@ -2,7 +2,7 @@
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from './components/ComplementRaceGame'
import { ComplementRaceProvider } from './context/ComplementRaceContext'
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
export default function ComplementRacePage() {
return (

View File

@@ -2,7 +2,7 @@
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from '../components/ComplementRaceGame'
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
export default function PracticeModePage() {
return (

View File

@@ -2,7 +2,7 @@
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from '../components/ComplementRaceGame'
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
export default function SprintModePage() {
return (

View File

@@ -2,7 +2,7 @@
import { PageWithNav } from '@/components/PageWithNav'
import { ComplementRaceGame } from '../components/ComplementRaceGame'
import { ComplementRaceProvider } from '../context/ComplementRaceContext'
import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider'
export default function SurvivalModePage() {
return (

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
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.6.0",
"version": "4.6.2",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [

View File

@@ -22,6 +22,7 @@
"python-shell": "^5.0.0"
},
"devDependencies": {
"@types/minimatch": "^6.0.0",
"@types/node": "^20.0.0",
"tsup": "^7.0.0",
"typescript": "^5.0.0",

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -29,6 +29,7 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@types/minimatch": "^6.0.0",
"@types/node": "^20.0.0",
"tsup": "^7.0.0",
"typescript": "^5.0.0",

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

6
pnpm-lock.yaml generated
View File

@@ -402,6 +402,9 @@ importers:
specifier: ^5.0.0
version: 5.0.0
devDependencies:
'@types/minimatch':
specifier: ^6.0.0
version: 6.0.0
'@types/node':
specifier: ^20.0.0
version: 20.19.19
@@ -417,6 +420,9 @@ importers:
packages/core/client/typescript:
devDependencies:
'@types/minimatch':
specifier: ^6.0.0
version: 6.0.0
'@types/node':
specifier: ^20.0.0
version: 20.19.19