Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b77ff78cfc | ||
|
|
9fb9786e54 | ||
|
|
72bb2eb58b | ||
|
|
55010d2bcd | ||
|
|
f735e5d3ba | ||
|
|
6e436db5e7 | ||
|
|
2953ef8917 | ||
|
|
7675e59868 | ||
|
|
357aa30618 | ||
|
|
22426f677f | ||
|
|
cf997b9cbc | ||
|
|
07d5607218 | ||
|
|
614a081ca6 | ||
|
|
71cdc342c9 | ||
|
|
214b9077ab | ||
|
|
76eb0517c2 | ||
|
|
820000f93b | ||
|
|
fa6b3b69d5 | ||
|
|
ca4ba6e2d7 | ||
|
|
ebfff1a62f | ||
|
|
ba04d7f491 | ||
|
|
054f0c0d23 | ||
|
|
45ff01e1fe | ||
|
|
7801dbb25f | ||
|
|
10eb4df09c | ||
|
|
09e21fa493 | ||
|
|
0541c115c5 | ||
|
|
325e07de59 | ||
|
|
03262dbf40 | ||
|
|
d8fdfeef74 | ||
|
|
005d945ca8 | ||
|
|
a6c20aab3b | ||
|
|
627ca68cff | ||
|
|
84d42e22ac | ||
|
|
37866ebb6d | ||
|
|
7030794fa1 | ||
|
|
ec1c8ed263 | ||
|
|
12f140d888 | ||
|
|
53bbae84af | ||
|
|
511636400c | ||
|
|
79db410b09 | ||
|
|
fedb32486a | ||
|
|
183494a22e | ||
|
|
325daeb0d9 | ||
|
|
7ed1b94b8f | ||
|
|
43f1f92900 | ||
|
|
5f146b0daf | ||
|
|
734da610b7 | ||
|
|
ea19ff918b | ||
|
|
ea1e548e61 | ||
|
|
d43829ad48 | ||
|
|
dbcedb7144 | ||
|
|
46a80cbcc8 | ||
|
|
5d89ad7ada | ||
|
|
30541304dd | ||
|
|
376c8eb901 | ||
|
|
66992e8770 | ||
|
|
52019a24c2 | ||
|
|
54b46e771e | ||
|
|
334a49c92e | ||
|
|
739e928c6e | ||
|
|
86af2fe902 | ||
|
|
60ce9c0eb1 | ||
|
|
230860b8a1 | ||
|
|
587203056a | ||
|
|
131c54b562 | ||
|
|
ed42651319 | ||
|
|
ed0ef2d3b8 |
227
CHANGELOG.md
227
CHANGELOG.md
@@ -1,3 +1,230 @@
|
||||
## [4.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.0...v4.7.1) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** remove number-guesser and math-sprint games ([9fb9786](https://github.com/antialias/soroban-abacus-flashcards/commit/9fb9786e54928e81ecf226b36d343a73143fb674))
|
||||
|
||||
## [4.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.10...v4.7.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** enable adaptive AI difficulty in arcade ([55010d2](https://github.com/antialias/soroban-abacus-flashcards/commit/55010d2bcd953718d8fea428b1f7f613a193779c))
|
||||
|
||||
## [4.6.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.9...v4.6.10) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** improve AI speech bubble positioning ([6e436db](https://github.com/antialias/soroban-abacus-flashcards/commit/6e436db5e709d944ebffed6936ea1f8e4bd2e19e))
|
||||
* **docker:** remove reference to deleted @soroban/client package ([2953ef8](https://github.com/antialias/soroban-abacus-flashcards/commit/2953ef8917f7b13f6eb562eb7d58d14179a718da))
|
||||
|
||||
## [4.6.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.8...v4.6.9) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add missing AI commentary cooldown updates ([357aa30](https://github.com/antialias/soroban-abacus-flashcards/commit/357aa30618f80d659ae515f94b7b9254bb458910))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove dead Python bridge and unused packages ([22426f6](https://github.com/antialias/soroban-abacus-flashcards/commit/22426f677f9b127441377b95571f0066a0990d3f))
|
||||
|
||||
## [4.6.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.7...v4.6.8) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** counter-flip AI speech bubbles to make text readable ([07d5607](https://github.com/antialias/soroban-abacus-flashcards/commit/07d5607218aee03e813eceff5d161a7838d66bcb))
|
||||
|
||||
## [4.6.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.6...v4.6.7) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use active local players pattern from navbar ([71cdc34](https://github.com/antialias/soroban-abacus-flashcards/commit/71cdc342c97ca53b5e7e4202d4d344199e8ddd98))
|
||||
|
||||
## [4.6.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.5...v4.6.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use local player emoji instead of first active player ([76eb051](https://github.com/antialias/soroban-abacus-flashcards/commit/76eb0517c202d1b9160b49dec0b99ff4972daff2))
|
||||
|
||||
## [4.6.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.4...v4.6.5) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** flip player avatar to face right in practice mode ([fa6b3b6](https://github.com/antialias/soroban-abacus-flashcards/commit/fa6b3b69d5a4a7eb70f8c18fc8c122c54c4d504a))
|
||||
|
||||
## [4.6.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.3...v4.6.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** flip AI racers to face right in practice mode ([ebfff1a](https://github.com/antialias/soroban-abacus-flashcards/commit/ebfff1a62fd104d531a8158345c8c012ec8a55d3))
|
||||
|
||||
## [4.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.2...v4.6.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** balance AI speeds to match original implementation ([054f0c0](https://github.com/antialias/soroban-abacus-flashcards/commit/054f0c0d235dc2b0042a0f6af48840d23a4c5ff8))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** restore AI opponents in practice and survival modes ([325e07d](https://github.com/antialias/soroban-abacus-flashcards/commit/325e07de5929169aa333ef16f7bca5b41eeb1622))
|
||||
|
||||
## [4.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.15...v4.5.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** add infinite win condition for Steam Sprint mode ([d8fdfee](https://github.com/antialias/soroban-abacus-flashcards/commit/d8fdfeef74a5d3bb9684254af1c9d64d264b46ad))
|
||||
|
||||
## [4.4.15](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.14...v4.4.15) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** track previous position to detect route threshold crossing ([a6c20aa](https://github.com/antialias/soroban-abacus-flashcards/commit/a6c20aab3b245d9893808d188d16a35ab80cfca9))
|
||||
|
||||
## [4.4.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.13...v4.4.14) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** remove dual game loop conflict preventing route progression ([84d42e2](https://github.com/antialias/soroban-abacus-flashcards/commit/84d42e22ac0cdd25e87e45dc698029ad7ed78559))
|
||||
|
||||
## [4.4.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.12...v4.4.13) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** show new passengers when route changes ([ec1c8ed](https://github.com/antialias/soroban-abacus-flashcards/commit/ec1c8ed263844f56477c1f709041339b42b48f4e))
|
||||
|
||||
## [4.4.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.11...v4.4.12) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** track physical car indices to prevent boarding issues ([53bbae8](https://github.com/antialias/soroban-abacus-flashcards/commit/53bbae84af7317d5e12109db2054cc70ca5bea27))
|
||||
* **complement-race:** update passenger display when state changes ([5116364](https://github.com/antialias/soroban-abacus-flashcards/commit/511636400c19776b58c6bddf8f7c9cc398a05236))
|
||||
|
||||
## [4.4.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.10...v4.4.11) (2025-10-17)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **logging:** replace per-frame debug logging with event-based logging ([fedb324](https://github.com/antialias/soroban-abacus-flashcards/commit/fedb32486ab5c6c619ebc03570b6c66529a1344e))
|
||||
|
||||
## [4.4.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.9...v4.4.10) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** correct passenger boarding to use multiplayer fields ([7ed1b94](https://github.com/antialias/soroban-abacus-flashcards/commit/7ed1b94b8fa620cb4f64ba43e160ef511704f3ce))
|
||||
|
||||
## [4.4.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.8...v4.4.9) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** reduce initial momentum from 50 to 10 to prevent train sailing past first station ([5f146b0](https://github.com/antialias/soroban-abacus-flashcards/commit/5f146b0daf74d54e1c7b9a57d3a2f37e73849ff2))
|
||||
|
||||
## [4.4.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.7...v4.4.8) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** implement client-side momentum with continuous decay for smooth train movement ([ea19ff9](https://github.com/antialias/soroban-abacus-flashcards/commit/ea19ff918bc70ad3eb0339e18dbd32195f34816e))
|
||||
|
||||
## [4.4.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.6...v4.4.7) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add missing useRef import ([d43829a](https://github.com/antialias/soroban-abacus-flashcards/commit/d43829ad48f7ee879a46879f5e6ac1256db1f564))
|
||||
|
||||
## [4.4.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.5...v4.4.6) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** restore smooth train movement with client-side game loop ([46a80cb](https://github.com/antialias/soroban-abacus-flashcards/commit/46a80cbcc8ec39224d4edaf540da25611d48fbdd))
|
||||
|
||||
## [4.4.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.4...v4.4.5) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add missing useEffect import ([3054130](https://github.com/antialias/soroban-abacus-flashcards/commit/30541304dd0f0801860dd62967f7f7cae717bcdd))
|
||||
|
||||
## [4.4.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.3...v4.4.4) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** add pressure decay system and improve logging ([66992e8](https://github.com/antialias/soroban-abacus-flashcards/commit/66992e877065a42d00379ef8fae0a6e252b0ffcb))
|
||||
|
||||
## [4.4.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.2...v4.4.3) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** train now moves in sprint mode ([54b46e7](https://github.com/antialias/soroban-abacus-flashcards/commit/54b46e771e654721e7fabb1f45ecd45daf8e447f))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* simplify train debug logs to strings only ([334a49c](https://github.com/antialias/soroban-abacus-flashcards/commit/334a49c92e112c852c483b5dbe3a3d0aef8a5c03))
|
||||
|
||||
## [4.4.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.1...v4.4.2) (2025-10-17)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **complement-race:** remove verbose logging, keep only train debug logs ([86af2fe](https://github.com/antialias/soroban-abacus-flashcards/commit/86af2fe902b3d3790b7b4659fdc698caed8e4dd9))
|
||||
|
||||
## [4.4.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.4.0...v4.4.1) (2025-10-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** clear input state on question transitions ([5872030](https://github.com/antialias/soroban-abacus-flashcards/commit/587203056a1e1692348805eb0de909d81d16e158))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **complement-race:** add Phase 9 for multiplayer visual features ([131c54b](https://github.com/antialias/soroban-abacus-flashcards/commit/131c54b5627ceeac7ca3653f683c32822a2007af))
|
||||
|
||||
## [4.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.3.1...v4.4.0) (2025-10-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** add mini app navigation bar ([ed0ef2d](https://github.com/antialias/soroban-abacus-flashcards/commit/ed0ef2d3b87324470d06b3246652967544caec26))
|
||||
|
||||
## [4.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.3.0...v4.3.1) (2025-10-16)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/core/client/node/package.json ./packages/core/client/node/
|
||||
COPY packages/core/client/typescript/package.json ./packages/core/client/typescript/
|
||||
COPY packages/abacus-react/package.json ./packages/abacus-react/
|
||||
COPY packages/templates/package.json ./packages/templates/
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Speed Complement Race - Multiplayer Migration Plan
|
||||
|
||||
**Status**: In Progress
|
||||
**Status**: Phase 1-8 Complete (70%) - **Multiplayer Visuals Remaining**
|
||||
**Created**: 2025-10-16
|
||||
**Updated**: 2025-10-16 (Post-Review)
|
||||
**Goal**: Migrate Speed Complement Race from standalone single-player game to modular multiplayer arcade room game
|
||||
|
||||
**Current State**: ✅ Backend/Server Complete | ⚠️ Frontend Needs Multiplayer UI
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
@@ -587,7 +590,7 @@ export default function ComplementRacePage({
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Testing & Validation ✓
|
||||
## Phase 8: Testing & Validation ⚠️ PENDING
|
||||
|
||||
### 8.1 Unit Tests
|
||||
- [ ] ComplementRaceValidator logic
|
||||
@@ -627,31 +630,751 @@ export default function ComplementRacePage({
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Multiplayer Visual Features ⚠️ REQUIRED FOR FULL SUPPORT
|
||||
|
||||
**Status**: Backend complete, frontend needs multiplayer visualization
|
||||
|
||||
**Goal**: Make multiplayer visible to players - currently only local player is shown on screen
|
||||
|
||||
### 9.1 Ghost Trains (Sprint Mode) 🚨 HIGH PRIORITY
|
||||
|
||||
**File**: `src/app/arcade/complement-race/components/SteamTrainJourney.tsx`
|
||||
|
||||
**Current State**: Only the local player's train is rendered
|
||||
|
||||
**Required Change**: Render all other players' trains with ghost effect
|
||||
|
||||
**Implementation** (Est: 2-3 hours):
|
||||
|
||||
```typescript
|
||||
// In SteamTrainJourney.tsx
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
|
||||
export function SteamTrainJourney() {
|
||||
const { state } = useComplementRace()
|
||||
const { localPlayerId } = useArcadeSession()
|
||||
|
||||
// Existing local player train (keep as-is)
|
||||
const localPlayer = state.players[localPlayerId]
|
||||
|
||||
return (
|
||||
<div className="steam-track">
|
||||
{/* Existing local player train - keep full opacity */}
|
||||
<Train
|
||||
position={localPlayer.position}
|
||||
momentum={localPlayer.momentum}
|
||||
passengers={localPlayer.claimedPassengers}
|
||||
color="blue"
|
||||
opacity={1.0}
|
||||
isLocalPlayer={true}
|
||||
/>
|
||||
|
||||
{/* NEW: Ghost trains for other players */}
|
||||
{Object.entries(state.players)
|
||||
.filter(([playerId]) => playerId !== localPlayerId)
|
||||
.map(([playerId, player]) => (
|
||||
<GhostTrain
|
||||
key={playerId}
|
||||
position={player.position}
|
||||
color={player.color}
|
||||
opacity={0.35}
|
||||
name={player.name}
|
||||
passengerCount={player.claimedPassengers?.length || 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Existing stations and passengers */}
|
||||
<Stations />
|
||||
<Passengers />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**New Component: GhostTrain**:
|
||||
|
||||
```typescript
|
||||
// src/app/arcade/complement-race/components/GhostTrain.tsx
|
||||
interface GhostTrainProps {
|
||||
position: number // 0-100%
|
||||
color: string // player color
|
||||
opacity: number // 0.35 for ghost effect
|
||||
name: string // player name
|
||||
passengerCount: number
|
||||
}
|
||||
|
||||
export function GhostTrain({ position, color, opacity, name, passengerCount }: GhostTrainProps) {
|
||||
return (
|
||||
<div
|
||||
className="ghost-train"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${position}%`,
|
||||
opacity,
|
||||
filter: 'blur(1px)', // subtle blur for ghost effect
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<div className={css({ fontSize: '2rem' })}>🚂</div>
|
||||
<div className={css({
|
||||
fontSize: '0.7rem',
|
||||
color,
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
{name}
|
||||
{passengerCount > 0 && ` (${passengerCount}👥)`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Visual Design**:
|
||||
- Local player: Full opacity (100%), vibrant colors, clear
|
||||
- Other players: 30-40% opacity, subtle blur, labeled with name
|
||||
- Show passenger count on ghost trains
|
||||
- No collision detection needed (trains pass through each other)
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Create GhostTrain component
|
||||
- [ ] Update SteamTrainJourney to render all players
|
||||
- [ ] Test with 2 players (local + 1 ghost)
|
||||
- [ ] Test with 4 players (local + 3 ghosts)
|
||||
- [ ] Verify position updates in real-time
|
||||
- [ ] Verify ghost effect (opacity, blur)
|
||||
|
||||
---
|
||||
|
||||
### 9.2 Multi-Lane Track (Practice Mode) 🚨 HIGH PRIORITY
|
||||
|
||||
**File**: `src/app/arcade/complement-race/components/LinearTrack.tsx`
|
||||
|
||||
**Current State**: Single horizontal lane showing only local player
|
||||
|
||||
**Required Change**: Stack 2-4 lanes vertically, one per player
|
||||
|
||||
**Implementation** (Est: 3-4 hours):
|
||||
|
||||
```typescript
|
||||
// In LinearTrack.tsx
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { useArcadeSession } from '@/lib/arcade/game-sdk'
|
||||
|
||||
export function LinearTrack() {
|
||||
const { state } = useComplementRace()
|
||||
const { localPlayerId } = useArcadeSession()
|
||||
|
||||
const players = Object.entries(state.players)
|
||||
const laneHeight = 120 // pixels per lane
|
||||
|
||||
return (
|
||||
<div className="track-container">
|
||||
{players.map(([playerId, player], index) => {
|
||||
const isLocalPlayer = playerId === localPlayerId
|
||||
|
||||
return (
|
||||
<Lane
|
||||
key={playerId}
|
||||
yOffset={index * laneHeight}
|
||||
isLocalPlayer={isLocalPlayer}
|
||||
>
|
||||
{/* Player racer */}
|
||||
<Racer
|
||||
position={player.position}
|
||||
color={player.color}
|
||||
name={player.name}
|
||||
opacity={isLocalPlayer ? 1.0 : 0.35}
|
||||
isLocalPlayer={isLocalPlayer}
|
||||
/>
|
||||
|
||||
{/* Track markers (start/finish) */}
|
||||
<StartLine />
|
||||
<FinishLine position={state.raceGoal} />
|
||||
|
||||
{/* Progress bar */}
|
||||
<ProgressBar
|
||||
progress={player.position}
|
||||
color={player.color}
|
||||
/>
|
||||
</Lane>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**New Component: Lane**:
|
||||
|
||||
```typescript
|
||||
// src/app/arcade/complement-race/components/Lane.tsx
|
||||
interface LaneProps {
|
||||
yOffset: number
|
||||
isLocalPlayer: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Lane({ yOffset, isLocalPlayer, children }: LaneProps) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
height: '120px',
|
||||
width: '100%',
|
||||
transform: `translateY(${yOffset}px)`,
|
||||
borderBottom: '2px dashed',
|
||||
borderColor: isLocalPlayer ? 'blue.500' : 'gray.300',
|
||||
backgroundColor: isLocalPlayer ? 'blue.50' : 'gray.50',
|
||||
transition: 'all 0.3s ease',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Layout Design**:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 🏁 [========🏃♂️=========> ] Player 1 │ ← Local player (highlighted)
|
||||
├──────────────────────────────────────────┤
|
||||
│ 🏁 [=======>🏃♀️ ] Player 2 │ ← Ghost (low opacity)
|
||||
├──────────────────────────────────────────┤
|
||||
│ 🏁 [===========>🤖 ] AI Bot 1 │ ← AI (low opacity)
|
||||
├──────────────────────────────────────────┤
|
||||
│ 🏁 [=====>🏃 ] Player 3 │ ← Ghost (low opacity)
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Each lane is color-coded per player
|
||||
- Local player's lane has brighter background
|
||||
- Progress bars show position clearly
|
||||
- Names/avatars next to each racer
|
||||
- Smooth position interpolation for animations
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Create Lane component
|
||||
- [ ] Create Racer component (or update existing)
|
||||
- [ ] Update LinearTrack to render multiple lanes
|
||||
- [ ] Test with 2 players
|
||||
- [ ] Test with 4 players (2 human + 2 AI)
|
||||
- [ ] Verify position updates synchronized
|
||||
- [ ] Verify local player lane is emphasized
|
||||
|
||||
---
|
||||
|
||||
### 9.3 Multiplayer Results Screen 🚨 HIGH PRIORITY
|
||||
|
||||
**File**: `src/app/arcade/complement-race/components/GameResults.tsx`
|
||||
|
||||
**Current State**: Shows only local player stats
|
||||
|
||||
**Required Change**: Show leaderboard with all players
|
||||
|
||||
**Implementation** (Est: 1-2 hours):
|
||||
|
||||
```typescript
|
||||
// In GameResults.tsx
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { useArcadeSession } from '@/lib/arcade/game-sdk'
|
||||
|
||||
export function GameResults() {
|
||||
const { state, playAgain } = useComplementRace()
|
||||
const { localPlayerId, isMultiplayer } = useArcadeSession()
|
||||
|
||||
// Calculate leaderboard
|
||||
const leaderboard = Object.entries(state.players)
|
||||
.map(([id, player]) => ({ id, ...player }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
const winner = leaderboard[0]
|
||||
const localPlayerRank = leaderboard.findIndex(p => p.id === localPlayerId) + 1
|
||||
|
||||
return (
|
||||
<div className="results-container">
|
||||
{/* Winner Announcement */}
|
||||
<div className="winner-banner">
|
||||
<h1>🏆 {winner.name} Wins!</h1>
|
||||
<p>{winner.score} points</p>
|
||||
</div>
|
||||
|
||||
{/* Full Leaderboard */}
|
||||
{isMultiplayer && (
|
||||
<div className="leaderboard">
|
||||
<h2>Final Standings</h2>
|
||||
{leaderboard.map((player, index) => (
|
||||
<LeaderboardRow
|
||||
key={player.id}
|
||||
rank={index + 1}
|
||||
player={player}
|
||||
isLocalPlayer={player.id === localPlayerId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Player Summary */}
|
||||
<div className="player-summary">
|
||||
<h3>Your Performance</h3>
|
||||
<StatCard label="Rank" value={`${localPlayerRank} / ${leaderboard.length}`} />
|
||||
<StatCard label="Score" value={state.players[localPlayerId].score} />
|
||||
<StatCard label="Accuracy" value={`${calculateAccuracy(state.players[localPlayerId])}%`} />
|
||||
<StatCard label="Best Streak" value={state.players[localPlayerId].bestStreak} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="actions">
|
||||
<Button onClick={playAgain}>Play Again</Button>
|
||||
<Button onClick={exitToLobby}>Back to Lobby</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**New Component: LeaderboardRow**:
|
||||
|
||||
```typescript
|
||||
interface LeaderboardRowProps {
|
||||
rank: number
|
||||
player: PlayerState
|
||||
isLocalPlayer: boolean
|
||||
}
|
||||
|
||||
export function LeaderboardRow({ rank, player, isLocalPlayer }: LeaderboardRowProps) {
|
||||
const medalEmoji = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
backgroundColor: isLocalPlayer ? 'blue.100' : 'white',
|
||||
borderLeft: isLocalPlayer ? '4px solid blue.500' : 'none',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<div className="rank">{medalEmoji || rank}</div>
|
||||
<div className="player-name">{player.name}</div>
|
||||
<div className="score">{player.score} pts</div>
|
||||
<div className="stats">
|
||||
{player.correctAnswers}/{player.totalQuestions} correct
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Update GameResults.tsx to show leaderboard
|
||||
- [ ] Create LeaderboardRow component
|
||||
- [ ] Add winner announcement
|
||||
- [ ] Highlight local player in leaderboard
|
||||
- [ ] Show individual stats per player
|
||||
- [ ] Test with 2 players
|
||||
- [ ] Test with 4 players
|
||||
- [ ] Verify "Play Again" works in multiplayer
|
||||
|
||||
---
|
||||
|
||||
### 9.4 Visual Lobby/Ready System ⚠️ MEDIUM PRIORITY
|
||||
|
||||
**File**: `src/app/arcade/complement-race/components/GameLobby.tsx` (NEW)
|
||||
|
||||
**Current State**: Game auto-starts, no visual ready check
|
||||
|
||||
**Required Change**: Show lobby with player list and ready indicators
|
||||
|
||||
**Implementation** (Est: 2-3 hours):
|
||||
|
||||
```typescript
|
||||
// NEW FILE: src/app/arcade/complement-race/components/GameLobby.tsx
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { useArcadeSession } from '@/lib/arcade/game-sdk'
|
||||
|
||||
export function GameLobby() {
|
||||
const { state, setReady } = useComplementRace()
|
||||
const { localPlayerId, isHost } = useArcadeSession()
|
||||
|
||||
const players = Object.entries(state.players)
|
||||
const allReady = players.every(([_, p]) => p.isReady)
|
||||
const canStart = players.length >= 1 && allReady
|
||||
|
||||
return (
|
||||
<div className="lobby-container">
|
||||
<h1>Waiting for Players...</h1>
|
||||
|
||||
{/* Player List */}
|
||||
<div className="player-list">
|
||||
{players.map(([playerId, player]) => (
|
||||
<PlayerCard
|
||||
key={playerId}
|
||||
player={player}
|
||||
isLocalPlayer={playerId === localPlayerId}
|
||||
isReady={player.isReady}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty slots */}
|
||||
{Array.from({ length: state.config.maxPlayers - players.length }).map((_, i) => (
|
||||
<EmptySlot key={`empty-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ready Toggle */}
|
||||
<div className="ready-controls">
|
||||
<Button
|
||||
onClick={() => setReady(!state.players[localPlayerId].isReady)}
|
||||
variant={state.players[localPlayerId].isReady ? 'success' : 'default'}
|
||||
>
|
||||
{state.players[localPlayerId].isReady ? '✓ Ready' : 'Ready Up'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Start Game (host only) */}
|
||||
{isHost && (
|
||||
<div className="host-controls">
|
||||
<Button
|
||||
onClick={startGame}
|
||||
disabled={!canStart}
|
||||
>
|
||||
{canStart ? 'Start Game' : 'Waiting for all players...'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countdown when starting */}
|
||||
{state.gamePhase === 'countdown' && (
|
||||
<Countdown seconds={state.countdownSeconds} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Component: PlayerCard**:
|
||||
|
||||
```typescript
|
||||
interface PlayerCardProps {
|
||||
player: PlayerState
|
||||
isLocalPlayer: boolean
|
||||
isReady: boolean
|
||||
}
|
||||
|
||||
export function PlayerCard({ player, isLocalPlayer, isReady }: PlayerCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: isLocalPlayer ? 'blue.100' : 'gray.100',
|
||||
border: isReady ? '2px solid green.500' : '2px solid gray.300',
|
||||
})}
|
||||
>
|
||||
<Avatar color={player.color} />
|
||||
<div className="player-info">
|
||||
<div className="name">
|
||||
{player.name}
|
||||
{isLocalPlayer && ' (You)'}
|
||||
</div>
|
||||
<div className="status">
|
||||
{isReady ? '✓ Ready' : '⏳ Not ready'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Integration**: Update Provider to handle lobby phase
|
||||
|
||||
```typescript
|
||||
// In Provider.tsx - add to context
|
||||
const setReady = useCallback((ready: boolean) => {
|
||||
emitMove({
|
||||
type: 'set-ready',
|
||||
ready,
|
||||
})
|
||||
}, [emitMove])
|
||||
|
||||
return (
|
||||
<ComplementRaceContext.Provider value={{
|
||||
state,
|
||||
// ... other methods
|
||||
setReady,
|
||||
}}>
|
||||
{children}
|
||||
</ComplementRaceContext.Provider>
|
||||
)
|
||||
```
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Create GameLobby.tsx component
|
||||
- [ ] Create PlayerCard component
|
||||
- [ ] Add setReady to Provider context
|
||||
- [ ] Update Validator to handle 'set-ready' move
|
||||
- [ ] Show lobby before game starts
|
||||
- [ ] Test ready/unready toggling
|
||||
- [ ] Test "Start Game" (host only)
|
||||
- [ ] Verify countdown before game starts
|
||||
|
||||
---
|
||||
|
||||
### 9.5 AI Opponents Display ⚠️ MEDIUM PRIORITY
|
||||
|
||||
**Current State**: AI opponents defined in types but not populated
|
||||
|
||||
**Files to Update**:
|
||||
1. `src/arcade-games/complement-race/Validator.ts` - AI logic
|
||||
2. Track components (LinearTrack, SteamTrainJourney) - AI rendering
|
||||
|
||||
**Implementation** (Est: 4-6 hours):
|
||||
|
||||
#### Step 1: Populate AI Opponents in Validator
|
||||
|
||||
```typescript
|
||||
// In Validator.ts - validateStartGame method
|
||||
validateStartGame(config: ComplementRaceConfig) {
|
||||
const humanPlayerCount = this.activePlayers.length
|
||||
const aiCount = config.enableAI
|
||||
? Math.min(config.aiOpponentCount, config.maxPlayers - humanPlayerCount)
|
||||
: 0
|
||||
|
||||
// Create AI players
|
||||
const aiOpponents: AIOpponent[] = []
|
||||
const aiPersonalities = ['speedy', 'steady', 'chaotic']
|
||||
|
||||
for (let i = 0; i < aiCount; i++) {
|
||||
const aiId = `ai-${i}`
|
||||
aiOpponents.push({
|
||||
id: aiId,
|
||||
name: `Bot ${i + 1}`,
|
||||
color: ['purple', 'orange', 'pink'][i],
|
||||
personality: aiPersonalities[i % aiPersonalities.length],
|
||||
difficulty: config.timeoutSetting,
|
||||
})
|
||||
|
||||
// Add to players map
|
||||
state.players[aiId] = {
|
||||
id: aiId,
|
||||
name: `Bot ${i + 1}`,
|
||||
score: 0,
|
||||
streak: 0,
|
||||
bestStreak: 0,
|
||||
position: 0,
|
||||
isReady: true, // AI always ready
|
||||
correctAnswers: 0,
|
||||
totalQuestions: 0,
|
||||
isAI: true,
|
||||
}
|
||||
}
|
||||
|
||||
state.aiOpponents = aiOpponents
|
||||
return state
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: Update AI Positions (per frame)
|
||||
|
||||
```typescript
|
||||
// In Validator.ts - new method
|
||||
updateAIPositions(state: ComplementRaceState, deltaTime: number) {
|
||||
state.aiOpponents.forEach((ai) => {
|
||||
const aiPlayer = state.players[ai.id]
|
||||
|
||||
// AI answers questions at interval based on difficulty
|
||||
const answerInterval = this.getAIAnswerInterval(ai.difficulty, ai.personality)
|
||||
const timeSinceLastAnswer = Date.now() - (aiPlayer.lastAnswerTime || 0)
|
||||
|
||||
if (timeSinceLastAnswer > answerInterval) {
|
||||
// AI answers question
|
||||
const correct = this.shouldAIAnswerCorrectly(ai.personality)
|
||||
|
||||
if (correct) {
|
||||
aiPlayer.score += 100
|
||||
aiPlayer.streak += 1
|
||||
aiPlayer.position += 1
|
||||
aiPlayer.correctAnswers += 1
|
||||
} else {
|
||||
aiPlayer.streak = 0
|
||||
}
|
||||
|
||||
aiPlayer.totalQuestions += 1
|
||||
aiPlayer.lastAnswerTime = Date.now()
|
||||
|
||||
// Generate new question for AI
|
||||
state.currentQuestions[ai.id] = this.generateQuestion(state.config)
|
||||
}
|
||||
})
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// Helper: AI answer timing based on difficulty
|
||||
private getAIAnswerInterval(difficulty: string, personality: string) {
|
||||
const baseInterval = {
|
||||
'preschool': 8000,
|
||||
'kindergarten': 6000,
|
||||
'relaxed': 5000,
|
||||
'slow': 4000,
|
||||
'normal': 3000,
|
||||
'fast': 2000,
|
||||
'expert': 1500,
|
||||
}[difficulty] || 3000
|
||||
|
||||
// Personality modifiers
|
||||
const modifier = {
|
||||
'speedy': 0.8, // 20% faster
|
||||
'steady': 1.0, // Normal
|
||||
'chaotic': 0.9 + Math.random() * 0.4, // Random 90-130%
|
||||
}[personality] || 1.0
|
||||
|
||||
return baseInterval * modifier
|
||||
}
|
||||
|
||||
// Helper: AI accuracy based on personality
|
||||
private shouldAIAnswerCorrectly(personality: string): boolean {
|
||||
const accuracy = {
|
||||
'speedy': 0.85, // Fast but less accurate
|
||||
'steady': 0.95, // Very accurate
|
||||
'chaotic': 0.70, // Unpredictable
|
||||
}[personality] || 0.85
|
||||
|
||||
return Math.random() < accuracy
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Render AI in UI
|
||||
|
||||
**Already handled by 9.1 and 9.2** - Since AI opponents are in `state.players`, they'll render automatically as ghost trains/lanes!
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Implement AI population in validateStartGame
|
||||
- [ ] Implement updateAIPositions logic
|
||||
- [ ] Add AI answer timing system
|
||||
- [ ] Add AI personality behaviors
|
||||
- [ ] Test with 1 human + 2 AI
|
||||
- [ ] Test with 2 human + 1 AI
|
||||
- [ ] Verify AI appears in results screen
|
||||
- [ ] Verify AI doesn't dominate human players
|
||||
|
||||
---
|
||||
|
||||
### 9.6 Event Feed (Optional Polish)
|
||||
|
||||
**Priority**: LOW (nice to have)
|
||||
|
||||
**File**: `src/app/arcade/complement-race/components/EventFeed.tsx` (NEW)
|
||||
|
||||
**Implementation** (Est: 3-4 hours):
|
||||
|
||||
```typescript
|
||||
// NEW FILE: EventFeed.tsx
|
||||
interface GameEvent {
|
||||
id: string
|
||||
type: 'passenger-claimed' | 'passenger-delivered' | 'wrong-answer' | 'overtake'
|
||||
playerId: string
|
||||
playerName: string
|
||||
playerColor: string
|
||||
timestamp: number
|
||||
data?: any
|
||||
}
|
||||
|
||||
export function EventFeed() {
|
||||
const [events, setEvents] = useState<GameEvent[]>([])
|
||||
|
||||
// Listen for game events
|
||||
useEffect(() => {
|
||||
// Subscribe to validator broadcasts
|
||||
socket.on('game:event', (event: GameEvent) => {
|
||||
setEvents((prev) => [event, ...prev].slice(0, 10)) // Keep last 10
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="event-feed">
|
||||
{events.map((event) => (
|
||||
<EventItem key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Checklist**:
|
||||
- [ ] Create EventFeed component
|
||||
- [ ] Update Validator to emit events
|
||||
- [ ] Add event types (claim, deliver, overtake)
|
||||
- [ ] Position feed in UI (corner overlay)
|
||||
- [ ] Auto-dismiss old events
|
||||
- [ ] Test with multiple players
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 Summary
|
||||
|
||||
**Total Estimated Time**: 15-20 hours
|
||||
|
||||
**Priority Breakdown**:
|
||||
- 🚨 **HIGH** (8-9 hours): Ghost trains, multi-lane track, results screen
|
||||
- ⚠️ **MEDIUM** (8-12 hours): Lobby system, AI opponents
|
||||
- ✅ **LOW** (3-4 hours): Event feed
|
||||
|
||||
**Completion Criteria**:
|
||||
- [ ] Can see all players' trains/positions in real-time
|
||||
- [ ] Multiplayer leaderboard shows all players
|
||||
- [ ] Lobby shows player list with ready indicators
|
||||
- [ ] AI opponents appear and compete
|
||||
- [ ] All animations smooth with multiple players
|
||||
- [ ] Zero visual glitches with 4 players
|
||||
|
||||
**Once Phase 9 is complete**:
|
||||
- Multiplayer will be FULLY functional
|
||||
- Overall implementation: 100% complete
|
||||
- Ready for Phase 8 (Testing & Validation)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Priority 1: Foundation (Days 1-2)
|
||||
### ✅ Priority 1: Foundation (COMPLETE)
|
||||
1. ✓ Define ComplementRaceGameConfig
|
||||
2. ✓ Disable debug logging
|
||||
3. ✓ Create ComplementRaceValidator skeleton
|
||||
4. ✓ Register in modular system
|
||||
|
||||
### Priority 2: Core Multiplayer (Days 3-5)
|
||||
### ✅ Priority 2: Core Multiplayer (COMPLETE)
|
||||
5. ✓ Implement validator methods
|
||||
6. ✓ Socket server integration
|
||||
7. ✓ Create RoomComplementRaceProvider
|
||||
7. ✓ Create RoomComplementRaceProvider (State Adapter Pattern)
|
||||
8. ✓ Update arcade room store
|
||||
|
||||
### Priority 3: UI Updates (Days 6-8)
|
||||
9. ✓ Add lobby/waiting phase
|
||||
10. ✓ Update track visualization for multiplayer
|
||||
11. ✓ Update settings UI
|
||||
12. ✓ Update results screen
|
||||
### ✅ Priority 3: Basic UI Integration (COMPLETE)
|
||||
9. ✓ Add navigation bar (PageWithNav)
|
||||
10. ✓ Update settings UI
|
||||
11. ✓ Config persistence
|
||||
12. ✓ Registry integration
|
||||
|
||||
### Priority 4: Polish & Test (Days 9-10)
|
||||
13. ✓ Write tests
|
||||
14. ✓ Manual testing
|
||||
15. ✓ Bug fixes
|
||||
16. ✓ Performance optimization
|
||||
### 🚨 Priority 4: Multiplayer Visuals (CRITICAL - NEXT)
|
||||
13. [ ] Ghost trains (Sprint Mode)
|
||||
14. [ ] Multi-lane track (Practice Mode)
|
||||
15. [ ] Multiplayer results screen
|
||||
16. [ ] Visual lobby with ready checks
|
||||
17. [ ] AI opponent display
|
||||
|
||||
### Priority 5: Testing & Polish (FINAL)
|
||||
18. [ ] Write tests (unit, integration, E2E)
|
||||
19. [ ] Manual testing with 2-4 players
|
||||
20. [ ] Bug fixes
|
||||
21. [ ] Performance optimization
|
||||
22. [ ] Event feed (optional)
|
||||
|
||||
---
|
||||
|
||||
@@ -682,29 +1405,61 @@ Use these as architectural reference:
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Complement Race appears in arcade room game selector
|
||||
- [ ] Can create room with complement-race
|
||||
- [ ] Multiple players can join and see each other
|
||||
- [ ] Settings persist across page refreshes
|
||||
- [ ] Real-time race progress updates work
|
||||
- [ ] All three modes work in multiplayer
|
||||
- [ ] AI opponents work with human players
|
||||
### ✅ Backend & Infrastructure (COMPLETE)
|
||||
- [x] Complement Race appears in arcade room game selector
|
||||
- [x] Can create room with complement-race
|
||||
- [x] Settings persist across page refreshes
|
||||
- [x] Socket server integration working
|
||||
- [x] Validator handles all game logic
|
||||
- [x] Zero TypeScript errors
|
||||
- [x] Pre-commit checks pass
|
||||
|
||||
### ⚠️ Multiplayer Visuals (IN PROGRESS - Phase 9)
|
||||
- [ ] **Sprint Mode**: Can see other players' trains (ghost effect)
|
||||
- [ ] **Practice Mode**: Multi-lane track shows all players
|
||||
- [ ] **Survival Mode**: Circular track with multiple players
|
||||
- [ ] Real-time position updates visible on screen
|
||||
- [ ] Multiplayer results screen shows full leaderboard
|
||||
- [ ] Visual lobby with player list and ready indicators
|
||||
- [ ] AI opponents visible in all game modes
|
||||
|
||||
### Testing & Polish (PENDING)
|
||||
- [ ] 2-player multiplayer test (all 3 modes)
|
||||
- [ ] 4-player multiplayer test (all 3 modes)
|
||||
- [ ] AI + human players test
|
||||
- [ ] Single-player mode still works (backward compat)
|
||||
- [ ] All animations and sounds intact
|
||||
- [ ] Zero TypeScript errors
|
||||
- [ ] Pre-commit checks pass
|
||||
- [ ] No console errors in production
|
||||
- [ ] Smooth performance with 4 players
|
||||
- [ ] Event feed for competitive tension (optional)
|
||||
|
||||
### Current Status: 70% Complete
|
||||
**What Works**: Backend, state management, config persistence, navigation
|
||||
**What's Missing**: Multiplayer visualization (ghost trains, multi-lane tracks, lobby UI)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Start with Phase 1: Configuration & Types
|
||||
2. Move to Phase 2: Validator skeleton
|
||||
3. Test each phase before moving to next
|
||||
4. Deploy to staging environment early
|
||||
5. Get user feedback on multiplayer mechanics
|
||||
**Immediate Priority**: Phase 9 - Multiplayer Visual Features
|
||||
|
||||
### Quick Wins (Do These First)
|
||||
1. **Ghost Trains** (2-3 hours) - Make Sprint mode multiplayer visible
|
||||
2. **Multi-Lane Track** (3-4 hours) - Make Practice mode multiplayer visible
|
||||
3. **Results Screen** (1-2 hours) - Show full leaderboard
|
||||
|
||||
### After Quick Wins
|
||||
4. **Visual Lobby** (2-3 hours) - Add ready check system
|
||||
5. **AI Opponents** (4-6 hours) - Populate and display AI players
|
||||
|
||||
### Then Testing
|
||||
6. Manual testing with 2+ players
|
||||
7. Bug fixes and polish
|
||||
8. Unit/integration tests
|
||||
9. Performance optimization
|
||||
|
||||
---
|
||||
|
||||
**Let's ship it! 🚀**
|
||||
**Current State**: Backend is rock-solid. Now we need to make multiplayer **visible** to players! 🎮
|
||||
|
||||
**Let's complete multiplayer support! 🚀**
|
||||
|
||||
508
apps/web/.claude/COMPLEMENT_RACE_MULTIPLAYER_REVIEW.md
Normal file
508
apps/web/.claude/COMPLEMENT_RACE_MULTIPLAYER_REVIEW.md
Normal file
@@ -0,0 +1,508 @@
|
||||
# Complement Race Multiplayer Implementation Review
|
||||
|
||||
**Date**: 2025-10-16
|
||||
**Reviewer**: Comprehensive analysis comparing migration plan vs actual implementation
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
✅ **Core Architecture**: CORRECT - Uses proper useArcadeSession pattern
|
||||
✅ **Validator Implementation**: COMPLETE - All game logic implemented
|
||||
✅ **State Management**: CORRECT - Proper state adapter for UI compatibility
|
||||
⚠️ **Multiplayer Features**: PARTIALLY IMPLEMENTED - Core structure present, some features need completion
|
||||
❌ **Visual Multiplayer**: MISSING - Ghost trains, multi-lane tracks not yet implemented
|
||||
|
||||
**Overall Status**: **70% Complete** - Solid foundation, needs visual multiplayer features
|
||||
|
||||
---
|
||||
|
||||
## Phase-by-Phase Assessment
|
||||
|
||||
### Phase 1: Configuration & Type System ✅ COMPLETE
|
||||
|
||||
**Plan Requirements**:
|
||||
- Define ComplementRaceGameConfig
|
||||
- Disable debug logging
|
||||
- Set up type system
|
||||
|
||||
**Actual Implementation**:
|
||||
```typescript
|
||||
// ✅ CORRECT: Full config interface in types.ts
|
||||
export interface ComplementRaceConfig {
|
||||
style: 'practice' | 'sprint' | 'survival'
|
||||
mode: 'friends5' | 'friends10' | 'mixed'
|
||||
complementDisplay: 'number' | 'abacus' | 'random'
|
||||
timeoutSetting: 'preschool' | ... | 'expert'
|
||||
enableAI: boolean
|
||||
aiOpponentCount: number
|
||||
maxPlayers: number
|
||||
routeDuration: number
|
||||
enablePassengers: boolean
|
||||
passengerCount: number
|
||||
maxConcurrentPassengers: number
|
||||
raceGoal: number
|
||||
winCondition: 'route-based' | 'score-based' | 'time-based'
|
||||
routeCount: number
|
||||
targetScore: number
|
||||
timeLimit: number
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Debug logging disabled** (DEBUG_PASSENGER_BOARDING = false)
|
||||
✅ **DEFAULT_COMPLEMENT_RACE_CONFIG defined** in game-configs.ts
|
||||
✅ **All types properly defined** in types.ts
|
||||
|
||||
**Grade**: ✅ A+ - Exceeds requirements
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Validator Implementation ✅ COMPLETE
|
||||
|
||||
**Plan Requirements**:
|
||||
- Create ComplementRaceValidator class
|
||||
- Implement all move validation methods
|
||||
- Handle scoring, questions, and game state
|
||||
|
||||
**Actual Implementation**:
|
||||
|
||||
**✅ All Required Methods Implemented**:
|
||||
- `validateStartGame` - Initialize multiplayer game
|
||||
- `validateSubmitAnswer` - Validate answers, update scores
|
||||
- `validateClaimPassenger` - Sprint mode passenger pickup
|
||||
- `validateDeliverPassenger` - Sprint mode passenger delivery
|
||||
- `validateSetReady` - Lobby ready system
|
||||
- `validateSetConfig` - Host-only config changes
|
||||
- `validateStartNewRoute` - Route transitions
|
||||
- `validateNextQuestion` - Generate new questions
|
||||
- `validateEndGame` - Finish game
|
||||
- `validatePlayAgain` - Restart
|
||||
|
||||
**✅ Helper Methods**:
|
||||
- `generateQuestion` - Random question generation
|
||||
- `calculateAnswerScore` - Scoring with speed/streak bonuses
|
||||
- `generatePassengers` - Sprint mode passenger spawning
|
||||
- `checkWinCondition` - All three win conditions (practice, sprint, survival)
|
||||
- `calculateLeaderboard` - Sort players by score
|
||||
|
||||
**✅ State Structure** matches plan:
|
||||
```typescript
|
||||
interface ComplementRaceState {
|
||||
config: ComplementRaceConfig ✅
|
||||
gamePhase: 'setup' | 'lobby' | 'countdown' | 'playing' | 'results' ✅
|
||||
activePlayers: string[] ✅
|
||||
playerMetadata: Record<string, {...}> ✅
|
||||
players: Record<playerId, PlayerState> ✅
|
||||
currentQuestions: Record<playerId, ComplementQuestion> ✅
|
||||
passengers: Passenger[] ✅
|
||||
stations: Station[] ✅
|
||||
// ... timing, race state, etc.
|
||||
}
|
||||
```
|
||||
|
||||
**Grade**: ✅ A - Fully functional
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Socket Server Integration ✅ COMPLETE
|
||||
|
||||
**Plan Requirements**:
|
||||
- Register in validators.ts
|
||||
- Socket event handling
|
||||
- Real-time synchronization
|
||||
|
||||
**Actual Implementation**:
|
||||
|
||||
✅ **Registered in validators.ts**:
|
||||
```typescript
|
||||
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
|
||||
|
||||
export const VALIDATORS = {
|
||||
matching: matchingGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
'complement-race': complementRaceValidator, // ✅ CORRECT
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Registered in game-registry.ts**:
|
||||
```typescript
|
||||
import { complementRaceGame } from '@/arcade-games/complement-race'
|
||||
|
||||
const GAME_REGISTRY = {
|
||||
matching: matchingGame,
|
||||
'number-guesser': numberGuesserGame,
|
||||
'complement-race': complementRaceGame, // ✅ CORRECT
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Uses standard useArcadeSession pattern** - Socket integration automatic via SDK
|
||||
|
||||
**Grade**: ✅ A - Proper integration
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Room Provider & Configuration ✅ COMPLETE (with adaptation)
|
||||
|
||||
**Plan Requirement**: Create RoomComplementRaceProvider with socket sync
|
||||
|
||||
**Actual Implementation**: **State Adapter Pattern** (Better Solution!)
|
||||
|
||||
Instead of creating a separate RoomProvider, we:
|
||||
1. ✅ Used standard **useArcadeSession** pattern in Provider.tsx
|
||||
2. ✅ Created **state transformation layer** to bridge multiplayer ↔ single-player UI
|
||||
3. ✅ Preserved ALL existing UI components without changes
|
||||
4. ✅ Config merging from roomData works correctly
|
||||
|
||||
**Key Innovation**:
|
||||
```typescript
|
||||
// Transform multiplayer state to look like single-player state
|
||||
const compatibleState = useMemo((): CompatibleGameState => {
|
||||
const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null
|
||||
|
||||
return {
|
||||
// Extract local player's data
|
||||
currentQuestion: multiplayerState.currentQuestions[localPlayerId],
|
||||
score: localPlayer?.score || 0,
|
||||
streak: localPlayer?.streak || 0,
|
||||
// ... etc
|
||||
}
|
||||
}, [multiplayerState, localPlayerId])
|
||||
```
|
||||
|
||||
This is **better than the plan** because:
|
||||
- No code duplication
|
||||
- Reuses existing components
|
||||
- Clean separation of concerns
|
||||
- Easy to maintain
|
||||
|
||||
**Grade**: ✅ A+ - Superior solution
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Multiplayer Game Logic ⚠️ PARTIALLY COMPLETE
|
||||
|
||||
**Plan Requirements** vs **Implementation**:
|
||||
|
||||
#### 5.1 Sprint Mode: Passenger Rush ✅ IMPLEMENTED
|
||||
- ✅ Shared passenger pool (all players see same passengers)
|
||||
- ✅ First-come-first-served claiming (`claimedBy` field)
|
||||
- ✅ Delivery points (10 regular, 20 urgent)
|
||||
- ✅ Capacity limits (maxConcurrentPassengers)
|
||||
- ❌ **MISSING**: Ghost train visualization (30-40% opacity)
|
||||
- ❌ **MISSING**: Real-time "race for passenger" alerts
|
||||
|
||||
**Status**: **Server logic complete, visual features missing**
|
||||
|
||||
#### 5.2 Practice Mode: Simultaneous Questions ⚠️ NEEDS WORK
|
||||
- ✅ Question generation per player works
|
||||
- ✅ Answer validation works
|
||||
- ✅ Position tracking works
|
||||
- ❌ **MISSING**: Multi-lane track visualization
|
||||
- ❌ **MISSING**: "First correct answer" bonus logic
|
||||
- ❌ **MISSING**: Visual feedback for other players answering
|
||||
|
||||
**Status**: **Backend works, frontend needs multiplayer UI**
|
||||
|
||||
#### 5.3 Survival Mode ⚠️ NEEDS WORK
|
||||
- ✅ Position/lap tracking logic exists
|
||||
- ❌ **MISSING**: Circular track with multiple players
|
||||
- ❌ **MISSING**: Lap counter display
|
||||
- ❌ **MISSING**: Time limit enforcement
|
||||
|
||||
**Status**: **Basic structure, needs multiplayer visuals**
|
||||
|
||||
#### 5.4 AI Opponent Scaling ❌ NOT IMPLEMENTED
|
||||
- ❌ AI opponents defined in types but not populated
|
||||
- ❌ No AI update logic in validator
|
||||
- ❌ `aiOpponents` array stays empty
|
||||
|
||||
**Status**: **Needs implementation**
|
||||
|
||||
#### 5.5 Live Updates & Broadcasts ❌ NOT IMPLEMENTED
|
||||
- ❌ No event feed component
|
||||
- ❌ No "race for passenger" alerts
|
||||
- ❌ No live leaderboard overlay
|
||||
- ❌ No player action announcements
|
||||
|
||||
**Status**: **Needs implementation**
|
||||
|
||||
**Phase 5 Grade**: ⚠️ C+ - Core logic works, visual features missing
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: UI Updates for Multiplayer ❌ MOSTLY MISSING
|
||||
|
||||
**Plan Requirements** vs **Implementation**:
|
||||
|
||||
#### 6.1 Track Visualization ❌ NOT UPDATED
|
||||
- ❌ Practice: No multi-lane track (still shows single player)
|
||||
- ❌ Sprint: No ghost trains (only local train visible)
|
||||
- ❌ Survival: No multi-player circular track
|
||||
|
||||
**Current State**: UI still shows **single-player view only**
|
||||
|
||||
#### 6.2 Settings UI ✅ COMPLETE
|
||||
- ✅ GameControls.tsx has all settings
|
||||
- ✅ Max players, AI settings, game mode all configurable
|
||||
- ✅ Settings persist via arcade room store
|
||||
|
||||
#### 6.3 Lobby/Waiting Room ⚠️ PARTIAL
|
||||
- ⚠️ Uses "controls" phase as lobby (functional but not ideal)
|
||||
- ❌ No visual "ready check" system
|
||||
- ❌ No player list with ready indicators
|
||||
- ❌ Auto-starts game immediately instead of countdown
|
||||
|
||||
**Should Add**: Proper lobby phase with visual ready checks
|
||||
|
||||
#### 6.4 Results Screen ⚠️ PARTIAL
|
||||
- ✅ GameResults.tsx exists
|
||||
- ❌ No multiplayer leaderboard (still shows single-player stats)
|
||||
- ❌ No per-player breakdown
|
||||
- ❌ No "Play Again" for room
|
||||
|
||||
**Phase 6 Grade**: ❌ D - Major UI work needed
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Registry & Routing ✅ COMPLETE
|
||||
|
||||
**Plan Requirements**:
|
||||
- Update game registry
|
||||
- Update validators
|
||||
- Update routing
|
||||
|
||||
**Actual Implementation**:
|
||||
- ✅ Registered in validators.ts
|
||||
- ✅ Registered in game-registry.ts
|
||||
- ✅ Registered in game-configs.ts
|
||||
- ✅ defineGame() properly exports modular game
|
||||
- ✅ GameComponent wrapper with PageWithNav
|
||||
- ✅ GameSelector.tsx shows game (maxPlayers: 4)
|
||||
|
||||
**Grade**: ✅ A - Fully integrated
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Testing & Validation ❌ NOT DONE
|
||||
|
||||
All testing checkboxes remain unchecked:
|
||||
- [ ] Unit tests
|
||||
- [ ] Integration tests
|
||||
- [ ] E2E tests
|
||||
- [ ] Manual testing checklist
|
||||
|
||||
**Grade**: ❌ F - No tests yet
|
||||
|
||||
---
|
||||
|
||||
## Critical Gaps Analysis
|
||||
|
||||
### 🚨 HIGH PRIORITY (Breaks Multiplayer Experience)
|
||||
|
||||
1. **Ghost Train Visualization** (Sprint Mode)
|
||||
- **What's Missing**: Other players' trains not visible
|
||||
- **Impact**: Can't see opponents, ruins competitive feel
|
||||
- **Where to Fix**: `SteamTrainJourney.tsx` component
|
||||
- **How**: Render semi-transparent trains for other players using `state.players`
|
||||
|
||||
2. **Multi-Lane Track** (Practice Mode)
|
||||
- **What's Missing**: Only shows single lane
|
||||
- **Impact**: Players can't see each other racing
|
||||
- **Where to Fix**: `LinearTrack.tsx` component
|
||||
- **How**: Stack 2-4 lanes vertically, render player in each
|
||||
|
||||
3. **Real-time Position Updates**
|
||||
- **What's Missing**: Player positions update but UI doesn't reflect it
|
||||
- **Impact**: Appears like single-player game
|
||||
- **Where to Fix**: Track components need to read `state.players[playerId].position`
|
||||
|
||||
### ⚠️ MEDIUM PRIORITY (Reduces Polish)
|
||||
|
||||
4. **AI Opponents Missing**
|
||||
- **What's Missing**: aiOpponents array never populated
|
||||
- **Impact**: Can't play solo with AI in multiplayer mode
|
||||
- **Where to Fix**: Validator needs AI update logic
|
||||
|
||||
5. **Lobby/Ready System**
|
||||
- **What's Missing**: Visual ready check before game starts
|
||||
- **Impact**: Game starts immediately, no coordination
|
||||
- **Where to Fix**: Add GameLobby.tsx component
|
||||
|
||||
6. **Multiplayer Results Screen**
|
||||
- **What's Missing**: Leaderboard with all players
|
||||
- **Impact**: Can't see who won in multiplayer
|
||||
- **Where to Fix**: `GameResults.tsx` needs multiplayer mode
|
||||
|
||||
### ✅ LOW PRIORITY (Nice to Have)
|
||||
|
||||
7. **Event Feed** - Live action announcements
|
||||
8. **Race Alerts** - "Player 2 is catching up!" notifications
|
||||
9. **Spectator Mode** - Watch after finishing
|
||||
|
||||
---
|
||||
|
||||
## Architectural Correctness Review
|
||||
|
||||
### ✅ What We Got RIGHT
|
||||
|
||||
1. **State Adapter Pattern** ⭐ **BRILLIANT SOLUTION**
|
||||
- Preserves existing UI without rewrite
|
||||
- Clean separation: multiplayer state ↔ single-player UI
|
||||
- Easy to maintain and extend
|
||||
- Better than migration plan's suggestion
|
||||
|
||||
2. **Validator Implementation** ⭐ **SOLID**
|
||||
- Comprehensive move validation
|
||||
- Proper win condition checks
|
||||
- Passenger management logic correct
|
||||
- Scoring system matches requirements
|
||||
|
||||
3. **Type Safety** ⭐ **EXCELLENT**
|
||||
- Full TypeScript coverage
|
||||
- Proper interfaces for all entities
|
||||
- No `any` types (except necessary places)
|
||||
|
||||
4. **Registry Integration** ⭐ **PERFECT**
|
||||
- Follows existing patterns
|
||||
- Properly registered everywhere
|
||||
- defineGame() usage correct
|
||||
|
||||
5. **Config Persistence** ⭐ **WORKS**
|
||||
- Room-based config saving
|
||||
- Merge with defaults
|
||||
- All settings persist
|
||||
|
||||
### ⚠️ What Needs ATTENTION
|
||||
|
||||
1. **Multiplayer UI** - Currently shows only local player
|
||||
2. **AI Integration** - Logic missing for AI opponents
|
||||
3. **Lobby System** - No visual ready check
|
||||
4. **Testing** - Zero test coverage
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria Checklist
|
||||
|
||||
From migration plan's "Success Criteria":
|
||||
|
||||
- ✅ Complement Race appears in arcade room game selector
|
||||
- ✅ Can create room with complement-race
|
||||
- ⚠️ Multiple players can join and see each other (**backend yes, visual no**)
|
||||
- ✅ Settings persist across page refreshes
|
||||
- ⚠️ Real-time race progress updates work (**data yes, display no**)
|
||||
- ❌ All three modes work in multiplayer (**need visual updates**)
|
||||
- ❌ AI opponents work with human players (**not implemented**)
|
||||
- ✅ Single-player mode still works (backward compat)
|
||||
- ✅ All animations and sounds intact
|
||||
- ✅ Zero TypeScript errors
|
||||
- ✅ Pre-commit checks pass
|
||||
- ✅ No console errors in production
|
||||
|
||||
**Score**: **9/12 (75%)**
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate Next Steps (To Complete Multiplayer)
|
||||
|
||||
1. **Implement Ghost Trains** (2-3 hours)
|
||||
```typescript
|
||||
// In SteamTrainJourney.tsx
|
||||
{Object.entries(state.players).map(([playerId, player]) => {
|
||||
if (playerId === localPlayerId) return null // Skip local player
|
||||
return (
|
||||
<Train
|
||||
key={playerId}
|
||||
position={player.position}
|
||||
color={player.color}
|
||||
opacity={0.35} // Ghost effect
|
||||
label={player.name}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
```
|
||||
|
||||
2. **Add Multi-Lane Track** (3-4 hours)
|
||||
```typescript
|
||||
// In LinearTrack.tsx
|
||||
const lanes = Object.values(state.players)
|
||||
return lanes.map((player, index) => (
|
||||
<Lane key={player.id} yOffset={index * 100}>
|
||||
<Player position={player.position} />
|
||||
</Lane>
|
||||
))
|
||||
```
|
||||
|
||||
3. **Create GameLobby.tsx** (2-3 hours)
|
||||
- Show connected players
|
||||
- Ready checkboxes
|
||||
- Start when all ready
|
||||
|
||||
4. **Update GameResults.tsx** (1-2 hours)
|
||||
- Show leaderboard from `state.leaderboard`
|
||||
- Display all player scores
|
||||
- Highlight winner
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
5. **AI Opponents** (4-6 hours)
|
||||
- Implement `updateAIPositions()` in validator
|
||||
- Update AI positions based on difficulty
|
||||
- Show AI players in UI
|
||||
|
||||
6. **Event Feed** (3-4 hours)
|
||||
- Create EventFeed component
|
||||
- Broadcast passenger claims/deliveries
|
||||
- Show overtakes and milestones
|
||||
|
||||
7. **Testing** (8-10 hours)
|
||||
- Unit tests for validator
|
||||
- E2E tests for multiplayer flow
|
||||
- Manual testing checklist
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
### Overall Grade: **B (70%)**
|
||||
|
||||
**Strengths**:
|
||||
- ⭐ **Excellent architecture** - State adapter is ingenious
|
||||
- ⭐ **Complete backend logic** - Validator fully functional
|
||||
- ⭐ **Proper integration** - Follows all patterns correctly
|
||||
- ⭐ **Type safety** - Zero TypeScript errors
|
||||
|
||||
**Weaknesses**:
|
||||
- ❌ **Missing multiplayer visuals** - Can't see other players
|
||||
- ❌ **No AI opponents** - Can't test solo
|
||||
- ❌ **Minimal lobby** - Auto-starts instead of ready check
|
||||
- ❌ **No tests** - Untested code
|
||||
|
||||
### Is Multiplayer Working?
|
||||
|
||||
**Backend**: ✅ YES - All server logic functional
|
||||
**Frontend**: ❌ NO - UI shows single-player only
|
||||
|
||||
**Can you play multiplayer?** Technically yes, but you won't see other players on screen. It's like racing blindfolded - your opponent's moves are tracked, but you can't see them.
|
||||
|
||||
### What Would Make This Complete?
|
||||
|
||||
**Minimum Viable Multiplayer** (8-10 hours of work):
|
||||
1. Ghost trains in sprint mode
|
||||
2. Multi-lane tracks in practice mode
|
||||
3. Multiplayer leaderboard in results
|
||||
4. Lobby with ready checks
|
||||
|
||||
**Full Polish** (20-25 hours total):
|
||||
- Above + AI opponents
|
||||
- Above + event feed
|
||||
- Above + comprehensive testing
|
||||
|
||||
---
|
||||
|
||||
**Status**: **FOUNDATION SOLID, VISUALS PENDING** 🏗️
|
||||
|
||||
The architecture is sound, the hard parts (validator, state management) are done correctly. What remains is "just" UI work to make multiplayer visible to players. The fact that we chose the state adapter pattern means this UI work won't require changing any existing game logic - just rendering multiple players instead of one.
|
||||
|
||||
**Verdict**: **Ship-ready for single-player, needs visual work for multiplayer** 🚀
|
||||
@@ -92,7 +92,9 @@
|
||||
"Bash(ls:*)",
|
||||
"Bash(do if [ -f \"$file\" ])",
|
||||
"Bash(! echo \"$file\")",
|
||||
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)"
|
||||
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)",
|
||||
"Bash(pnpm install)",
|
||||
"Bash(pnpm exec turbo build --filter=@soroban/web)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-spring/web": "^10.0.2",
|
||||
"@soroban/abacus-react": "workspace:*",
|
||||
"@soroban/client": "workspace:*",
|
||||
"@soroban/core": "workspace:*",
|
||||
"@soroban/templates": "workspace:*",
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty'
|
||||
import { useAIRacers } from '../hooks/useAIRacers'
|
||||
import { useSoundEffects } from '../hooks/useSoundEffects'
|
||||
import { useSteamJourney } from '../hooks/useSteamJourney'
|
||||
import { generatePassengers } from '../lib/passengerGenerator'
|
||||
import { AbacusTarget } from './AbacusTarget'
|
||||
import { CircularTrack } from './RaceTrack/CircularTrack'
|
||||
@@ -16,10 +15,9 @@ import { RouteCelebration } from './RouteCelebration'
|
||||
type FeedbackAnimation = 'correct' | 'incorrect' | null
|
||||
|
||||
export function GameDisplay() {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { state, dispatch, boostMomentum } = useComplementRace()
|
||||
useAIRacers() // Activate AI racer updates (not used in sprint mode)
|
||||
const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty()
|
||||
const { boostMomentum } = useSteamJourney()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [feedbackAnimation, setFeedbackAnimation] = useState<FeedbackAnimation>(null)
|
||||
|
||||
@@ -109,7 +107,7 @@ export function GameDisplay() {
|
||||
|
||||
// Boost momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum()
|
||||
boostMomentum(true)
|
||||
|
||||
// Play train whistle for milestones in sprint mode (line 13222-13235)
|
||||
if (newStreak >= 5 && newStreak % 3 === 0) {
|
||||
@@ -144,6 +142,11 @@ export function GameDisplay() {
|
||||
// Play incorrect sound (from web_generator.py line 11589)
|
||||
playSound('incorrect')
|
||||
|
||||
// Reduce momentum for sprint mode
|
||||
if (state.style === 'sprint') {
|
||||
boostMomentum(false)
|
||||
}
|
||||
|
||||
// Show adaptive feedback
|
||||
const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime)
|
||||
if (feedback) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
|
||||
interface PassengerCardProps {
|
||||
passenger: Passenger
|
||||
@@ -17,24 +17,27 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
if (!destinationStation || !originStation) return null
|
||||
|
||||
// Vintage train station colors
|
||||
const bgColor = passenger.isDelivered
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const isBoarded = passenger.claimedBy !== null
|
||||
const isDelivered = passenger.deliveredBy !== null
|
||||
|
||||
const bgColor = isDelivered
|
||||
? '#1a3a1a' // Dark green for delivered
|
||||
: !passenger.isBoarded
|
||||
: !isBoarded
|
||||
? '#2a2419' // Dark brown/sepia for waiting
|
||||
: passenger.isUrgent
|
||||
? '#3a2419' // Dark red-brown for urgent
|
||||
: '#1a2a3a' // Dark blue for aboard
|
||||
|
||||
const accentColor = passenger.isDelivered
|
||||
const accentColor = isDelivered
|
||||
? '#4ade80' // Green
|
||||
: !passenger.isBoarded
|
||||
: !isBoarded
|
||||
? '#d4af37' // Gold for waiting
|
||||
: passenger.isUrgent
|
||||
? '#ff6b35' // Orange-red for urgent
|
||||
: '#60a5fa' // Blue for aboard
|
||||
|
||||
const borderColor =
|
||||
passenger.isUrgent && passenger.isBoarded && !passenger.isDelivered ? '#ff6b35' : '#d4af37'
|
||||
const borderColor = passenger.isUrgent && isBoarded && !isDelivered ? '#ff6b35' : '#d4af37'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -46,13 +49,13 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
minWidth: '220px',
|
||||
maxWidth: '280px',
|
||||
boxShadow:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
passenger.isUrgent && !isDelivered && isBoarded
|
||||
? '0 0 16px rgba(255, 107, 53, 0.5)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
position: 'relative',
|
||||
fontFamily: '"Courier New", Courier, monospace',
|
||||
animation:
|
||||
passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded
|
||||
passenger.isUrgent && !isDelivered && isBoarded
|
||||
? 'urgentFlicker 1.5s ease-in-out infinite'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
@@ -79,7 +82,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', lineHeight: '1' }}>
|
||||
{passenger.isDelivered ? '✅' : passenger.avatar}
|
||||
{isDelivered ? '✅' : passenger.avatar}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -109,7 +112,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
marginTop: '0',
|
||||
}}
|
||||
>
|
||||
{passenger.isDelivered ? 'DLVRD' : passenger.isBoarded ? 'BOARD' : 'WAIT'}
|
||||
{isDelivered ? 'DLVRD' : isBoarded ? 'BOARD' : 'WAIT'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +190,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
</div>
|
||||
|
||||
{/* Points badge */}
|
||||
{!passenger.isDelivered && (
|
||||
{!isDelivered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -208,7 +211,7 @@ export const PassengerCard = memo(function PassengerCard({
|
||||
)}
|
||||
|
||||
{/* Urgent indicator */}
|
||||
{passenger.isUrgent && !passenger.isDelivered && passenger.isBoarded && (
|
||||
{passenger.isUrgent && !isDelivered && isBoarded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -17,15 +17,16 @@ interface CircularTrackProps {
|
||||
|
||||
export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { players, activePlayers } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
const { playSound } = useSoundEffects()
|
||||
const [celebrationCooldown, setCelebrationCooldown] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the current user's active local players (consistent with navbar pattern)
|
||||
const activeLocalPlayers = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
|
||||
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 })
|
||||
|
||||
// Update dimensions on mount and resize
|
||||
@@ -400,7 +401,11 @@ export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: C
|
||||
{activeBubble && (
|
||||
<div
|
||||
style={{
|
||||
transform: `rotate(${-aiPos.angle}deg)`, // Counter-rotate bubble
|
||||
position: 'absolute',
|
||||
bottom: '100%', // Position above the AI racer
|
||||
left: '50%',
|
||||
transform: `translate(-50%, -15px) rotate(${-aiPos.angle}deg)`, // Offset 15px above, counter-rotate bubble
|
||||
zIndex: 20, // Above player (10) and AI racers (5)
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { ComplementQuestion, Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { AbacusTarget } from '../AbacusTarget'
|
||||
import { PassengerCard } from '../PassengerCard'
|
||||
import { PressureGauge } from '../PressureGauge'
|
||||
|
||||
@@ -20,13 +20,14 @@ export function LinearTrack({
|
||||
showFinishLine = true,
|
||||
}: LinearTrackProps) {
|
||||
const { state, dispatch } = useComplementRace()
|
||||
const { players } = useGameMode()
|
||||
const { players, activePlayers } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the current user's active local players (consistent with navbar pattern)
|
||||
const activeLocalPlayers = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false)
|
||||
const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤'
|
||||
|
||||
// Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2)
|
||||
// 2% minimum (start), 98% maximum (near finish), 96% range for race
|
||||
@@ -110,7 +111,7 @@ export function LinearTrack({
|
||||
position: 'absolute',
|
||||
left: `${playerPosition}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transform: 'translate(-50%, -50%) scaleX(-1)',
|
||||
fontSize: '32px',
|
||||
transition: 'left 0.3s ease-out',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
@@ -132,7 +133,7 @@ export function LinearTrack({
|
||||
position: 'absolute',
|
||||
left: `${aiPosition}%`,
|
||||
top: `${35 + index * 15}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
transform: 'translate(-50%, -50%) scaleX(-1)',
|
||||
fontSize: '28px',
|
||||
transition: 'left 0.2s linear',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))',
|
||||
@@ -141,10 +142,20 @@ export function LinearTrack({
|
||||
>
|
||||
{racer.icon}
|
||||
{activeBubble && (
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%', // Position above the AI racer
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -15px) scaleX(-1)', // Offset 15px above, counter-flip bubble
|
||||
zIndex: 20, // Above player (10) and AI racers (5)
|
||||
}}
|
||||
>
|
||||
<SpeechBubble
|
||||
message={activeBubble}
|
||||
onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { Landmark } from '../../lib/landmarks'
|
||||
|
||||
interface RailroadTrackPathProps {
|
||||
@@ -100,18 +100,19 @@ export const RailroadTrackPath = memo(
|
||||
{stationPositions.map((pos, index) => {
|
||||
const station = stations[index]
|
||||
// Find passengers waiting at this station (exclude currently boarding)
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const waitingPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.originStationId === station?.id &&
|
||||
!p.isBoarded &&
|
||||
!p.isDelivered &&
|
||||
p.claimedBy === null &&
|
||||
p.deliveredBy === null &&
|
||||
!boardingAnimations.has(p.id)
|
||||
)
|
||||
// Find passengers delivered at this station (exclude currently disembarking)
|
||||
const deliveredPassengers = passengers.filter(
|
||||
(p) =>
|
||||
p.destinationStationId === station?.id &&
|
||||
p.isDelivered &&
|
||||
p.deliveredBy !== null &&
|
||||
!disembarkingAnimations.has(p.id)
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { ComplementQuestion } from '../../lib/gameTypes'
|
||||
import { useSteamJourney } from '../../hooks/useSteamJourney'
|
||||
import { useTrackManagement } from '../../hooks/useTrackManagement'
|
||||
import { useTrainTransforms } from '../../hooks/useTrainTransforms'
|
||||
import { calculateMaxConcurrentPassengers } from '../../lib/passengerGenerator'
|
||||
import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { getRouteTheme } from '../../lib/routeThemes'
|
||||
import { GameHUD } from './GameHUD'
|
||||
@@ -94,6 +93,7 @@ export function SteamTrainJourney({
|
||||
currentInput,
|
||||
}: SteamTrainJourneyProps) {
|
||||
const { state } = useComplementRace()
|
||||
|
||||
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
|
||||
const _skyGradient = getSkyGradient()
|
||||
const period = getTimeOfDayPeriod()
|
||||
@@ -109,12 +109,9 @@ export function SteamTrainJourney({
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
|
||||
|
||||
// Calculate the number of train cars dynamically based on max concurrent passengers
|
||||
const maxCars = useMemo(() => {
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
// Ensure at least 1 car, even if no passengers
|
||||
return Math.max(1, maxPassengers)
|
||||
}, [state.passengers, state.stations])
|
||||
// Use server's authoritative maxConcurrentPassengers calculation
|
||||
// This ensures visual display matches game logic and console logs
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
|
||||
const carSpacing = 7 // Distance between cars (in % of track)
|
||||
|
||||
@@ -166,13 +163,14 @@ export function SteamTrainJourney({
|
||||
const routeTheme = getRouteTheme(state.currentRoute)
|
||||
|
||||
// Memoize filtered passenger lists to avoid recalculating on every render
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
const boardedPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => p.isBoarded && !p.isDelivered),
|
||||
() => displayPassengers.filter((p) => p.claimedBy !== null && p.deliveredBy === null),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
const nonDeliveredPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => !p.isDelivered),
|
||||
() => displayPassengers.filter((p) => p.deliveredBy === null),
|
||||
[displayPassengers]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import type { Passenger } from '../../lib/gameTypes'
|
||||
import type { Passenger } from '@/arcade-games/complement-race/types'
|
||||
|
||||
interface TrainCarTransform {
|
||||
x: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { GameHUD } from '../GameHUD'
|
||||
|
||||
// Mock child components
|
||||
@@ -33,9 +33,11 @@ describe('GameHUD', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
{ id: 'station3', name: 'Station 3', icon: '🏪', emoji: '🏪', position: 80 },
|
||||
]
|
||||
|
||||
// Mock passengers - initial set
|
||||
// Mock passengers - initial set (multiplayer format)
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'p1',
|
||||
@@ -55,9 +55,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👩',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
@@ -65,9 +67,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station2',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -111,18 +115,18 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
|
||||
// Initially 2 passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(false)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe(null)
|
||||
|
||||
// Board first passenger
|
||||
const boardedPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true } : p
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: boardedPassengers, position: 25 })
|
||||
|
||||
// Should show updated passengers
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
})
|
||||
|
||||
test('passengers do NOT update during route transition (train moving)', () => {
|
||||
@@ -153,9 +157,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -196,9 +202,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -239,9 +247,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -316,18 +326,18 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
|
||||
// Initially 2 passengers, neither delivered
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(false)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe(null)
|
||||
|
||||
// Deliver first passenger
|
||||
const deliveredPassengers = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, isBoarded: true, isDelivered: true } : p
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0, deliveredBy: 'player1' } : p
|
||||
)
|
||||
|
||||
rerender({ passengers: deliveredPassengers, position: 55 })
|
||||
|
||||
// Should show updated passengers immediately
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
})
|
||||
|
||||
test('multiple rapid passenger updates during same route', () => {
|
||||
@@ -350,25 +360,27 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
expect(result.current.displayPassengers).toHaveLength(2)
|
||||
|
||||
// Board p1
|
||||
let updated = mockPassengers.map((p) => (p.id === 'p1' ? { ...p, isBoarded: true } : p))
|
||||
let updated = mockPassengers.map((p) =>
|
||||
p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p
|
||||
)
|
||||
rerender({ passengers: updated, position: 26 })
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
|
||||
// Board p2
|
||||
updated = updated.map((p) => (p.id === 'p2' ? { ...p, isBoarded: true } : p))
|
||||
updated = updated.map((p) => (p.id === 'p2' ? { ...p, claimedBy: 'player1', carIndex: 1 } : p))
|
||||
rerender({ passengers: updated, position: 52 })
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[1].claimedBy).toBe('player1')
|
||||
|
||||
// Deliver p1
|
||||
updated = updated.map((p) => (p.id === 'p1' ? { ...p, isDelivered: true } : p))
|
||||
updated = updated.map((p) => (p.id === 'p1' ? { ...p, deliveredBy: 'player1' } : p))
|
||||
rerender({ passengers: updated, position: 53 })
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
|
||||
// All updates should have been reflected
|
||||
expect(result.current.displayPassengers[0].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[0].isDelivered).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isBoarded).toBe(true)
|
||||
expect(result.current.displayPassengers[1].isDelivered).toBe(false)
|
||||
expect(result.current.displayPassengers[0].claimedBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[0].deliveredBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[1].claimedBy).toBe('player1')
|
||||
expect(result.current.displayPassengers[1].deliveredBy).toBe(null)
|
||||
})
|
||||
|
||||
test('EDGE CASE: new passengers at position 0 with old route', () => {
|
||||
@@ -402,9 +414,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -445,9 +459,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -483,9 +499,11 @@ describe('useTrackManagement - Passenger Display', () => {
|
||||
avatar: '👴',
|
||||
originStationId: 'station1',
|
||||
destinationStationId: 'station3',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import type { Passenger, Station } from '../../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
import { useTrackManagement } from '../useTrackManagement'
|
||||
|
||||
@@ -60,9 +60,11 @@ describe('useTrackManagement', () => {
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -155,6 +157,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -174,6 +178,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
@@ -200,6 +206,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
@@ -227,6 +235,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: -5 },
|
||||
@@ -250,9 +260,11 @@ describe('useTrackManagement', () => {
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -287,12 +299,15 @@ describe('useTrackManagement', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
name: 'Passenger 2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
isBoarded: false,
|
||||
isDelivered: false,
|
||||
isUrgent: false,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -328,7 +343,9 @@ describe('useTrackManagement', () => {
|
||||
})
|
||||
|
||||
test('updates passengers immediately during same route', () => {
|
||||
const updatedPassengers: Passenger[] = [{ ...mockPassengers[0], isBoarded: true }]
|
||||
const updatedPassengers: Passenger[] = [
|
||||
{ ...mockPassengers[0], claimedBy: 'player1', carIndex: 0 },
|
||||
]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ passengers, position }) =>
|
||||
@@ -368,6 +385,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useComplementRace } from '@/arcade-games/complement-race/Provider'
|
||||
import { calculateMaxConcurrentPassengers, generatePassengers } from '../lib/passengerGenerator'
|
||||
import { useSoundEffects } from './useSoundEffects'
|
||||
|
||||
/**
|
||||
@@ -44,26 +43,44 @@ export function useSteamJourney() {
|
||||
const gameStartTimeRef = useRef<number>(0)
|
||||
const lastUpdateRef = useRef<number>(0)
|
||||
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
|
||||
const missedPassengersRef = useRef<Set<string>>(new Set()) // Track which passengers have been logged as missed
|
||||
const pendingBoardingRef = useRef<Set<string>>(new Set()) // Track passengers with pending boarding requests across frames
|
||||
const previousTrainPositionRef = useRef<number>(0) // Track previous position to detect threshold crossings
|
||||
|
||||
// Initialize game start time and generate initial passengers
|
||||
// Initialize game start time
|
||||
useEffect(() => {
|
||||
if (state.isGameActive && state.style === 'sprint' && gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
|
||||
// Generate initial passengers if none exist
|
||||
if (state.passengers.length === 0) {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store exit threshold for this route
|
||||
const CAR_SPACING = 7
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
|
||||
}
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.stations, state.passengers.length, dispatch])
|
||||
}, [state.isGameActive, state.style, state.stations, state.passengers])
|
||||
|
||||
// Calculate exit threshold when route changes or config updates
|
||||
useEffect(() => {
|
||||
if (state.passengers.length > 0 && state.stations.length > 0) {
|
||||
const CAR_SPACING = 7
|
||||
// Use server-calculated maxConcurrentPassengers
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING
|
||||
}
|
||||
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
|
||||
|
||||
// Clean up pendingBoardingRef when passengers are claimed/delivered or route changes
|
||||
useEffect(() => {
|
||||
// Remove passengers from pending set if they've been claimed or delivered
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
|
||||
pendingBoardingRef.current.delete(passenger.id)
|
||||
}
|
||||
})
|
||||
}, [state.passengers])
|
||||
|
||||
// Clear all pending boarding requests when route changes
|
||||
useEffect(() => {
|
||||
pendingBoardingRef.current.clear()
|
||||
missedPassengersRef.current.clear()
|
||||
previousTrainPositionRef.current = 0 // Reset previous position for new route
|
||||
}, [state.currentRoute])
|
||||
|
||||
// Momentum decay and position update loop
|
||||
useEffect(() => {
|
||||
@@ -77,116 +94,48 @@ export function useSteamJourney() {
|
||||
|
||||
// Steam Sprint is infinite - no time limit
|
||||
|
||||
// Get decay rate based on timeout setting (skill level)
|
||||
const decayRate =
|
||||
MOMENTUM_DECAY_RATES[state.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
|
||||
MOMENTUM_DECAY_RATES.normal
|
||||
|
||||
// Calculate momentum decay for this frame
|
||||
const momentumLoss = (decayRate * deltaTime) / 1000
|
||||
|
||||
// Update momentum (don't go below 0)
|
||||
const newMomentum = Math.max(0, state.momentum - momentumLoss)
|
||||
|
||||
// Calculate speed from momentum (% per second)
|
||||
const speed = newMomentum * SPEED_MULTIPLIER
|
||||
|
||||
// Update train position (accumulate, never go backward)
|
||||
// Allow position to go past 100% so entire train (including cars) can exit tunnel
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
const trainPosition = state.trainPosition + positionDelta
|
||||
|
||||
// Calculate pressure (0-150 PSI) - based on momentum as percentage of max
|
||||
const maxMomentum = 100 // Theoretical max momentum
|
||||
const pressure = Math.min(150, (newMomentum / maxMomentum) * 150)
|
||||
|
||||
// Update state
|
||||
dispatch({
|
||||
type: 'UPDATE_STEAM_JOURNEY',
|
||||
momentum: newMomentum,
|
||||
trainPosition,
|
||||
pressure,
|
||||
elapsedTime: elapsed,
|
||||
})
|
||||
// Train position, momentum, and pressure are all managed by the Provider's game loop
|
||||
// This hook only reads those values and handles game logic (boarding, delivery, route completion)
|
||||
const trainPosition = state.trainPosition
|
||||
|
||||
// Check for passengers that should board
|
||||
// Passengers board when an EMPTY car reaches their station
|
||||
const CAR_SPACING = 7 // Must match SteamTrainJourney component
|
||||
const maxPassengers = calculateMaxConcurrentPassengers(state.passengers, state.stations)
|
||||
const maxCars = Math.max(1, maxPassengers)
|
||||
const currentBoardedPassengers = state.passengers.filter((p) => p.isBoarded && !p.isDelivered)
|
||||
// Use server-calculated maxConcurrentPassengers (updates per route based on passenger layout)
|
||||
const maxCars = Math.max(1, state.maxConcurrentPassengers || 3)
|
||||
|
||||
// Debug logging flag - enable when debugging passenger boarding issues
|
||||
// TO ENABLE: Change this to true, save, and the logs will appear in the browser console
|
||||
// When you see passengers getting left behind, copy the entire console log and paste into Claude Code
|
||||
const DEBUG_PASSENGER_BOARDING = false
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n'.repeat(3))
|
||||
console.log('='.repeat(80))
|
||||
console.log('🚂 PASSENGER BOARDING DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
console.log('ISSUE: Passengers are getting left behind at stations')
|
||||
console.log('PURPOSE: This log captures all state during boarding/delivery logic')
|
||||
console.log('USAGE: Copy this entire log and paste into Claude Code for debugging')
|
||||
console.log('='.repeat(80))
|
||||
console.log('\n📊 CURRENT FRAME STATE:')
|
||||
console.log(` Train Position: ${trainPosition.toFixed(2)}`)
|
||||
console.log(` Speed: ${speed.toFixed(2)}% per second`)
|
||||
console.log(` Momentum: ${newMomentum.toFixed(2)}`)
|
||||
console.log(` Max Cars: ${maxCars}`)
|
||||
console.log(` Car Spacing: ${CAR_SPACING}`)
|
||||
console.log(` Distance Tolerance: 5`)
|
||||
|
||||
console.log('\n🚉 STATIONS:')
|
||||
state.stations.forEach((station) => {
|
||||
console.log(` ${station.emoji} ${station.name} (ID: ${station.id})`)
|
||||
console.log(` Position: ${station.position}`)
|
||||
})
|
||||
|
||||
console.log('\n👥 ALL PASSENGERS:')
|
||||
state.passengers.forEach((p, idx) => {
|
||||
const origin = state.stations.find((s) => s.id === p.originStationId)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
console.log(` [${idx}] ${p.name} (ID: ${p.id})`)
|
||||
// Debug: Log train configuration at start (only once per route)
|
||||
if (trainPosition < 1 && state.passengers.length > 0) {
|
||||
const lastLoggedRoute = (window as any).__lastLoggedRoute || 0
|
||||
if (lastLoggedRoute !== state.currentRoute) {
|
||||
console.log(
|
||||
` Status: ${p.isDelivered ? 'DELIVERED' : p.isBoarded ? 'BOARDED' : 'WAITING'}`
|
||||
`\n🚆 ROUTE ${state.currentRoute} START - Train has ${maxCars} cars (server maxConcurrentPassengers: ${state.maxConcurrentPassengers}) for ${state.passengers.length} passengers`
|
||||
)
|
||||
console.log(
|
||||
` Route: ${origin?.emoji} ${origin?.name} (pos ${origin?.position}) → ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`
|
||||
)
|
||||
console.log(` Urgent: ${p.isUrgent}`)
|
||||
})
|
||||
|
||||
console.log('\n🚃 CAR POSITIONS:')
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
|
||||
console.log(` Car ${i}: position ${carPos.toFixed(2)}`)
|
||||
state.passengers.forEach((p) => {
|
||||
const origin = state.stations.find((s) => s.id === p.originStationId)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
console.log(
|
||||
` 📍 ${p.name}: ${origin?.emoji} ${origin?.name} (${origin?.position}) → ${dest?.emoji} ${dest?.name} (${dest?.position}) ${p.isUrgent ? '⚡' : ''}`
|
||||
)
|
||||
})
|
||||
console.log('') // Blank line for readability
|
||||
;(window as any).__lastLoggedRoute = state.currentRoute
|
||||
}
|
||||
|
||||
console.log('\n🔍 CURRENTLY BOARDED PASSENGERS:')
|
||||
currentBoardedPassengers.forEach((p, carIndex) => {
|
||||
const carPos = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const dest = state.stations.find((s) => s.id === p.destinationStationId)
|
||||
const distToDest = Math.abs(carPos - (dest?.position || 0))
|
||||
console.log(` Car ${carIndex}: ${p.name}`)
|
||||
console.log(` Car position: ${carPos.toFixed(2)}`)
|
||||
console.log(` Destination: ${dest?.emoji} ${dest?.name} (pos ${dest?.position})`)
|
||||
console.log(` Distance to dest: ${distToDest.toFixed(2)}`)
|
||||
console.log(` Will deliver: ${distToDest < 5 ? 'YES' : 'NO'}`)
|
||||
})
|
||||
}
|
||||
const currentBoardedPassengers = state.passengers.filter(
|
||||
(p) => p.claimedBy !== null && p.deliveredBy === null
|
||||
)
|
||||
|
||||
// FIRST: Identify which passengers will be delivered in this frame
|
||||
const passengersToDeliver = new Set<string>()
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
// Calculate this passenger's car position using PHYSICAL carIndex
|
||||
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), mark for delivery
|
||||
@@ -195,159 +144,161 @@ export function useSteamJourney() {
|
||||
}
|
||||
})
|
||||
|
||||
// Build a map of which cars are occupied (excluding passengers being delivered this frame)
|
||||
// Build a map of which cars are occupied (using PHYSICAL car index, not array index!)
|
||||
// This is critical: passenger.carIndex stores the physical car (0-N) they're seated in
|
||||
const occupiedCars = new Map<number, (typeof currentBoardedPassengers)[0]>()
|
||||
currentBoardedPassengers.forEach((passenger, arrayIndex) => {
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
// Don't count a car as occupied if its passenger is being delivered this frame
|
||||
if (!passengersToDeliver.has(passenger.id)) {
|
||||
occupiedCars.set(arrayIndex, passenger)
|
||||
if (!passengersToDeliver.has(passenger.id) && passenger.carIndex !== null) {
|
||||
occupiedCars.set(passenger.carIndex, passenger) // Use physical carIndex, NOT array index!
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n📦 PASSENGERS TO DELIVER THIS FRAME:')
|
||||
if (passengersToDeliver.size === 0) {
|
||||
console.log(' None')
|
||||
} else {
|
||||
passengersToDeliver.forEach((id) => {
|
||||
const p = state.passengers.find((passenger) => passenger.id === id)
|
||||
console.log(` - ${p?.name} (ID: ${id})`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🚗 OCCUPIED CARS (after excluding deliveries):')
|
||||
if (occupiedCars.size === 0) {
|
||||
console.log(' All cars are empty')
|
||||
} else {
|
||||
occupiedCars.forEach((passenger, carIndex) => {
|
||||
console.log(` Car ${carIndex}: ${passenger.name}`)
|
||||
})
|
||||
}
|
||||
|
||||
console.log('\n🔄 BOARDING ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
|
||||
// Find waiting passengers whose origin station has an empty car nearby
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.isBoarded || passenger.isDelivered) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
`\n Passenger: ${passenger.name} waiting at ${station.emoji} ${station.name} (pos ${station.position})`
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
let boarded = false
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
const isOccupied = occupiedCars.has(carIndex)
|
||||
const isAssigned = carsAssignedThisFrame.has(carIndex)
|
||||
const inRange = distance < 5
|
||||
const occupant = occupiedCars.get(carIndex)
|
||||
|
||||
console.log(` Car ${carIndex} @ pos ${carPosition.toFixed(2)}:`)
|
||||
console.log(` Distance to station: ${distance.toFixed(2)}`)
|
||||
console.log(` In range (<5): ${inRange}`)
|
||||
console.log(
|
||||
` Occupied: ${isOccupied}${isOccupied ? ` (by ${occupant?.name})` : ''}`
|
||||
)
|
||||
console.log(` Assigned this frame: ${isAssigned}`)
|
||||
console.log(` Can board: ${!isOccupied && !isAssigned && inRange}`)
|
||||
}
|
||||
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
const distance2 = Math.abs(carPosition - station.position)
|
||||
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
// Increased tolerance to ensure fast-moving trains don't miss passengers
|
||||
if (distance2 < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(` ✅ BOARDING ${passenger.name} onto Car ${carIndex}`)
|
||||
}
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
})
|
||||
// Mark this car as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
boarded = true
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING && !boarded) {
|
||||
console.log(` ❌ ${passenger.name} NOT BOARDED - no suitable car found`)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log('\n🎯 DELIVERY ATTEMPTS:')
|
||||
}
|
||||
|
||||
// Check for deliverable passengers
|
||||
// Passengers disembark when THEIR car reaches their destination
|
||||
currentBoardedPassengers.forEach((passenger, carIndex) => {
|
||||
if (!passenger || passenger.isDelivered) return
|
||||
// PRIORITY 1: Process deliveries FIRST (dispatch DELIVER moves before BOARD moves)
|
||||
// This ensures the server frees up cars before processing new boarding requests
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
// Calculate this passenger's car position
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
// Calculate this passenger's car position using PHYSICAL carIndex
|
||||
const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
// If this car is at the destination station (within 5% tolerance), deliver
|
||||
if (distance < 5) {
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(
|
||||
` ✅ DELIVERING ${passenger.name} from Car ${carIndex} to ${station.emoji} ${station.name}`
|
||||
)
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
)
|
||||
}
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
console.log(
|
||||
`🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
|
||||
)
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
points,
|
||||
})
|
||||
} else if (DEBUG_PASSENGER_BOARDING) {
|
||||
}
|
||||
})
|
||||
|
||||
// Debug: Log car states periodically at stations
|
||||
const isAtStation = state.stations.some((s) => Math.abs(trainPosition - s.position) < 3)
|
||||
if (isAtStation && Math.floor(trainPosition) !== Math.floor(state.trainPosition)) {
|
||||
const nearStation = state.stations.find((s) => Math.abs(trainPosition - s.position) < 3)
|
||||
console.log(
|
||||
`\n🚃 Train arriving at ${nearStation?.emoji} ${nearStation?.name} (trainPos=${trainPosition.toFixed(1)}) - ${maxCars} cars total:`
|
||||
)
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING)
|
||||
const occupant = occupiedCars.get(i)
|
||||
if (occupant) {
|
||||
const dest = state.stations.find((s) => s.id === occupant.destinationStationId)
|
||||
console.log(
|
||||
` Car ${i}: @ ${carPos.toFixed(1)}% - ${occupant.name} → ${dest?.emoji} ${dest?.name}`
|
||||
)
|
||||
} else {
|
||||
console.log(` Car ${i}: @ ${carPos.toFixed(1)}% - EMPTY`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track which cars are assigned in THIS frame to prevent double-boarding
|
||||
const carsAssignedThisFrame = new Set<number>()
|
||||
// Track which passengers are assigned in THIS frame to prevent same passenger boarding multiple cars
|
||||
const passengersAssignedThisFrame = new Set<string>()
|
||||
|
||||
// PRIORITY 2: Process boardings AFTER deliveries
|
||||
|
||||
// Find waiting passengers whose origin station has an empty car nearby
|
||||
state.passengers.forEach((passenger) => {
|
||||
// Skip if already claimed or delivered (optimistic update marks immediately)
|
||||
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) return
|
||||
|
||||
// Skip if already assigned in this frame OR has a pending boarding request from previous frames
|
||||
if (
|
||||
passengersAssignedThisFrame.has(passenger.id) ||
|
||||
pendingBoardingRef.current.has(passenger.id)
|
||||
)
|
||||
return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!station) return
|
||||
|
||||
// Don't allow boarding if locomotive has passed too far beyond this station
|
||||
// Station stays open until the LAST car has passed (accounting for train length)
|
||||
const STATION_CLOSURE_BUFFER = 10 // Extra buffer beyond the last car
|
||||
const lastCarOffset = maxCars * CAR_SPACING // Distance from locomotive to last car
|
||||
const stationClosureThreshold = lastCarOffset + STATION_CLOSURE_BUFFER
|
||||
|
||||
if (trainPosition > station.position + stationClosureThreshold) {
|
||||
console.log(
|
||||
` ⏳ ${passenger.name} in Car ${carIndex} heading to ${station.emoji} ${station.name}`
|
||||
`❌ MISSED: ${passenger.name} at ${station.emoji} ${station.name} - train too far past (trainPos=${trainPosition.toFixed(1)}, station=${station.position}, threshold=${stationClosureThreshold})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any empty car is at this station
|
||||
// Cars are at positions: trainPosition - 7, trainPosition - 14, etc.
|
||||
let closestCarDistance = 999
|
||||
let closestCarReason = ''
|
||||
|
||||
for (let carIndex = 0; carIndex < maxCars; carIndex++) {
|
||||
const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING)
|
||||
const distance = Math.abs(carPosition - station.position)
|
||||
|
||||
if (distance < closestCarDistance) {
|
||||
closestCarDistance = distance
|
||||
if (occupiedCars.has(carIndex)) {
|
||||
const occupant = occupiedCars.get(carIndex)
|
||||
closestCarReason = `Car ${carIndex} occupied by ${occupant?.name}`
|
||||
} else if (carsAssignedThisFrame.has(carIndex)) {
|
||||
closestCarReason = `Car ${carIndex} just assigned`
|
||||
} else if (distance >= 5) {
|
||||
closestCarReason = `Car ${carIndex} too far (dist=${distance.toFixed(1)})`
|
||||
} else {
|
||||
closestCarReason = 'available'
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if this car already has a passenger OR was assigned this frame
|
||||
if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue
|
||||
|
||||
// If car is at or near station (within 5% tolerance for fast trains), board this passenger
|
||||
if (distance < 5) {
|
||||
console.log(
|
||||
`🚂 BOARDING: ${passenger.name} boarding Car ${carIndex} at ${station.emoji} ${station.name} (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
|
||||
)
|
||||
|
||||
// Mark as pending BEFORE dispatch to prevent duplicate boarding attempts across frames
|
||||
pendingBoardingRef.current.add(passenger.id)
|
||||
|
||||
dispatch({
|
||||
type: 'BOARD_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
carIndex, // Pass physical car index to server
|
||||
})
|
||||
// Mark this car and passenger as assigned in this frame
|
||||
carsAssignedThisFrame.add(carIndex)
|
||||
passengersAssignedThisFrame.add(passenger.id)
|
||||
return // Board this passenger and move on
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, passenger wasn't boarded - log why
|
||||
if (closestCarDistance < 10) {
|
||||
// Only log if train is somewhat near
|
||||
console.log(
|
||||
` Car position: ${carPosition.toFixed(2)}, Station: ${station.position}, Distance: ${distance.toFixed(2)}`
|
||||
`⏸️ WAITING: ${passenger.name} at ${station.emoji} ${station.name} - ${closestCarReason} (trainPos=${trainPosition.toFixed(1)}, maxCars=${maxCars})`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (DEBUG_PASSENGER_BOARDING) {
|
||||
console.log(`\n${'='.repeat(80)}`)
|
||||
console.log('END OF DEBUG LOG')
|
||||
console.log('='.repeat(80))
|
||||
}
|
||||
|
||||
// Check for route completion (entire train exits tunnel)
|
||||
// Use stored threshold (stable for entire route)
|
||||
const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current
|
||||
const previousPosition = previousTrainPositionRef.current
|
||||
|
||||
if (
|
||||
trainPosition >= ENTIRE_TRAIN_EXIT_THRESHOLD &&
|
||||
state.trainPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
|
||||
previousPosition < ENTIRE_TRAIN_EXIT_THRESHOLD
|
||||
) {
|
||||
// Play celebration whistle
|
||||
playSound('train_whistle', 0.6)
|
||||
@@ -357,52 +308,24 @@ export function useSteamJourney() {
|
||||
|
||||
// Auto-advance to next route
|
||||
const nextRoute = state.currentRoute + 1
|
||||
console.log(
|
||||
`🏁 ROUTE COMPLETE: Train crossed exit threshold (${trainPosition.toFixed(1)} >= ${ENTIRE_TRAIN_EXIT_THRESHOLD}). Advancing to Route ${nextRoute}`
|
||||
)
|
||||
dispatch({
|
||||
type: 'START_NEW_ROUTE',
|
||||
routeNumber: nextRoute,
|
||||
stations: state.stations,
|
||||
})
|
||||
|
||||
// Generate new passengers
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
|
||||
// Calculate and store new exit threshold for next route
|
||||
const newMaxPassengers = calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
const newMaxCars = Math.max(1, newMaxPassengers)
|
||||
routeExitThresholdRef.current = 100 + newMaxCars * CAR_SPACING
|
||||
// Note: New passengers will be generated by the server when it handles START_NEW_ROUTE
|
||||
}
|
||||
|
||||
// Update previous position for next frame
|
||||
previousTrainPositionRef.current = trainPosition
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
state.isGameActive,
|
||||
state.style,
|
||||
state.momentum,
|
||||
state.trainPosition,
|
||||
state.timeoutSetting,
|
||||
state.passengers,
|
||||
state.stations,
|
||||
state.currentRoute,
|
||||
dispatch,
|
||||
playSound,
|
||||
])
|
||||
|
||||
// Auto-regenerate passengers when all are delivered
|
||||
useEffect(() => {
|
||||
if (!state.isGameActive || state.style !== 'sprint') return
|
||||
|
||||
// Check if all passengers are delivered
|
||||
const allDelivered = state.passengers.length > 0 && state.passengers.every((p) => p.isDelivered)
|
||||
|
||||
if (allDelivered) {
|
||||
// Generate new passengers after a short delay
|
||||
setTimeout(() => {
|
||||
const newPassengers = generatePassengers(state.stations)
|
||||
dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers })
|
||||
}, 1000)
|
||||
}
|
||||
}, [state.isGameActive, state.style, state.passengers, state.stations, dispatch])
|
||||
}, [state.isGameActive, state.style, state.timeoutSetting, dispatch, playSound])
|
||||
|
||||
// Add momentum on correct answer
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Passenger, Station } from '../lib/gameTypes'
|
||||
import type { Passenger, Station } from '@/arcade-games/complement-race/types'
|
||||
import { generateLandmarks, type Landmark } from '../lib/landmarks'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
@@ -67,7 +67,7 @@ export function useTrackManagement({
|
||||
|
||||
// Apply pending track when train resets to beginning
|
||||
useEffect(() => {
|
||||
if (pendingTrackData && trainPosition < 0) {
|
||||
if (pendingTrackData && trainPosition <= 0) {
|
||||
setTrackData(pendingTrackData)
|
||||
previousRouteRef.current = currentRoute
|
||||
setPendingTrackData(null)
|
||||
@@ -77,22 +77,34 @@ export function useTrackManagement({
|
||||
// Manage passenger display during route transitions
|
||||
useEffect(() => {
|
||||
// Only switch to new passengers when:
|
||||
// 1. Train has reset to start position (< 0) - track has changed, OR
|
||||
// 2. Same route AND train is in middle of track (10-90%) - gameplay updates like boarding/delivering
|
||||
const trainReset = trainPosition < 0
|
||||
// 1. Train has reset to start position (<= 0) - track has changed, OR
|
||||
// 2. Same route AND (in middle of track OR passengers have changed state)
|
||||
const trainReset = trainPosition <= 0
|
||||
const sameRoute = currentRoute === displayRouteRef.current
|
||||
const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones
|
||||
|
||||
// Detect if passenger states have changed (boarding or delivery)
|
||||
// This allows updates even when train is past 90% threshold
|
||||
const passengerStatesChanged =
|
||||
sameRoute &&
|
||||
passengers.some((p) => {
|
||||
const oldPassenger = displayPassengers.find((dp) => dp.id === p.id)
|
||||
return (
|
||||
oldPassenger &&
|
||||
(oldPassenger.claimedBy !== p.claimedBy || oldPassenger.deliveredBy !== p.deliveredBy)
|
||||
)
|
||||
})
|
||||
|
||||
if (trainReset) {
|
||||
// Train reset - update to new route's passengers
|
||||
setDisplayPassengers(passengers)
|
||||
displayRouteRef.current = currentRoute
|
||||
} else if (sameRoute && inMiddleOfTrack) {
|
||||
// Same route and train in middle of track - update passengers for gameplay changes (boarding/delivery)
|
||||
} else if (sameRoute && (inMiddleOfTrack || passengerStatesChanged)) {
|
||||
// Same route and either in middle of track OR passenger states changed - update for gameplay
|
||||
setDisplayPassengers(passengers)
|
||||
}
|
||||
// Otherwise, keep displaying old passengers until train resets
|
||||
}, [passengers, trainPosition, currentRoute])
|
||||
}, [passengers, displayPassengers, trainPosition, currentRoute])
|
||||
|
||||
// Generate ties and rails when path is ready
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('GameHUD', () => {
|
||||
|
||||
const mockPassenger: Passenger = {
|
||||
id: 'passenger-1',
|
||||
name: 'Test Passenger',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('usePassengerAnimations', () => {
|
||||
// Create mock passengers
|
||||
mockPassenger1 = {
|
||||
id: 'passenger-1',
|
||||
name: 'Passenger 1',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
@@ -54,6 +55,7 @@ describe('usePassengerAnimations', () => {
|
||||
|
||||
mockPassenger2 = {
|
||||
id: 'passenger-2',
|
||||
name: 'Passenger 2',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('useTrackManagement', () => {
|
||||
mockPassengers = [
|
||||
{
|
||||
id: 'passenger-1',
|
||||
name: 'Test Passenger',
|
||||
avatar: '👨',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
@@ -73,6 +74,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -90,6 +93,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -107,6 +112,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -123,6 +130,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -142,6 +151,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -161,6 +172,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
@@ -187,6 +200,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: 0 },
|
||||
@@ -214,6 +229,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
}),
|
||||
{
|
||||
initialProps: { route: 1, position: -5 },
|
||||
@@ -233,6 +250,7 @@ describe('useTrackManagement', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
name: 'New Passenger',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
@@ -273,6 +291,7 @@ describe('useTrackManagement', () => {
|
||||
const newPassengers: Passenger[] = [
|
||||
{
|
||||
id: 'passenger-2',
|
||||
name: 'New Passenger',
|
||||
avatar: '👩',
|
||||
originStationId: 'station-1',
|
||||
destinationStationId: 'station-2',
|
||||
@@ -354,6 +373,8 @@ describe('useTrackManagement', () => {
|
||||
pathRef: mockPathRef,
|
||||
stations: mockStations,
|
||||
passengers: mockPassengers,
|
||||
maxCars: 3,
|
||||
carSpacing: 7,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
@@ -29,6 +38,7 @@ interface CompatibleGameState {
|
||||
style: string
|
||||
timeoutSetting: string
|
||||
complementDisplay: string
|
||||
maxConcurrentPassengers: number
|
||||
|
||||
// Current question (extracted from currentQuestions[localPlayerId])
|
||||
currentQuestion: any | null
|
||||
@@ -93,7 +103,7 @@ interface ComplementRaceContextValue {
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
submitAnswer: (answer: number, responseTime: number) => void
|
||||
claimPassenger: (passengerId: string) => void
|
||||
claimPassenger: (passengerId: string, carIndex: number) => void
|
||||
deliverPassenger: (passengerId: string) => void
|
||||
nextQuestion: () => void
|
||||
endGame: () => void
|
||||
@@ -102,6 +112,7 @@ interface ComplementRaceContextValue {
|
||||
setConfig: (field: keyof ComplementRaceConfig, value: unknown) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
boostMomentum: (correct: boolean) => void // Client-side momentum boost/reduce
|
||||
}
|
||||
|
||||
const ComplementRaceContext = createContext<ComplementRaceContextValue | null>(null)
|
||||
@@ -119,12 +130,71 @@ export function useComplementRace() {
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* For now, just return current state - server will validate and send back authoritative state
|
||||
* Apply moves immediately on client for responsive UI, server will confirm or reject
|
||||
*/
|
||||
function applyMoveOptimistically(state: ComplementRaceState, move: GameMove): ComplementRaceState {
|
||||
// Simple optimistic updates can be added here later
|
||||
// For now, rely on server validation
|
||||
return state
|
||||
const typedMove = move as ComplementRaceMove
|
||||
|
||||
switch (typedMove.type) {
|
||||
case 'CLAIM_PASSENGER': {
|
||||
// Optimistically mark passenger as claimed and assign to car
|
||||
const passengerId = typedMove.data.passengerId
|
||||
const carIndex = typedMove.data.carIndex
|
||||
const updatedPassengers = state.passengers.map((p) =>
|
||||
p.id === passengerId ? { ...p, claimedBy: typedMove.playerId, carIndex } : p
|
||||
)
|
||||
|
||||
// Optimistically add to player's passenger list
|
||||
const updatedPlayers = { ...state.players }
|
||||
const player = updatedPlayers[typedMove.playerId]
|
||||
if (player) {
|
||||
updatedPlayers[typedMove.playerId] = {
|
||||
...player,
|
||||
passengers: [...player.passengers, passengerId],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
passengers: updatedPassengers,
|
||||
players: updatedPlayers,
|
||||
}
|
||||
}
|
||||
|
||||
case 'DELIVER_PASSENGER': {
|
||||
// Optimistically mark passenger as delivered and award points
|
||||
const passengerId = typedMove.data.passengerId
|
||||
const passenger = state.passengers.find((p) => p.id === passengerId)
|
||||
if (!passenger) return state
|
||||
|
||||
const points = passenger.isUrgent ? 20 : 10
|
||||
const updatedPassengers = state.passengers.map((p) =>
|
||||
p.id === passengerId ? { ...p, deliveredBy: typedMove.playerId } : p
|
||||
)
|
||||
|
||||
// Optimistically remove from player's passenger list and update score
|
||||
const updatedPlayers = { ...state.players }
|
||||
const player = updatedPlayers[typedMove.playerId]
|
||||
if (player) {
|
||||
updatedPlayers[typedMove.playerId] = {
|
||||
...player,
|
||||
passengers: player.passengers.filter((id) => id !== passengerId),
|
||||
deliveredPassengers: player.deliveredPassengers + 1,
|
||||
score: player.score + points,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
passengers: updatedPassengers,
|
||||
players: updatedPlayers,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// For other moves, rely on server validation
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,6 +310,45 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
}, [activePlayers, players])
|
||||
|
||||
// Debug logging ref (track last logged values)
|
||||
const lastLogRef = useState({ key: '', count: 0 })[0]
|
||||
|
||||
// Client-side game state (NOT synced to server - purely visual/gameplay)
|
||||
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)
|
||||
|
||||
// Decay rates based on skill level (momentum lost per second)
|
||||
const MOMENTUM_DECAY_RATES = {
|
||||
preschool: 2.0,
|
||||
kindergarten: 3.5,
|
||||
relaxed: 5.0,
|
||||
slow: 7.0,
|
||||
normal: 9.0,
|
||||
fast: 11.0,
|
||||
expert: 13.0,
|
||||
}
|
||||
|
||||
const MOMENTUM_GAIN_PER_CORRECT = 15
|
||||
const MOMENTUM_LOSS_PER_WRONG = 10
|
||||
const SPEED_MULTIPLIER = 0.15 // momentum * 0.15 = % per second
|
||||
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
|
||||
|
||||
// Transform multiplayer state to look like single-player state
|
||||
const compatibleState = useMemo((): CompatibleGameState => {
|
||||
const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null
|
||||
@@ -264,6 +373,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
style: multiplayerState.config.style,
|
||||
timeoutSetting: multiplayerState.config.timeoutSetting,
|
||||
complementDisplay: multiplayerState.config.complementDisplay,
|
||||
maxConcurrentPassengers: multiplayerState.config.maxConcurrentPassengers,
|
||||
|
||||
// Current question
|
||||
currentQuestion: localPlayerId
|
||||
@@ -290,23 +400,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
// Race mechanics
|
||||
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,
|
||||
})),
|
||||
speedMultiplier:
|
||||
multiplayerState.config.style === 'practice'
|
||||
? 0.7
|
||||
: multiplayerState.config.style === 'sprint'
|
||||
? 0.9
|
||||
: 1.0, // Base speed multipliers by mode
|
||||
aiRacers: clientAIRacers, // Use client-side AI state
|
||||
|
||||
// Sprint mode specific
|
||||
momentum: localPlayer?.momentum || 0,
|
||||
trainPosition: localPlayer?.position || 0,
|
||||
pressure: localPlayer?.momentum ? Math.min(100, localPlayer.momentum + 10) : 0,
|
||||
// Sprint mode specific (all client-side for smooth movement)
|
||||
momentum: clientMomentum, // Client-only state with continuous decay
|
||||
trainPosition: clientPosition, // Client-calculated from momentum
|
||||
pressure: clientPressure, // Client-calculated from momentum (0-150 PSI)
|
||||
elapsedTime: multiplayerState.gameStartTime ? Date.now() - multiplayerState.gameStartTime : 0,
|
||||
lastCorrectAnswerTime: localPlayer?.lastAnswerTime || Date.now(),
|
||||
currentRoute: multiplayerState.currentRoute,
|
||||
@@ -328,7 +433,131 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
adaptiveFeedback: localUIState.adaptiveFeedback,
|
||||
difficultyTracker: localUIState.difficultyTracker,
|
||||
}
|
||||
}, [multiplayerState, localPlayerId, localUIState])
|
||||
}, [
|
||||
multiplayerState,
|
||||
localPlayerId,
|
||||
localUIState,
|
||||
clientPosition,
|
||||
clientPressure,
|
||||
clientMomentum,
|
||||
clientAIRacers,
|
||||
])
|
||||
|
||||
// Initialize game start time when game becomes active
|
||||
useEffect(() => {
|
||||
if (compatibleState.isGameActive && compatibleState.style === 'sprint') {
|
||||
if (gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
// Reset client state for new game
|
||||
setClientMomentum(10) // Start with gentle push
|
||||
setClientPosition(0)
|
||||
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
|
||||
}
|
||||
} else {
|
||||
// Reset when game ends
|
||||
gameStartTimeRef.current = 0
|
||||
}
|
||||
}, [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 = ['Swift AI', 'Math Bot', 'Speed Demon', 'Brain Bot']
|
||||
const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical']
|
||||
|
||||
const newAI = []
|
||||
for (let i = 0; i < Math.min(count, aiNames.length); i++) {
|
||||
// Use original balanced speeds: 0.32 for Swift AI, 0.2 for Math Bot
|
||||
const baseSpeed = i === 0 ? 0.32 : 0.2
|
||||
newAI.push({
|
||||
id: `ai-${i}`,
|
||||
name: aiNames[i],
|
||||
personality: personalities[i % personalities.length] as 'competitive' | 'analytical',
|
||||
position: 0,
|
||||
speed: baseSpeed, // Balanced speed from original single-player version
|
||||
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
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const deltaTime = now - lastUpdateRef.current
|
||||
lastUpdateRef.current = now
|
||||
|
||||
// Get decay rate based on skill level
|
||||
const decayRate =
|
||||
MOMENTUM_DECAY_RATES[compatibleState.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] ||
|
||||
MOMENTUM_DECAY_RATES.normal
|
||||
|
||||
setClientMomentum((prevMomentum) => {
|
||||
// Calculate momentum decay for this frame
|
||||
const momentumLoss = (decayRate * deltaTime) / 1000
|
||||
|
||||
// Update momentum (don't go below 0)
|
||||
const newMomentum = Math.max(0, prevMomentum - momentumLoss)
|
||||
|
||||
// Calculate speed from momentum (% per second)
|
||||
const speed = newMomentum * SPEED_MULTIPLIER
|
||||
|
||||
// Update position (accumulate, never go backward)
|
||||
const positionDelta = (speed * deltaTime) / 1000
|
||||
setClientPosition((prev) => prev + positionDelta)
|
||||
|
||||
// Calculate pressure (0-150 PSI)
|
||||
const pressure = Math.min(150, (newMomentum / 100) * 150)
|
||||
setClientPressure(pressure)
|
||||
|
||||
return newMomentum
|
||||
})
|
||||
}, UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
compatibleState.isGameActive,
|
||||
compatibleState.style,
|
||||
compatibleState.timeoutSetting,
|
||||
MOMENTUM_DECAY_RATES,
|
||||
SPEED_MULTIPLIER,
|
||||
UPDATE_INTERVAL,
|
||||
])
|
||||
|
||||
// Reset client position when route changes
|
||||
useEffect(() => {
|
||||
const currentRoute = multiplayerState.currentRoute
|
||||
// When route changes, reset position and give starting momentum
|
||||
if (currentRoute > 1 && compatibleState.style === 'sprint') {
|
||||
console.log(
|
||||
`[Provider] Route changed to ${currentRoute}, resetting position. Passengers: ${multiplayerState.passengers.length}`
|
||||
)
|
||||
setClientPosition(0)
|
||||
setClientMomentum(10) // Reset to starting momentum (gentle push)
|
||||
}
|
||||
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
|
||||
|
||||
// Keep lastLogRef for future debugging needs
|
||||
// (removed debug logging)
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
@@ -374,7 +603,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
const claimPassenger = useCallback(
|
||||
(passengerId: string) => {
|
||||
(passengerId: string, carIndex: number) => {
|
||||
const currentPlayerId = activePlayers.find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal
|
||||
@@ -386,7 +615,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
type: 'CLAIM_PASSENGER',
|
||||
playerId: currentPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { passengerId },
|
||||
data: { passengerId, carIndex },
|
||||
} as ComplementRaceMove)
|
||||
},
|
||||
[activePlayers, players, viewerId, sendMove]
|
||||
@@ -482,8 +711,6 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
// Compatibility dispatch function for existing UI components
|
||||
const dispatch = useCallback(
|
||||
(action: { type: string; [key: string]: any }) => {
|
||||
console.log('[ComplementRaceProvider] dispatch called (compatibility layer):', action.type)
|
||||
|
||||
// Map old reducer actions to new action creators
|
||||
switch (action.type) {
|
||||
case 'START_COUNTDOWN':
|
||||
@@ -497,6 +724,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
break
|
||||
case 'NEXT_QUESTION':
|
||||
setLocalUIState((prev) => ({ ...prev, currentInput: '' }))
|
||||
nextQuestion()
|
||||
break
|
||||
case 'END_RACE':
|
||||
@@ -529,8 +757,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
break
|
||||
case 'BOARD_PASSENGER':
|
||||
case 'CLAIM_PASSENGER':
|
||||
if (action.passengerId !== undefined) {
|
||||
claimPassenger(action.passengerId)
|
||||
if (action.passengerId !== undefined && action.carIndex !== undefined) {
|
||||
claimPassenger(action.passengerId, action.carIndex)
|
||||
}
|
||||
break
|
||||
case 'DELIVER_PASSENGER':
|
||||
@@ -538,6 +766,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
deliverPassenger(action.passengerId)
|
||||
}
|
||||
break
|
||||
case 'START_NEW_ROUTE':
|
||||
// Send route progression to server
|
||||
if (action.routeNumber !== undefined) {
|
||||
console.log(`[Provider] Dispatching START_NEW_ROUTE for route ${action.routeNumber}`)
|
||||
sendMove({
|
||||
type: 'START_NEW_ROUTE',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { routeNumber: action.routeNumber },
|
||||
} as ComplementRaceMove)
|
||||
}
|
||||
break
|
||||
// Local UI state actions
|
||||
case 'UPDATE_INPUT':
|
||||
setLocalUIState((prev) => ({ ...prev, currentInput: action.input || '' }))
|
||||
@@ -560,6 +800,18 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
newBubbles.set(action.racerId, action.message)
|
||||
return { ...prev, activeSpeechBubbles: newBubbles }
|
||||
})
|
||||
// Update racer's lastComment time and cooldown to prevent spam
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) =>
|
||||
racer.id === action.racerId
|
||||
? {
|
||||
...racer,
|
||||
lastComment: Date.now(),
|
||||
commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds
|
||||
}
|
||||
: racer
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'CLEAR_AI_COMMENT': {
|
||||
@@ -570,15 +822,55 @@ 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
|
||||
}
|
||||
case 'UPDATE_AI_SPEEDS': {
|
||||
// Update client-side AI speeds (adaptive difficulty)
|
||||
if (action.racers && Array.isArray(action.racers)) {
|
||||
setClientAIRacers((prevRacers) =>
|
||||
prevRacers.map((racer) => {
|
||||
const update = action.racers.find(
|
||||
(r: { id: string; speed: number }) => r.id === racer.id
|
||||
)
|
||||
return update
|
||||
? {
|
||||
...racer,
|
||||
speed: update.speed,
|
||||
}
|
||||
: racer
|
||||
})
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'UPDATE_DIFFICULTY_TRACKER': {
|
||||
// Update local difficulty tracker state
|
||||
setLocalUIState((prev) => ({ ...prev, difficultyTracker: action.tracker }))
|
||||
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':
|
||||
case 'UPDATE_DIFFICULTY_TRACKER':
|
||||
case 'UPDATE_AI_SPEEDS':
|
||||
case 'GENERATE_PASSENGERS':
|
||||
case 'START_NEW_ROUTE':
|
||||
case 'GENERATE_PASSENGERS': // Passengers generated server-side when route starts
|
||||
case 'COMPLETE_ROUTE':
|
||||
case 'HIDE_ROUTE_CELEBRATION':
|
||||
case 'COMPLETE_LAP':
|
||||
@@ -598,9 +890,28 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
claimPassenger,
|
||||
deliverPassenger,
|
||||
multiplayerState.questionStartTime,
|
||||
sendMove,
|
||||
activePlayers,
|
||||
viewerId,
|
||||
]
|
||||
)
|
||||
|
||||
// Client-side momentum boost/reduce (sprint mode only)
|
||||
const boostMomentum = useCallback(
|
||||
(correct: boolean) => {
|
||||
if (compatibleState.style !== 'sprint') return
|
||||
|
||||
setClientMomentum((prevMomentum) => {
|
||||
if (correct) {
|
||||
return Math.min(100, prevMomentum + MOMENTUM_GAIN_PER_CORRECT)
|
||||
} else {
|
||||
return Math.max(0, prevMomentum - MOMENTUM_LOSS_PER_WRONG)
|
||||
}
|
||||
})
|
||||
},
|
||||
[compatibleState.style, MOMENTUM_GAIN_PER_CORRECT, MOMENTUM_LOSS_PER_WRONG]
|
||||
)
|
||||
|
||||
const contextValue: ComplementRaceContextValue = {
|
||||
state: compatibleState, // Use transformed state
|
||||
dispatch,
|
||||
@@ -616,6 +927,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
boostMomentum, // Client-side momentum control
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -76,12 +76,6 @@ export class ComplementRaceValidator
|
||||
implements GameValidator<ComplementRaceState, ComplementRaceMove>
|
||||
{
|
||||
validateMove(state: ComplementRaceState, move: ComplementRaceMove): ValidationResult {
|
||||
console.log('[ComplementRace] Validating move:', {
|
||||
type: move.type,
|
||||
playerId: move.playerId,
|
||||
gamePhase: state.gamePhase,
|
||||
})
|
||||
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
@@ -104,7 +98,12 @@ export class ComplementRaceValidator
|
||||
return this.validateUpdateInput(state, move.playerId, move.data.input)
|
||||
|
||||
case 'CLAIM_PASSENGER':
|
||||
return this.validateClaimPassenger(state, move.playerId, move.data.passengerId)
|
||||
return this.validateClaimPassenger(
|
||||
state,
|
||||
move.playerId,
|
||||
move.data.passengerId,
|
||||
move.data.carIndex
|
||||
)
|
||||
|
||||
case 'DELIVER_PASSENGER':
|
||||
return this.validateDeliverPassenger(state, move.playerId, move.data.passengerId)
|
||||
@@ -168,8 +167,7 @@ export class ComplementRaceValidator
|
||||
bestStreak: 0,
|
||||
correctAnswers: 0,
|
||||
totalQuestions: 0,
|
||||
position: 0,
|
||||
momentum: 50, // Start with some momentum in sprint mode
|
||||
position: 0, // Only used for practice/survival; sprint mode is client-side
|
||||
isReady: false,
|
||||
isActive: true,
|
||||
currentAnswer: null,
|
||||
@@ -191,8 +189,25 @@ export class ComplementRaceValidator
|
||||
? this.generatePassengers(state.config.passengerCount, state.stations)
|
||||
: []
|
||||
|
||||
// Calculate maxConcurrentPassengers based on initial passenger layout (sprint mode only)
|
||||
let updatedConfig = state.config
|
||||
if (state.config.style === 'sprint' && passengers.length > 0) {
|
||||
const maxConcurrentPassengers = Math.max(
|
||||
1,
|
||||
this.calculateMaxConcurrentPassengers(passengers, state.stations)
|
||||
)
|
||||
console.log(
|
||||
`[Game Start] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${passengers.length} passengers`
|
||||
)
|
||||
updatedConfig = {
|
||||
...state.config,
|
||||
maxConcurrentPassengers,
|
||||
}
|
||||
}
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
...state,
|
||||
config: updatedConfig,
|
||||
gamePhase: 'playing', // Go directly to playing (countdown can be added later)
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata as typeof state.playerMetadata,
|
||||
@@ -203,6 +218,7 @@ export class ComplementRaceValidator
|
||||
routeStartTime: state.config.style === 'sprint' ? Date.now() : null,
|
||||
raceStartTime: Date.now(), // Race starts immediately
|
||||
gameStartTime: Date.now(),
|
||||
aiOpponents: [], // AI handled client-side
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -317,12 +333,9 @@ export class ComplementRaceValidator
|
||||
updatedPlayer.position = Math.min(100, player.position + 100 / state.config.raceGoal)
|
||||
}
|
||||
} else if (state.config.style === 'sprint') {
|
||||
// Sprint: Update momentum
|
||||
if (correct) {
|
||||
updatedPlayer.momentum = Math.min(100, player.momentum + 15)
|
||||
} else {
|
||||
updatedPlayer.momentum = Math.max(0, player.momentum - 10)
|
||||
}
|
||||
// Sprint: All momentum/position handled client-side for smooth 20fps movement
|
||||
// Server only tracks scoring, passengers, and game progression
|
||||
// No server-side position updates needed
|
||||
} else if (state.config.style === 'survival') {
|
||||
// Survival: Always move forward, speed based on accuracy
|
||||
const moveDistance = correct ? 5 : 2
|
||||
@@ -391,7 +404,8 @@ export class ComplementRaceValidator
|
||||
private validateClaimPassenger(
|
||||
state: ComplementRaceState,
|
||||
playerId: string,
|
||||
passengerId: string
|
||||
passengerId: string,
|
||||
carIndex: number
|
||||
): ValidationResult {
|
||||
if (state.config.style !== 'sprint') {
|
||||
return { valid: false, error: 'Passengers only available in sprint mode' }
|
||||
@@ -418,22 +432,27 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Passenger already claimed' }
|
||||
}
|
||||
|
||||
// Check if player is at the origin station (within 5% tolerance)
|
||||
const originStation = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!originStation) {
|
||||
return { valid: false, error: 'Origin station not found' }
|
||||
// Sprint mode: Position is client-side, trust client's spatial checking
|
||||
// (Client checks position in useSteamJourney before sending CLAIM move)
|
||||
// Other modes: Validate position server-side
|
||||
if (state.config.style !== 'sprint') {
|
||||
const originStation = state.stations.find((s) => s.id === passenger.originStationId)
|
||||
if (!originStation) {
|
||||
return { valid: false, error: 'Origin station not found' }
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - originStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at origin station' }
|
||||
}
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - originStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at origin station' }
|
||||
}
|
||||
|
||||
// Claim passenger
|
||||
// Claim passenger and assign to physical car
|
||||
const updatedPassengers = [...state.passengers]
|
||||
updatedPassengers[passengerIndex] = {
|
||||
...passenger,
|
||||
claimedBy: playerId,
|
||||
carIndex, // Store which physical car (0-N) the passenger is seated in
|
||||
}
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
@@ -481,15 +500,19 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Passenger already delivered' }
|
||||
}
|
||||
|
||||
// Check if player is at destination station (within 5% tolerance)
|
||||
const destStation = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!destStation) {
|
||||
return { valid: false, error: 'Destination station not found' }
|
||||
}
|
||||
// Sprint mode: Position is client-side, trust client's spatial checking
|
||||
// (Client checks position in useSteamJourney before sending DELIVER move)
|
||||
// Other modes: Validate position server-side
|
||||
if (state.config.style !== 'sprint') {
|
||||
const destStation = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!destStation) {
|
||||
return { valid: false, error: 'Destination station not found' }
|
||||
}
|
||||
|
||||
const distance = Math.abs(player.position - destStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at destination station' }
|
||||
const distance = Math.abs(player.position - destStation.position)
|
||||
if (distance > 5) {
|
||||
return { valid: false, error: 'Not at destination station' }
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver passenger and award points
|
||||
@@ -522,12 +545,12 @@ export class ComplementRaceValidator
|
||||
return { valid: false, error: 'Routes only available in sprint mode' }
|
||||
}
|
||||
|
||||
// Reset all player positions to 0
|
||||
// Reset all player positions to 0 for new route (client handles momentum reset)
|
||||
const resetPlayers: Record<string, PlayerState> = {}
|
||||
for (const [playerId, player] of Object.entries(state.players)) {
|
||||
resetPlayers[playerId] = {
|
||||
...player,
|
||||
position: 0,
|
||||
position: 0, // Server position not used in sprint; client will reset
|
||||
passengers: [], // Clear any remaining passengers
|
||||
}
|
||||
}
|
||||
@@ -535,12 +558,26 @@ export class ComplementRaceValidator
|
||||
// Generate new passengers
|
||||
const newPassengers = this.generatePassengers(state.config.passengerCount, state.stations)
|
||||
|
||||
// Calculate maxConcurrentPassengers based on the new route's passenger layout
|
||||
const maxConcurrentPassengers = Math.max(
|
||||
1,
|
||||
this.calculateMaxConcurrentPassengers(newPassengers, state.stations)
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[Route ${routeNumber}] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${newPassengers.length} passengers`
|
||||
)
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
...state,
|
||||
currentRoute: routeNumber,
|
||||
routeStartTime: Date.now(),
|
||||
players: resetPlayers,
|
||||
passengers: newPassengers,
|
||||
config: {
|
||||
...state.config,
|
||||
maxConcurrentPassengers, // Update config with calculated value
|
||||
},
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
@@ -661,37 +698,126 @@ export class ComplementRaceValidator
|
||||
|
||||
private generatePassengers(count: number, stations: Station[]): Passenger[] {
|
||||
const passengers: Passenger[] = []
|
||||
const usedCombos = new Set<string>()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Pick random origin and destination (must be different)
|
||||
const originIndex = Math.floor(Math.random() * stations.length)
|
||||
let destIndex = Math.floor(Math.random() * stations.length)
|
||||
while (destIndex === originIndex) {
|
||||
destIndex = Math.floor(Math.random() * stations.length)
|
||||
let name: string
|
||||
let avatar: string
|
||||
let comboKey: string
|
||||
|
||||
// Keep trying until we get a unique name/avatar combo
|
||||
do {
|
||||
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
|
||||
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
|
||||
name = PASSENGER_NAMES[nameIndex]
|
||||
avatar = PASSENGER_AVATARS[avatarIndex]
|
||||
comboKey = `${name}-${avatar}`
|
||||
} while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop
|
||||
|
||||
usedCombos.add(comboKey)
|
||||
|
||||
// Pick origin and destination stations
|
||||
// KEY: Destination must be AHEAD of origin (higher position on track)
|
||||
// This ensures passengers travel forward, creating better overlap
|
||||
let originStation: Station
|
||||
let destinationStation: Station
|
||||
|
||||
if (Math.random() < 0.4 || stations.length < 3) {
|
||||
// 40% chance to start at depot (first station)
|
||||
originStation = stations[0]
|
||||
// Pick any station ahead as destination
|
||||
const stationsAhead = stations.slice(1)
|
||||
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
} else {
|
||||
// Start at a random non-depot, non-final station
|
||||
const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station
|
||||
originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]
|
||||
// Pick a station ahead of origin (higher position)
|
||||
const stationsAhead = stations.filter((s) => s.position > originStation.position)
|
||||
destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)]
|
||||
}
|
||||
|
||||
const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length)
|
||||
const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length)
|
||||
// 30% chance of urgent
|
||||
const isUrgent = Math.random() < 0.3
|
||||
|
||||
passengers.push({
|
||||
const passenger = {
|
||||
id: `p-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: PASSENGER_NAMES[nameIndex],
|
||||
avatar: PASSENGER_AVATARS[avatarIndex],
|
||||
originStationId: stations[originIndex].id,
|
||||
destinationStationId: stations[destIndex].id,
|
||||
isUrgent: Math.random() < 0.3, // 30% chance of urgent
|
||||
name,
|
||||
avatar,
|
||||
originStationId: originStation.id,
|
||||
destinationStationId: destinationStation.id,
|
||||
isUrgent,
|
||||
claimedBy: null,
|
||||
deliveredBy: null,
|
||||
carIndex: null, // Not boarded yet
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
passengers.push(passenger)
|
||||
|
||||
console.log(
|
||||
`[Passenger ${i + 1}/${count}] ${name} waiting at ${originStation.emoji} ${originStation.name} (pos ${originStation.position}) → ${destinationStation.emoji} ${destinationStation.name} (pos ${destinationStation.position}) ${isUrgent ? '⚡ URGENT' : ''}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[Generated ${passengers.length} passengers total]`)
|
||||
return passengers
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the maximum number of passengers that will be on the train
|
||||
* concurrently at any given moment during the route
|
||||
*/
|
||||
private calculateMaxConcurrentPassengers(passengers: Passenger[], stations: Station[]): number {
|
||||
// Create events for boarding and delivery
|
||||
interface StationEvent {
|
||||
position: number
|
||||
isBoarding: boolean // true = board, false = delivery
|
||||
}
|
||||
|
||||
const events: StationEvent[] = []
|
||||
|
||||
for (const passenger of passengers) {
|
||||
const originStation = stations.find((s) => s.id === passenger.originStationId)
|
||||
const destStation = stations.find((s) => s.id === passenger.destinationStationId)
|
||||
|
||||
if (originStation && destStation) {
|
||||
events.push({ position: originStation.position, isBoarding: true })
|
||||
events.push({ position: destStation.position, isBoarding: false })
|
||||
}
|
||||
}
|
||||
|
||||
// Sort events by position, with deliveries before boardings at the same position
|
||||
events.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
// At same position, deliveries happen before boarding
|
||||
return a.isBoarding ? 1 : -1
|
||||
})
|
||||
|
||||
// Track current passenger count and maximum
|
||||
let currentCount = 0
|
||||
let maxCount = 0
|
||||
|
||||
for (const event of events) {
|
||||
if (event.isBoarding) {
|
||||
currentCount++
|
||||
maxCount = Math.max(maxCount, currentCount)
|
||||
} else {
|
||||
currentCount--
|
||||
}
|
||||
}
|
||||
|
||||
return maxCount
|
||||
}
|
||||
|
||||
private checkWinCondition(state: ComplementRaceState): string | null {
|
||||
const { config, players } = state
|
||||
|
||||
// Infinite mode: Never end the game
|
||||
if (config.winCondition === 'infinite') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Practice mode: First to reach goal
|
||||
if (config.style === 'practice') {
|
||||
for (const [playerId, player] of Object.entries(players)) {
|
||||
@@ -699,6 +825,7 @@ export class ComplementRaceValidator
|
||||
return playerId
|
||||
}
|
||||
}
|
||||
// AI wins handled client-side via useAIRacers hook
|
||||
}
|
||||
|
||||
// Sprint mode: Check route-based, score-based, or time-based win conditions
|
||||
@@ -750,12 +877,15 @@ export class ComplementRaceValidator
|
||||
// Find player with highest position (most laps)
|
||||
let maxPosition = 0
|
||||
let winner: string | null = null
|
||||
|
||||
for (const [playerId, player] of Object.entries(players)) {
|
||||
if (player.position > maxPosition) {
|
||||
maxPosition = player.position
|
||||
winner = playerId
|
||||
}
|
||||
}
|
||||
// AI wins handled client-side via useAIRacers hook
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Complement Race Game Component with Navigation
|
||||
* Wraps the existing ComplementRaceGame with PageWithNav for arcade play
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { ComplementRaceGame } from '@/app/arcade/complement-race/components/ComplementRaceGame'
|
||||
import { useComplementRace } from '../Provider'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, goToSetup } = useComplementRace()
|
||||
|
||||
// Get display name based on style
|
||||
const getNavTitle = () => {
|
||||
switch (state.style) {
|
||||
case 'sprint':
|
||||
return 'Steam Sprint'
|
||||
case 'survival':
|
||||
return 'Endless Circuit'
|
||||
case 'practice':
|
||||
default:
|
||||
return 'Complement Race'
|
||||
}
|
||||
}
|
||||
|
||||
// Get emoji based on style
|
||||
const getNavEmoji = () => {
|
||||
switch (state.style) {
|
||||
case 'sprint':
|
||||
return '🚂'
|
||||
case 'survival':
|
||||
return '♾️'
|
||||
case 'practice':
|
||||
default:
|
||||
return '🏁'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle={getNavTitle()}
|
||||
navEmoji={getNavEmoji()}
|
||||
emphasizePlayerSelection={state.gamePhase === 'controls'}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
goToSetup()
|
||||
}}
|
||||
>
|
||||
<ComplementRaceGame />
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { complementRaceValidator } from './Validator'
|
||||
import { ComplementRaceProvider } from './Provider'
|
||||
import { ComplementRaceGame } from '@/app/arcade/complement-race/components/ComplementRaceGame'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import type { ComplementRaceConfig, ComplementRaceState, ComplementRaceMove } from './types'
|
||||
|
||||
// Game manifest
|
||||
@@ -41,7 +41,7 @@ const defaultConfig: ComplementRaceConfig = {
|
||||
passengerCount: 6,
|
||||
maxConcurrentPassengers: 3,
|
||||
raceGoal: 20,
|
||||
winCondition: 'route-based',
|
||||
winCondition: 'infinite', // Sprint mode is infinite by default (Steam Sprint)
|
||||
routeCount: 3,
|
||||
targetScore: 100,
|
||||
timeLimit: 300,
|
||||
@@ -69,7 +69,7 @@ export const complementRaceGame = defineGame<
|
||||
>({
|
||||
manifest,
|
||||
Provider: ComplementRaceProvider,
|
||||
GameComponent: ComplementRaceGame,
|
||||
GameComponent,
|
||||
validator: complementRaceValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateComplementRaceConfig,
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Passenger {
|
||||
isUrgent: boolean // Urgent passengers worth 2x points
|
||||
claimedBy: string | null // playerId who picked up this passenger (null = unclaimed)
|
||||
deliveredBy: string | null // playerId who delivered (null = not delivered yet)
|
||||
carIndex: number | null // Physical car index (0-N) where passenger is seated (null = not boarded)
|
||||
timestamp: number // When passenger spawned
|
||||
}
|
||||
|
||||
@@ -61,8 +62,7 @@ export interface PlayerState {
|
||||
totalQuestions: number
|
||||
|
||||
// Position & Progress
|
||||
position: number // 0-100% for practice/sprint, lap count for survival
|
||||
momentum: number // 0-100 (sprint mode only)
|
||||
position: number // 0-100% for practice/survival only (sprint mode: client-side)
|
||||
|
||||
// Current state
|
||||
isReady: boolean
|
||||
@@ -143,7 +143,7 @@ export type ComplementRaceMove = BaseGameMove &
|
||||
// Playing phase
|
||||
| { type: 'SUBMIT_ANSWER'; data: { answer: number; responseTime: number } }
|
||||
| { type: 'UPDATE_INPUT'; data: { input: string } } // Show "thinking" indicator
|
||||
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string } } // Sprint mode: pickup
|
||||
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string; carIndex: number } } // Sprint mode: pickup
|
||||
| { type: 'DELIVER_PASSENGER'; data: { passengerId: string } } // Sprint mode: delivery
|
||||
|
||||
// Game flow
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Provider
|
||||
*
|
||||
* Context provider for Math Sprint game state management.
|
||||
* Demonstrates free-for-all gameplay with TEAM_MOVE pattern.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
|
||||
import type { Difficulty, MathSprintState } from './types'
|
||||
|
||||
/**
|
||||
* Context value provided to child components
|
||||
*/
|
||||
interface MathSprintContextValue {
|
||||
state: MathSprintState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
submitAnswer: (answer: number) => void
|
||||
nextQuestion: () => void
|
||||
resetGame: () => void
|
||||
setConfig: (field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const MathSprintContext = createContext<MathSprintContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Math Sprint context
|
||||
*/
|
||||
export function useMathSprint() {
|
||||
const context = useContext(MathSprintContext)
|
||||
if (!context) {
|
||||
throw new Error('useMathSprint must be used within MathSprintProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Math Sprint Provider Component
|
||||
*/
|
||||
export function MathSprintProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array (keep Set iteration order)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room with defaults
|
||||
const gameConfig = useMemo(() => {
|
||||
const allGameConfigs = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = allGameConfigs?.['math-sprint'] as Record<string, unknown> | undefined
|
||||
return {
|
||||
difficulty: (savedConfig?.difficulty as Difficulty) || 'medium',
|
||||
questionsPerRound: (savedConfig?.questionsPerRound as number) || 10,
|
||||
timePerQuestion: (savedConfig?.timePerQuestion as number) || 30,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Initial state with merged config
|
||||
const initialState = useMemo<MathSprintState>(
|
||||
() => ({
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
difficulty: gameConfig.difficulty,
|
||||
questionsPerRound: gameConfig.questionsPerRound,
|
||||
timePerQuestion: gameConfig.timePerQuestion,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [],
|
||||
scores: {},
|
||||
correctAnswersCount: {},
|
||||
answers: [],
|
||||
questionStartTime: 0,
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}),
|
||||
[gameConfig]
|
||||
)
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession<MathSprintState>(
|
||||
{
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: (state) => state, // Server handles all state updates
|
||||
}
|
||||
)
|
||||
|
||||
// Action: Start game
|
||||
const startGame = useCallback(() => {
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: TEAM_MOVE, // Free-for-all: no specific turn owner
|
||||
userId: viewerId || '',
|
||||
data: { activePlayers, playerMetadata },
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
// Action: Submit answer
|
||||
const submitAnswer = useCallback(
|
||||
(answer: number) => {
|
||||
// Find this user's player ID from game state
|
||||
const myPlayerId = state.activePlayers.find((pid) => {
|
||||
return state.playerMetadata[pid]?.userId === viewerId
|
||||
})
|
||||
|
||||
if (!myPlayerId) {
|
||||
console.error('[MathSprint] No player found for current user')
|
||||
return
|
||||
}
|
||||
|
||||
sendMove({
|
||||
type: 'SUBMIT_ANSWER',
|
||||
playerId: myPlayerId, // Specific player answering
|
||||
userId: viewerId || '',
|
||||
data: { answer },
|
||||
})
|
||||
},
|
||||
[state.activePlayers, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
// Action: Next question
|
||||
const nextQuestion = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_QUESTION',
|
||||
playerId: TEAM_MOVE, // Any player can advance
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
// Action: Reset game
|
||||
const resetGame = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'RESET_GAME',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
// Action: Set config
|
||||
const setConfig = useCallback(
|
||||
(field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: TEAM_MOVE,
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database for next session
|
||||
if (roomData?.id) {
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: {
|
||||
...roomData.gameConfig,
|
||||
'math-sprint': {
|
||||
...(roomData.gameConfig?.['math-sprint'] || {}),
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[viewerId, sendMove, updateGameConfig, roomData]
|
||||
)
|
||||
|
||||
const contextValue: MathSprintContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
submitAnswer,
|
||||
nextQuestion,
|
||||
resetGame,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return <MathSprintContext.Provider value={contextValue}>{children}</MathSprintContext.Provider>
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Validator
|
||||
*
|
||||
* Server-side validation for Math Sprint game.
|
||||
* Generates questions, validates answers, awards points.
|
||||
*/
|
||||
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type {
|
||||
Difficulty,
|
||||
MathSprintConfig,
|
||||
MathSprintMove,
|
||||
MathSprintState,
|
||||
Operation,
|
||||
Question,
|
||||
} from './types'
|
||||
|
||||
export class MathSprintValidator implements GameValidator<MathSprintState, MathSprintMove> {
|
||||
/**
|
||||
* Validate a game move
|
||||
*/
|
||||
validateMove(
|
||||
state: MathSprintState,
|
||||
move: MathSprintMove,
|
||||
context?: { userId?: string }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'SUBMIT_ANSWER':
|
||||
return this.validateSubmitAnswer(
|
||||
state,
|
||||
move.playerId,
|
||||
Number(move.data.answer),
|
||||
move.timestamp
|
||||
)
|
||||
|
||||
case 'NEXT_QUESTION':
|
||||
return this.validateNextQuestion(state)
|
||||
|
||||
case 'RESET_GAME':
|
||||
return this.validateResetGame(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
return this.validateSetConfig(state, move.data.field, move.data.value)
|
||||
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if game is complete
|
||||
*/
|
||||
isGameComplete(state: MathSprintState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initial state for new game
|
||||
*/
|
||||
getInitialState(config: unknown): MathSprintState {
|
||||
const { difficulty, questionsPerRound, timePerQuestion } = config as MathSprintConfig
|
||||
|
||||
return {
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
difficulty: difficulty || 'medium',
|
||||
questionsPerRound: questionsPerRound || 10,
|
||||
timePerQuestion: timePerQuestion || 30,
|
||||
currentQuestionIndex: 0,
|
||||
questions: [],
|
||||
scores: {},
|
||||
correctAnswersCount: {},
|
||||
answers: [],
|
||||
questionStartTime: 0,
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Methods
|
||||
// ============================================================================
|
||||
|
||||
private validateStartGame(
|
||||
state: MathSprintState,
|
||||
activePlayers: string[],
|
||||
playerMetadata: Record<string, any>
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Game already started' }
|
||||
}
|
||||
|
||||
if (activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
// Generate questions
|
||||
const questions = this.generateQuestions(state.difficulty, state.questionsPerRound)
|
||||
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
questions,
|
||||
currentQuestionIndex: 0,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
correctAnswersCount: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
answers: [],
|
||||
questionStartTime: Date.now(),
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSubmitAnswer(
|
||||
state: MathSprintState,
|
||||
playerId: string,
|
||||
answer: number,
|
||||
timestamp: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in progress' }
|
||||
}
|
||||
|
||||
if (!state.activePlayers.includes(playerId)) {
|
||||
return { valid: false, error: 'Player not in game' }
|
||||
}
|
||||
|
||||
if (state.questionAnswered) {
|
||||
return { valid: false, error: 'Question already answered correctly' }
|
||||
}
|
||||
|
||||
// Check if player already answered this question
|
||||
const alreadyAnswered = state.answers.some((a) => a.playerId === playerId)
|
||||
if (alreadyAnswered) {
|
||||
return { valid: false, error: 'You already answered this question' }
|
||||
}
|
||||
|
||||
const currentQuestion = state.questions[state.currentQuestionIndex]
|
||||
const correct = answer === currentQuestion.correctAnswer
|
||||
|
||||
const answerRecord = {
|
||||
playerId,
|
||||
answer,
|
||||
timestamp,
|
||||
correct,
|
||||
}
|
||||
|
||||
const newAnswers = [...state.answers, answerRecord]
|
||||
let newState = { ...state, answers: newAnswers }
|
||||
|
||||
// If correct, award points and mark question as answered
|
||||
if (correct) {
|
||||
newState = {
|
||||
...newState,
|
||||
questionAnswered: true,
|
||||
winnerId: playerId,
|
||||
scores: {
|
||||
...state.scores,
|
||||
[playerId]: state.scores[playerId] + 10,
|
||||
},
|
||||
correctAnswersCount: {
|
||||
...state.correctAnswersCount,
|
||||
[playerId]: state.correctAnswersCount[playerId] + 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateNextQuestion(state: MathSprintState): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in progress' }
|
||||
}
|
||||
|
||||
if (!state.questionAnswered) {
|
||||
return { valid: false, error: 'Current question not answered yet' }
|
||||
}
|
||||
|
||||
const isLastQuestion = state.currentQuestionIndex >= state.questions.length - 1
|
||||
|
||||
if (isLastQuestion) {
|
||||
// Game complete, go to results
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
}
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Move to next question
|
||||
const newState: MathSprintState = {
|
||||
...state,
|
||||
currentQuestionIndex: state.currentQuestionIndex + 1,
|
||||
answers: [],
|
||||
questionStartTime: Date.now(),
|
||||
questionAnswered: false,
|
||||
winnerId: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateResetGame(state: MathSprintState): ValidationResult {
|
||||
const newState = this.getInitialState({
|
||||
difficulty: state.difficulty,
|
||||
questionsPerRound: state.questionsPerRound,
|
||||
timePerQuestion: state.timePerQuestion,
|
||||
})
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetConfig(state: MathSprintState, field: string, value: any): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Cannot change config during game' }
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Question Generation
|
||||
// ============================================================================
|
||||
|
||||
private generateQuestions(difficulty: Difficulty, count: number): Question[] {
|
||||
const questions: Question[] = []
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const operation = this.randomOperation()
|
||||
const question = this.generateQuestion(difficulty, operation, `q-${i}`)
|
||||
questions.push(question)
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
private generateQuestion(difficulty: Difficulty, operation: Operation, id: string): Question {
|
||||
let operand1: number
|
||||
let operand2: number
|
||||
let correctAnswer: number
|
||||
|
||||
switch (difficulty) {
|
||||
case 'easy':
|
||||
operand1 = this.randomInt(1, 10)
|
||||
operand2 = this.randomInt(1, 10)
|
||||
break
|
||||
case 'medium':
|
||||
operand1 = this.randomInt(10, 50)
|
||||
operand2 = this.randomInt(1, 20)
|
||||
break
|
||||
case 'hard':
|
||||
operand1 = this.randomInt(10, 100)
|
||||
operand2 = this.randomInt(10, 50)
|
||||
break
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'addition':
|
||||
correctAnswer = operand1 + operand2
|
||||
break
|
||||
case 'subtraction':
|
||||
// Ensure positive result
|
||||
if (operand1 < operand2) {
|
||||
;[operand1, operand2] = [operand2, operand1]
|
||||
}
|
||||
correctAnswer = operand1 - operand2
|
||||
break
|
||||
case 'multiplication':
|
||||
// Smaller numbers for multiplication
|
||||
if (difficulty === 'hard') {
|
||||
operand1 = this.randomInt(2, 20)
|
||||
operand2 = this.randomInt(2, 12)
|
||||
} else {
|
||||
operand1 = this.randomInt(2, 10)
|
||||
operand2 = this.randomInt(2, 10)
|
||||
}
|
||||
correctAnswer = operand1 * operand2
|
||||
break
|
||||
}
|
||||
|
||||
const operationSymbol = this.getOperationSymbol(operation)
|
||||
const displayText = `${operand1} ${operationSymbol} ${operand2} = ?`
|
||||
|
||||
return {
|
||||
id,
|
||||
operand1,
|
||||
operand2,
|
||||
operation,
|
||||
correctAnswer,
|
||||
displayText,
|
||||
}
|
||||
}
|
||||
|
||||
private randomOperation(): Operation {
|
||||
const operations: Operation[] = ['addition', 'subtraction', 'multiplication']
|
||||
return operations[Math.floor(Math.random() * operations.length)]
|
||||
}
|
||||
|
||||
private randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
private getOperationSymbol(operation: Operation): string {
|
||||
switch (operation) {
|
||||
case 'addition':
|
||||
return '+'
|
||||
case 'subtraction':
|
||||
return '−'
|
||||
case 'multiplication':
|
||||
return '×'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mathSprintValidator = new MathSprintValidator()
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Game Component
|
||||
*
|
||||
* Main wrapper component with navigation and phase routing.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useMathSprint } from '../Provider'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame } = useMathSprint()
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Math Sprint"
|
||||
navEmoji="🧮"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
// No currentPlayerId - free-for-all game, everyone can act simultaneously
|
||||
playerScores={state.scores}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
resetGame()
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Playing Phase
|
||||
*
|
||||
* Main gameplay: show question, accept answers, show feedback.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
|
||||
export function PlayingPhase() {
|
||||
const { state, submitAnswer, nextQuestion, lastError, clearError } = useMathSprint()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const currentQuestion = state.questions[state.currentQuestionIndex]
|
||||
const progress = `${state.currentQuestionIndex + 1} / ${state.questions.length}`
|
||||
|
||||
// Find if current user answered
|
||||
const myPlayerId = Object.keys(state.playerMetadata).find(
|
||||
(pid) => state.playerMetadata[pid]?.userId === viewerId
|
||||
)
|
||||
const myAnswer = state.answers.find((a) => a.playerId === myPlayerId)
|
||||
|
||||
// Auto-clear error after 3 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
// Clear input after question changes
|
||||
useEffect(() => {
|
||||
setInputValue('')
|
||||
}, [state.currentQuestionIndex])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const answer = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(answer)) return
|
||||
|
||||
submitAnswer(answer)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '700px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>
|
||||
Question {progress}
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
background: 'gray.200',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(90deg, #a78bfa, #8b5cf6)',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
transition: 'width 0.3s',
|
||||
})}
|
||||
style={{
|
||||
width: `${((state.currentQuestionIndex + 1) / state.questions.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '12px',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span>⚠️</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'red.700' })}>{lastError}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
padding: '4px 8px',
|
||||
background: 'red.100',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'red.200' },
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Display */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.300',
|
||||
borderRadius: '16px',
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{currentQuestion.displayText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Answer Input */}
|
||||
{!state.questionAnswered && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: myAnswer ? 'gray.300' : 'purple.500',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
{myAnswer ? (
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Your answer: <strong>{myAnswer.answer}</strong>
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
|
||||
Waiting for others or correct answer...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Your Answer
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '12px' })}>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your answer..."
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'purple.500',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: inputValue ? 'purple.600' : 'gray.400',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: inputValue ? 'pointer' : 'not-allowed',
|
||||
_hover: {
|
||||
background: inputValue ? 'purple.700' : 'gray.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Winner Display */}
|
||||
{state.questionAnswered && state.winnerId && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
|
||||
border: '2px solid',
|
||||
borderColor: 'green.400',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '3xl', marginBottom: '8px' })}>🎉</div>
|
||||
<div className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'green.700' })}>
|
||||
{state.playerMetadata[state.winnerId]?.name || 'Someone'} got it right!
|
||||
</div>
|
||||
<div className={css({ fontSize: 'md', color: 'green.600', marginTop: '4px' })}>
|
||||
Answer: {currentQuestion.correctAnswer}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextQuestion}
|
||||
className={css({
|
||||
marginTop: '16px',
|
||||
padding: '12px 32px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: 'green.600',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'green.700' },
|
||||
})}
|
||||
>
|
||||
Next Question →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Scores
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
|
||||
{Object.entries(state.scores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([playerId, score]) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
background: 'gray.50',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{player?.emoji}</span>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
|
||||
{player?.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}
|
||||
>
|
||||
{score} pts
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Results Phase
|
||||
*
|
||||
* Show final scores and winner.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, resetGame } = useMathSprint()
|
||||
|
||||
// Sort players by score
|
||||
const sortedPlayers = Object.entries(state.scores)
|
||||
.map(([playerId, score]) => ({
|
||||
playerId,
|
||||
score,
|
||||
correct: state.correctAnswersCount[playerId] || 0,
|
||||
player: state.playerMetadata[playerId],
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
const winner = sortedPlayers[0]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Winner Announcement */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
border: '2px solid',
|
||||
borderColor: 'yellow.400',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '4xl', marginBottom: '12px' })}>🏆</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'yellow.800',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{winner.player?.name} Wins!
|
||||
</h2>
|
||||
<div className={css({ fontSize: 'lg', color: 'yellow.700' })}>
|
||||
{winner.score} points • {winner.correct} correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final Scores */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Final Scores
|
||||
</h3>
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
|
||||
{sortedPlayers.map((item, index) => (
|
||||
<div
|
||||
key={item.playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
background: index === 0 ? 'linear-gradient(135deg, #fef3c7, #fde68a)' : 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: index === 0 ? 'yellow.300' : 'gray.200',
|
||||
borderRadius: '12px',
|
||||
})}
|
||||
>
|
||||
{/* Rank */}
|
||||
<div
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: index === 0 ? 'yellow.500' : 'gray.300',
|
||||
color: index === 0 ? 'white' : 'gray.700',
|
||||
borderRadius: '50%',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Player Info */}
|
||||
<div className={css({ flex: 1 })}>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{item.player?.emoji}</span>
|
||||
<span className={css({ fontSize: 'md', fontWeight: 'semibold' })}>
|
||||
{item.player?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.600', marginTop: '2px' })}>
|
||||
{item.correct} / {state.questions.length} correct
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: index === 0 ? 'yellow.700' : 'purple.600',
|
||||
})}
|
||||
>
|
||||
{item.score}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
|
||||
border: '1px solid',
|
||||
borderColor: 'purple.300',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
|
||||
{state.questions.length}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Questions</div>
|
||||
</div>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
|
||||
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Difficulty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Play Again Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetGame}
|
||||
className={css({
|
||||
padding: '14px 28px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: 'purple.600',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
_hover: {
|
||||
background: 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* Math Sprint - Setup Phase
|
||||
*
|
||||
* Configure game settings before starting.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMathSprint } from '../Provider'
|
||||
import type { Difficulty } from '../types'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, startGame, setConfig } = useMathSprint()
|
||||
|
||||
const handleDifficultyChange = (difficulty: Difficulty) => {
|
||||
setConfig('difficulty', difficulty)
|
||||
}
|
||||
|
||||
const handleQuestionsChange = (questions: number) => {
|
||||
setConfig('questionsPerRound', questions)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
padding: '32px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Game Title */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.700',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
🧮 Math Sprint
|
||||
</h1>
|
||||
<p className={css({ color: 'gray.600' })}>
|
||||
Race to solve math problems! First correct answer wins points.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings Card */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Game Settings
|
||||
</h2>
|
||||
|
||||
{/* Difficulty */}
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Difficulty
|
||||
</label>
|
||||
<div className={css({ display: 'flex', gap: '8px' })}>
|
||||
{(['easy', 'medium', 'hard'] as Difficulty[]).map((diff) => (
|
||||
<button
|
||||
key={diff}
|
||||
type="button"
|
||||
onClick={() => handleDifficultyChange(diff)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: state.difficulty === diff ? 'purple.500' : 'gray.300',
|
||||
background: state.difficulty === diff ? 'purple.50' : 'white',
|
||||
color: state.difficulty === diff ? 'purple.700' : 'gray.700',
|
||||
fontWeight: state.difficulty === diff ? 'semibold' : 'normal',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
borderColor: 'purple.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{diff.charAt(0).toUpperCase() + diff.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.500', marginTop: '4px' })}>
|
||||
{state.difficulty === 'easy' && 'Numbers 1-10, simple operations'}
|
||||
{state.difficulty === 'medium' && 'Numbers 1-50, varied operations'}
|
||||
{state.difficulty === 'hard' && 'Numbers 1-100, harder calculations'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Questions Per Round */}
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Questions: {state.questionsPerRound}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="20"
|
||||
step="5"
|
||||
value={state.questionsPerRound}
|
||||
onChange={(e) => handleQuestionsChange(Number(e.target.value))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}
|
||||
>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
<span>15</span>
|
||||
<span>20</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.300',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3 className={css({ fontSize: 'sm', fontWeight: 'semibold', marginBottom: '8px' })}>
|
||||
How to Play
|
||||
</h3>
|
||||
<ul className={css({ fontSize: 'sm', color: 'gray.700', paddingLeft: '20px' })}>
|
||||
<li>Solve math problems as fast as you can</li>
|
||||
<li>First correct answer earns 10 points</li>
|
||||
<li>Everyone can answer at the same time</li>
|
||||
<li>Most points wins!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Start Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
disabled={state.activePlayers.length < 2}
|
||||
className={css({
|
||||
padding: '14px 28px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.600',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: state.activePlayers.length < 2 ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.length < 2
|
||||
? `Need ${2 - state.activePlayers.length} more player(s)`
|
||||
: 'Start Game'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
name: math-sprint
|
||||
displayName: Math Sprint
|
||||
icon: 🧮
|
||||
description: Fast-paced math racing game
|
||||
longDescription: Race against other players to solve math problems! Answer questions quickly to earn points. First person to answer correctly wins the round. Features multiple difficulty levels and customizable question counts.
|
||||
maxPlayers: 6
|
||||
difficulty: Beginner
|
||||
chips:
|
||||
- 👥 Multiplayer
|
||||
- ⚡ Free-for-All
|
||||
- 🧮 Math Skills
|
||||
- 🏃 Speed
|
||||
color: purple
|
||||
gradient: linear-gradient(135deg, #ddd6fe, #c4b5fd)
|
||||
borderColor: purple.200
|
||||
available: true
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Game Definition
|
||||
*
|
||||
* A free-for-all math game demonstrating the TEAM_MOVE pattern.
|
||||
* Players race to solve math problems - first correct answer wins points.
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { MathSprintProvider } from './Provider'
|
||||
import type { MathSprintConfig, MathSprintMove, MathSprintState } from './types'
|
||||
import { mathSprintValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'math-sprint',
|
||||
displayName: 'Math Sprint',
|
||||
icon: '🧮',
|
||||
description: 'Race to solve math problems!',
|
||||
longDescription:
|
||||
'A fast-paced free-for-all game where players compete to solve math problems. First correct answer earns points. Choose your difficulty and test your mental math skills!',
|
||||
maxPlayers: 8,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '⚡ Fast-Paced', '🧠 Mental Math'],
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #ddd6fe, #c4b5fd)',
|
||||
borderColor: 'purple.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: MathSprintConfig = {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
'difficulty' in config &&
|
||||
'questionsPerRound' in config &&
|
||||
'timePerQuestion' in config &&
|
||||
['easy', 'medium', 'hard'].includes((config as any).difficulty) &&
|
||||
typeof (config as any).questionsPerRound === 'number' &&
|
||||
typeof (config as any).timePerQuestion === 'number' &&
|
||||
(config as any).questionsPerRound >= 5 &&
|
||||
(config as any).questionsPerRound <= 20 &&
|
||||
(config as any).timePerQuestion >= 10
|
||||
)
|
||||
}
|
||||
|
||||
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
|
||||
manifest,
|
||||
Provider: MathSprintProvider,
|
||||
GameComponent,
|
||||
validator: mathSprintValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateMathSprintConfig,
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Math Sprint Game Types
|
||||
*
|
||||
* A free-for-all game where players race to solve math problems.
|
||||
* Demonstrates the TEAM_MOVE pattern (no specific turn owner).
|
||||
*/
|
||||
|
||||
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
|
||||
|
||||
/**
|
||||
* Difficulty levels for math problems
|
||||
*/
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||
|
||||
/**
|
||||
* Math operation types
|
||||
*/
|
||||
export type Operation = 'addition' | 'subtraction' | 'multiplication'
|
||||
|
||||
/**
|
||||
* Game configuration (persisted to database)
|
||||
*/
|
||||
export interface MathSprintConfig extends GameConfig {
|
||||
difficulty: Difficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number // seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* A math question
|
||||
*/
|
||||
export interface Question {
|
||||
id: string
|
||||
operand1: number
|
||||
operand2: number
|
||||
operation: Operation
|
||||
correctAnswer: number
|
||||
displayText: string // e.g., "5 + 3 = ?"
|
||||
}
|
||||
|
||||
/**
|
||||
* Player answer submission
|
||||
*/
|
||||
export interface Answer {
|
||||
playerId: string
|
||||
answer: number
|
||||
timestamp: number
|
||||
correct: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Game state (synchronized across all clients)
|
||||
*/
|
||||
export interface MathSprintState extends GameState {
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
|
||||
|
||||
// Configuration
|
||||
difficulty: Difficulty
|
||||
questionsPerRound: number
|
||||
timePerQuestion: number
|
||||
|
||||
// Game progress
|
||||
currentQuestionIndex: number
|
||||
questions: Question[]
|
||||
|
||||
// Scoring
|
||||
scores: Record<string, number> // playerId -> score
|
||||
correctAnswersCount: Record<string, number> // playerId -> count
|
||||
|
||||
// Current question state
|
||||
answers: Answer[] // All answers for current question
|
||||
questionStartTime: number // Timestamp when question was shown
|
||||
questionAnswered: boolean // True if someone got it right
|
||||
winnerId: string | null // Winner of current question (first correct)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move types for Math Sprint
|
||||
*/
|
||||
export type MathSprintMove =
|
||||
| StartGameMove
|
||||
| SubmitAnswerMove
|
||||
| NextQuestionMove
|
||||
| ResetGameMove
|
||||
| SetConfigMove
|
||||
|
||||
export interface StartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmitAnswerMove extends GameMove {
|
||||
type: 'SUBMIT_ANSWER'
|
||||
data: {
|
||||
answer: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextQuestionMove extends GameMove {
|
||||
type: 'NEXT_QUESTION'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface ResetGameMove extends GameMove {
|
||||
type: 'RESET_GAME'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion'
|
||||
value: Difficulty | number
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Number Guesser Provider
|
||||
* Manages game state using the Arcade SDK
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import {
|
||||
type GameMove,
|
||||
buildPlayerMetadata,
|
||||
useArcadeSession,
|
||||
useGameMode,
|
||||
useRoomData,
|
||||
useUpdateGameConfig,
|
||||
useViewerId,
|
||||
} from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserState } from './types'
|
||||
|
||||
/**
|
||||
* Context value interface
|
||||
*/
|
||||
interface NumberGuesserContextValue {
|
||||
state: NumberGuesserState
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
chooseNumber: (number: number) => void
|
||||
makeGuess: (guess: number) => void
|
||||
nextRound: () => void
|
||||
goToSetup: () => void
|
||||
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
|
||||
clearError: () => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
const NumberGuesserContext = createContext<NumberGuesserContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access Number Guesser context
|
||||
*/
|
||||
export function useNumberGuesser() {
|
||||
const context = useContext(NumberGuesserContext)
|
||||
if (!context) {
|
||||
throw new Error('useNumberGuesser must be used within NumberGuesserProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application
|
||||
*/
|
||||
function applyMoveOptimistically(state: NumberGuesserState, move: GameMove): NumberGuesserState {
|
||||
// For simplicity, just return current state
|
||||
// Server will send back the validated new state
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Number Guesser Provider Component
|
||||
*/
|
||||
export function NumberGuesserProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active players as array (keep Set iteration order to match UI display)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Merge saved config from room
|
||||
const initialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
|
||||
const savedConfig = gameConfig?.['number-guesser'] as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
minNumber: (savedConfig?.minNumber as number) || 1,
|
||||
maxNumber: (savedConfig?.maxNumber as number) || 100,
|
||||
roundsToWin: (savedConfig?.roundsToWin as number) || 3,
|
||||
gamePhase: 'setup' as const,
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}, [roomData?.gameConfig])
|
||||
|
||||
// Arcade session integration
|
||||
const { state, sendMove, exitSession, lastError, clearError } =
|
||||
useArcadeSession<NumberGuesserState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (activePlayers.length < 2) {
|
||||
console.error('Need at least 2 players to start')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
|
||||
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: activePlayers[0],
|
||||
userId: viewerId || '',
|
||||
data: {
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
},
|
||||
})
|
||||
}, [activePlayers, players, viewerId, sendMove])
|
||||
|
||||
const chooseNumber = useCallback(
|
||||
(secretNumber: number) => {
|
||||
sendMove({
|
||||
type: 'CHOOSE_NUMBER',
|
||||
playerId: state.chooser,
|
||||
userId: viewerId || '',
|
||||
data: { secretNumber },
|
||||
})
|
||||
},
|
||||
[state.chooser, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const makeGuess = useCallback(
|
||||
(guess: number) => {
|
||||
const playerName = state.playerMetadata[state.currentGuesser]?.name || 'Unknown'
|
||||
|
||||
sendMove({
|
||||
type: 'MAKE_GUESS',
|
||||
playerId: state.currentGuesser,
|
||||
userId: viewerId || '',
|
||||
data: { guess, playerName },
|
||||
})
|
||||
},
|
||||
[state.currentGuesser, state.playerMetadata, viewerId, sendMove]
|
||||
)
|
||||
|
||||
const nextRound = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'NEXT_ROUND',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, viewerId, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: activePlayers[0] || state.chooser || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [activePlayers, state.chooser, viewerId, sendMove])
|
||||
|
||||
const setConfig = useCallback(
|
||||
(field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => {
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId: activePlayers[0] || '',
|
||||
userId: viewerId || '',
|
||||
data: { field, value },
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
if (roomData?.id) {
|
||||
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
|
||||
const currentNumberGuesserConfig =
|
||||
(currentGameConfig['number-guesser'] as Record<string, unknown>) || {}
|
||||
|
||||
const updatedConfig = {
|
||||
...currentGameConfig,
|
||||
'number-guesser': {
|
||||
...currentNumberGuesserConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
|
||||
updateGameConfig({
|
||||
roomId: roomData.id,
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
}
|
||||
},
|
||||
[activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
)
|
||||
|
||||
const contextValue: NumberGuesserContextValue = {
|
||||
state,
|
||||
lastError,
|
||||
startGame,
|
||||
chooseNumber,
|
||||
makeGuess,
|
||||
nextRound,
|
||||
goToSetup,
|
||||
setConfig,
|
||||
clearError,
|
||||
exitSession,
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* Server-side validator for Number Guesser game
|
||||
*/
|
||||
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
|
||||
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
|
||||
|
||||
export class NumberGuesserValidator
|
||||
implements GameValidator<NumberGuesserState, NumberGuesserMove>
|
||||
{
|
||||
validateMove(state: NumberGuesserState, move: NumberGuesserMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
|
||||
|
||||
case 'CHOOSE_NUMBER':
|
||||
// Ensure secretNumber is a number (JSON deserialization can make it a string)
|
||||
return this.validateChooseNumber(state, Number(move.data.secretNumber), move.playerId)
|
||||
|
||||
case 'MAKE_GUESS':
|
||||
// Ensure guess is a number (JSON deserialization can make it a string)
|
||||
return this.validateMakeGuess(
|
||||
state,
|
||||
Number(move.data.guess),
|
||||
move.playerId,
|
||||
move.data.playerName
|
||||
)
|
||||
|
||||
case 'NEXT_ROUND':
|
||||
return this.validateNextRound(state)
|
||||
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
|
||||
case 'SET_CONFIG':
|
||||
// Ensure value is a number (JSON deserialization can make it a string)
|
||||
return this.validateSetConfig(state, move.data.field, Number(move.data.value))
|
||||
|
||||
default:
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unknown move type: ${(move as { type: string }).type}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(
|
||||
state: NumberGuesserState,
|
||||
activePlayers: string[],
|
||||
playerMetadata: Record<string, unknown>
|
||||
): ValidationResult {
|
||||
if (!activePlayers || activePlayers.length < 2) {
|
||||
return { valid: false, error: 'Need at least 2 players' }
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'choosing',
|
||||
activePlayers,
|
||||
playerMetadata: playerMetadata as typeof state.playerMetadata,
|
||||
chooser: activePlayers[0],
|
||||
currentGuesser: '',
|
||||
secretNumber: null,
|
||||
guesses: [],
|
||||
roundNumber: 1,
|
||||
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateChooseNumber(
|
||||
state: NumberGuesserState,
|
||||
secretNumber: number,
|
||||
playerId: string
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'choosing') {
|
||||
return { valid: false, error: 'Not in choosing phase' }
|
||||
}
|
||||
|
||||
if (playerId !== state.chooser) {
|
||||
return { valid: false, error: 'Not your turn to choose' }
|
||||
}
|
||||
|
||||
if (
|
||||
secretNumber < state.minNumber ||
|
||||
secretNumber > state.maxNumber ||
|
||||
!Number.isInteger(secretNumber)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Number must be between ${state.minNumber} and ${state.maxNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[NumberGuesser] Setting secret number:', {
|
||||
secretNumber,
|
||||
secretNumberType: typeof secretNumber,
|
||||
})
|
||||
|
||||
// First guesser is the next player after chooser
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const firstGuesserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
const firstGuesser = state.activePlayers[firstGuesserIndex]
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'guessing',
|
||||
secretNumber,
|
||||
currentGuesser: firstGuesser,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateMakeGuess(
|
||||
state: NumberGuesserState,
|
||||
guess: number,
|
||||
playerId: string,
|
||||
playerName: string
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing') {
|
||||
return { valid: false, error: 'Not in guessing phase' }
|
||||
}
|
||||
|
||||
if (playerId !== state.currentGuesser) {
|
||||
return { valid: false, error: 'Not your turn to guess' }
|
||||
}
|
||||
|
||||
if (guess < state.minNumber || guess > state.maxNumber || !Number.isInteger(guess)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Guess must be between ${state.minNumber} and ${state.maxNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.secretNumber) {
|
||||
return { valid: false, error: 'No secret number set' }
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[NumberGuesser] Validating guess:', {
|
||||
guess,
|
||||
guessType: typeof guess,
|
||||
secretNumber: state.secretNumber,
|
||||
secretNumberType: typeof state.secretNumber,
|
||||
})
|
||||
|
||||
const distance = Math.abs(guess - state.secretNumber)
|
||||
|
||||
console.log('[NumberGuesser] Calculated distance:', distance)
|
||||
const newGuess = {
|
||||
playerId,
|
||||
playerName,
|
||||
guess,
|
||||
distance,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
const guesses = [...state.guesses, newGuess]
|
||||
|
||||
// Check if guess is correct
|
||||
if (distance === 0) {
|
||||
// Correct guess! Award point and end round
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[playerId]: (state.scores[playerId] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if player won
|
||||
const winner = newScores[playerId] >= state.roundsToWin ? playerId : null
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
guesses,
|
||||
scores: newScores,
|
||||
gamePhase: winner ? 'results' : 'guessing',
|
||||
gameEndTime: winner ? Date.now() : null,
|
||||
winner,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// Incorrect guess, move to next guesser
|
||||
const guesserIndex = state.activePlayers.indexOf(state.currentGuesser)
|
||||
let nextGuesserIndex = (guesserIndex + 1) % state.activePlayers.length
|
||||
|
||||
// Skip the chooser
|
||||
if (state.activePlayers[nextGuesserIndex] === state.chooser) {
|
||||
nextGuesserIndex = (nextGuesserIndex + 1) % state.activePlayers.length
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
guesses,
|
||||
currentGuesser: state.activePlayers[nextGuesserIndex],
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateNextRound(state: NumberGuesserState): ValidationResult {
|
||||
if (state.gamePhase !== 'guessing') {
|
||||
return { valid: false, error: 'Not in guessing phase' }
|
||||
}
|
||||
|
||||
// Check if the round is complete (someone guessed correctly)
|
||||
const roundComplete =
|
||||
state.guesses.length > 0 && state.guesses[state.guesses.length - 1].distance === 0
|
||||
|
||||
if (!roundComplete) {
|
||||
return { valid: false, error: 'Round not complete yet - no one has guessed the number' }
|
||||
}
|
||||
|
||||
// Rotate chooser to next player
|
||||
const chooserIndex = state.activePlayers.indexOf(state.chooser)
|
||||
const nextChooserIndex = (chooserIndex + 1) % state.activePlayers.length
|
||||
const nextChooser = state.activePlayers[nextChooserIndex]
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'choosing',
|
||||
chooser: nextChooser,
|
||||
currentGuesser: '',
|
||||
secretNumber: null,
|
||||
guesses: [],
|
||||
roundNumber: state.roundNumber + 1,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: NumberGuesserState): ValidationResult {
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateSetConfig(
|
||||
state: NumberGuesserState,
|
||||
field: 'minNumber' | 'maxNumber' | 'roundsToWin',
|
||||
value: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Can only change config in setup' }
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
return { valid: false, error: 'Value must be a positive integer' }
|
||||
}
|
||||
|
||||
if (field === 'minNumber' && value >= state.maxNumber) {
|
||||
return { valid: false, error: 'Min must be less than max' }
|
||||
}
|
||||
|
||||
if (field === 'maxNumber' && value <= state.minNumber) {
|
||||
return { valid: false, error: 'Max must be greater than min' }
|
||||
}
|
||||
|
||||
const newState: NumberGuesserState = {
|
||||
...state,
|
||||
[field]: value,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
isGameComplete(state: NumberGuesserState): boolean {
|
||||
return state.gamePhase === 'results' && state.winner !== null
|
||||
}
|
||||
|
||||
getInitialState(config: unknown): NumberGuesserState {
|
||||
const { minNumber, maxNumber, roundsToWin } = config as NumberGuesserConfig
|
||||
|
||||
return {
|
||||
minNumber: minNumber || 1,
|
||||
maxNumber: maxNumber || 100,
|
||||
roundsToWin: roundsToWin || 3,
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
secretNumber: null,
|
||||
chooser: '',
|
||||
currentGuesser: '',
|
||||
guesses: [],
|
||||
roundNumber: 0,
|
||||
scores: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
winner: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const numberGuesserValidator = new NumberGuesserValidator()
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Choosing Phase - Chooser picks a secret number
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function ChoosingPhase() {
|
||||
const { state, chooseNumber } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const chooserMetadata = state.playerMetadata[state.chooser]
|
||||
const isChooser = chooserMetadata?.userId === viewerId
|
||||
|
||||
const handleSubmit = () => {
|
||||
const number = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(number)) return
|
||||
|
||||
chooseNumber(number)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{chooserMetadata?.emoji || '🤔'}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{isChooser ? "You're choosing!" : `${chooserMetadata?.name || 'Someone'} is choosing...`}
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Round {state.roundNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isChooser ? (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
})}
|
||||
>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a secret number ({state.minNumber} - {state.maxNumber})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
min={state.minNumber}
|
||||
max={state.maxNumber}
|
||||
placeholder={`${state.minNumber} - ${state.maxNumber}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'xl',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Confirm Choice
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Waiting for {chooserMetadata?.name || 'player'} to choose a number...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '32px',
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Scores
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.map((playerId) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
background: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{player?.emoji} {player?.name}: {state.scores[playerId] || 0}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Number Guesser Game Component
|
||||
* Main component that switches between game phases
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
import { ChoosingPhase } from './ChoosingPhase'
|
||||
import { GuessingPhase } from './GuessingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, goToSetup } = useNumberGuesser()
|
||||
|
||||
// Determine whose turn it is based on game phase
|
||||
const currentPlayerId =
|
||||
state.gamePhase === 'choosing'
|
||||
? state.chooser
|
||||
: state.gamePhase === 'guessing'
|
||||
? state.currentGuesser
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Number Guesser"
|
||||
navEmoji="🎯"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={state.scores}
|
||||
onExitSession={() => {
|
||||
exitSession?.()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onNewGame={() => {
|
||||
goToSetup?.()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #fff7ed, #ffedd5)',
|
||||
}}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'choosing' && <ChoosingPhase />}
|
||||
{state.gamePhase === 'guessing' && <GuessingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
/**
|
||||
* Guessing Phase - Players take turns guessing the secret number
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function GuessingPhase() {
|
||||
const { state, makeGuess, nextRound, lastError, clearError } = useNumberGuesser()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const currentGuesserMetadata = state.playerMetadata[state.currentGuesser]
|
||||
const isCurrentGuesser = currentGuesserMetadata?.userId === viewerId
|
||||
|
||||
// Check if someone just won the round
|
||||
const lastGuess = state.guesses[state.guesses.length - 1]
|
||||
const roundJustEnded = lastGuess?.distance === 0
|
||||
|
||||
// Auto-clear error after 5 seconds
|
||||
useEffect(() => {
|
||||
if (lastError) {
|
||||
const timeout = setTimeout(() => clearError(), 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [lastError, clearError])
|
||||
|
||||
const handleSubmit = () => {
|
||||
const guess = Number.parseInt(inputValue, 10)
|
||||
if (Number.isNaN(guess)) return
|
||||
|
||||
makeGuess(guess)
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
const getHotColdMessage = (distance: number) => {
|
||||
if (distance === 0) return '🎯 Correct!'
|
||||
if (distance <= 5) return '🔥 Very Hot!'
|
||||
if (distance <= 10) return '🌡️ Hot'
|
||||
if (distance <= 20) return '😊 Warm'
|
||||
if (distance <= 30) return '😐 Cool'
|
||||
if (distance <= 50) return '❄️ Cold'
|
||||
return '🧊 Very Cold'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '64px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
{roundJustEnded ? '🎉' : currentGuesserMetadata?.emoji || '🤔'}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
{roundJustEnded
|
||||
? `${lastGuess.playerName} guessed it!`
|
||||
: isCurrentGuesser
|
||||
? 'Your turn to guess!'
|
||||
: `${currentGuesserMetadata?.name || 'Someone'} is guessing...`}
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Round {state.roundNumber} • Range: {state.minNumber} - {state.maxNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
animation: 'slideIn 0.3s ease',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
})}
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
color: 'red.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Move Rejected
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'red.600',
|
||||
})}
|
||||
>
|
||||
{lastError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
color: 'red.700',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
background: 'red.50',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Round ended - show next round button */}
|
||||
{roundJustEnded && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'green.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
🎯
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
The secret number was <strong>{state.secretNumber}</strong>!
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextRound}
|
||||
className={css({
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Next Round
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guessing input (only if round not ended) */}
|
||||
{!roundJustEnded && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
{isCurrentGuesser ? (
|
||||
<>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'md',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Make your guess ({state.minNumber} - {state.maxNumber})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && inputValue) {
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
min={state.minNumber}
|
||||
max={state.maxNumber}
|
||||
placeholder={`${state.minNumber} - ${state.maxNumber}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'xl',
|
||||
textAlign: 'center',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_disabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Submit Guess
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
⏳
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
Waiting for {currentGuesserMetadata?.name || 'player'} to guess...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guess history */}
|
||||
{state.guesses.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
})}
|
||||
>
|
||||
Guess History
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
{state.guesses.map((guess, index) => {
|
||||
const player = state.playerMetadata[guess.playerId]
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={css({
|
||||
padding: '12px',
|
||||
background: guess.distance === 0 ? 'green.50' : 'gray.50',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<span>{player?.emoji || '🎮'}</span>
|
||||
<span className={css({ fontWeight: '600' })}>{guess.playerName}</span>
|
||||
<span className={css({ color: 'gray.600' })}>guessed</span>
|
||||
<span className={css({ fontWeight: 'bold', fontSize: 'lg' })}>
|
||||
{guess.guess}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
color: guess.distance === 0 ? 'green.700' : 'orange.700',
|
||||
})}
|
||||
>
|
||||
{getHotColdMessage(guess.distance)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Scores (First to {state.roundsToWin} wins!)
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{state.activePlayers.map((playerId) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
const score = state.scores[playerId] || 0
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
background: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{player?.emoji} {player?.name}: {score}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* Results Phase - Shows winner and final scores
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, goToSetup } = useNumberGuesser()
|
||||
|
||||
const winnerMetadata = state.winner ? state.playerMetadata[state.winner] : null
|
||||
const winnerScore = state.winner ? state.scores[state.winner] : 0
|
||||
|
||||
// Sort players by score
|
||||
const sortedPlayers = [...state.activePlayers].sort((a, b) => {
|
||||
const scoreA = state.scores[a] || 0
|
||||
const scoreB = state.scores[b] || 0
|
||||
return scoreB - scoreA
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Winner Celebration */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '32px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '96px',
|
||||
marginBottom: '16px',
|
||||
animation: 'bounce 1s ease-in-out infinite',
|
||||
})}
|
||||
>
|
||||
{winnerMetadata?.emoji || '🏆'}
|
||||
</div>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
{winnerMetadata?.name || 'Someone'} Wins!
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
with {winnerScore} {winnerScore === 1 ? 'round' : 'rounds'} won
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Final Standings */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Final Standings
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
{sortedPlayers.map((playerId, index) => {
|
||||
const player = state.playerMetadata[playerId]
|
||||
const score = state.scores[playerId] || 0
|
||||
const isWinner = playerId === state.winner
|
||||
|
||||
return (
|
||||
<div
|
||||
key={playerId}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px',
|
||||
background: isWinner ? 'linear-gradient(135deg, #fed7aa, #fdba74)' : 'gray.100',
|
||||
borderRadius: '8px',
|
||||
border: isWinner ? '2px solid' : 'none',
|
||||
borderColor: isWinner ? 'orange.300' : undefined,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.400',
|
||||
width: '32px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={css({ fontSize: '32px' })}>{player?.emoji || '🎮'}</span>
|
||||
<span className={css({ fontSize: 'lg', fontWeight: '600' })}>
|
||||
{player?.name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: isWinner ? 'orange.700' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
{score} {isWinner && '🏆'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Stats */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Game Stats
|
||||
</h3>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
|
||||
{state.roundNumber} {state.roundNumber === 1 ? 'round' : 'rounds'} played
|
||||
</p>
|
||||
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
|
||||
{state.guesses.length} {state.guesses.length === 1 ? 'guess' : 'guesses'} made
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Setup Phase - Game configuration
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useNumberGuesser } from '../Provider'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, startGame, setConfig } = useNumberGuesser()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
🎯 Number Guesser Setup
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Game Rules
|
||||
</h3>
|
||||
<ul
|
||||
className={css({
|
||||
listStyle: 'disc',
|
||||
paddingLeft: '24px',
|
||||
lineHeight: '1.6',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<li>One player chooses a secret number</li>
|
||||
<li>Other players take turns guessing</li>
|
||||
<li>Get feedback on how close your guess is</li>
|
||||
<li>First to guess correctly wins the round!</li>
|
||||
<li>First to {state.roundsToWin} rounds wins the game!</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
border: '2px solid',
|
||||
borderColor: 'orange.200',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
})}
|
||||
>
|
||||
Configuration
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Minimum Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.minNumber ?? 1}
|
||||
onChange={(e) => setConfig('minNumber', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Maximum Number
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.maxNumber ?? 100}
|
||||
onChange={(e) => setConfig('maxNumber', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
Rounds to Win
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={state.roundsToWin ?? 3}
|
||||
onChange={(e) => setConfig('roundsToWin', Number.parseInt(e.target.value, 10))}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '6px',
|
||||
fontSize: 'md',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={startGame}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '16px',
|
||||
background: 'linear-gradient(135deg, #fb923c, #f97316)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
name: number-guesser
|
||||
displayName: Number Guesser
|
||||
icon: 🎯
|
||||
description: Classic turn-based number guessing game
|
||||
longDescription: One player thinks of a number, others take turns guessing. Get hot/cold feedback as you try to find the secret number. Perfect for testing your deduction skills!
|
||||
maxPlayers: 4
|
||||
difficulty: Beginner
|
||||
chips:
|
||||
- 👥 Multiplayer
|
||||
- 🎲 Turn-Based
|
||||
- 🧠 Logic Puzzle
|
||||
color: orange
|
||||
gradient: linear-gradient(135deg, #fed7aa, #fdba74)
|
||||
borderColor: orange.200
|
||||
available: true
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Number Guesser Game Definition
|
||||
* Exports the complete game using the Arcade SDK
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { NumberGuesserProvider } from './Provider'
|
||||
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
|
||||
import { numberGuesserValidator } from './Validator'
|
||||
|
||||
// Game manifest (matches game.yaml)
|
||||
const manifest: GameManifest = {
|
||||
name: 'number-guesser',
|
||||
displayName: 'Number Guesser',
|
||||
icon: '🎯',
|
||||
description: 'Classic turn-based number guessing game',
|
||||
longDescription:
|
||||
'One player thinks of a number, others take turns guessing. Get hot/cold feedback to narrow down your guesses. First to guess wins the round!',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['👥 Multiplayer', '🎲 Turn-Based', '🧠 Logic Puzzle'],
|
||||
color: 'orange',
|
||||
gradient: 'linear-gradient(135deg, #fed7aa, #fdba74)',
|
||||
borderColor: 'orange.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
const defaultConfig: NumberGuesserConfig = {
|
||||
minNumber: 1,
|
||||
maxNumber: 100,
|
||||
roundsToWin: 3,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateNumberGuesserConfig(config: unknown): config is NumberGuesserConfig {
|
||||
return (
|
||||
typeof config === 'object' &&
|
||||
config !== null &&
|
||||
'minNumber' in config &&
|
||||
'maxNumber' in config &&
|
||||
'roundsToWin' in config &&
|
||||
typeof config.minNumber === 'number' &&
|
||||
typeof config.maxNumber === 'number' &&
|
||||
typeof config.roundsToWin === 'number' &&
|
||||
config.minNumber >= 1 &&
|
||||
config.maxNumber > config.minNumber &&
|
||||
config.roundsToWin >= 1
|
||||
)
|
||||
}
|
||||
|
||||
// Export game definition
|
||||
export const numberGuesserGame = defineGame<
|
||||
NumberGuesserConfig,
|
||||
NumberGuesserState,
|
||||
NumberGuesserMove
|
||||
>({
|
||||
manifest,
|
||||
Provider: NumberGuesserProvider,
|
||||
GameComponent,
|
||||
validator: numberGuesserValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateNumberGuesserConfig,
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Type definitions for Number Guesser game
|
||||
*/
|
||||
|
||||
import type { GameMove } from '@/lib/arcade/game-sdk'
|
||||
|
||||
/**
|
||||
* Game configuration
|
||||
*/
|
||||
export type NumberGuesserConfig = {
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single guess attempt
|
||||
*/
|
||||
export interface Guess {
|
||||
playerId: string
|
||||
playerName: string
|
||||
guess: number
|
||||
distance: number // How far from the secret number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Game phases
|
||||
*/
|
||||
export type GamePhase = 'setup' | 'choosing' | 'guessing' | 'results'
|
||||
|
||||
/**
|
||||
* Game state
|
||||
*/
|
||||
export type NumberGuesserState = {
|
||||
// Configuration
|
||||
minNumber: number
|
||||
maxNumber: number
|
||||
roundsToWin: number
|
||||
|
||||
// Game phase
|
||||
gamePhase: GamePhase
|
||||
|
||||
// Players
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
|
||||
|
||||
// Current round
|
||||
secretNumber: number | null
|
||||
chooser: string // Player ID who chose the number
|
||||
currentGuesser: string // Player ID whose turn it is to guess
|
||||
|
||||
// Round history
|
||||
guesses: Guess[]
|
||||
roundNumber: number
|
||||
|
||||
// Scores
|
||||
scores: Record<string, number>
|
||||
|
||||
// Game state
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
winner: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Game moves
|
||||
*/
|
||||
export interface StartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChooseNumberMove extends GameMove {
|
||||
type: 'CHOOSE_NUMBER'
|
||||
data: {
|
||||
secretNumber: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MakeGuessMove extends GameMove {
|
||||
type: 'MAKE_GUESS'
|
||||
data: {
|
||||
guess: number
|
||||
playerName: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface NextRoundMove extends GameMove {
|
||||
type: 'NEXT_ROUND'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface GoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'minNumber' | 'maxNumber' | 'roundsToWin'
|
||||
value: number
|
||||
}
|
||||
}
|
||||
|
||||
export type NumberGuesserMove =
|
||||
| StartGameMove
|
||||
| ChooseNumberMove
|
||||
| MakeGuessMove
|
||||
| NextRoundMove
|
||||
| GoToSetupMove
|
||||
| SetConfigMove
|
||||
@@ -107,32 +107,28 @@ export function useArcadeSession<TState>(
|
||||
exitSession: socketExitSession,
|
||||
} = useArcadeSocket({
|
||||
onSessionState: (data) => {
|
||||
console.log('[ArcadeSession] Syncing with server state')
|
||||
optimistic.syncWithServer(data.gameState as TState, data.version)
|
||||
},
|
||||
|
||||
onMoveAccepted: (data) => {
|
||||
console.log('[ArcadeSession] Move accepted by server')
|
||||
optimistic.handleMoveAccepted(data.gameState as TState, data.version, data.move)
|
||||
},
|
||||
|
||||
onMoveRejected: (data) => {
|
||||
console.log('[ArcadeSession] Move rejected by server:', data.error)
|
||||
console.log(`[ArcadeSession] Move rejected: ${data.error}`)
|
||||
optimistic.handleMoveRejected(data.error, data.move)
|
||||
},
|
||||
|
||||
onSessionEnded: () => {
|
||||
console.log('[ArcadeSession] Session ended')
|
||||
optimistic.reset()
|
||||
},
|
||||
|
||||
onNoActiveSession: () => {
|
||||
console.log('[ArcadeSession] No active session found')
|
||||
// Silent - normal state
|
||||
},
|
||||
|
||||
onError: (data) => {
|
||||
console.error('[ArcadeSession] Error:', data.error)
|
||||
// Users can handle errors via the onMoveRejected callback
|
||||
console.error(`[ArcadeSession] Error: ${data.error}`)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -62,22 +62,19 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||
})
|
||||
|
||||
socketInstance.on('session-state', (data) => {
|
||||
console.log('[ArcadeSocket] Received session state', data)
|
||||
eventsRef.current.onSessionState?.(data)
|
||||
})
|
||||
|
||||
socketInstance.on('no-active-session', () => {
|
||||
console.log('[ArcadeSocket] No active session')
|
||||
eventsRef.current.onNoActiveSession?.()
|
||||
})
|
||||
|
||||
socketInstance.on('move-accepted', (data) => {
|
||||
console.log('[ArcadeSocket] Move accepted', data)
|
||||
eventsRef.current.onMoveAccepted?.(data)
|
||||
})
|
||||
|
||||
socketInstance.on('move-rejected', (data) => {
|
||||
console.log('[ArcadeSocket] Move rejected', data)
|
||||
console.log(`[ArcadeSocket] Move rejected: ${data.error}`)
|
||||
eventsRef.current.onMoveRejected?.(data)
|
||||
})
|
||||
|
||||
@@ -124,12 +121,7 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
|
||||
console.warn('[ArcadeSocket] Cannot send move - socket not connected')
|
||||
return
|
||||
}
|
||||
const payload = { userId, move, roomId }
|
||||
console.log(
|
||||
'[ArcadeSocket] Sending game-move event with payload:',
|
||||
JSON.stringify(payload, null, 2)
|
||||
)
|
||||
socket.emit('game-move', payload)
|
||||
socket.emit('game-move', { userId, move, roomId })
|
||||
},
|
||||
[socket]
|
||||
)
|
||||
|
||||
@@ -451,7 +451,6 @@ export function useRoomData() {
|
||||
gameName: string | null
|
||||
gameConfig?: Record<string, unknown>
|
||||
}) => {
|
||||
console.log('[useRoomData] Room game changed:', data)
|
||||
if (data.roomId === roomData?.id) {
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
@@ -683,18 +682,6 @@ async function updateGameConfigApi(params: {
|
||||
roomId: string
|
||||
gameConfig: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
console.log(
|
||||
'[updateGameConfigApi] Sending PATCH to server:',
|
||||
JSON.stringify(
|
||||
{
|
||||
url: `/api/arcade/rooms/${params.roomId}/settings`,
|
||||
gameConfig: params.gameConfig,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -705,11 +692,8 @@ async function updateGameConfigApi(params: {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
console.error('[updateGameConfigApi] Server error:', JSON.stringify(errorData, null, 2))
|
||||
throw new Error(errorData.error || 'Failed to update game config')
|
||||
}
|
||||
|
||||
console.log('[updateGameConfigApi] Server responded OK')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -730,10 +714,6 @@ export function useUpdateGameConfig() {
|
||||
gameConfig: variables.gameConfig,
|
||||
}
|
||||
})
|
||||
console.log(
|
||||
'[useUpdateGameConfig] Updated cache with new gameConfig:',
|
||||
JSON.stringify(variables.gameConfig, null, 2)
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Shared game configuration types
|
||||
*
|
||||
* ARCHITECTURE: Phase 3 - Type Inference
|
||||
* - Modern games (number-guesser, math-sprint, memory-quiz, matching): Types inferred from game definitions
|
||||
* - Modern games (memory-quiz, matching): Types inferred from game definitions
|
||||
* - Legacy games (complement-race): Manual types until migrated
|
||||
*
|
||||
* These types are used across:
|
||||
@@ -13,8 +13,6 @@
|
||||
*/
|
||||
|
||||
// Type-only imports (won't load React components at runtime)
|
||||
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
import type { mathSprintGame } from '@/arcade-games/math-sprint'
|
||||
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
|
||||
import type { matchingGame } from '@/arcade-games/matching'
|
||||
|
||||
@@ -28,18 +26,6 @@ type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : n
|
||||
// Modern Games (Type Inference from Game Definitions)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for number-guesser game
|
||||
* INFERRED from numberGuesserGame.defaultConfig
|
||||
*/
|
||||
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
|
||||
|
||||
/**
|
||||
* Configuration for math-sprint game
|
||||
* INFERRED from mathSprintGame.defaultConfig
|
||||
*/
|
||||
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
|
||||
|
||||
/**
|
||||
* Configuration for memory-quiz (soroban lightning) game
|
||||
* INFERRED from memoryQuizGame.defaultConfig
|
||||
@@ -89,7 +75,7 @@ export interface ComplementRaceGameConfig {
|
||||
raceGoal: number // questions to win practice mode (default 20)
|
||||
|
||||
// Win Conditions
|
||||
winCondition: 'route-based' | 'score-based' | 'time-based'
|
||||
winCondition: 'route-based' | 'score-based' | 'time-based' | 'infinite'
|
||||
targetScore?: number // for score-based (e.g., 100)
|
||||
timeLimit?: number // for time-based (e.g., 300 seconds)
|
||||
routeCount?: number // for route-based (e.g., 3 routes)
|
||||
@@ -108,8 +94,6 @@ export interface ComplementRaceGameConfig {
|
||||
*/
|
||||
export type GameConfigByName = {
|
||||
// Modern games (inferred types)
|
||||
'number-guesser': NumberGuesserGameConfig
|
||||
'math-sprint': MathSprintGameConfig
|
||||
'memory-quiz': MemoryQuizGameConfig
|
||||
matching: MatchingGameConfig
|
||||
|
||||
@@ -171,20 +155,8 @@ export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
|
||||
raceGoal: 20,
|
||||
|
||||
// Win conditions
|
||||
winCondition: 'route-based',
|
||||
winCondition: 'infinite', // Sprint mode is infinite by default (Steam Sprint)
|
||||
routeCount: 3,
|
||||
targetScore: 100,
|
||||
timeLimit: 300,
|
||||
}
|
||||
|
||||
export const DEFAULT_NUMBER_GUESSER_CONFIG: NumberGuesserGameConfig = {
|
||||
minNumber: 1,
|
||||
maxNumber: 100,
|
||||
roundsToWin: 3,
|
||||
}
|
||||
|
||||
export const DEFAULT_MATH_SPRINT_CONFIG: MathSprintGameConfig = {
|
||||
difficulty: 'medium',
|
||||
questionsPerRound: 10,
|
||||
timePerQuestion: 30,
|
||||
}
|
||||
|
||||
@@ -106,14 +106,10 @@ export function clearRegistry(): void {
|
||||
// Game Registrations
|
||||
// ============================================================================
|
||||
|
||||
import { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
import { mathSprintGame } from '@/arcade-games/math-sprint'
|
||||
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
|
||||
import { matchingGame } from '@/arcade-games/matching'
|
||||
import { complementRaceGame } from '@/arcade-games/complement-race/index'
|
||||
|
||||
registerGame(numberGuesserGame)
|
||||
registerGame(mathSprintGame)
|
||||
registerGame(memoryQuizGame)
|
||||
registerGame(matchingGame)
|
||||
registerGame(complementRaceGame)
|
||||
|
||||
@@ -79,11 +79,6 @@ export async function createArcadeSession(
|
||||
// Check if session already exists for this room (roomId is PRIMARY KEY)
|
||||
const existingRoomSession = await getArcadeSessionByRoom(options.roomId)
|
||||
if (existingRoomSession) {
|
||||
console.log('[Session Manager] Room session already exists, returning existing:', {
|
||||
roomId: options.roomId,
|
||||
sessionUserId: existingRoomSession.userId,
|
||||
version: existingRoomSession.version,
|
||||
})
|
||||
return existingRoomSession
|
||||
}
|
||||
|
||||
@@ -93,7 +88,6 @@ export async function createArcadeSession(
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
console.log('[Session Manager] Creating new user with guestId:', options.userId)
|
||||
const [newUser] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
@@ -102,9 +96,6 @@ export async function createArcadeSession(
|
||||
})
|
||||
.returning()
|
||||
user = newUser
|
||||
console.log('[Session Manager] Created user with id:', user.id)
|
||||
} else {
|
||||
console.log('[Session Manager] Found existing user with id:', user.id)
|
||||
}
|
||||
|
||||
const newSession: schema.NewArcadeSession = {
|
||||
@@ -121,12 +112,6 @@ export async function createArcadeSession(
|
||||
version: 1,
|
||||
}
|
||||
|
||||
console.log('[Session Manager] Creating new session:', {
|
||||
roomId: options.roomId,
|
||||
userId: user.id,
|
||||
gameName: options.gameName,
|
||||
})
|
||||
|
||||
try {
|
||||
const [session] = await db.insert(schema.arcadeSessions).values(newSession).returning()
|
||||
return session
|
||||
@@ -134,10 +119,6 @@ export async function createArcadeSession(
|
||||
// Handle PRIMARY KEY constraint violation (UNIQUE constraint on roomId)
|
||||
// This can happen if two users try to create a session for the same room simultaneously
|
||||
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
||||
console.log(
|
||||
'[Session Manager] Session already exists (race condition), fetching existing session for room:',
|
||||
options.roomId
|
||||
)
|
||||
const existingSession = await getArcadeSessionByRoom(options.roomId)
|
||||
if (existingSession) {
|
||||
return existingSession
|
||||
@@ -180,7 +161,6 @@ export async function getArcadeSession(guestId: string): Promise<schema.ArcadeSe
|
||||
})
|
||||
|
||||
if (!room) {
|
||||
console.log('[Session Manager] Deleting session with non-existent room:', session.roomId)
|
||||
await deleteArcadeSessionByRoom(session.roomId)
|
||||
return undefined
|
||||
}
|
||||
@@ -220,16 +200,6 @@ export async function applyGameMove(
|
||||
// Get the validator for this game
|
||||
const validator = getValidator(session.currentGame as GameName)
|
||||
|
||||
console.log('[SessionManager] About to validate move:', {
|
||||
gameName: session.currentGame,
|
||||
moveType: move.type,
|
||||
playerId: move.playerId,
|
||||
moveData: move.type === 'SET_CONFIG' ? (move as any).data : undefined,
|
||||
gameStateCurrentPlayer: (session.gameState as any)?.currentPlayer,
|
||||
gameStateActivePlayers: (session.gameState as any)?.activePlayers,
|
||||
gameStatePhase: (session.gameState as any)?.gamePhase,
|
||||
})
|
||||
|
||||
// Fetch player ownership for authorization checks (room-based games)
|
||||
let playerOwnership: PlayerOwnershipMap | undefined
|
||||
let internalUserId: string | undefined
|
||||
@@ -247,8 +217,6 @@ export async function applyGameMove(
|
||||
|
||||
// Use centralized ownership utility
|
||||
playerOwnership = await buildPlayerOwnershipMap(session.roomId)
|
||||
console.log('[SessionManager] Player ownership map:', playerOwnership)
|
||||
console.log('[SessionManager] Internal userId for authorization:', internalUserId)
|
||||
} catch (error) {
|
||||
console.error('[SessionManager] Failed to fetch player ownership:', error)
|
||||
}
|
||||
@@ -260,11 +228,6 @@ export async function applyGameMove(
|
||||
playerOwnership,
|
||||
})
|
||||
|
||||
console.log('[SessionManager] Validation result:', {
|
||||
valid: validationResult.valid,
|
||||
error: validationResult.error,
|
||||
})
|
||||
|
||||
if (!validationResult.valid) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -373,10 +336,6 @@ export async function updateSessionActivePlayers(
|
||||
// Only update if game is in setup phase (not started yet)
|
||||
const gameState = session.gameState as any
|
||||
if (gameState.gamePhase !== 'setup') {
|
||||
console.log('[Session Manager] Cannot update activePlayers - game already started:', {
|
||||
roomId,
|
||||
gamePhase: gameState.gamePhase,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -397,12 +356,6 @@ export async function updateSessionActivePlayers(
|
||||
})
|
||||
.where(eq(schema.arcadeSessions.roomId, roomId))
|
||||
|
||||
console.log('[Session Manager] Updated session activePlayers:', {
|
||||
roomId,
|
||||
playerIds,
|
||||
count: playerIds.length,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
import { matchingGameValidator } from '@/arcade-games/matching/Validator'
|
||||
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
|
||||
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
|
||||
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
|
||||
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
|
||||
import type { GameValidator } from './validation/types'
|
||||
|
||||
@@ -25,8 +23,6 @@ import type { GameValidator } from './validation/types'
|
||||
export const validatorRegistry = {
|
||||
matching: matchingGameValidator,
|
||||
'memory-quiz': memoryQuizGameValidator,
|
||||
'number-guesser': numberGuesserValidator,
|
||||
'math-sprint': mathSprintValidator,
|
||||
'complement-race': complementRaceValidator,
|
||||
// Add new games here - GameName type will auto-update
|
||||
} as const
|
||||
@@ -94,10 +90,4 @@ export function assertValidGameName(gameName: unknown): asserts gameName is Game
|
||||
/**
|
||||
* Re-export validators for backwards compatibility
|
||||
*/
|
||||
export {
|
||||
matchingGameValidator,
|
||||
memoryQuizGameValidator,
|
||||
numberGuesserValidator,
|
||||
mathSprintValidator,
|
||||
complementRaceValidator,
|
||||
}
|
||||
export { matchingGameValidator, memoryQuizGameValidator, complementRaceValidator }
|
||||
|
||||
@@ -41,7 +41,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
})
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('🔌 Client connected:', socket.id)
|
||||
let currentUserId: string | null = null
|
||||
|
||||
// Join arcade session room
|
||||
@@ -50,12 +49,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
async ({ userId, roomId }: { userId: string; roomId?: string }) => {
|
||||
currentUserId = userId
|
||||
socket.join(`arcade:${userId}`)
|
||||
console.log(`👤 User ${userId} joined arcade room`)
|
||||
|
||||
// If this session is part of a room, also join the game room for multi-user sync
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`)
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`)
|
||||
}
|
||||
|
||||
// Send current session state if exists
|
||||
@@ -68,19 +65,14 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
// If no session exists for this room, create one in setup phase
|
||||
// This allows users to send SET_CONFIG moves before starting the game
|
||||
if (!session && roomId) {
|
||||
console.log('[join-arcade-session] Creating initial session for room:', roomId)
|
||||
|
||||
// Get the room to determine game type and config
|
||||
const room = await getRoomById(roomId)
|
||||
if (room) {
|
||||
// Fetch all active player IDs from room members (respects isActive flag)
|
||||
const roomPlayerIds = await getRoomPlayerIds(roomId)
|
||||
console.log('[join-arcade-session] Room active players:', roomPlayerIds)
|
||||
|
||||
// Get initial state from the correct validator based on game type
|
||||
console.log('[join-arcade-session] Room game name:', room.gameName)
|
||||
const validator = getValidator(room.gameName as GameName)
|
||||
console.log('[join-arcade-session] Got validator for:', room.gameName)
|
||||
|
||||
// Get game-specific config from database (type-safe)
|
||||
const gameConfig = await getGameConfig(roomId, room.gameName as GameName)
|
||||
@@ -94,23 +86,10 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
activePlayers: roomPlayerIds, // Include all room members' active players
|
||||
roomId: room.id,
|
||||
})
|
||||
|
||||
console.log('[join-arcade-session] Created initial session:', {
|
||||
roomId,
|
||||
sessionId: session.userId,
|
||||
gamePhase: (session.gameState as any).gamePhase,
|
||||
activePlayersCount: roomPlayerIds.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (session) {
|
||||
console.log('[join-arcade-session] Found session:', {
|
||||
userId,
|
||||
roomId,
|
||||
version: session.version,
|
||||
sessionUserId: session.userId,
|
||||
})
|
||||
socket.emit('session-state', {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
@@ -119,10 +98,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
version: session.version,
|
||||
})
|
||||
} else {
|
||||
console.log('[join-arcade-session] No active session found for:', {
|
||||
userId,
|
||||
roomId,
|
||||
})
|
||||
socket.emit('no-active-session')
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -134,15 +109,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
// Handle game moves
|
||||
socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => {
|
||||
console.log('🎮 Game move received:', {
|
||||
userId: data.userId,
|
||||
moveType: data.move.type,
|
||||
playerId: data.move.playerId,
|
||||
timestamp: data.move.timestamp,
|
||||
roomId: data.roomId,
|
||||
fullMove: JSON.stringify(data.move, null, 2),
|
||||
})
|
||||
|
||||
try {
|
||||
// Special handling for START_GAME - create session if it doesn't exist
|
||||
if (data.move.type === 'START_GAME') {
|
||||
@@ -152,12 +118,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
: await getArcadeSession(data.userId)
|
||||
|
||||
if (!existingSession) {
|
||||
console.log('🎯 Creating new session for START_GAME')
|
||||
|
||||
// activePlayers must be provided in the START_GAME move data
|
||||
const activePlayers = (data.move.data as any)?.activePlayers
|
||||
if (!activePlayers || activePlayers.length === 0) {
|
||||
console.error('❌ START_GAME move missing activePlayers')
|
||||
socket.emit('move-rejected', {
|
||||
error: 'START_GAME requires at least one active player',
|
||||
move: data.move,
|
||||
@@ -186,7 +149,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
existingRoom.status !== 'finished'
|
||||
) {
|
||||
room = existingRoom
|
||||
console.log('🏠 Using existing room:', room.code)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -205,7 +167,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
},
|
||||
ttlMinutes: 60,
|
||||
})
|
||||
console.log('🏠 Created new room:', room.code)
|
||||
}
|
||||
|
||||
// Now create the session linked to the room
|
||||
@@ -218,8 +179,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
roomId: room.id,
|
||||
})
|
||||
|
||||
console.log('✅ Session created successfully with room association')
|
||||
|
||||
// Notify all connected clients about the new session
|
||||
const newSession = await getArcadeSession(data.userId)
|
||||
if (newSession) {
|
||||
@@ -230,7 +189,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
activePlayers: newSession.activePlayers,
|
||||
version: newSession.version,
|
||||
})
|
||||
console.log('📢 Emitted session-state to notify clients of new session')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +209,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
// If this is a room-based session, ALSO broadcast to all users in the room
|
||||
if (result.session.roomId) {
|
||||
io!.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData)
|
||||
console.log(`📢 Broadcasted move to game room ${result.session.roomId}`)
|
||||
}
|
||||
|
||||
// Update activity timestamp
|
||||
@@ -275,8 +232,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
// Handle session exit
|
||||
socket.on('exit-arcade-session', async ({ userId }: { userId: string }) => {
|
||||
console.log('🚪 User exiting arcade session:', userId)
|
||||
|
||||
try {
|
||||
await deleteArcadeSession(userId)
|
||||
io!.to(`arcade:${userId}`).emit('session-ended')
|
||||
@@ -298,8 +253,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
// Room: Join
|
||||
socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
console.log(`🏠 User ${userId} joining room ${roomId}`)
|
||||
|
||||
try {
|
||||
// Join the socket room
|
||||
socket.join(`room:${roomId}`)
|
||||
@@ -323,10 +276,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds)
|
||||
|
||||
if (sessionUpdated) {
|
||||
console.log(`🎮 Updated session activePlayers for room ${roomId}:`, {
|
||||
playerCount: roomPlayerIds.length,
|
||||
})
|
||||
|
||||
// Broadcast updated session state to all users in the game room
|
||||
const updatedSession = await getArcadeSessionByRoom(roomId)
|
||||
if (updatedSession) {
|
||||
@@ -337,7 +286,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
activePlayers: updatedSession.activePlayers,
|
||||
version: updatedSession.version,
|
||||
})
|
||||
console.log(`📢 Broadcasted updated session state to game room ${roomId}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,8 +303,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`✅ User ${userId} joined room ${roomId}`)
|
||||
} catch (error) {
|
||||
console.error('Error joining room:', error)
|
||||
socket.emit('room-error', { error: 'Failed to join room' })
|
||||
@@ -365,11 +311,9 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
// User Channel: Join (for moderation events)
|
||||
socket.on('join-user-channel', async ({ userId }: { userId: string }) => {
|
||||
console.log(`👤 User ${userId} joining user-specific channel`)
|
||||
try {
|
||||
// Join user-specific channel for moderation notifications
|
||||
socket.join(`user:${userId}`)
|
||||
console.log(`✅ User ${userId} joined user channel`)
|
||||
} catch (error) {
|
||||
console.error('Error joining user channel:', error)
|
||||
}
|
||||
@@ -377,8 +321,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
// Room: Leave
|
||||
socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
console.log(`🚪 User ${userId} leaving room ${roomId}`)
|
||||
|
||||
try {
|
||||
// Leave the socket room
|
||||
socket.leave(`room:${roomId}`)
|
||||
@@ -403,8 +345,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`✅ User ${userId} left room ${roomId}`)
|
||||
} catch (error) {
|
||||
console.error('Error leaving room:', error)
|
||||
}
|
||||
@@ -412,8 +352,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
|
||||
// Room: Players updated
|
||||
socket.on('players-updated', async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`)
|
||||
|
||||
try {
|
||||
// Get updated player data
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
@@ -429,11 +367,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds)
|
||||
|
||||
if (sessionUpdated) {
|
||||
console.log(`🎮 Updated session activePlayers after player toggle:`, {
|
||||
roomId,
|
||||
playerCount: roomPlayerIds.length,
|
||||
})
|
||||
|
||||
// Broadcast updated session state to all users in the game room
|
||||
const updatedSession = await getArcadeSessionByRoom(roomId)
|
||||
if (updatedSession) {
|
||||
@@ -444,7 +377,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
activePlayers: updatedSession.activePlayers,
|
||||
version: updatedSession.version,
|
||||
})
|
||||
console.log(`📢 Broadcasted updated session state to game room ${roomId}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,8 +385,6 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
roomId,
|
||||
memberPlayers: memberPlayersObj,
|
||||
})
|
||||
|
||||
console.log(`✅ Broadcasted player updates for room ${roomId}`)
|
||||
} catch (error) {
|
||||
console.error('Error updating room players:', error)
|
||||
socket.emit('room-error', { error: 'Failed to update players' })
|
||||
@@ -462,16 +392,11 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('🔌 Client disconnected:', socket.id)
|
||||
if (currentUserId) {
|
||||
// Don't delete session on disconnect - it persists across devices
|
||||
console.log(`👤 User ${currentUserId} disconnected but session persists`)
|
||||
}
|
||||
// Don't delete session on disconnect - it persists across devices
|
||||
})
|
||||
})
|
||||
|
||||
// Store in globalThis to make accessible across module boundaries
|
||||
globalThis.__socketIO = io
|
||||
console.log('✅ Socket.IO initialized on /api/socket')
|
||||
return io
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.3.1",
|
||||
"version": "4.7.1",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
17
packages/core/client/browser/node_modules/.bin/tsc
generated
vendored
17
packages/core/client/browser/node_modules/.bin/tsc
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
17
packages/core/client/browser/node_modules/.bin/tsserver
generated
vendored
17
packages/core/client/browser/node_modules/.bin/tsserver
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
17
packages/core/client/browser/node_modules/.bin/vite
generated
vendored
17
packages/core/client/browser/node_modules/.bin/vite
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../vite/bin/vite.js" "$@"
|
||||
fi
|
||||
1
packages/core/client/browser/node_modules/@myriaddreamin/typst-ts-renderer
generated
vendored
1
packages/core/client/browser/node_modules/@myriaddreamin/typst-ts-renderer
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../../node_modules/.pnpm/@myriaddreamin+typst-ts-renderer@0.6.0/node_modules/@myriaddreamin/typst-ts-renderer
|
||||
1
packages/core/client/browser/node_modules/@myriaddreamin/typst-ts-web-compiler
generated
vendored
1
packages/core/client/browser/node_modules/@myriaddreamin/typst-ts-web-compiler
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../../node_modules/.pnpm/@myriaddreamin+typst-ts-web-compiler@0.6.0/node_modules/@myriaddreamin/typst-ts-web-compiler
|
||||
1
packages/core/client/browser/node_modules/vite
generated
vendored
1
packages/core/client/browser/node_modules/vite
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../node_modules/.pnpm/vite@5.4.20_@types+node@20.19.19_terser@5.44.0/node_modules/vite
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "soroban-flashcards-browser",
|
||||
"version": "1.0.0",
|
||||
"description": "Browser-based Soroban Flashcard Generator using Typst.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@myriaddreamin/typst-ts-web-compiler": "^0.6.0",
|
||||
"@myriaddreamin/typst-ts-renderer": "^0.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
81
packages/core/client/node/dist/index.d.ts
vendored
81
packages/core/client/node/dist/index.d.ts
vendored
@@ -2,12 +2,12 @@
|
||||
* Node.js TypeScript wrapper for Soroban Flashcard Generator
|
||||
* Calls Python functions via child_process
|
||||
*/
|
||||
interface FlashcardConfig$1 {
|
||||
interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
paperSize?: "us-letter" | "a4" | "a3" | "a5";
|
||||
orientation?: "portrait" | "landscape";
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
@@ -24,12 +24,12 @@ interface FlashcardConfig$1 {
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: 'diamond' | 'circle' | 'square';
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
|
||||
beadShape?: "diamond" | "circle" | "square";
|
||||
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
declare class SorobanGenerator$1 {
|
||||
declare class SorobanGenerator {
|
||||
private pythonPath;
|
||||
private generatorPath;
|
||||
private projectRoot;
|
||||
@@ -38,15 +38,15 @@ declare class SorobanGenerator$1 {
|
||||
/**
|
||||
* Generate flashcards and return PDF as Buffer
|
||||
*/
|
||||
generate(config: FlashcardConfig$1): Promise<Buffer>;
|
||||
generate(config: FlashcardConfig): Promise<Buffer>;
|
||||
/**
|
||||
* Generate flashcards and save to file
|
||||
*/
|
||||
generateToFile(config: FlashcardConfig$1, outputPath: string): Promise<void>;
|
||||
generateToFile(config: FlashcardConfig, outputPath: string): Promise<void>;
|
||||
/**
|
||||
* Generate flashcards and return as base64 string
|
||||
*/
|
||||
generateBase64(config: FlashcardConfig$1): Promise<string>;
|
||||
generateBase64(config: FlashcardConfig): Promise<string>;
|
||||
private executePython;
|
||||
/**
|
||||
* Check if all dependencies are installed
|
||||
@@ -59,65 +59,4 @@ declare class SorobanGenerator$1 {
|
||||
}
|
||||
declare function expressExample(): Promise<void>;
|
||||
|
||||
/**
|
||||
* TypeScript wrapper using python-shell for clean function interface
|
||||
* No CLI arguments - just function calls with objects
|
||||
*/
|
||||
interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
gutter?: string;
|
||||
shuffle?: boolean;
|
||||
seed?: number;
|
||||
showCutMarks?: boolean;
|
||||
showRegistration?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: 'diamond' | 'circle' | 'square';
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
format?: 'pdf' | 'svg';
|
||||
mode?: 'single-card' | 'flashcards';
|
||||
number?: number;
|
||||
}
|
||||
interface FlashcardResult {
|
||||
pdf: string;
|
||||
count: number;
|
||||
numbers: number[];
|
||||
}
|
||||
declare class SorobanGenerator {
|
||||
private pythonShell;
|
||||
private projectRoot;
|
||||
constructor(projectRoot?: string);
|
||||
/**
|
||||
* Initialize persistent Python process for better performance
|
||||
*/
|
||||
initialize(): Promise<void>;
|
||||
/**
|
||||
* Generate flashcards - clean function interface
|
||||
*/
|
||||
generate(config: FlashcardConfig): Promise<FlashcardResult>;
|
||||
/**
|
||||
* Generate and return as Buffer
|
||||
*/
|
||||
generateBuffer(config: FlashcardConfig): Promise<Buffer>;
|
||||
/**
|
||||
* Clean up Python process
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export { FlashcardConfig as BridgeFlashcardConfig, FlashcardResult as BridgeFlashcardResult, FlashcardConfig$1 as FlashcardConfig, SorobanGenerator$1 as SorobanGenerator, SorobanGenerator as SorobanGeneratorBridge, SorobanGenerator$1 as default, expressExample };
|
||||
export { type FlashcardConfig, SorobanGenerator, SorobanGenerator as default, expressExample };
|
||||
|
||||
89
packages/core/client/node/dist/index.js
vendored
89
packages/core/client/node/dist/index.js
vendored
@@ -31,7 +31,6 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
||||
var src_exports = {};
|
||||
__export(src_exports, {
|
||||
SorobanGenerator: () => SorobanGenerator,
|
||||
SorobanGeneratorBridge: () => SorobanGenerator2,
|
||||
default: () => SorobanGenerator,
|
||||
expressExample: () => expressExample
|
||||
});
|
||||
@@ -194,96 +193,8 @@ var SorobanGenerator = class {
|
||||
async function expressExample() {
|
||||
const generator = new SorobanGenerator();
|
||||
}
|
||||
|
||||
// src/soroban-generator-bridge.ts
|
||||
var import_python_shell = require("python-shell");
|
||||
var path2 = __toESM(require("path"));
|
||||
var SorobanGenerator2 = class {
|
||||
pythonShell = null;
|
||||
projectRoot;
|
||||
constructor(projectRoot) {
|
||||
this.projectRoot = projectRoot || path2.join(__dirname, "../../");
|
||||
}
|
||||
/**
|
||||
* Initialize persistent Python process for better performance
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.pythonShell)
|
||||
return;
|
||||
this.pythonShell = new import_python_shell.PythonShell(path2.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
pythonOptions: ["-u"],
|
||||
// Unbuffered
|
||||
scriptPath: this.projectRoot
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate flashcards - clean function interface
|
||||
*/
|
||||
async generate(config) {
|
||||
if (!this.pythonShell) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const shell = new import_python_shell.PythonShell(path2.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
scriptPath: this.projectRoot
|
||||
});
|
||||
shell.on("message", (message) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
});
|
||||
shell.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
shell.send(config);
|
||||
shell.end((err, code, signal) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.pythonShell) {
|
||||
reject(new Error("Not initialized"));
|
||||
return;
|
||||
}
|
||||
const handler = (message) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
this.pythonShell?.removeListener("message", handler);
|
||||
};
|
||||
this.pythonShell.on("message", handler);
|
||||
this.pythonShell.send(config);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate and return as Buffer
|
||||
*/
|
||||
async generateBuffer(config) {
|
||||
const result = await this.generate(config);
|
||||
return Buffer.from(result.pdf, "base64");
|
||||
}
|
||||
/**
|
||||
* Clean up Python process
|
||||
*/
|
||||
async close() {
|
||||
if (this.pythonShell) {
|
||||
this.pythonShell.end(() => {
|
||||
});
|
||||
this.pythonShell = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
SorobanGenerator,
|
||||
SorobanGeneratorBridge,
|
||||
expressExample
|
||||
});
|
||||
|
||||
88
packages/core/client/node/dist/index.mjs
vendored
88
packages/core/client/node/dist/index.mjs
vendored
@@ -155,96 +155,8 @@ var SorobanGenerator = class {
|
||||
async function expressExample() {
|
||||
const generator = new SorobanGenerator();
|
||||
}
|
||||
|
||||
// src/soroban-generator-bridge.ts
|
||||
import { PythonShell } from "python-shell";
|
||||
import * as path2 from "path";
|
||||
var SorobanGenerator2 = class {
|
||||
pythonShell = null;
|
||||
projectRoot;
|
||||
constructor(projectRoot) {
|
||||
this.projectRoot = projectRoot || path2.join(__dirname, "../../");
|
||||
}
|
||||
/**
|
||||
* Initialize persistent Python process for better performance
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.pythonShell)
|
||||
return;
|
||||
this.pythonShell = new PythonShell(path2.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
pythonOptions: ["-u"],
|
||||
// Unbuffered
|
||||
scriptPath: this.projectRoot
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate flashcards - clean function interface
|
||||
*/
|
||||
async generate(config) {
|
||||
if (!this.pythonShell) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const shell = new PythonShell(path2.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
scriptPath: this.projectRoot
|
||||
});
|
||||
shell.on("message", (message) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
});
|
||||
shell.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
shell.send(config);
|
||||
shell.end((err, code, signal) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.pythonShell) {
|
||||
reject(new Error("Not initialized"));
|
||||
return;
|
||||
}
|
||||
const handler = (message) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message);
|
||||
}
|
||||
this.pythonShell?.removeListener("message", handler);
|
||||
};
|
||||
this.pythonShell.on("message", handler);
|
||||
this.pythonShell.send(config);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Generate and return as Buffer
|
||||
*/
|
||||
async generateBuffer(config) {
|
||||
const result = await this.generate(config);
|
||||
return Buffer.from(result.pdf, "base64");
|
||||
}
|
||||
/**
|
||||
* Clean up Python process
|
||||
*/
|
||||
async close() {
|
||||
if (this.pythonShell) {
|
||||
this.pythonShell.end(() => {
|
||||
});
|
||||
this.pythonShell = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
export {
|
||||
SorobanGenerator,
|
||||
SorobanGenerator2 as SorobanGeneratorBridge,
|
||||
SorobanGenerator as default,
|
||||
expressExample
|
||||
};
|
||||
|
||||
6
packages/core/client/node/node_modules/.bin/esbuild
generated
vendored
6
packages/core/client/node/node_modules/.bin/esbuild
generated
vendored
@@ -6,9 +6,9 @@ case `uname` in
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.19.12/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/esbuild" "$@"
|
||||
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.19.12/node_modules/esbuild/bin/esbuild" "$@"
|
||||
exit $?
|
||||
|
||||
1
packages/core/client/node/node_modules/python-shell
generated
vendored
1
packages/core/client/node/node_modules/python-shell
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../node_modules/.pnpm/python-shell@5.0.0/node_modules/python-shell
|
||||
@@ -18,9 +18,6 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"python-shell": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^7.0.0",
|
||||
|
||||
@@ -5,12 +5,5 @@
|
||||
|
||||
export * from "./soroban-generator";
|
||||
|
||||
// Export bridge generator with different name to avoid conflicts
|
||||
export {
|
||||
SorobanGenerator as SorobanGeneratorBridge,
|
||||
FlashcardConfig as BridgeFlashcardConfig,
|
||||
FlashcardResult as BridgeFlashcardResult,
|
||||
} from "./soroban-generator-bridge";
|
||||
|
||||
// Default export for convenience
|
||||
export { SorobanGenerator as default } from "./soroban-generator";
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* TypeScript wrapper using python-shell for clean function interface
|
||||
* No CLI arguments - just function calls with objects
|
||||
*/
|
||||
|
||||
import { PythonShell } from "python-shell";
|
||||
import * as path from "path";
|
||||
|
||||
export interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: "us-letter" | "a4" | "a3" | "a5";
|
||||
orientation?: "portrait" | "landscape";
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
gutter?: string;
|
||||
shuffle?: boolean;
|
||||
seed?: number;
|
||||
showCutMarks?: boolean;
|
||||
showRegistration?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: "diamond" | "circle" | "square";
|
||||
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
format?: "pdf" | "svg";
|
||||
mode?: "single-card" | "flashcards";
|
||||
number?: number;
|
||||
}
|
||||
|
||||
export interface FlashcardResult {
|
||||
pdf: string; // base64 encoded PDF or SVG content (depending on format)
|
||||
count: number;
|
||||
numbers: number[];
|
||||
}
|
||||
|
||||
export class SorobanGenerator {
|
||||
private pythonShell: PythonShell | null = null;
|
||||
private projectRoot: string;
|
||||
|
||||
constructor(projectRoot?: string) {
|
||||
this.projectRoot = projectRoot || path.join(__dirname, "../../");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize persistent Python process for better performance
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.pythonShell) return;
|
||||
|
||||
this.pythonShell = new PythonShell(path.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
pythonOptions: ["-u"], // Unbuffered
|
||||
scriptPath: this.projectRoot,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flashcards - clean function interface
|
||||
*/
|
||||
async generate(config: FlashcardConfig): Promise<FlashcardResult> {
|
||||
// One-shot mode if not initialized
|
||||
if (!this.pythonShell) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const shell = new PythonShell(path.join("src", "bridge.py"), {
|
||||
mode: "json",
|
||||
pythonPath: "python3",
|
||||
scriptPath: this.projectRoot,
|
||||
});
|
||||
|
||||
shell.on("message", (message: any) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message as FlashcardResult);
|
||||
}
|
||||
});
|
||||
|
||||
shell.on("error", (err: any) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
shell.send(config);
|
||||
shell.end((err: any, code: any, signal: any) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Persistent mode
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.pythonShell) {
|
||||
reject(new Error("Not initialized"));
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (message: any) => {
|
||||
if (message.error) {
|
||||
reject(new Error(message.error));
|
||||
} else {
|
||||
resolve(message as FlashcardResult);
|
||||
}
|
||||
this.pythonShell?.removeListener("message", handler);
|
||||
};
|
||||
|
||||
this.pythonShell.on("message", handler);
|
||||
this.pythonShell.send(config);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and return as Buffer
|
||||
*/
|
||||
async generateBuffer(config: FlashcardConfig): Promise<Buffer> {
|
||||
const result = await this.generate(config);
|
||||
return Buffer.from(result.pdf, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up Python process
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.pythonShell) {
|
||||
this.pythonShell.end(() => {});
|
||||
this.pythonShell = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage - just like calling a regular TypeScript function
|
||||
async function example() {
|
||||
const generator = new SorobanGenerator();
|
||||
|
||||
// Just call it like a function!
|
||||
const result = await generator.generate({
|
||||
range: "0-99",
|
||||
cardsPerPage: 6,
|
||||
colorScheme: "place-value",
|
||||
coloredNumerals: true,
|
||||
showCutMarks: true,
|
||||
});
|
||||
|
||||
// You get back a clean result object
|
||||
console.log(`Generated ${result.count} flashcards`);
|
||||
|
||||
// Convert to Buffer if needed
|
||||
const pdfBuffer = Buffer.from(result.pdf, "base64");
|
||||
|
||||
// Or use persistent mode for better performance
|
||||
await generator.initialize();
|
||||
|
||||
// Now calls are faster
|
||||
const result2 = await generator.generate({ range: "0-9" });
|
||||
const result3 = await generator.generate({ range: "10-19" });
|
||||
|
||||
await generator.close();
|
||||
}
|
||||
|
||||
// Express example - clean function calls
|
||||
export function expressRoute(app: any) {
|
||||
const generator = new SorobanGenerator();
|
||||
|
||||
app.post("/api/flashcards", async (req: any, res: any) => {
|
||||
try {
|
||||
// Just pass the config object directly!
|
||||
const result = await generator.generate(req.body);
|
||||
|
||||
// Send back JSON or PDF
|
||||
if (req.query.format === "json") {
|
||||
res.json(result);
|
||||
} else {
|
||||
const pdfBuffer = Buffer.from(result.pdf, "base64");
|
||||
res.contentType("application/pdf");
|
||||
res.send(pdfBuffer);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
}
|
||||
11
packages/core/client/node/tsconfig.json
Normal file
11
packages/core/client/node/tsconfig.json
Normal 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"]
|
||||
}
|
||||
58
packages/core/client/typescript/dist/index.d.ts
vendored
58
packages/core/client/typescript/dist/index.d.ts
vendored
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* TypeScript client for Soroban Flashcard Generator API
|
||||
*/
|
||||
interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5';
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
gutter?: string;
|
||||
shuffle?: boolean;
|
||||
seed?: number;
|
||||
showCutMarks?: boolean;
|
||||
showRegistration?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: 'diamond' | 'circle' | 'square';
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating';
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
interface FlashcardResponse {
|
||||
pdf: string;
|
||||
count: number;
|
||||
numbers: number[];
|
||||
}
|
||||
declare class SorobanFlashcardClient {
|
||||
private apiUrl;
|
||||
constructor(apiUrl?: string);
|
||||
/**
|
||||
* Generate flashcards and return as base64 PDF
|
||||
*/
|
||||
generate(config: FlashcardConfig): Promise<FlashcardResponse>;
|
||||
/**
|
||||
* Generate flashcards and download as PDF file
|
||||
*/
|
||||
generateAndDownload(config: FlashcardConfig, filename?: string): Promise<void>;
|
||||
/**
|
||||
* Generate flashcards and open in new tab
|
||||
*/
|
||||
generateAndOpen(config: FlashcardConfig): Promise<void>;
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
health(): Promise<boolean>;
|
||||
}
|
||||
declare function example(): Promise<void>;
|
||||
|
||||
export { FlashcardConfig, FlashcardResponse, SorobanFlashcardClient, SorobanFlashcardClient as default, example };
|
||||
148
packages/core/client/typescript/dist/index.js
vendored
148
packages/core/client/typescript/dist/index.js
vendored
@@ -1,148 +0,0 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/index.ts
|
||||
var src_exports = {};
|
||||
__export(src_exports, {
|
||||
SorobanFlashcardClient: () => SorobanFlashcardClient,
|
||||
default: () => SorobanFlashcardClient,
|
||||
example: () => example
|
||||
});
|
||||
module.exports = __toCommonJS(src_exports);
|
||||
|
||||
// src/soroban-flashcards.ts
|
||||
var SorobanFlashcardClient = class {
|
||||
apiUrl;
|
||||
constructor(apiUrl = "http://localhost:8000") {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and return as base64 PDF
|
||||
*/
|
||||
async generate(config) {
|
||||
const response = await fetch(`${this.apiUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
range: config.range,
|
||||
step: config.step ?? 1,
|
||||
cards_per_page: config.cardsPerPage ?? 6,
|
||||
paper_size: config.paperSize ?? "us-letter",
|
||||
orientation: config.orientation ?? "portrait",
|
||||
margins: config.margins ?? {
|
||||
top: "0.5in",
|
||||
bottom: "0.5in",
|
||||
left: "0.5in",
|
||||
right: "0.5in"
|
||||
},
|
||||
gutter: config.gutter ?? "5mm",
|
||||
shuffle: config.shuffle ?? false,
|
||||
seed: config.seed,
|
||||
show_cut_marks: config.showCutMarks ?? false,
|
||||
show_registration: config.showRegistration ?? false,
|
||||
font_family: config.fontFamily ?? "DejaVu Sans",
|
||||
font_size: config.fontSize ?? "48pt",
|
||||
columns: config.columns ?? "auto",
|
||||
show_empty_columns: config.showEmptyColumns ?? false,
|
||||
hide_inactive_beads: config.hideInactiveBeads ?? false,
|
||||
bead_shape: config.beadShape ?? "diamond",
|
||||
color_scheme: config.colorScheme ?? "monochrome",
|
||||
colored_numerals: config.coloredNumerals ?? false,
|
||||
scale_factor: config.scaleFactor ?? 0.9,
|
||||
format: "base64"
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to generate flashcards");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and download as PDF file
|
||||
*/
|
||||
async generateAndDownload(config, filename = "flashcards.pdf") {
|
||||
const result = await this.generate(config);
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and open in new tab
|
||||
*/
|
||||
async generateAndOpen(config) {
|
||||
const result = await this.generate(config);
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
async health() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/health`);
|
||||
const data = await response.json();
|
||||
return data.status === "healthy";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
async function example() {
|
||||
const client = new SorobanFlashcardClient();
|
||||
await client.generateAndDownload({
|
||||
range: "0-99",
|
||||
cardsPerPage: 6,
|
||||
colorScheme: "place-value",
|
||||
coloredNumerals: true,
|
||||
showCutMarks: true
|
||||
});
|
||||
await client.generateAndDownload(
|
||||
{
|
||||
range: "0-100",
|
||||
step: 5,
|
||||
cardsPerPage: 6
|
||||
},
|
||||
"counting-by-5s.pdf"
|
||||
);
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
SorobanFlashcardClient,
|
||||
example
|
||||
});
|
||||
120
packages/core/client/typescript/dist/index.mjs
vendored
120
packages/core/client/typescript/dist/index.mjs
vendored
@@ -1,120 +0,0 @@
|
||||
// src/soroban-flashcards.ts
|
||||
var SorobanFlashcardClient = class {
|
||||
apiUrl;
|
||||
constructor(apiUrl = "http://localhost:8000") {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and return as base64 PDF
|
||||
*/
|
||||
async generate(config) {
|
||||
const response = await fetch(`${this.apiUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
range: config.range,
|
||||
step: config.step ?? 1,
|
||||
cards_per_page: config.cardsPerPage ?? 6,
|
||||
paper_size: config.paperSize ?? "us-letter",
|
||||
orientation: config.orientation ?? "portrait",
|
||||
margins: config.margins ?? {
|
||||
top: "0.5in",
|
||||
bottom: "0.5in",
|
||||
left: "0.5in",
|
||||
right: "0.5in"
|
||||
},
|
||||
gutter: config.gutter ?? "5mm",
|
||||
shuffle: config.shuffle ?? false,
|
||||
seed: config.seed,
|
||||
show_cut_marks: config.showCutMarks ?? false,
|
||||
show_registration: config.showRegistration ?? false,
|
||||
font_family: config.fontFamily ?? "DejaVu Sans",
|
||||
font_size: config.fontSize ?? "48pt",
|
||||
columns: config.columns ?? "auto",
|
||||
show_empty_columns: config.showEmptyColumns ?? false,
|
||||
hide_inactive_beads: config.hideInactiveBeads ?? false,
|
||||
bead_shape: config.beadShape ?? "diamond",
|
||||
color_scheme: config.colorScheme ?? "monochrome",
|
||||
colored_numerals: config.coloredNumerals ?? false,
|
||||
scale_factor: config.scaleFactor ?? 0.9,
|
||||
format: "base64"
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to generate flashcards");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and download as PDF file
|
||||
*/
|
||||
async generateAndDownload(config, filename = "flashcards.pdf") {
|
||||
const result = await this.generate(config);
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
/**
|
||||
* Generate flashcards and open in new tab
|
||||
*/
|
||||
async generateAndOpen(config) {
|
||||
const result = await this.generate(config);
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
async health() {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/health`);
|
||||
const data = await response.json();
|
||||
return data.status === "healthy";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
async function example() {
|
||||
const client = new SorobanFlashcardClient();
|
||||
await client.generateAndDownload({
|
||||
range: "0-99",
|
||||
cardsPerPage: 6,
|
||||
colorScheme: "place-value",
|
||||
coloredNumerals: true,
|
||||
showCutMarks: true
|
||||
});
|
||||
await client.generateAndDownload(
|
||||
{
|
||||
range: "0-100",
|
||||
step: 5,
|
||||
cardsPerPage: 6
|
||||
},
|
||||
"counting-by-5s.pdf"
|
||||
);
|
||||
}
|
||||
export {
|
||||
SorobanFlashcardClient,
|
||||
SorobanFlashcardClient as default,
|
||||
example
|
||||
};
|
||||
14
packages/core/client/typescript/node_modules/.bin/esbuild
generated
vendored
14
packages/core/client/typescript/node_modules/.bin/esbuild
generated
vendored
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/esbuild@0.18.20/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
"$basedir/../../../../../../node_modules/.pnpm/esbuild@0.18.20/node_modules/esbuild/bin/esbuild" "$@"
|
||||
exit $?
|
||||
17
packages/core/client/typescript/node_modules/.bin/tsc
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/tsc
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
17
packages/core/client/typescript/node_modules/.bin/tsserver
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/tsserver
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
17
packages/core/client/typescript/node_modules/.bin/tsup
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/tsup
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../tsup/dist/cli-default.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../tsup/dist/cli-default.js" "$@"
|
||||
fi
|
||||
17
packages/core/client/typescript/node_modules/.bin/tsup-node
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/tsup-node
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/dist/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../tsup/dist/cli-node.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../tsup/dist/cli-node.js" "$@"
|
||||
fi
|
||||
17
packages/core/client/typescript/node_modules/.bin/vitest
generated
vendored
17
packages/core/client/typescript/node_modules/.bin/vitest
generated
vendored
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules:/Users/antialias/projects/soroban-abacus-flashcards/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
||||
fi
|
||||
1
packages/core/client/typescript/node_modules/tsup
generated
vendored
1
packages/core/client/typescript/node_modules/tsup
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../node_modules/.pnpm/tsup@7.3.0_postcss@8.5.6_typescript@5.9.3/node_modules/tsup
|
||||
1
packages/core/client/typescript/node_modules/vitest
generated
vendored
1
packages/core/client/typescript/node_modules/vitest
generated
vendored
@@ -1 +0,0 @@
|
||||
../../../../../node_modules/.pnpm/vitest@1.6.1_@types+node@20.19.19_@vitest+ui@3.2.4_happy-dom@18.0.1_jsdom@27.0.0_postcss@8.5.6__terser@5.44.0/node_modules/vitest
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "@soroban/client",
|
||||
"version": "1.0.0",
|
||||
"description": "TypeScript client for Soroban Flashcard Generator",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.esm.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
||||
"test": "vitest",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"keywords": [
|
||||
"soroban",
|
||||
"abacus",
|
||||
"flashcards",
|
||||
"education",
|
||||
"math"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"tsup": "^7.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Soroban Flashcard Generator - TypeScript Client
|
||||
* Re-export main client functionality
|
||||
*/
|
||||
|
||||
export * from "./soroban-flashcards";
|
||||
export { SorobanFlashcardClient as default } from "./soroban-flashcards";
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* TypeScript client for Soroban Flashcard Generator API
|
||||
*/
|
||||
|
||||
export interface FlashcardConfig {
|
||||
range: string;
|
||||
step?: number;
|
||||
cardsPerPage?: number;
|
||||
paperSize?: "us-letter" | "a4" | "a3" | "a5";
|
||||
orientation?: "portrait" | "landscape";
|
||||
margins?: {
|
||||
top?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
gutter?: string;
|
||||
shuffle?: boolean;
|
||||
seed?: number;
|
||||
showCutMarks?: boolean;
|
||||
showRegistration?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
columns?: string | number;
|
||||
showEmptyColumns?: boolean;
|
||||
hideInactiveBeads?: boolean;
|
||||
beadShape?: "diamond" | "circle" | "square";
|
||||
colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating";
|
||||
coloredNumerals?: boolean;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
export interface FlashcardResponse {
|
||||
pdf: string; // base64 encoded PDF
|
||||
count: number;
|
||||
numbers: number[];
|
||||
}
|
||||
|
||||
export class SorobanFlashcardClient {
|
||||
private apiUrl: string;
|
||||
|
||||
constructor(apiUrl: string = "http://localhost:8000") {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flashcards and return as base64 PDF
|
||||
*/
|
||||
async generate(config: FlashcardConfig): Promise<FlashcardResponse> {
|
||||
const response = await fetch(`${this.apiUrl}/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
range: config.range,
|
||||
step: config.step ?? 1,
|
||||
cards_per_page: config.cardsPerPage ?? 6,
|
||||
paper_size: config.paperSize ?? "us-letter",
|
||||
orientation: config.orientation ?? "portrait",
|
||||
margins: config.margins ?? {
|
||||
top: "0.5in",
|
||||
bottom: "0.5in",
|
||||
left: "0.5in",
|
||||
right: "0.5in",
|
||||
},
|
||||
gutter: config.gutter ?? "5mm",
|
||||
shuffle: config.shuffle ?? false,
|
||||
seed: config.seed,
|
||||
show_cut_marks: config.showCutMarks ?? false,
|
||||
show_registration: config.showRegistration ?? false,
|
||||
font_family: config.fontFamily ?? "DejaVu Sans",
|
||||
font_size: config.fontSize ?? "48pt",
|
||||
columns: config.columns ?? "auto",
|
||||
show_empty_columns: config.showEmptyColumns ?? false,
|
||||
hide_inactive_beads: config.hideInactiveBeads ?? false,
|
||||
bead_shape: config.beadShape ?? "diamond",
|
||||
color_scheme: config.colorScheme ?? "monochrome",
|
||||
colored_numerals: config.coloredNumerals ?? false,
|
||||
scale_factor: config.scaleFactor ?? 0.9,
|
||||
format: "base64",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || "Failed to generate flashcards");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flashcards and download as PDF file
|
||||
*/
|
||||
async generateAndDownload(
|
||||
config: FlashcardConfig,
|
||||
filename: string = "flashcards.pdf",
|
||||
): Promise<void> {
|
||||
const result = await this.generate(config);
|
||||
|
||||
// Convert base64 to blob
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
|
||||
// Create download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flashcards and open in new tab
|
||||
*/
|
||||
async generateAndOpen(config: FlashcardConfig): Promise<void> {
|
||||
const result = await this.generate(config);
|
||||
|
||||
// Convert base64 to blob
|
||||
const pdfBytes = atob(result.pdf);
|
||||
const byteArray = new Uint8Array(pdfBytes.length);
|
||||
for (let i = 0; i < pdfBytes.length; i++) {
|
||||
byteArray[i] = pdfBytes.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([byteArray], { type: "application/pdf" });
|
||||
|
||||
// Open in new tab
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}/health`);
|
||||
const data = await response.json();
|
||||
return data.status === "healthy";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage function
|
||||
export async function example() {
|
||||
const client = new SorobanFlashcardClient();
|
||||
|
||||
// Generate and download flashcards for 0-99 with place-value coloring
|
||||
await client.generateAndDownload({
|
||||
range: "0-99",
|
||||
cardsPerPage: 6,
|
||||
colorScheme: "place-value",
|
||||
coloredNumerals: true,
|
||||
showCutMarks: true,
|
||||
});
|
||||
|
||||
// Generate counting by 5s
|
||||
await client.generateAndDownload(
|
||||
{
|
||||
range: "0-100",
|
||||
step: 5,
|
||||
cardsPerPage: 6,
|
||||
},
|
||||
"counting-by-5s.pdf",
|
||||
);
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python bridge for Node.js integration
|
||||
Provides a clean function interface instead of CLI
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import base64
|
||||
import tempfile
|
||||
import os
|
||||
import glob
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
# Import our existing functions
|
||||
from generate import parse_range, generate_typst_file, generate_single_card_typst
|
||||
|
||||
def generate_flashcards_json(config_json):
|
||||
"""
|
||||
Generate flashcards from JSON config
|
||||
Returns base64 encoded PDF
|
||||
"""
|
||||
config = json.loads(config_json)
|
||||
|
||||
# Parse numbers
|
||||
numbers = parse_range(
|
||||
config.get('range', '0-9'),
|
||||
config.get('step', 1)
|
||||
)
|
||||
|
||||
# Handle shuffle
|
||||
if config.get('shuffle', False):
|
||||
import random
|
||||
if 'seed' in config:
|
||||
random.seed(config['seed'])
|
||||
random.shuffle(numbers)
|
||||
|
||||
# Build Typst config
|
||||
typst_config = {
|
||||
'cards_per_page': config.get('cardsPerPage', 6),
|
||||
'paper_size': config.get('paperSize', 'us-letter'),
|
||||
'orientation': config.get('orientation', 'portrait'),
|
||||
'margins': config.get('margins', {
|
||||
'top': '0.5in',
|
||||
'bottom': '0.5in',
|
||||
'left': '0.5in',
|
||||
'right': '0.5in'
|
||||
}),
|
||||
'gutter': config.get('gutter', '5mm'),
|
||||
'show_cut_marks': config.get('showCutMarks', False),
|
||||
'show_registration': config.get('showRegistration', False),
|
||||
'font_family': config.get('fontFamily', 'DejaVu Sans'),
|
||||
'font_size': config.get('fontSize', '48pt'),
|
||||
'columns': config.get('columns', 'auto'),
|
||||
'show_empty_columns': config.get('showEmptyColumns', False),
|
||||
'hide_inactive_beads': config.get('hideInactiveBeads', False),
|
||||
'bead_shape': config.get('beadShape', 'diamond'),
|
||||
'color_scheme': config.get('colorScheme', 'monochrome'),
|
||||
'colored_numerals': config.get('coloredNumerals', False),
|
||||
'scale_factor': config.get('scaleFactor', 0.9),
|
||||
}
|
||||
|
||||
# Generate in core package directory to match main generator behavior
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
# Generate Typst file - same setup as main generate.py
|
||||
core_package_root = Path(__file__).parent.parent # packages/core directory
|
||||
|
||||
# Create temp files in core package root, not temp directory
|
||||
temp_typst = core_package_root / f'temp_flashcards_{os.getpid()}.typ'
|
||||
temp_pdf = core_package_root / f'temp_flashcards_{os.getpid()}.pdf'
|
||||
|
||||
# Convert Python list to Typst array syntax (same as main generate.py)
|
||||
if numbers:
|
||||
numbers_str = '(' + ', '.join(str(n) for n in numbers) + ',)'
|
||||
else:
|
||||
numbers_str = '()'
|
||||
|
||||
# Create temp Typst with relative imports (works when run from core package root)
|
||||
typst_content = f'''
|
||||
#import "templates/flashcards.typ": generate-flashcards
|
||||
|
||||
#generate-flashcards(
|
||||
{numbers_str},
|
||||
cards-per-page: {typst_config['cards_per_page']},
|
||||
paper-size: "{typst_config['paper_size']}",
|
||||
orientation: "{typst_config['orientation']}",
|
||||
margins: (
|
||||
top: {typst_config['margins'].get('top', '0.5in')},
|
||||
bottom: {typst_config['margins'].get('bottom', '0.5in')},
|
||||
left: {typst_config['margins'].get('left', '0.5in')},
|
||||
right: {typst_config['margins'].get('right', '0.5in')}
|
||||
),
|
||||
gutter: {typst_config['gutter']},
|
||||
show-cut-marks: {str(typst_config['show_cut_marks']).lower()},
|
||||
show-registration: {str(typst_config['show_registration']).lower()},
|
||||
font-family: "{typst_config['font_family']}",
|
||||
font-size: {typst_config['font_size']},
|
||||
columns: {typst_config['columns']},
|
||||
show-empty-columns: {str(typst_config['show_empty_columns']).lower()},
|
||||
hide-inactive-beads: {str(typst_config['hide_inactive_beads']).lower()},
|
||||
bead-shape: "{typst_config['bead_shape']}",
|
||||
color-scheme: "{typst_config['color_scheme']}",
|
||||
colored-numerals: {str(typst_config['colored_numerals']).lower()},
|
||||
scale-factor: {typst_config['scale_factor']}
|
||||
)
|
||||
'''
|
||||
|
||||
with open(temp_typst, 'w') as f:
|
||||
f.write(typst_content)
|
||||
|
||||
# Get format preference
|
||||
output_format = config.get('format', 'pdf').lower()
|
||||
temp_svg = None
|
||||
|
||||
try:
|
||||
if output_format == 'svg':
|
||||
# Generate SVG using Typst with page template for multi-page support
|
||||
temp_svg = core_package_root / f'temp_flashcards_{os.getpid()}_{{p}}.svg'
|
||||
result = subprocess.run(
|
||||
['typst', 'compile', str(temp_typst), str(temp_svg), '--format', 'svg'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(core_package_root)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return json.dumps({
|
||||
'error': f'Typst SVG compilation failed: {result.stderr}'
|
||||
})
|
||||
|
||||
# Read SVG content - find the first generated page
|
||||
svg_pattern = core_package_root / f'temp_flashcards_{os.getpid()}_*.svg'
|
||||
import glob
|
||||
svg_files = glob.glob(str(svg_pattern))
|
||||
|
||||
if not svg_files:
|
||||
return json.dumps({
|
||||
'error': 'No SVG files were generated'
|
||||
})
|
||||
|
||||
# Read the first SVG file (page 1)
|
||||
svg_file = Path(svg_files[0])
|
||||
with open(svg_file, 'r', encoding='utf-8') as f:
|
||||
svg_content = f.read()
|
||||
|
||||
# Clean up all generated SVG files
|
||||
for svg_path in svg_files:
|
||||
Path(svg_path).unlink()
|
||||
|
||||
result_data = {
|
||||
'pdf': svg_content, # Keep field name for compatibility
|
||||
'count': len(numbers),
|
||||
'numbers': numbers[:100]
|
||||
}
|
||||
else:
|
||||
# Generate PDF (default)
|
||||
result = subprocess.run(
|
||||
['typst', 'compile', str(temp_typst), str(temp_pdf)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(core_package_root)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return json.dumps({
|
||||
'error': f'Typst compilation failed: {result.stderr}'
|
||||
})
|
||||
|
||||
# Read and encode PDF
|
||||
with open(temp_pdf, 'rb') as f:
|
||||
pdf_bytes = f.read()
|
||||
|
||||
result_data = {
|
||||
'pdf': base64.b64encode(pdf_bytes).decode('utf-8'),
|
||||
'count': len(numbers),
|
||||
'numbers': numbers[:100] # Limit preview
|
||||
}
|
||||
finally:
|
||||
# Clean up temp files
|
||||
for temp_file in [temp_typst, temp_pdf, temp_svg if output_format == 'svg' else None]:
|
||||
if temp_file and temp_file.exists():
|
||||
temp_file.unlink()
|
||||
|
||||
return json.dumps(result_data)
|
||||
|
||||
def generate_single_card_json(config_json):
|
||||
"""
|
||||
Generate a single card SVG from JSON config
|
||||
Specifically for preview - always returns front side (abacus)
|
||||
"""
|
||||
config = json.loads(config_json)
|
||||
|
||||
# Extract the single number
|
||||
number = config.get('number')
|
||||
if number is None:
|
||||
return json.dumps({'error': 'Missing number parameter'})
|
||||
|
||||
# Build Typst config optimized for preview display
|
||||
typst_config = {
|
||||
'bead_shape': config.get('beadShape', 'diamond'),
|
||||
'color_scheme': config.get('colorScheme', 'monochrome'),
|
||||
'color_palette': config.get('colorPalette', 'default'),
|
||||
'colored_numerals': config.get('coloredNumerals', False),
|
||||
'hide_inactive_beads': config.get('hideInactiveBeads', False),
|
||||
'show_empty_columns': config.get('showEmptyColumns', False),
|
||||
'columns': config.get('columns', 'auto'),
|
||||
'transparent': config.get('transparent', False),
|
||||
'card_width': '120pt', # Smaller card for larger abacus
|
||||
'card_height': '160pt', # Smaller card for larger abacus
|
||||
'font_size': config.get('fontSize', '48pt'),
|
||||
'font_family': config.get('fontFamily', 'DejaVu Sans'),
|
||||
'scale_factor': config.get('scaleFactor', 4.0), # Much larger scale for preview visibility
|
||||
}
|
||||
|
||||
# Generate in core package directory
|
||||
core_package_root = Path(__file__).parent.parent
|
||||
temp_typst = core_package_root / f'temp_single_{number}_{os.getpid()}.typ'
|
||||
temp_svg = core_package_root / f'temp_single_{number}_{os.getpid()}.svg'
|
||||
|
||||
try:
|
||||
# Create single card content directly with correct template path
|
||||
typst_content = f'''
|
||||
#import "templates/single-card.typ": generate-single-card
|
||||
|
||||
#generate-single-card(
|
||||
{number},
|
||||
side: "front",
|
||||
bead-shape: "{typst_config['bead_shape']}",
|
||||
color-scheme: "{typst_config['color_scheme']}",
|
||||
color-palette: "{typst_config['color_palette']}",
|
||||
colored-numerals: {str(typst_config['colored_numerals']).lower()},
|
||||
hide-inactive-beads: {str(typst_config['hide_inactive_beads']).lower()},
|
||||
show-empty-columns: {str(typst_config['show_empty_columns']).lower()},
|
||||
columns: {typst_config['columns']},
|
||||
transparent: {str(typst_config['transparent']).lower()},
|
||||
width: {typst_config['card_width']},
|
||||
height: {typst_config['card_height']},
|
||||
font-size: {typst_config['font_size']},
|
||||
font-family: "{typst_config['font_family']}",
|
||||
scale-factor: {typst_config['scale_factor']}
|
||||
)
|
||||
'''
|
||||
|
||||
with open(temp_typst, 'w') as f:
|
||||
f.write(typst_content)
|
||||
|
||||
# Generate SVG
|
||||
result = subprocess.run(
|
||||
['typst', 'compile', str(temp_typst), str(temp_svg), '--format', 'svg'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(core_package_root)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return json.dumps({
|
||||
'error': f'Typst SVG compilation failed: {result.stderr}'
|
||||
})
|
||||
|
||||
# Read SVG content
|
||||
if not temp_svg.exists():
|
||||
return json.dumps({
|
||||
'error': 'SVG file was not generated'
|
||||
})
|
||||
|
||||
with open(temp_svg, 'r', encoding='utf-8') as f:
|
||||
svg_content = f.read()
|
||||
|
||||
return json.dumps({
|
||||
'pdf': svg_content, # Keep field name for compatibility
|
||||
'count': 1,
|
||||
'numbers': [number]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({'error': f'Single card generation failed: {str(e)}'})
|
||||
finally:
|
||||
# Clean up temp files
|
||||
for temp_file in [temp_typst, temp_svg]:
|
||||
if temp_file and temp_file.exists():
|
||||
temp_file.unlink()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Read JSON from stdin, write JSON to stdout
|
||||
# This allows clean function-like communication
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
config = json.loads(line.strip())
|
||||
|
||||
# Check if this is a single-card generation request
|
||||
if config.get('mode') == 'single-card':
|
||||
result = generate_single_card_json(line.strip())
|
||||
else:
|
||||
result = generate_flashcards_json(line.strip())
|
||||
|
||||
print(result)
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
print(json.dumps({'error': str(e)}))
|
||||
sys.stdout.flush()
|
||||
54
pnpm-lock.yaml
generated
54
pnpm-lock.yaml
generated
@@ -119,9 +119,6 @@ importers:
|
||||
'@soroban/abacus-react':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/abacus-react
|
||||
'@soroban/client':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core/client/typescript
|
||||
'@soroban/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core/client/node
|
||||
@@ -380,42 +377,7 @@ importers:
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
|
||||
|
||||
packages/core/client/browser:
|
||||
dependencies:
|
||||
'@myriaddreamin/typst-ts-renderer':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
'@myriaddreamin/typst-ts-web-compiler':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^5.0.0
|
||||
version: 5.4.20(@types/node@20.19.19)(terser@5.44.0)
|
||||
|
||||
packages/core/client/node:
|
||||
dependencies:
|
||||
python-shell:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.19
|
||||
tsup:
|
||||
specifier: ^7.0.0
|
||||
version: 7.3.0(postcss@8.5.6)(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
|
||||
|
||||
packages/core/client/typescript:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
@@ -1951,12 +1913,6 @@ packages:
|
||||
'@types/react': '>=16'
|
||||
react: '>=16'
|
||||
|
||||
'@myriaddreamin/typst-ts-renderer@0.6.0':
|
||||
resolution: {integrity: sha512-56Mids4E5Ob6LeEeXDedvmsVnEWnLmc1qeUOeUSruL/zI3S9QXleF/c3Os1FXwJmLuCFbWTEIq8Quh2cXlnxKw==}
|
||||
|
||||
'@myriaddreamin/typst-ts-web-compiler@0.6.0':
|
||||
resolution: {integrity: sha512-P/eIJ5RnfElj0NYzn5PI296t/IwWtgqUyyTMi5Jm5X3V5kZfskkH+LI7mSQe8tEyxwgCvxbxvFe5adinA3K8Gg==}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -7767,10 +7723,6 @@ packages:
|
||||
python-bridge@1.1.0:
|
||||
resolution: {integrity: sha512-qjQ0QB8p9cn/XDeILQH0aP307hV58lrmv0Opjyub68Um7FHdF+ZXlTqyxNkKaXOFk2QSkScoPWwn7U9GGnrkeQ==}
|
||||
|
||||
python-shell@5.0.0:
|
||||
resolution: {integrity: sha512-RUOOOjHLhgR1MIQrCtnEqz/HJ1RMZBIN+REnpSUrfft2bXqXy69fwJASVziWExfFXsR1bCY0TznnHooNsCo0/w==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
qs@6.13.0:
|
||||
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -10808,10 +10760,6 @@ snapshots:
|
||||
'@types/react': 18.3.26
|
||||
react: 18.3.1
|
||||
|
||||
'@myriaddreamin/typst-ts-renderer@0.6.0': {}
|
||||
|
||||
'@myriaddreamin/typst-ts-web-compiler@0.6.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.5.0
|
||||
@@ -17636,8 +17584,6 @@ snapshots:
|
||||
dependencies:
|
||||
bluebird: 3.7.2
|
||||
|
||||
python-shell@5.0.0: {}
|
||||
|
||||
qs@6.13.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
Reference in New Issue
Block a user