Compare commits

..

6 Commits

Author SHA1 Message Date
semantic-release-bot
dde7ca39cc chore(release): 4.68.2 [skip ci]
## [4.68.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.68.1...v4.68.2) (2025-10-23)

### Bug Fixes

* **complement-race:** prevent passenger delivery render loop causing thrashing ([f637ddf](f637ddfdb8))
2025-10-23 11:01:37 +00:00
Thomas Hallock
f637ddfdb8 fix(complement-race): prevent passenger delivery render loop causing thrashing
Fix render loop that was dispatching DELIVER_PASSENGER hundreds of times
per second when train remained at station, causing:
- Train stoppage despite correct answers
- Violent flashing in passenger HUD list
- Server rejecting moves: "Player does not have this passenger"

Solution:
- Add pendingDeliveryRef to track passengers with pending deliveries
- Check if delivery already dispatched before dispatching again
- Mark passenger as pending immediately before dispatch
- Clean up pending set when passengers are delivered
- Clear pending set on route changes

This mirrors the existing pendingBoardingRef pattern used for boarding.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 06:00:09 -05:00
semantic-release-bot
128da7f3d2 chore(release): 4.68.1 [skip ci]
## [4.68.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.68.0...v4.68.1) (2025-10-23)

### Performance Improvements

* **complement-race:** increase spring animation responsiveness to reduce lag ([5bd0dad](5bd0dadfdf))
2025-10-23 00:51:31 +00:00
Thomas Hallock
5bd0dadfdf perf(complement-race): increase spring animation responsiveness to reduce lag
Increased spring animation config from tension:280/friction:60 to
tension:600/friction:35 for both local and ghost trains. This makes the
animations much more responsive and reduces visual lag behind the actual
game state position.

The previous slow springs caused the visual train to lag noticeably behind
the actual position, especially when answering questions (which adds momentum
and increases speed). The train would appear "stuck" even though position was
updating correctly in the game loop.

The new faster config still provides smooth interpolation to hide 100ms update
jitter, but responds quickly enough to avoid noticeable lag.

Changes:
- useTrainTransforms: Update locomotive and car spring configs to tension:600, friction:35
- GhostTrain: Update locomotive and car spring configs to match local train

Fixes:
- Train now moves immediately when answering questions
- No more "needing multiple answers" to see train movement
- Reduces perceived flashing/stuttering from render lag

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 19:50:16 -05:00
semantic-release-bot
755487c42d chore(release): 4.68.0 [skip ci]
## [4.68.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.1...v4.68.0) (2025-10-22)

### Features

* **complement-race:** add react-spring animations to local train for smooth movement ([e5b58c8](e5b58c844c))
2025-10-22 19:11:36 +00:00
Thomas Hallock
e5b58c844c feat(complement-race): add react-spring animations to local train for smooth movement
Apply the same react-spring animation treatment to the local player's train
that was previously added to ghost trains. This eliminates the low-resolution
"choppy" movement by smoothly interpolating between position updates.

Changes:
- Convert useTrainTransforms hook to use react-spring (useSpring, useSprings)
- Update TrainAndCars component to use animated.g and to() interpolation
- Animate locomotive position, rotation, and opacity
- Animate all train cars with individual springs
- Use tension:280, friction:60 config for smooth but responsive movement

Both local and ghost trains now have butter-smooth 60fps interpolated movement.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 14:10:18 -05:00
275 changed files with 7043 additions and 65691 deletions

View File

@@ -19,18 +19,12 @@ yarn-error.log*
# Testing
coverage
.nyc_output
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
# IDE
.vscode
.idea
*.swp
*.swo
.claude
# OS
.DS_Store
@@ -52,21 +46,7 @@ packages/core/.venv/
# Storybook
storybook-static
**/*.stories.tsx
**/*.stories.ts
.storybook
# Deployment files
nas-deployment/
DEPLOYMENT_PLAN.md
# SQLite database files (created at runtime)
**/data/*.db
**/data/*.db-shm
**/data/*.db-wal
# Build artifacts (rebuilt during Docker build)
**/dist
**/.next
**/build
**/styled-system
DEPLOYMENT_PLAN.md

View File

@@ -1,16 +0,0 @@
{
"mcpServers": {
"sqlite": {
"command": "/Users/antialias/.nvm/versions/node/v20.19.3/bin/npx",
"args": [
"-y",
"mcp-server-sqlite-npx",
"/Users/antialias/projects/soroban-abacus-flashcards/apps/web/data/sqlite.db"
],
"env": {
"PATH": "/Users/antialias/.nvm/versions/node/v20.19.3/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin",
"NODE_PATH": "/Users/antialias/.nvm/versions/node/v20.19.3/lib/node_modules"
}
}
}
}

14468
CHANGELOG.md

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ COPY packages/core/client/node/package.json ./packages/core/client/node/
COPY packages/abacus-react/package.json ./packages/abacus-react/
COPY packages/templates/package.json ./packages/templates/
# Install ALL dependencies for build stage
# Install dependencies (will use .npmrc with hoisted mode)
RUN pnpm install --frozen-lockfile
# Builder stage
@@ -44,93 +44,16 @@ RUN cd apps/web && npx @pandacss/dev
# Build using turbo for apps/web and its dependencies
RUN turbo build --filter=@soroban/web
# Production dependencies stage - install only runtime dependencies
# IMPORTANT: Must use same base as runner stage for binary compatibility (better-sqlite3)
FROM node:18-slim AS deps
# Production image
FROM node:18-alpine AS runner
WORKDIR /app
# Install build tools temporarily for better-sqlite3 installation
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Install pnpm
RUN npm install -g pnpm@9.15.4
# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/core/client/node/package.json ./packages/core/client/node/
COPY packages/abacus-react/package.json ./packages/abacus-react/
COPY packages/templates/package.json ./packages/templates/
# Install ONLY production dependencies
RUN pnpm install --frozen-lockfile --prod
# Typst builder stage - download and prepare typst binary
FROM node:18-slim AS typst-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
xz-utils \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
TYPST_ARCH="x86_64-unknown-linux-musl"; \
elif [ "$ARCH" = "aarch64" ]; then \
TYPST_ARCH="aarch64-unknown-linux-musl"; \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
TYPST_VERSION="v0.11.1" && \
wget -q "https://github.com/typst/typst/releases/download/${TYPST_VERSION}/typst-${TYPST_ARCH}.tar.xz" && \
tar -xf "typst-${TYPST_ARCH}.tar.xz" && \
mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \
chmod +x /usr/local/bin/typst
# BOSL2 builder stage - clone and minimize the library
FROM node:18-slim AS bosl2-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /bosl2 && \
cd /bosl2 && \
git clone --depth 1 https://github.com/BelfrySCAD/BOSL2.git . && \
# Remove unnecessary files to minimize size
rm -rf .git .github tests tutorials examples images *.md CONTRIBUTING* LICENSE* && \
# Keep only .scad files and essential directories
find . -type f ! -name "*.scad" -delete && \
find . -type d -empty -delete
# Production image - Using Debian base for OpenSCAD availability
FROM node:18-slim AS runner
WORKDIR /app
# Install ONLY runtime dependencies (no build tools)
# Using Debian because OpenSCAD is not available in Alpine repos
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
qpdf \
openscad \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy typst binary from typst-builder stage
COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst
# Copy minimized BOSL2 library from bosl2-builder stage
RUN mkdir -p /usr/share/openscad/libraries
COPY --from=bosl2-builder /bosl2 /usr/share/openscad/libraries/BOSL2
# Install Python, pip, build tools for better-sqlite3, Typst, and qpdf (needed at runtime)
RUN apk add --no-cache python3 py3-pip py3-setuptools make g++ typst qpdf
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built Next.js application
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./apps/web/.next
@@ -146,9 +69,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/dist ./apps/web/dist
# Copy database migrations
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
# Copy PRODUCTION node_modules only (no dev dependencies)
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=deps --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
# Copy node_modules (for dependencies)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
# Copy core package (needed for Python flashcard generation scripts)
COPY --from=builder --chown=nextjs:nodejs /app/packages/core ./packages/core
@@ -169,9 +92,6 @@ WORKDIR /app/apps/web
# Create data directory for SQLite database
RUN mkdir -p data && chown nextjs:nodejs data
# Create tmp directory for 3D job outputs
RUN mkdir -p tmp/3d-jobs && chown nextjs:nodejs tmp
USER nextjs
EXPOSE 3000
ENV PORT 3000
@@ -179,4 +99,4 @@ ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV production
# Start the application
CMD ["node", "server.js"]
CMD ["node", "server.js"]

View File

@@ -1,325 +0,0 @@
# 3D Printing Docker Setup
## Summary
The 3D printable abacus customization feature is fully containerized with optimized Docker multi-stage builds.
**Key Technologies:**
- OpenSCAD 2021.01 (for rendering STL/3MF from .scad files)
- BOSL2 v2.0.0 (minimized library, .scad files only)
- Typst v0.11.1 (pre-built binary)
**Image Size:** ~257MB (optimized with multi-stage builds, saved ~38MB)
**Build Stages:** 7 total (base → builder → deps → typst-builder → bosl2-builder → runner)
## Overview
The 3D printable abacus customization feature requires OpenSCAD and the BOSL2 library to be available in the Docker container.
## Size Optimization Strategy
The Dockerfile uses **multi-stage builds** to minimize the final image size:
1. **typst-builder stage** - Downloads and extracts typst, discards wget/xz-utils
2. **bosl2-builder stage** - Clones BOSL2 and removes unnecessary files (tests, docs, examples, images)
3. **runner stage** - Only copies final binaries and minimized libraries
### Size Reductions
- **Removed from runner**: git, wget, curl, xz-utils (~40MB)
- **BOSL2 minimized**: Removed .git, tests, tutorials, examples, images, markdown files (~2-3MB savings)
- **Kept only .scad files** in BOSL2 library
## Dockerfile Changes
### Build Stages Overview
The Dockerfile now has **7 stages**:
1. **base** (Alpine) - Install build tools and dependencies
2. **builder** (Alpine) - Build Next.js application
3. **deps** (Alpine) - Install production node_modules
4. **typst-builder** (Debian) - Download and extract typst binary
5. **bosl2-builder** (Debian) - Clone and minimize BOSL2 library
6. **runner** (Debian) - Final production image
### Stage 1-3: Base, Builder, Deps (unchanged)
Uses Alpine Linux for building the application (smaller and faster builds).
### Stage 4: Typst Builder (lines 68-87)
```dockerfile
FROM node:18-slim AS typst-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
xz-utils \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN ARCH=$(uname -m) && \
... download and install typst from GitHub releases
```
**Purpose:** Download typst binary in isolation, then discard build tools (wget, xz-utils).
**Result:** Only the typst binary is copied to runner stage (line 120).
### Stage 5: BOSL2 Builder (lines 90-103)
```dockerfile
FROM node:18-slim AS bosl2-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /bosl2 && \
cd /bosl2 && \
git clone --depth 1 --branch v2.0.0 https://github.com/BelfrySCAD/BOSL2.git . && \
# Remove unnecessary files to minimize size
rm -rf .git .github tests tutorials examples images *.md CONTRIBUTING* LICENSE* && \
# Keep only .scad files and essential directories
find . -type f ! -name "*.scad" -delete && \
find . -type d -empty -delete
```
**Purpose:** Clone BOSL2 and aggressively minimize by removing:
- `.git` directory
- Tests, tutorials, examples
- Documentation (markdown files)
- Images
- All non-.scad files
**Result:** Minimized BOSL2 library (~1-2MB instead of ~5MB) copied to runner (line 124).
### Stage 6: Runner - Production Image (lines 106-177)
**Base Image:** `node:18-slim` (Debian) - Required for OpenSCAD availability
**Runtime Dependencies (lines 111-117):**
```dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
qpdf \
openscad \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
```
**Removed from runner:**
- ❌ git (only needed in bosl2-builder)
- ❌ wget (only needed in typst-builder)
- ❌ curl (not needed at runtime)
- ❌ xz-utils (only needed in typst-builder)
**Artifacts Copied from Other Stages:**
```dockerfile
# From typst-builder (line 120)
COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst
# From bosl2-builder (line 124)
COPY --from=bosl2-builder /bosl2 /usr/share/openscad/libraries/BOSL2
# From builder (lines 131-159)
# Next.js app, styled-system, server files, etc.
# From deps (lines 145-146)
# Production node_modules only
```
BOSL2 v2.0.0 (minimized) is copied to `/usr/share/openscad/libraries/BOSL2/`, which is OpenSCAD's default library search path. This allows `include <BOSL2/std.scad>` to work in the abacus.scad file.
### Temp Directory for Job Outputs (line 168)
```dockerfile
RUN mkdir -p tmp/3d-jobs && chown nextjs:nodejs tmp
```
Creates the directory where JobManager stores generated 3D files.
## Files Included in Docker Image
The following files are automatically included via the `COPY` command at line 132:
```
apps/web/public/3d-models/
├── abacus.scad (parametric OpenSCAD source)
└── simplified.abacus.stl (base model, 4.8MB)
```
These files are NOT excluded by `.dockerignore`.
## Testing the Docker Build
### Local Testing
1. **Build the Docker image:**
```bash
docker build -t soroban-abacus-test .
```
2. **Run the container:**
```bash
docker run -p 3000:3000 soroban-abacus-test
```
3. **Test OpenSCAD inside the container:**
```bash
docker exec -it <container-id> sh
openscad --version
ls /usr/share/openscad/libraries/BOSL2
```
4. **Test the 3D printing endpoint:**
- Visit http://localhost:3000/3d-print
- Adjust parameters and generate a file
- Monitor job progress
- Download the result
### Verify BOSL2 Installation
Inside the running container:
```bash
# Check OpenSCAD version
openscad --version
# Verify BOSL2 library exists
ls -la /usr/share/openscad/libraries/BOSL2/
# Test rendering a simple file
cd /app/apps/web/public/3d-models
openscad -o /tmp/test.stl abacus.scad
```
## Production Deployment
### Environment Variables
No additional environment variables are required for the 3D printing feature.
### Volume Mounts (Optional)
For better performance and to avoid rebuilding the image when updating 3D models:
```bash
docker run -p 3000:3000 \
-v $(pwd)/apps/web/public/3d-models:/app/apps/web/public/3d-models:ro \
soroban-abacus-test
```
### Disk Space Considerations
- **BOSL2 library**: ~5MB (cloned during build)
- **Base STL file**: 4.8MB (in public/3d-models/)
- **Generated files**: Vary by parameters, typically 1-10MB each
- **Job cleanup**: Old jobs are automatically cleaned up after 1 hour
## Image Size
The final image is Debian-based (required for OpenSCAD), but optimized using multi-stage builds:
**Before optimization (original Debian approach):**
- Base runner: ~250MB
- With all build tools (git, wget, curl, xz-utils): ~290MB
- With BOSL2 (full): ~295MB
- **Total: ~295MB**
**After optimization (current multi-stage approach):**
- Base runner: ~250MB
- Runtime deps only (no build tools): ~250MB
- BOSL2 (minimized, .scad only): ~252MB
- 3D models (STL): ~257MB
- **Total: ~257MB**
**Savings: ~38MB (~13% reduction)**
### What Was Removed
- ❌ git (~15MB)
- ❌ wget (~2MB)
- ❌ curl (~5MB)
- ❌ xz-utils (~1MB)
- ❌ BOSL2 .git directory (~1MB)
- ❌ BOSL2 tests, examples, tutorials (~10MB)
- ❌ BOSL2 images and docs (~4MB)
**Total removed: ~38MB**
This trade-off (Debian vs Alpine) is necessary for OpenSCAD availability, but the multi-stage approach minimizes the size impact.
## Troubleshooting
### OpenSCAD Not Found
If you see "openscad: command not found" in logs:
1. Verify OpenSCAD is installed:
```bash
docker exec -it <container-id> which openscad
docker exec -it <container-id> openscad --version
```
2. Check if the Debian package install succeeded:
```bash
docker exec -it <container-id> dpkg -l | grep openscad
```
### BOSL2 Include Error
If OpenSCAD reports "Can't open library 'BOSL2/std.scad'":
1. Check BOSL2 exists:
```bash
docker exec -it <container-id> ls /usr/share/openscad/libraries/BOSL2/std.scad
```
2. Test include path:
```bash
docker exec -it <container-id> sh -c "cd /tmp && echo 'include <BOSL2/std.scad>; cube(10);' > test.scad && openscad -o test.stl test.scad"
```
### Job Fails with "Permission Denied"
Check tmp directory permissions:
```bash
docker exec -it <container-id> ls -la /app/apps/web/tmp
# Should show: drwxr-xr-x ... nextjs nodejs ... 3d-jobs
```
### Large File Generation Timeout
Jobs timeout after 60 seconds. For complex models, increase the timeout in `jobManager.ts:138`:
```typescript
timeout: 120000, // 2 minutes instead of 60 seconds
```
## Performance Notes
- **Cold start**: First generation takes ~5-10 seconds (OpenSCAD initialization)
- **Warm generations**: Subsequent generations take ~3-5 seconds
- **STL size**: Typically 5-15MB depending on scale parameters
- **3MF size**: Similar to STL (no significant compression)
- **SCAD size**: ~1KB (just text parameters)
## Monitoring
Job processing is logged to stdout:
```
Executing: openscad -o /app/apps/web/tmp/3d-jobs/abacus-abc123.stl ...
Job abc123 completed successfully
```
Check logs with:
```bash
docker logs <container-id> | grep "Job"
```

View File

@@ -1,420 +0,0 @@
# Card Sorting: Multiplayer & Spectator Features Plan
## Overview
Add collaborative and competitive multiplayer modes to the card-sorting game, plus enhanced spectator experience with real-time player indicators.
---
## 1. Core Feature: Player Emoji on Moving Cards
**When any player (including network players) moves a card, show their emoji on it.**
### Data Structure Changes
#### `CardPosition` type enhancement:
```typescript
export interface CardPosition {
cardId: string
x: number // % of viewport width (0-100)
y: number // % of viewport height (0-100)
rotation: number // degrees (-15 to 15)
zIndex: number
draggedByPlayerId?: string // NEW: ID of player currently dragging this card
}
```
### Implementation Details
1. **When starting drag (local player):**
- Set `draggedByPlayerId` to current player's ID
- Broadcast position update immediately with this field
2. **During drag:**
- Continue including `draggedByPlayerId` in position updates
- Other clients show the emoji overlay
3. **When ending drag:**
- Clear `draggedByPlayerId` (set to `undefined`)
- Broadcast final position without this field
4. **Visual indicator:**
- Show player emoji in top-right corner of card
- Semi-transparent background circle
- Small size (24-28px diameter)
- Positioned absolutely within card container
- Example styling:
```typescript
{
position: 'absolute',
top: '4px',
right: '4px',
width: '28px',
height: '28px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.9)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
border: '2px solid rgba(59, 130, 246, 0.6)',
zIndex: 10
}
```
5. **Access to player metadata:**
- Need to map `playerId` → `PlayerMetadata`
- Current state only has single `playerMetadata`
- For multiplayer, Provider needs to maintain `players: Map<string, PlayerMetadata>`
- Get from room members data
---
## 2. Spectator Mode UI Enhancements
### 2.1 Spectator Banner
**Top banner that clearly indicates spectator status**
```typescript
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: '48px',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
zIndex: 100,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}}>
<div>👀 Spectating: {playerName} {playerEmoji}</div>
<div>Progress: {cardsPlaced}/{totalCards} cards placed</div>
</div>
```
### 2.2 Educational Mode Toggle
**Allow spectators to see the correct answer (for learning)**
- Toggle button in spectator banner
- When enabled: show faint green checkmarks on correctly positioned cards
- Don't show actual numbers unless player revealed them
### 2.3 Player Stats Sidebar
**Show real-time stats (optional, can collapse)**
- Time elapsed
- Cards placed vs. total
- Number of moves made
- Current accuracy (% of cards in correct relative order)
---
## 3. Collaborative Mode: "Team Sort"
### 3.1 Core Mechanics
- Multiple players share the same board and card set
- Anyone can move any card at any time
- Shared timer and shared score
- Team wins/loses together
### 3.2 State Changes
#### `CardSortingState` additions:
```typescript
export interface CardSortingState extends GameState {
// ... existing fields ...
gameMode: 'solo' | 'collaborative' | 'competitive' | 'relay' // NEW
players: Map<string, PlayerMetadata> // NEW: all active players
activePlayers: string[] // NEW: players currently in game (not spectators)
}
```
### 3.3 Visual Indicators
1. **Colored cursors for each player:**
- Show a small colored dot/cursor at other players' pointer positions
- Color derived from player's emoji or assigned color
- Update positions via WebSocket (throttled to 30Hz)
2. **Card claiming indicator:**
- When player starts dragging, show their emoji on card (as per feature #1)
- Other players see animated emoji bouncing slightly
- Prevents confusion about who's moving what
3. **Activity feed (optional):**
- Small toast notifications for key actions
- "🎭 Bob placed card #3"
- "🦊 Alice revealed numbers"
- Auto-dismiss after 3 seconds
### 3.4 New Moves
```typescript
// In CardSortingMove union:
| {
type: 'JOIN_COLLABORATIVE_GAME'
playerId: string
userId: string
timestamp: number
data: {
playerMetadata: PlayerMetadata
}
}
| {
type: 'LEAVE_COLLABORATIVE_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
```
### 3.5 Scoring
- Same scoring algorithm but labeled as "Team Score"
- All players see the same results
- Leaderboard entry records all participants
---
## 4. Competitive Mode: "Race Sort"
### 4.1 Core Mechanics
- 2-4 players get the **same** card set
- Each player has their **own separate board**
- Race to finish first OR best score after time limit
- Live leaderboard shows current standings
### 4.2 State Architecture
**Problem:** Current state is single-player only.
**Solution:** Each player needs their own game state, but they're in the same room.
#### Option A: Separate Sessions
- Each competitive player creates their own session
- Room tracks all session IDs
- Client fetches all sessions and displays them
- **Pros:** Minimal changes to game logic
- **Cons:** Complex room management
#### Option B: Multi-Player State (RECOMMENDED)
```typescript
export interface CompetitiveGameState extends GameState {
gameMode: 'competitive'
sharedCards: SortingCard[] // Same cards for everyone
correctOrder: SortingCard[] // Shared answer
playerBoards: Map<string, PlayerBoard> // Each player's board state
gameStartTime: number
gameEndTime: number | null
winners: string[] // Player IDs who completed, in order
}
export interface PlayerBoard {
playerId: string
placedCards: (SortingCard | null)[]
cardPositions: CardPosition[]
availableCards: SortingCard[]
numbersRevealed: boolean
completedAt: number | null
scoreBreakdown: ScoreBreakdown | null
}
```
### 4.3 UI Layout
**Split-screen view:**
```
┌─────────────────────────────────────┐
│ Leaderboard (top bar) │
├──────────────┬──────────────────────┤
│ │ │
│ Your Board │ Opponent Preview │
│ (full size) │ (smaller, ghosted) │
│ │ │
└──────────────┴──────────────────────┘
```
**Your board:**
- Normal interactive gameplay
- Full size, left side
**Opponent preview(s):**
- Right side (or bottom on mobile)
- Smaller scale (50-70% size)
- Semi-transparent cards
- Shows their real-time positions
- Can toggle between different opponents
**Leaderboard bar:**
```
┌─────────────────────────────────────┐
│ 🥇 Alice (5/8) • 🥈 You (4/8) • ... │
└─────────────────────────────────────┘
```
### 4.4 Spectator View for Competitive
- Can watch all players simultaneously
- Grid layout showing all boards
- Highlight current leader with gold border
---
## 5. Hybrid Mode: "Relay Sort" (Future)
### 5.1 Core Mechanics
- Players take turns (30-60 seconds each)
- Cumulative team score
- Can "pass" turn early
- Strategy: communicate via chat about optimal moves
### 5.2 Turn Management
```typescript
export interface RelayGameState extends GameState {
gameMode: 'relay'
turnOrder: string[] // Player IDs
currentTurnIndex: number
turnStartTime: number
turnDuration: number // seconds
// ... rest similar to collaborative
}
```
---
## 6. Implementation Phases
### Phase 1: Foundation (Do First) ✅
- [x] Add `draggedByPlayerId` to `CardPosition`
- [x] Show player emoji on cards being dragged
- [x] Add `players` map to Provider context
- [x] Fetch room members and map to player metadata
### Phase 2: Spectator Enhancements
- [ ] Spectator banner component
- [ ] Educational mode toggle
- [ ] Stats sidebar (collapsible)
### Phase 3: Collaborative Mode
- [ ] Add `gameMode` to state and config
- [ ] Implement JOIN/LEAVE moves
- [ ] Colored cursor tracking
- [ ] Activity feed notifications
- [ ] Team scoring UI
### Phase 4: Competitive Mode
- [ ] Design multi-player state structure
- [ ] Refactor Provider for per-player boards
- [ ] Split-screen UI layout
- [ ] Live leaderboard
- [ ] Ghost opponent preview
- [ ] Winner determination
### Phase 5: Polish & Testing
- [ ] Mobile responsive layouts
- [ ] Performance optimization (many simultaneous players)
- [ ] Network resilience (handle disconnects)
- [ ] Accessibility (keyboard nav, screen readers)
---
## 7. Technical Considerations
### 7.1 WebSocket Message Frequency
- **Current:** Position updates throttled to 100ms (10Hz)
- **Collaborative:** May need higher frequency for smoothness
- **Recommendation:** 50ms (20Hz) for active drag, 100ms otherwise
### 7.2 State Synchronization
- Use optimistic updates for local player
- Reconcile with server state on conflicts
- Use timestamp-based conflict resolution
### 7.3 Player Disconnection Handling
- Collaborative: Keep their last positions, mark as "disconnected"
- Competitive: Pause their timer, allow rejoin within 60s
- Spectators: Just remove from viewer list
### 7.4 Security & Validation
- Server validates all moves (already done)
- Prevent players from seeing others' moves before they happen
- Rate limit position updates per player
---
## 8. Database Schema Changes
### New Tables
#### `competitive_rounds` (for competitive mode)
```sql
CREATE TABLE competitive_rounds (
id UUID PRIMARY KEY,
room_id UUID REFERENCES arcade_rooms(id),
started_at TIMESTAMP,
ended_at TIMESTAMP,
card_set JSON, -- The shared cards
winners JSON -- Array of player IDs in finish order
);
```
#### `player_round_results` (for competitive mode)
```sql
CREATE TABLE player_round_results (
id UUID PRIMARY KEY,
round_id UUID REFERENCES competitive_rounds(id),
player_id UUID,
score_breakdown JSON,
completed_at TIMESTAMP,
final_placement INTEGER -- 1st, 2nd, 3rd, etc.
);
```
---
## 9. Open Questions / Decisions Needed
1. **Collaborative: Card collision handling?**
- What if two players try to grab the same card simultaneously?
- Option A: First one wins, second gets error toast
- Option B: Allow both, last update wins
- **Recommendation:** Option A for better UX
2. **Competitive: Show opponents' exact positions?**
- Option A: Full transparency (see everything)
- Option B: Only show general progress (X/N cards placed)
- Option C: Ghost view (see positions but semi-transparent)
- **Recommendation:** Option C
3. **Spectator limit?**
- Max 10 spectators per game?
- Performance considerations for broadcasting positions
4. **Replay feature?**
- Record all position updates for playback?
- Storage implications?
- **Recommendation:** Future feature, not in initial scope
---
## 10. Success Metrics
- **Engagement:** % of games played in multiplayer vs. solo
- **Completion rate:** Do multiplayer games finish more/less often?
- **Session duration:** How long do multiplayer games last?
- **Return rate:** Do players come back for multiplayer?
- **Social sharing:** Do players invite friends?
---
## Next Steps
1. Get user approval on overall plan
2. Start with Phase 1 (player emoji on cards)
3. Build spectator UI enhancements (Phase 2)
4. Choose between Collaborative or Competitive for Phase 3/4
5. Iterate based on testing and feedback

View File

@@ -44,25 +44,14 @@ When asked to make ANY changes:
1. Make your code changes
2. Run `npm run pre-commit`
3. If it fails, fix the issues and run again
4. **STOP - Tell user changes are ready for testing**
5. **WAIT for user to manually test and approve**
6. Only commit/push when user explicitly approves or requests it
4. Only after all checks pass can you:
- Say the work is "done" or "complete"
- Mark tasks as finished
- Create commits
- Tell the user it's working
5. Push immediately after committing
**CRITICAL:** Passing `npm run pre-commit` only verifies code quality (TypeScript, linting, formatting). It does NOT verify that features work correctly. Manual testing by the user is REQUIRED before committing.
**Never auto-commit or auto-push after making changes.**
## Dev Server Management
**CRITICAL: The user manages running the dev server, NOT Claude Code.**
- ❌ DO NOT run `npm run dev` or `npm start`
- ❌ DO NOT attempt to start, stop, or restart the dev server
- ❌ DO NOT use background Bash processes for the dev server
- ✅ Make code changes and let the user restart the server when needed
- ✅ You may run other commands like `npm run type-check`, `npm run lint`, etc.
The user will manually start/restart the dev server after you make changes.
**Nothing is complete until `npm run pre-commit` passes.**
## Details
@@ -132,51 +121,6 @@ className="bg-blue-200 border-gray-300 text-brand-600"
See `.claude/GAME_THEMES.md` for standardized color theme usage in arcade games.
## Data Attributes for All Elements
**MANDATORY: All new elements MUST have data attributes for easy reference.**
When creating ANY new HTML/JSX element (div, button, section, etc.), add appropriate data attributes:
**Required patterns:**
- `data-component="component-name"` - For top-level component containers
- `data-element="element-name"` - For major UI elements
- `data-section="section-name"` - For page sections
- `data-action="action-name"` - For interactive elements (buttons, links)
- `data-setting="setting-name"` - For game settings/config elements
- `data-status="status-value"` - For status indicators
**Why this matters:**
- Allows easy element selection for testing, debugging, and automation
- Makes it simple to reference elements by name in discussions
- Provides semantic meaning beyond CSS classes
- Enables reliable E2E testing selectors
**Examples:**
```typescript
// Component container
<div data-component="game-board" className={css({...})}>
// Interactive button
<button data-action="start-game" onClick={handleStart}>
// Settings toggle
<div data-setting="sound-enabled">
// Status indicator
<div data-status={isOnline ? 'online' : 'offline'}>
```
**DO NOT:**
- ❌ Skip data attributes on new elements
- ❌ Use generic names like `data-element="div"`
- ❌ Use data attributes for styling (use CSS classes instead)
**DO:**
- ✅ Use descriptive, kebab-case names
- ✅ Add data attributes to ALL significant elements
- ✅ Make names semantic and self-documenting
## Abacus Visualizations
**CRITICAL: This project uses @soroban/abacus-react for all abacus visualizations.**
@@ -194,50 +138,6 @@ When creating ANY new HTML/JSX element (div, button, section, etc.), add appropr
- ✅ Use `useAbacusConfig` for abacus configuration
- ✅ Use `useAbacusDisplay` for reading abacus state
**Server-Side Rendering (CRITICAL):**
`AbacusReact` already supports server-side rendering - it detects SSR and disables animations automatically.
**✅ CORRECT - Use in build scripts:**
```typescript
// scripts/generateAbacusIcons.tsx
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { AbacusReact } from '@soroban/abacus-react'
const svg = renderToStaticMarkup(<AbacusReact value={5} columns={2} />)
// This works! Scripts can use react-dom/server
```
**❌ WRONG - Do NOT use in Next.js route handlers:**
```typescript
// src/app/icon/route.tsx - DON'T DO THIS!
import { renderToStaticMarkup } from 'react-dom/server' // ❌ Next.js forbids this!
import { AbacusReact } from '@soroban/abacus-react'
export async function GET() {
const svg = renderToStaticMarkup(<AbacusReact ... />) // ❌ Will fail!
}
```
**✅ CORRECT - Pre-generate and read in route handlers:**
```typescript
// src/app/icon/route.tsx
import { readFileSync } from 'fs'
export async function GET() {
// Read pre-generated SVG from scripts/generateAbacusIcons.tsx
const svg = readFileSync('public/icons/day-01.svg', 'utf-8')
return new Response(svg, { headers: { 'Content-Type': 'image/svg+xml' } })
}
```
**Pattern to follow:**
1. Generate static SVGs using `scripts/generateAbacusIcons.tsx` (uses renderToStaticMarkup)
2. Commit generated SVGs to `public/icons/` or `public/`
3. Route handlers read and serve the pre-generated files
4. Regenerate icons when abacus styling changes
**MANDATORY: Read the Docs Before Customizing**
**ALWAYS read the full README documentation before customizing or styling AbacusReact:**
@@ -368,139 +268,3 @@ Before setting a z-index, always check:
1. What stacking context is this element in?
2. Am I comparing against siblings or global elements?
3. Does my parent create a stacking context?
## Database Access
This project uses SQLite with Drizzle ORM. Database location: `./data/sqlite.db`
**ALWAYS use MCP SQLite tools for database operations:**
- `mcp__sqlite__list_tables` - List all tables
- `mcp__sqlite__describe_table` - Get table schema
- `mcp__sqlite__read_query` - Run SELECT queries
- `mcp__sqlite__write_query` - Run INSERT/UPDATE/DELETE queries
- `mcp__sqlite__create_table` - Create new tables
- **DO NOT use bash `sqlite3` commands** - use the MCP tools instead
**Database Schema:**
- Schema definitions: `src/db/schema/`
- Drizzle config: `drizzle.config.ts`
- Migrations: `drizzle/` directory
### Creating Database Migrations
**CRITICAL: NEVER manually create migration SQL files or edit the journal.**
When adding/modifying database schema:
1. **Update the schema file** in `src/db/schema/`:
```typescript
// Example: Add new column to existing table
export const abacusSettings = sqliteTable('abacus_settings', {
userId: text('user_id').primaryKey(),
// ... existing columns ...
newField: integer('new_field', { mode: 'boolean' }).notNull().default(false),
})
```
2. **Generate migration using drizzle-kit**:
```bash
npx drizzle-kit generate --custom
```
This creates:
- A new SQL file in `drizzle/####_name.sql`
- Updates `drizzle/meta/_journal.json`
- Creates a snapshot in `drizzle/meta/####_snapshot.json`
3. **Edit the generated SQL file** (it will be empty):
```sql
-- Custom SQL migration file, put your code below! --
ALTER TABLE `abacus_settings` ADD `new_field` integer DEFAULT 0 NOT NULL;
```
4. **Test the migration** on your local database:
```bash
npm run db:migrate
```
5. **Verify** the column was added:
```bash
mcp__sqlite__describe_table table_name
```
**What NOT to do:**
- ❌ DO NOT manually create SQL files in `drizzle/` without using `drizzle-kit generate`
- ❌ DO NOT manually edit `drizzle/meta/_journal.json`
- ❌ DO NOT run SQL directly with `sqlite3` command
- ❌ DO NOT use `drizzle-kit generate` without `--custom` flag (it requires interactive prompts)
**Why this matters:**
- Drizzle tracks applied migrations in `__drizzle_migrations` table
- Manual SQL files won't be tracked properly
- Production deployments run `npm run db:migrate` automatically
- Improperly created migrations will fail in production
## Deployment Verification
**CRITICAL: Never assume deployment is complete just because the website is accessible.**
When monitoring deployments to production (NAS at abaci.one):
1. **GitHub Actions Success ≠ NAS Deployment**
- GitHub Actions builds and pushes Docker images to GHCR
- The NAS must separately pull and restart containers
- There may be a delay or manual step between these
2. **Always verify the deployed commit:**
```bash
# Check what's actually running on production
ssh nas.home.network '/usr/local/bin/docker inspect soroban-abacus-flashcards --format="{{index .Config.Labels \"org.opencontainers.image.revision\"}}"'
# Or check the deployment info modal in the app UI
# Look for the "Commit" field and compare to current HEAD
```
3. **Compare commits explicitly:**
```bash
# Current HEAD
git rev-parse HEAD
# If NAS deployed commit doesn't match HEAD, deployment is INCOMPLETE
```
4. **Never report "deployed successfully" unless:**
- ✅ GitHub Actions completed
- ✅ NAS commit SHA matches origin/main HEAD
- ✅ Website is accessible AND serving the new code
5. **If commits don't match:**
- Report the gap clearly: "NAS is X commits behind origin/main"
- List what features are NOT yet deployed
- Ask if manual NAS deployment action is needed
**Common mistake:** Seeing https://abaci.one is online and assuming the new code is deployed. Always verify the commit SHA.
## Rithmomachia Game
When working on the Rithmomachia arcade game, refer to:
- **`src/arcade-games/rithmomachia/SPEC.md`** - Complete game specification
- Official implementation spec v1
- Board dimensions (8×16), piece types, movement rules
- Mathematical capture relations (equality, sum, difference, multiple, divisor, product, ratio)
- Harmony (progression) victory conditions
- Data models, server protocol, validation logic
- Test cases and UI/UX suggestions
**Quick Reference:**
- **Board**: 8 rows × 16 columns (A-P, 1-8)
- **Pieces per side**: 25 total (12 Circles, 6 Triangles, 6 Squares, 1 Pyramid)
- **Movement**: Geometric (C=diagonal, T=orthogonal, S=queen, P=king)
- **Captures**: Mathematical relations between piece values
- **Victory**: Harmony (3+ pieces in enemy half forming arithmetic/geometric/harmonic progression), exhaustion, or optional point threshold
**Critical Rules**:
- All piece values are positive integers (use `number`, not `bigint` for game state serialization)
- No jumping - pieces must have clear paths
- Captures require valid mathematical relations (use helper pieces for sum/diff/product/ratio)
- Pyramid pieces have 4 faces - face value must be chosen during relation checks

View File

@@ -1,584 +0,0 @@
# Cross-Game Stats Analysis & Universal Data Model
## Overview
This document analyzes ALL arcade games to ensure our `GameResult` type works universally.
## Games Analyzed
1.**Matching** (Memory Pairs)
2.**Complement Race** (Math race game)
3.**Memory Quiz** (Number memory game)
4.**Card Sorting** (Sort abacus cards)
5.**Rithmomachia** (Strategic board game)
6. 🔍 **YJS Demo** (Multiplayer demo - skipping for now)
---
## Per-Game Analysis
### 1. Matching (Memory Pairs)
**Game Type**: Memory/Pattern Matching
**Players**: 1-N (competitive multiplayer)
**How to Win**: Most pairs matched (multiplayer) OR complete all pairs (solo)
**Data Tracked**:
```typescript
{
scores: { [playerId]: matchCount }
moves: number
matchedPairs: number
totalPairs: number
gameTime: milliseconds
accuracy: percentage (matchedPairs / moves * 100)
grade: 'A+' | 'A' | 'B+' | ...
starRating: 1-5
}
```
**Winner Determination**:
- Solo: completed = won
- Multiplayer: highest score wins
**Fits GameResult?**
```typescript
{
gameType: 'matching',
duration: gameTime,
playerResults: [{
playerId,
won: isWinner,
score: matchCount,
accuracy: 0.0-1.0,
metrics: { moves, matchedPairs, difficulty }
}]
}
```
---
### 2. Complement Race
**Game Type**: Racing/Quiz hybrid
**Players**: 1-N (competitive race)
**How to Win**: Highest score OR reach finish line first (depending on mode)
**Data Tracked**:
```typescript
{
players: {
[playerId]: {
score: number
streak: number
bestStreak: number
correctAnswers: number
totalQuestions: number
position: 0-100% (for practice/survival)
deliveredPassengers: number (sprint mode)
}
}
gameTime: milliseconds
winner: playerId | null
leaderboard: [{ playerId, score, rank }]
}
```
**Winner Determination**:
- Practice/Survival: reach 100% position
- Sprint: highest score (delivered passengers)
**Fits GameResult?**
```typescript
{
gameType: 'complement-race',
duration: gameTime,
playerResults: [{
playerId,
won: winnerId === playerId,
score: player.score,
accuracy: player.correctAnswers / player.totalQuestions,
placement: leaderboard rank,
metrics: {
streak: player.bestStreak,
correctAnswers: player.correctAnswers,
totalQuestions: player.totalQuestions
}
}]
}
```
---
### 3. Memory Quiz
**Game Type**: Memory/Recall
**Players**: 1-N (cooperative OR competitive)
**How to Win**:
- Cooperative: team finds all numbers
- Competitive: most correct answers
**Data Tracked**:
```typescript
{
playerScores: {
[playerId]: { correct: number, incorrect: number }
}
foundNumbers: number[]
correctAnswers: number[]
selectedCount: 2 | 5 | 8 | 12 | 15
playMode: 'cooperative' | 'competitive'
gameTime: milliseconds
}
```
**Winner Determination**:
- Cooperative: ALL found = team wins
- Competitive: highest correct count wins
**Fits GameResult?****BUT needs special handling for cooperative**
```typescript
{
gameType: 'memory-quiz',
duration: gameTime,
playerResults: [{
playerId,
won: playMode === 'cooperative'
? foundAll // All players win or lose together
: hasHighestScore, // Individual winner
score: playerScores[playerId].correct,
accuracy: correct / (correct + incorrect),
metrics: {
correct: playerScores[playerId].correct,
incorrect: playerScores[playerId].incorrect,
difficulty: selectedCount
}
}],
metadata: {
playMode: 'cooperative' | 'competitive',
isTeamVictory: boolean // ← IMPORTANT for cooperative games
}
}
```
**NEW INSIGHT**: Cooperative games need special handling - all players share win/loss!
---
### 4. Card Sorting
**Game Type**: Sorting/Puzzle
**Players**: 1-N (solo, collaborative, competitive, relay)
**How to Win**:
- Solo: achieve high score (0-100)
- Collaborative: team achieves score
- Competitive: highest individual score
- Relay: TBD (not fully implemented)
**Data Tracked**:
```typescript
{
scoreBreakdown: {
finalScore: 0-100
exactMatches: number
lcsLength: number // Longest common subsequence
inversions: number // Out-of-order pairs
relativeOrderScore: 0-100
exactPositionScore: 0-100
inversionScore: 0-100
elapsedTime: seconds
}
gameMode: 'solo' | 'collaborative' | 'competitive' | 'relay'
}
```
**Winner Determination**:
- Solo/Collaborative: score > threshold (e.g., 70+)
- Competitive: highest score
**Fits GameResult?****Similar to Memory Quiz**
```typescript
{
gameType: 'card-sorting',
duration: elapsedTime * 1000,
playerResults: [{
playerId,
won: gameMode === 'collaborative'
? scoreBreakdown.finalScore >= 70 // Team threshold
: hasHighestScore,
score: scoreBreakdown.finalScore,
accuracy: scoreBreakdown.exactMatches / cardCount,
metrics: {
exactMatches: scoreBreakdown.exactMatches,
inversions: scoreBreakdown.inversions,
lcsLength: scoreBreakdown.lcsLength
}
}],
metadata: {
gameMode,
isTeamVictory: gameMode === 'collaborative'
}
}
```
---
### 5. Rithmomachia
**Game Type**: Strategic board game (2-player only)
**Players**: Exactly 2 (White vs Black)
**How to Win**: Multiple victory conditions (harmony, points, exhaustion, resignation)
**Data Tracked**:
```typescript
{
winner: 'W' | 'B' | null
winCondition: 'HARMONY' | 'EXHAUSTION' | 'RESIGNATION' | 'POINTS' | ...
capturedPieces: { W: Piece[], B: Piece[] }
pointsCaptured: { W: number, B: number }
history: MoveRecord[]
gameTime: milliseconds (computed from history)
}
```
**Winner Determination**:
- Specific win condition triggered
- No draws (or rare)
**Fits GameResult?****Needs win condition metadata**
```typescript
{
gameType: 'rithmomachia',
duration: gameTime,
playerResults: [
{
playerId: whitePlayerId,
won: winner === 'W',
score: capturedPieces.W.length, // or pointsCaptured.W
metrics: {
capturedPieces: capturedPieces.W.length,
points: pointsCaptured?.W || 0,
moves: history.filter(m => m.color === 'W').length
}
},
{
playerId: blackPlayerId,
won: winner === 'B',
score: capturedPieces.B.length,
metrics: {
capturedPieces: capturedPieces.B.length,
points: pointsCaptured?.B || 0,
moves: history.filter(m => m.color === 'B').length
}
}
],
metadata: {
winCondition: 'HARMONY' | 'POINTS' | ...
}
}
```
---
## Cross-Game Patterns Identified
### Pattern 1: Competitive (Most Common)
**Games**: Matching (multiplayer), Complement Race, Memory Quiz (competitive), Card Sorting (competitive)
**Characteristics**:
- Each player has their own score
- Winner = highest score
- Players track individually
**Stats to track per player**:
- games_played ++
- wins ++ (if winner)
- losses ++ (if not winner)
- best_time (if faster)
- highest_accuracy (if better)
---
### Pattern 2: Cooperative (Team-Based)
**Games**: Memory Quiz (cooperative), Card Sorting (collaborative)
**Characteristics**:
- All players share outcome
- Team wins or loses together
- Individual contributions still tracked
**Stats to track per player**:
- games_played ++
- wins ++ (if TEAM won) ← Key difference
- losses ++ (if TEAM lost)
- Individual metrics still tracked (correct answers, etc.)
**CRITICAL**: Check `metadata.isTeamVictory` to determine if all players get same win/loss!
---
### Pattern 3: Head-to-Head (Exactly 2 Players)
**Games**: Rithmomachia
**Characteristics**:
- Always 2 players
- One wins, one loses (rare draws)
- Different win conditions
**Stats to track per player**:
- games_played ++
- wins ++ (winner only)
- losses ++ (loser only)
- Game-specific metrics (captures, harmonies)
---
### Pattern 4: Solo Completion
**Games**: Matching (solo), Complement Race (practice), Memory Quiz (solo), Card Sorting (solo)
**Characteristics**:
- Single player
- Win = completion or threshold
- Compete against self/time
**Stats to track**:
- games_played ++
- wins ++ (if completed/threshold met)
- losses ++ (if failed/gave up)
- best_time, highest_accuracy
---
## Refined Universal Data Model
### GameResult Type (UPDATED)
```typescript
export interface GameResult {
// Game identification
gameType: string // e.g., "matching", "complement-race", etc.
// Player results (supports 1-N players)
playerResults: PlayerGameResult[]
// Timing
completedAt: number // timestamp
duration: number // milliseconds
// Optional game-specific data
metadata?: {
// For cooperative games
isTeamVictory?: boolean // ← NEW: all players share win/loss
// For specific win conditions
winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT"
// For game modes
gameMode?: string // e.g., "solo", "competitive", "cooperative"
// Any other game-specific info
[key: string]: unknown
}
}
export interface PlayerGameResult {
playerId: string
// Outcome
won: boolean // For cooperative: all players same value
placement?: number // 1st, 2nd, 3rd (for competitive with >2 players)
// Performance
score?: number
accuracy?: number // 0.0 - 1.0
completionTime?: number // milliseconds (player-specific time)
// Game-specific metrics (optional, stored as JSON in DB)
metrics?: {
// Matching
moves?: number
matchedPairs?: number
difficulty?: number
// Complement Race
streak?: number
correctAnswers?: number
totalQuestions?: number
// Memory Quiz
correct?: number
incorrect?: number
// Card Sorting
exactMatches?: number
inversions?: number
lcsLength?: number
// Rithmomachia
capturedPieces?: number
points?: number
// Extensible for future games
[key: string]: unknown
}
}
```
---
## Stats Recording Logic (UPDATED)
### For Each Player in GameResult
```typescript
// Fetch player stats
const stats = await getPlayerStats(playerId)
// Always increment
stats.gamesPlayed++
// Handle wins/losses based on game type
if (gameResult.metadata?.isTeamVictory !== undefined) {
// COOPERATIVE: All players share outcome
if (playerResult.won) {
stats.totalWins++
} else {
stats.totalLosses++
}
} else {
// COMPETITIVE/SOLO: Individual outcome
if (playerResult.won) {
stats.totalWins++
} else {
stats.totalLosses++
}
}
// Update performance metrics
if (playerResult.completionTime && (
!stats.bestTime || playerResult.completionTime < stats.bestTime
)) {
stats.bestTime = playerResult.completionTime
}
if (playerResult.accuracy && playerResult.accuracy > stats.highestAccuracy) {
stats.highestAccuracy = playerResult.accuracy
}
// Update per-game stats (JSON)
stats.gameStats[gameResult.gameType] = {
gamesPlayed: (stats.gameStats[gameResult.gameType]?.gamesPlayed || 0) + 1,
wins: (stats.gameStats[gameResult.gameType]?.wins || 0) + (playerResult.won ? 1 : 0),
// ... other game-specific aggregates
}
// Update favorite game type (most played)
stats.favoriteGameType = getMostPlayedGame(stats.gameStats)
// Update timestamps
stats.lastPlayedAt = gameResult.completedAt
stats.updatedAt = Date.now()
```
---
## Database Schema (CONFIRMED)
No changes needed from original design! The `metrics` JSON field handles game-specific data perfectly.
```sql
CREATE TABLE player_stats (
player_id TEXT PRIMARY KEY,
-- Aggregates
games_played INTEGER NOT NULL DEFAULT 0,
total_wins INTEGER NOT NULL DEFAULT 0,
total_losses INTEGER NOT NULL DEFAULT 0,
-- Performance
best_time INTEGER,
highest_accuracy REAL NOT NULL DEFAULT 0,
-- Per-game breakdown (JSON)
game_stats TEXT NOT NULL DEFAULT '{}',
-- Meta
favorite_game_type TEXT,
last_played_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
---
## Key Insights & Design Decisions
### 1. Cooperative Games Need Special Flag
**Problem**: Memory Quiz (cooperative) and Card Sorting (collaborative) - all players share win/loss.
**Solution**: Add `metadata.isTeamVictory: boolean` to `GameResult`. When `true`, recording logic gives ALL players the same win/loss.
### 2. Flexible Metrics Field
**Problem**: Each game tracks different metrics (moves, streak, inversions, etc.).
**Solution**: `PlayerGameResult.metrics` is an open object. Store game-specific data here, saved as JSON in DB.
### 3. Placement for Tournaments
**Problem**: 3+ player games need to track ranking (1st, 2nd, 3rd).
**Solution**: `PlayerGameResult.placement` field. Useful for leaderboards.
### 4. Win Conditions Matter
**Problem**: Rithmomachia has multiple win conditions (harmony, points, etc.).
**Solution**: `metadata.winCondition` stores how the game was won. Useful for achievements/stats breakdown.
### 5. Score is Optional
**Problem**: Not all games have scores (e.g., Rithmomachia can win by harmony without points enabled).
**Solution**: Make `score` optional. Use `won` as primary outcome indicator.
---
## Testing Matrix
### Scenarios to Test
| Game | Mode | Players | Expected Outcome |
|------|------|---------|------------------|
| Matching | Solo | 1 | Player wins if completed |
| Matching | Competitive | 2+ | Winner = highest score, others lose |
| Complement Race | Sprint | 2+ | Winner = highest score |
| Memory Quiz | Cooperative | 2+ | ALL win or ALL lose (team) |
| Memory Quiz | Competitive | 2+ | Winner = most correct |
| Card Sorting | Solo | 1 | Win if score >= 70 |
| Card Sorting | Collaborative | 2+ | ALL win or ALL lose (team) |
| Card Sorting | Competitive | 2+ | Winner = highest score |
| Rithmomachia | PvP | 2 | One wins (by condition), one loses |
---
## Conclusion
**Universal `GameResult` type CONFIRMED to work for all games**
**Key Requirements**:
1. Support 1-N players (flexible array)
2. Support cooperative games (isTeamVictory flag)
3. Support game-specific metrics (open metrics object)
4. Support multiple win conditions (winCondition metadata)
5. Track both individual AND team performance
**Next Steps**:
1. Update `.claude/PER_PLAYER_STATS_ARCHITECTURE.md` with refined types
2. Implement database schema
3. Build API endpoints
4. Create React hooks
5. Integrate with each game (starting with Matching)
---
**Status**: ✅ Complete cross-game analysis
**Result**: GameResult type is universal and robust
**Date**: 2025-01-03

View File

@@ -1,468 +0,0 @@
# Google Classroom Integration Setup Guide
**Goal:** Set up Google Classroom API integration using mostly CLI commands, minimizing web console interaction.
**Time Required:** 15-20 minutes
**Cost:** $0 (free for educational use)
---
## Prerequisites
**gcloud CLI installed** (already installed at `/opt/homebrew/bin/gcloud`)
**Valid Google account**
- **Billing account** (required by Google, but FREE for Classroom API)
---
## Quick Start (TL;DR)
```bash
# Run the automated setup script
./scripts/setup-google-classroom.sh
```
The script will:
1. Authenticate with your Google account
2. Create a GCP project
3. Enable Classroom & People APIs
4. Guide you through OAuth setup (2 web console steps)
5. Configure your `.env.local` file
**Note:** Steps 6 & 7 still require web console (Google doesn't provide CLI for OAuth consent screen), but the script opens the pages for you and provides exact instructions.
---
## What the Script Does (Step by Step)
### 1. Authentication ✅ Fully Automated
```bash
gcloud auth login
```
Opens browser, you log in with Google, done.
### 2. Create GCP Project ✅ Fully Automated
```bash
PROJECT_ID="soroban-abacus-$(date +%s)" # Unique ID with timestamp
gcloud projects create "$PROJECT_ID" --name="Soroban Abacus Flashcards"
gcloud config set project "$PROJECT_ID"
```
### 3. Link Billing Account ✅ Mostly Automated
```bash
# List your billing accounts
gcloud billing accounts list
# Link to project
gcloud billing projects link "$PROJECT_ID" --billing-account="BILLING_ACCOUNT_ID"
```
**Why billing is required:**
- Google requires billing for API access (even free APIs!)
- Classroom API is **FREE** with no usage charges
- You won't be charged unless you enable paid services
**If you don't have a billing account:**
- Script will prompt you to create one at: https://console.cloud.google.com/billing
- It's quick: just add payment method (won't be charged)
- Press Enter in terminal after creation
### 4. Enable APIs ✅ Fully Automated
```bash
gcloud services enable classroom.googleapis.com
gcloud services enable people.googleapis.com
```
Takes 1-2 minutes to propagate.
### 5. Create OAuth Credentials ⚠️ Requires Web Console
**Why CLI doesn't work:**
Google doesn't provide `gcloud` commands for creating OAuth clients. You need the web console.
**What the script does:**
- Opens: https://console.cloud.google.com/apis/credentials?project=YOUR_PROJECT
- Provides exact instructions (copy-paste ready)
**Manual steps (takes 2 minutes):**
1. Click "**Create Credentials**" → "**OAuth client ID**"
2. Application type: **"Web application"**
3. Name: **"Soroban Abacus Web"**
4. **Authorized JavaScript origins:**
```
http://localhost:3000
https://abaci.one
```
5. **Authorized redirect URIs:**
```
http://localhost:3000/api/auth/callback/google
https://abaci.one/api/auth/callback/google
```
6. Click **"Create"**
7. **Copy** the Client ID and Client Secret (you'll paste into terminal)
### 6. Configure OAuth Consent Screen ⚠️ Requires Web Console
**Why CLI doesn't work:**
OAuth consent screen configuration is web-only.
**What the script does:**
- Opens: https://console.cloud.google.com/apis/credentials/consent?project=YOUR_PROJECT
- Provides step-by-step instructions
**Manual steps (takes 3 minutes):**
**Screen 1: OAuth consent screen**
- User Type: **"External"** (unless you have Google Workspace)
- Click "**Create**"
**Screen 2: App information**
- App name: **"Soroban Abacus Flashcards"**
- User support email: **Your email**
- App logo: (optional)
- App domain: (optional, can add later)
- Developer contact: **Your email**
- Click "**Save and Continue**"
**Screen 3: Scopes**
- Click "**Add or Remove Scopes**"
- Filter/search for these scopes and check them:
- ✅ `.../auth/userinfo.email` (See your primary Google Account email)
- ✅ `.../auth/userinfo.profile` (See your personal info)
- ✅ `.../auth/classroom.courses.readonly` (View courses)
- ✅ `.../auth/classroom.rosters.readonly` (View class rosters)
- Click "**Update**"
- Click "**Save and Continue**"
**Screen 4: Test users**
- Click "**Add Users**"
- Add your email address (for testing)
- Click "**Save and Continue**"
**Screen 5: Summary**
- Review and click "**Back to Dashboard**"
Done! ✅
### 7. Save Credentials to .env.local ✅ Fully Automated
Script prompts you for:
- Client ID (paste from step 5)
- Client Secret (paste from step 5)
Then automatically adds to `.env.local`:
```bash
# Google OAuth (Generated by setup-google-classroom.sh)
GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="GOCSPX-your-secret"
```
---
## After Running the Script
### Verify Setup
```bash
# Check project configuration
gcloud config get-value project
# List enabled APIs
gcloud services list --enabled
# Check Classroom API is enabled
gcloud services list --enabled | grep classroom
```
Expected output:
```
classroom.googleapis.com Google Classroom API
```
### Test API Access
```bash
# Get an access token
gcloud auth application-default login
gcloud auth application-default print-access-token
# Test Classroom API (replace TOKEN)
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://classroom.googleapis.com/v1/courses
```
Expected response (if you have no courses yet):
```json
{}
```
---
## NextAuth Configuration
Now that you have credentials, add Google provider to NextAuth:
### 1. Check Current NextAuth Config
```bash
cat src/app/api/auth/[...nextauth]/route.ts
```
### 2. Add Google Provider
Add to your NextAuth providers array:
```typescript
import GoogleProvider from "next-auth/providers/google"
export const authOptions: NextAuthOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
scope: [
'openid',
'email',
'profile',
'https://www.googleapis.com/auth/classroom.courses.readonly',
'https://www.googleapis.com/auth/classroom.rosters.readonly',
].join(' '),
prompt: 'consent',
access_type: 'offline',
response_type: 'code'
}
}
}),
// ... your existing providers
],
// ... rest of config
}
```
### 3. Test Login
```bash
# Start dev server
npm run dev
# Open browser
open http://localhost:3000
```
Click "Sign in with Google" and verify:
- ✅ OAuth consent screen appears
- ✅ Shows requested permissions
- ✅ Successfully logs in
- ✅ User profile is created
---
## Troubleshooting
### "Billing account required"
**Problem:** Can't enable APIs without billing
**Solution:** Create billing account at https://console.cloud.google.com/billing
- Won't be charged for Classroom API (it's free)
- Just need payment method on file
### "Error 401: deleted_client"
**Problem:** OAuth client was deleted or not created properly
**Solution:** Re-run OAuth client creation (step 5)
```bash
open "https://console.cloud.google.com/apis/credentials?project=$(gcloud config get-value project)"
```
### "Error 403: Access Not Configured"
**Problem:** APIs not enabled yet (takes 1-2 min to propagate)
**Solution:** Wait 2 minutes, then verify:
```bash
gcloud services list --enabled | grep classroom
```
### "Invalid redirect URI"
**Problem:** Redirect URI doesn't match OAuth client config
**Solution:** Check that these URIs are in your OAuth client:
- http://localhost:3000/api/auth/callback/google
- https://abaci.one/api/auth/callback/google
### "App is not verified"
**Problem:** OAuth consent screen in "Testing" mode
**Solution:** This is **normal** for development!
- Click "Advanced" → "Go to [app name] (unsafe)"
- Only affects external test users
- For production, submit for verification (takes 1-2 weeks)
---
## CLI Reference
### Project Management
```bash
# List all your projects
gcloud projects list
# Switch project
gcloud config set project PROJECT_ID
# Delete project (if needed)
gcloud projects delete PROJECT_ID
```
### API Management
```bash
# List enabled APIs
gcloud services list --enabled
# Enable an API
gcloud services enable APINAME.googleapis.com
# Disable an API
gcloud services disable APINAME.googleapis.com
# Check quota
gcloud services quota describe classroom.googleapis.com
```
### OAuth Management
```bash
# List OAuth clients (requires REST API)
PROJECT_ID=$(gcloud config get-value project)
ACCESS_TOKEN=$(gcloud auth application-default print-access-token)
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://oauth2.googleapis.com/v1/projects/$PROJECT_ID/oauthClients"
```
### Billing
```bash
# List billing accounts
gcloud billing accounts list
# Link billing to project
gcloud billing projects link PROJECT_ID --billing-account=ACCOUNT_ID
# Check project billing status
gcloud billing projects describe PROJECT_ID
```
---
## What You Can Do From CLI (Summary)
✅ **Fully Automated:**
- Authenticate with Google
- Create GCP project
- Enable APIs
- Link billing account
- Configure environment variables
⚠️ **Requires Web Console (2-5 minutes):**
- Create OAuth client (2 min)
- Configure OAuth consent screen (3 min)
**Why web console required:**
Google doesn't provide CLI for these security-sensitive operations. But the script:
- Opens the exact pages for you
- Provides step-by-step instructions
- Makes it as painless as possible
---
## Cost Breakdown
| Item | Cost |
|------|------|
| GCP project | $0 |
| Google Classroom API | $0 (free forever) |
| Google People API | $0 (free forever) |
| Billing account requirement | $0 (no charges) |
| **Total** | **$0** |
**Note:** You need to add a payment method for billing account, but Google Classroom API is completely free with no usage limits.
---
## Next Steps After Setup
1. ✅ Run the setup script: `./scripts/setup-google-classroom.sh`
2. ✅ Add Google provider to NextAuth
3. ✅ Test "Sign in with Google"
4. 📝 Implement class import feature (Phase 2 of roadmap)
5. 📝 Build teacher dashboard
6. 📝 Add assignment integration
Refer to `.claude/PLATFORM_INTEGRATION_ROADMAP.md` for full implementation timeline.
---
## Security Best Practices
### Protect Your Secrets
```bash
# Check .env.local is in .gitignore
cat .gitignore | grep .env.local
```
Should see:
```
.env*.local
```
### Rotate Credentials Periodically
```bash
# Open credentials page
PROJECT_ID=$(gcloud config get-value project)
open "https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID"
# Delete old client, create new one
# Update .env.local with new credentials
```
### Use Different Credentials for Dev/Prod
**Development:**
- OAuth client: `http://localhost:3000/api/auth/callback/google`
- Test users only
**Production:**
- OAuth client: `https://abaci.one/api/auth/callback/google`
- Verified app (submit for review)
---
## Resources
**Official Documentation:**
- GCP CLI: https://cloud.google.com/sdk/gcloud
- Classroom API: https://developers.google.com/classroom
- OAuth 2.0: https://developers.google.com/identity/protocols/oauth2
**Script Location:**
- `scripts/setup-google-classroom.sh`
**Configuration Files:**
- `.env.local` (credentials)
- `src/app/api/auth/[...nextauth]/route.ts` (NextAuth config)
---
**Ready to run?**
```bash
./scripts/setup-google-classroom.sh
```
Good luck! 🚀

View File

@@ -1,283 +0,0 @@
# Matching Game Stats Integration Guide
## Quick Reference
**Files to modify**: `src/arcade-games/matching/components/ResultsPhase.tsx`
**What we're adding**: Call `useRecordGameResult()` when game completes to save per-player stats.
## Current State Analysis
### ResultsPhase.tsx (lines 9-29)
Already has all the data we need:
```typescript
const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const gameTime = state.gameEndTime && state.gameStartTime
? state.gameEndTime - state.gameStartTime
: 0
const analysis = getPerformanceAnalysis(state)
const multiplayerResult = gameMode === 'multiplayer'
? getMultiplayerWinner(state, activePlayers)
: null
```
**Available data:**
-`state.scores` - scores by player ID
-`state.gameStartTime`, `state.gameEndTime` - timing
-`state.matchedPairs`, `state.totalPairs` - completion
-`state.moves` - total moves
-`activePlayers` - array of player IDs
-`multiplayerResult.winners` - who won
-`analysis.statistics.accuracy` - accuracy percentage
## Implementation Steps
### Step 1: Add state flag to prevent duplicate recording
Add `recorded: boolean` to `MatchingState` type:
```typescript
// src/arcade-games/matching/types.ts (add to MatchingState interface)
export interface MatchingState extends GameState {
// ... existing fields ...
// Stats recording
recorded?: boolean // ← ADD THIS
}
```
### Step 2: Import the hook in ResultsPhase.tsx
```typescript
// At top of src/arcade-games/matching/components/ResultsPhase.tsx
import { useEffect } from 'react' // ← ADD if not present
import { useRecordGameResult } from '@/hooks/useRecordGameResult'
import type { GameResult } from '@/lib/arcade/stats/types'
```
### Step 3: Call the hook
```typescript
// Inside ResultsPhase component, after existing hooks
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
// ← ADD THIS
const { mutate: recordGame, isPending: isRecording } = useRecordGameResult()
// ... existing code ...
```
### Step 4: Record game result on mount
Add this useEffect after the hook declarations:
```typescript
// Record game result once when entering results phase
useEffect(() => {
// Only record if we haven't already
if (state.phase === 'results' && !state.recorded && !isRecording) {
const gameTime = state.gameEndTime && state.gameStartTime
? state.gameEndTime - state.gameStartTime
: 0
const analysis = getPerformanceAnalysis(state)
const multiplayerResult = gameMode === 'multiplayer'
? getMultiplayerWinner(state, activePlayers)
: null
// Build GameResult
const gameResult: GameResult = {
gameType: state.gameType === 'abacus-numeral'
? 'matching-abacus'
: 'matching-complements',
completedAt: state.gameEndTime || Date.now(),
duration: gameTime,
playerResults: activePlayers.map(playerId => {
const score = state.scores[playerId] || 0
const won = multiplayerResult
? multiplayerResult.winners.includes(playerId)
: state.matchedPairs === state.totalPairs // Solo = completed
// In multiplayer, calculate per-player accuracy from their score
// In single player, use overall accuracy
const playerAccuracy = gameMode === 'multiplayer'
? score / state.totalPairs // Their score as fraction of total pairs
: analysis.statistics.accuracy / 100 // Convert percentage to 0-1
return {
playerId,
won,
score,
accuracy: playerAccuracy,
completionTime: gameTime,
metrics: {
moves: state.moves,
matchedPairs: state.matchedPairs,
difficulty: state.difficulty,
}
}
}),
metadata: {
gameType: state.gameType,
difficulty: state.difficulty,
grade: analysis.grade,
starRating: analysis.starRating,
}
}
// Record to database
recordGame(gameResult, {
onSuccess: (updates) => {
console.log('✅ Stats recorded:', updates)
// Mark as recorded to prevent duplicate saves
// Note: This assumes Provider has a way to update state.recorded
// We'll need to add an action for this
},
onError: (error) => {
console.error('❌ Failed to record stats:', error)
}
})
}
}, [state.phase, state.recorded, isRecording, /* ... deps */])
```
### Step 5: Add loading state UI (optional)
Show a subtle loading indicator while recording:
```typescript
// At the top of the return statement in ResultsPhase
if (isRecording) {
return (
<div className={css({
textAlign: 'center',
padding: '20px',
})}>
<p>Saving results...</p>
</div>
)
}
```
Or keep it subtle and just disable buttons:
```typescript
// On the "Play Again" button
<button
disabled={isRecording}
className={css({
// ... styles ...
opacity: isRecording ? 0.5 : 1,
cursor: isRecording ? 'not-allowed' : 'pointer',
})}
onClick={resetGame}
>
{isRecording ? '💾 Saving...' : '🎮 Play Again'}
</button>
```
## Provider Changes Needed
The Provider needs an action to mark the game as recorded:
```typescript
// src/arcade-games/matching/Provider.tsx
// Add to the context type
export interface MatchingContextType {
// ... existing ...
markAsRecorded: () => void // ← ADD THIS
}
// Add to the reducer or state update logic
const markAsRecorded = useCallback(() => {
setState(prev => ({ ...prev, recorded: true }))
}, [])
// Add to the context value
const contextValue: MatchingContextType = {
// ... existing ...
markAsRecorded,
}
```
Then in ResultsPhase useEffect:
```typescript
onSuccess: (updates) => {
console.log('✅ Stats recorded:', updates)
markAsRecorded() // ← Use this instead
}
```
## Testing Checklist
### Solo Game
- [ ] Play a game to completion
- [ ] Check console for "✅ Stats recorded"
- [ ] Refresh page
- [ ] Go to `/games` page
- [ ] Verify player's gamesPlayed incremented
- [ ] Verify player's totalWins incremented (if completed)
### Multiplayer Game
- [ ] Activate 2+ players
- [ ] Play a game to completion
- [ ] Check console for stats for ALL players
- [ ] Go to `/games` page
- [ ] Verify each player's stats updated independently
- [ ] Winner should have +1 win
- [ ] All players should have +1 games played
### Edge Cases
- [ ] Incomplete game (exit early) - should NOT record
- [ ] Play again from results - should NOT duplicate record
- [ ] Network error during save - should show error, not mark as recorded
## Common Issues
### Issue: Stats recorded multiple times
**Cause**: useEffect dependency array missing or incorrect
**Fix**: Ensure `state.recorded` is in deps and checked in condition
### Issue: Can't read property 'id' of undefined
**Cause**: Player not found in playerMap
**Fix**: Add null checks when mapping activePlayers
### Issue: Accuracy is always 100% or 0%
**Cause**: Wrong calculation or unit (percentage vs decimal)
**Fix**: Ensure accuracy is 0.0 - 1.0, not 0-100
### Issue: Single player never "wins"
**Cause**: Wrong win condition for solo mode
**Fix**: Solo player wins if they complete all pairs (`state.matchedPairs === state.totalPairs`)
## Next Steps After Integration
1. ✅ Verify stats save correctly
2. ✅ Update `/games` page to fetch and display per-player stats
3. ✅ Test with different game modes and difficulties
4. 🔄 Repeat this pattern for other arcade games
5. 📊 Add stats visualization/charts (future)
---
**Status**: Ready for implementation
**Blocked by**:
- Database schema (player_stats table)
- API endpoints (/api/player-stats/record-game)
- React hooks (useRecordGameResult)

View File

@@ -1,594 +0,0 @@
# Per-Player Stats Architecture & Implementation Plan
## Executive Summary
This document outlines the architecture for tracking game statistics per-player (not per-user). Each local player profile will maintain their own game history, wins, losses, and performance metrics. We'll build a universal framework that any arcade game can use to record results.
**Starting point**: Matching/Memory Lightning game
## Current State Problems
1. ❌ Global `user_stats` table exists but games never update it
2.`/games` page shows same global stats for all players
3. ❌ No framework for games to save results
4. ❌ Players table has no stats fields
## Architecture Design
### 1. Database Schema
#### New Table: `player_stats`
```sql
CREATE TABLE player_stats (
player_id TEXT PRIMARY KEY REFERENCES players(id) ON DELETE CASCADE,
-- Aggregate stats
games_played INTEGER NOT NULL DEFAULT 0,
total_wins INTEGER NOT NULL DEFAULT 0,
total_losses INTEGER NOT NULL DEFAULT 0,
-- Performance metrics
best_time INTEGER, -- Best completion time (ms)
highest_accuracy REAL NOT NULL DEFAULT 0, -- 0.0 - 1.0
-- Game preferences
favorite_game_type TEXT, -- Most played game
-- Per-game stats (JSON)
game_stats TEXT NOT NULL DEFAULT '{}', -- { "matching": { wins: 5, played: 10 }, ... }
-- Timestamps
last_played_at INTEGER, -- timestamp
created_at INTEGER NOT NULL, -- timestamp
updated_at INTEGER NOT NULL -- timestamp
);
CREATE INDEX player_stats_last_played_idx ON player_stats(last_played_at);
```
#### Per-Game Stats Structure (JSON)
```typescript
type PerGameStats = {
[gameName: string]: {
gamesPlayed: number
wins: number
losses: number
bestTime: number | null
highestAccuracy: number
averageScore: number
lastPlayed: number // timestamp
}
}
```
#### Keep `user_stats`?
**Decision**: Deprecate `user_stats` table. All stats are now per-player.
**Reasoning**:
- Users can have multiple players
- Aggregate "user level" stats can be computed by summing player stats
- Simpler mental model: players compete, players have stats
- `/games` page displays players, so showing player stats makes sense
### 2. Universal Game Result Types
**Analysis**: Examined 5 arcade games (Matching, Complement Race, Memory Quiz, Card Sorting, Rithmomachia)
**Key Finding**: Cooperative games need special handling - all players share win/loss!
**See**: `.claude/GAME_STATS_COMPARISON.md` for detailed cross-game analysis
```typescript
// src/lib/arcade/stats/types.ts
/**
* Standard game result that all arcade games must provide
*
* Supports:
* - 1-N players
* - Competitive (individual winners)
* - Cooperative (team wins/losses)
* - Solo completion
* - Head-to-head (2-player)
*/
export interface GameResult {
// Game identification
gameType: string // e.g., "matching", "complement-race", "memory-quiz"
// Player results (for multiplayer, array of results)
playerResults: PlayerGameResult[]
// Game metadata
completedAt: number // timestamp
duration: number // milliseconds
// Optional game-specific data
metadata?: {
// For cooperative games (Memory Quiz, Card Sorting collaborative)
isTeamVictory?: boolean // All players share win/loss
// For specific win conditions (Rithmomachia)
winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT"
// For game modes
gameMode?: string // e.g., "solo", "competitive", "cooperative"
// Extensible for other game-specific info
[key: string]: unknown
}
}
export interface PlayerGameResult {
playerId: string
// Outcome
won: boolean // For cooperative: all players have same value
placement?: number // 1st, 2nd, 3rd place (for tournaments with 3+ players)
// Performance
score?: number
accuracy?: number // 0.0 - 1.0
completionTime?: number // milliseconds (player-specific)
// Game-specific metrics (stored as JSON in DB)
metrics?: {
// Matching
moves?: number
matchedPairs?: number
difficulty?: number
// Complement Race
streak?: number
correctAnswers?: number
totalQuestions?: number
// Memory Quiz
correct?: number
incorrect?: number
// Card Sorting
exactMatches?: number
inversions?: number
lcsLength?: number
// Rithmomachia
capturedPieces?: number
points?: number
// Extensible for future games
[key: string]: unknown
}
}
/**
* Stats update returned from API
*/
export interface StatsUpdate {
playerId: string
previousStats: PlayerStats
newStats: PlayerStats
changes: {
gamesPlayed: number
wins: number
losses: number
}
}
export interface PlayerStats {
playerId: string
gamesPlayed: number
totalWins: number
totalLosses: number
bestTime: number | null
highestAccuracy: number
favoriteGameType: string | null
gameStats: PerGameStats
lastPlayedAt: number | null
createdAt: number
updatedAt: number
}
```
### 3. API Endpoints
#### POST `/api/player-stats/record-game`
Records a game result and updates player stats.
**Request:**
```typescript
{
gameResult: GameResult
}
```
**Response:**
```typescript
{
success: true,
updates: StatsUpdate[] // One per player
}
```
**Logic:**
1. Validate game result structure
2. For each player result:
- Fetch or create player_stats record
- Increment games_played
- Increment wins/losses based on outcome
- **Special case**: If `metadata.isTeamVictory === true`, all players share win/loss
- Cooperative games: all win or all lose together
- Competitive games: individual outcomes
- Update best_time if improved
- Update highest_accuracy if improved
- Update game-specific stats in JSON
- Update favorite_game_type based on most played
- Set last_played_at
3. Return updates for all players
**Example pseudo-code**:
```typescript
for (const playerResult of gameResult.playerResults) {
const stats = await getPlayerStats(playerResult.playerId)
stats.gamesPlayed++
// Handle cooperative games specially
if (gameResult.metadata?.isTeamVictory !== undefined) {
// Cooperative: all players share outcome
if (playerResult.won) {
stats.totalWins++
} else {
stats.totalLosses++
}
} else {
// Competitive/Solo: individual outcome
if (playerResult.won) {
stats.totalWins++
} else {
stats.totalLosses++
}
}
// ... rest of stats update
}
```
#### GET `/api/player-stats/:playerId`
Fetch stats for a specific player.
**Response:**
```typescript
{
stats: PlayerStats
}
```
#### GET `/api/player-stats`
Fetch stats for all current user's players.
**Response:**
```typescript
{
playerStats: PlayerStats[]
}
```
### 4. React Hooks
#### `useRecordGameResult()`
Main hook that games use to record results.
```typescript
// src/hooks/useRecordGameResult.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { GameResult, StatsUpdate } from '@/lib/arcade/stats/types'
export function useRecordGameResult() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (gameResult: GameResult): Promise<StatsUpdate[]> => {
const res = await fetch('/api/player-stats/record-game', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameResult }),
})
if (!res.ok) throw new Error('Failed to record game result')
const data = await res.json()
return data.updates
},
onSuccess: (updates) => {
// Invalidate player stats queries to trigger refetch
queryClient.invalidateQueries({ queryKey: ['player-stats'] })
// Show success feedback (optional)
console.log('✅ Game result recorded:', updates)
},
onError: (error) => {
console.error('❌ Failed to record game result:', error)
},
})
}
```
#### `usePlayerStats(playerId?)`
Fetch stats for a player (or all players if no ID).
```typescript
// src/hooks/usePlayerStats.ts
import { useQuery } from '@tanstack/react-query'
import type { PlayerStats } from '@/lib/arcade/stats/types'
export function usePlayerStats(playerId?: string) {
return useQuery({
queryKey: playerId ? ['player-stats', playerId] : ['player-stats'],
queryFn: async (): Promise<PlayerStats | PlayerStats[]> => {
const url = playerId
? `/api/player-stats/${playerId}`
: '/api/player-stats'
const res = await fetch(url)
if (!res.ok) throw new Error('Failed to fetch player stats')
const data = await res.json()
return playerId ? data.stats : data.playerStats
},
})
}
```
### 5. Game Integration Pattern
Every arcade game should follow this pattern when completing:
```typescript
// In results phase component (e.g., ResultsPhase.tsx)
import { useRecordGameResult } from '@/hooks/useRecordGameResult'
import type { GameResult } from '@/lib/arcade/stats/types'
export function ResultsPhase() {
const { state, activePlayers } = useGameContext()
const { mutate: recordGame, isPending } = useRecordGameResult()
// Record game result on mount (once)
useEffect(() => {
if (state.phase === 'results' && !state.recorded) {
const gameResult: GameResult = {
gameType: 'matching',
completedAt: Date.now(),
duration: state.gameEndTime - state.gameStartTime,
playerResults: activePlayers.map(player => ({
playerId: player.id,
won: player.id === winnerId,
score: player.matchCount,
accuracy: player.matchCount / state.totalPairs,
completionTime: player.completionTime,
})),
}
recordGame(gameResult, {
onSuccess: () => {
// Mark as recorded to prevent duplicates
setState({ recorded: true })
}
})
}
}, [state.phase, state.recorded])
// Show loading state while recording
if (isPending) {
return <div>Saving results...</div>
}
// Show results UI
return <div>...</div>
}
```
## Implementation Plan
### Phase 1: Foundation (Database & API)
1. **Create database schema**
- File: `src/db/schema/player-stats.ts`
- Define `player_stats` table with Drizzle ORM
- Add type exports
2. **Generate migration**
```bash
npx drizzle-kit generate:sqlite
```
3. **Create type definitions**
- File: `src/lib/arcade/stats/types.ts`
- Define `GameResult`, `PlayerGameResult`, `StatsUpdate`, `PlayerStats`
4. **Build API endpoint**
- File: `src/app/api/player-stats/record-game/route.ts`
- Implement POST handler with validation
- Handle per-player stat updates
- Transaction safety
5. **Build query endpoints**
- File: `src/app/api/player-stats/route.ts` (GET all)
- File: `src/app/api/player-stats/[playerId]/route.ts` (GET one)
### Phase 2: React Hooks & Integration
6. **Create React hooks**
- File: `src/hooks/useRecordGameResult.ts`
- File: `src/hooks/usePlayerStats.ts`
7. **Update GameModeContext**
- Expose helper to get player stats map
- Integrate with usePlayerStats hook
### Phase 3: Matching Game Integration
8. **Analyze matching game completion flow**
- Find where game completes
- Identify winner calculation
- Map state to GameResult format
9. **Integrate stats recording**
- Add useRecordGameResult to ResultsPhase
- Build GameResult from game state
- Handle recording state to prevent duplicates
10. **Test matching game stats**
- Play solo game, verify stats update
- Play multiplayer game, verify all players update
- Check accuracy calculations
- Check time tracking
### Phase 4: UI Updates
11. **Update /games page**
- Fetch per-player stats with usePlayerStats
- Display correct stats for each player card
- Remove dependency on global user profile
12. **Add stats visualization**
- Per-game breakdown
- Win/loss ratio
- Performance trends
### Phase 5: Documentation & Rollout
13. **Document integration pattern**
- Create guide for adding stats to other games
- Code examples
- Common pitfalls
14. **Roll out to other games**
- Complement Race
- Memory Quiz
- Card Sorting
- (Future games)
## Data Migration Strategy
### Handling Existing `user_stats`
**Option A: Drop the table**
- Simple, clean break
- No historical data
**Option B: Migrate to player stats**
- For each user with stats, assign to their first/active player
- More complex but preserves history
**Recommendation**: Option A (drop it) since:
- Very new feature, unlikely much data exists
- Cleaner architecture
- Users can rebuild stats by playing
### Migration SQL
```sql
-- Drop old user_stats table
DROP TABLE IF EXISTS user_stats;
-- Create new player_stats table
-- (Drizzle migration will handle this)
```
## Testing Strategy
### Unit Tests
- `GameResult` validation
- Stats calculation logic
- JSON merge for per-game stats
- Favorite game detection
### Integration Tests
- API endpoint: record game, verify DB update
- API endpoint: fetch stats, verify response
- React hook: record game, verify cache invalidation
### E2E Tests
- Play matching game solo, check stats on /games page
- Play matching game multiplayer, verify each player's stats
- Verify stats persist across sessions
## Success Criteria
✅ Player stats save correctly after game completion
✅ Each player maintains separate stats
✅ /games page displays correct per-player stats
✅ Stats survive page refresh
✅ Multiplayer games update all participants
✅ Framework is reusable for other games
✅ No duplicate recordings
✅ Performance acceptable (< 200ms to record)
## Open Questions
1. **Leaderboards?** - Future consideration, need global rankings
2. **Historical games?** - Store individual game records or just aggregates?
3. **Stats reset?** - Should users be able to reset player stats?
4. **Achievements?** - Track milestones? (100 games, 50 wins, etc.)
## File Structure
```
src/
├── db/
│ └── schema/
│ └── player-stats.ts # NEW: Drizzle schema
├── lib/
│ └── arcade/
│ └── stats/
│ ├── types.ts # NEW: Type definitions
│ └── utils.ts # NEW: Helper functions
├── hooks/
│ ├── useRecordGameResult.ts # NEW: Record game hook
│ └── usePlayerStats.ts # NEW: Fetch stats hook
├── app/
│ └── api/
│ └── player-stats/
│ ├── route.ts # NEW: GET all
│ ├── record-game/
│ │ └── route.ts # NEW: POST record
│ └── [playerId]/
│ └── route.ts # NEW: GET one
└── arcade-games/
└── matching/
└── components/
└── ResultsPhase.tsx # MODIFY: Add stats recording
.claude/
└── PER_PLAYER_STATS_ARCHITECTURE.md # THIS FILE
```
## Next Steps
1. Review this plan with user
2. Create database schema and types
3. Build API endpoints
4. Create React hooks
5. Integrate with matching game
6. Test thoroughly
7. Roll out to other games
---
**Document Status**: Draft for review
**Last Updated**: 2025-01-03
**Owner**: Claude Code

View File

@@ -1,943 +0,0 @@
# Platform Integration Roadmap
**Project:** Soroban Abacus Flashcards - Arcade Games Platform
**Focus Areas:** Educational Platform SSO & Game Distribution Channels
**Document Version:** 1.0
**Last Updated:** 2025-11-02
---
## Executive Summary
This roadmap outlines integration strategies for four major platform categories to expand reach and player acquisition:
1. **Google Classroom Integration** - Direct access to teachers and students
2. **Clever/ClassLink SSO** - K-12 institution single sign-on (massive reach)
3. **Game Distribution Portals** - CrazyGames, Poki, Kongregate (casual player discovery)
4. **Steam Distribution** - Educational game marketplace with social features
**Estimated Total Timeline:** 6-9 months for full implementation
**Estimated Total Cost:** $600-1,500 (app fees + hosting)
**Expected Impact:** 10x-100x increase in player discovery reach
---
## Platform #1: Google Classroom Integration
### Overview
Google Classroom API enables teachers to:
- Import game rooms directly into their classes
- Auto-create student accounts with SSO
- Track student progress and scores
- Assign abacus practice as homework
**Target Audience:** 150+ million students and teachers using Google Classroom globally
### Technical Requirements
**Prerequisites:**
- Google Cloud Platform (GCP) project
- OAuth 2.0 implementation
- Google Classroom API access
- HTTPS endpoints
**API Capabilities Needed:**
- Courses API (read class rosters)
- Coursework API (create assignments)
- Students API (manage student accounts)
- Submissions API (track game completion)
### Implementation Phases
#### Phase 1: Basic OAuth & SSO (2-3 weeks)
**Tasks:**
1. Set up GCP project and enable Classroom API
2. Implement OAuth 2.0 "Sign in with Google" flow
3. Add Classroom scope permissions (`classroom.courses.readonly`, `classroom.rosters.readonly`)
4. Create user account mapping (Google ID → Internal User ID)
5. Test with sandbox Google Workspace account
**Deliverables:**
- "Sign in with Google" button on login page
- Auto-account creation for Google users
- Basic profile sync (name, email, photo)
**Effort:** 40-60 hours
**Cost:** $0 (Google API is free for educational use)
#### Phase 2: Class Import & Roster Sync (3-4 weeks)
**Tasks:**
1. Build UI for teachers to import Classroom rosters
2. Implement Courses API integration (list teacher's classes)
3. Create room auto-provisioning from class data
4. Set up automatic roster synchronization (daily sync)
5. Handle student account creation/deactivation
6. Add class management dashboard for teachers
**Deliverables:**
- "Import from Google Classroom" feature
- Teacher dashboard showing all imported classes
- Automatic student account provisioning
- Daily roster sync (new students, removed students)
**Effort:** 80-100 hours
**Cost:** $0
#### Phase 3: Assignment Integration (3-4 weeks)
**Tasks:**
1. Build "Assign to Classroom" feature for game rooms
2. Implement Coursework API integration
3. Create assignment templates for each game type
4. Build grade sync (game scores → Classroom grades)
5. Add completion tracking and submission API integration
6. Create teacher analytics dashboard
**Deliverables:**
- Teachers can push game assignments to Classroom
- Students see assignments in Classroom feed
- Automatic grade passback on game completion
- Teacher analytics (who played, scores, time spent)
**Effort:** 100-120 hours
**Cost:** $0
#### Phase 4: Deep Integration & Polish (2-3 weeks)
**Tasks:**
1. Add "Share Turn" notifications (via Classroom API announcements)
2. Implement progress tracking dashboard
3. Create teacher resource library (lesson plans, tutorials)
4. Add student portfolio (history of games played)
5. Build reporting exports (CSV, PDF)
6. Classroom API webhook integration for real-time updates
**Deliverables:**
- Comprehensive teacher dashboard
- Student progress portfolios
- Automated reporting
- Real-time roster updates
**Effort:** 60-80 hours
**Cost:** $0
### Total Timeline: 10-14 weeks
### Total Effort: 280-360 hours
### Total Cost: $0
### Dependencies
- Existing user authentication system (✓ NextAuth in place)
- Room management system (✓ exists)
- Score/progress tracking (✓ exists per game)
- Email notification system (partially implemented)
### Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| OAuth complexity | Medium | Medium | Use official Google client libraries |
| API rate limits | Low | Low | Implement caching and batch requests |
| Grade sync errors | High | Medium | Add retry logic and error notifications |
| Teacher adoption | High | High | Create detailed onboarding videos |
| Privacy compliance (COPPA) | Critical | Medium | Implement parent consent workflows |
### Success Metrics
- **Adoption:** 100+ teachers in first 3 months
- **Usage:** 50+ classes imported
- **Retention:** 70%+ of teachers assign games monthly
- **Referrals:** 20% teacher-to-teacher referrals
---
## Platform #2: Clever & ClassLink SSO
### Overview
**Clever:** SSO platform serving 75% of U.S. K-12 schools (13,000+ districts)
**ClassLink:** SSO platform serving 20+ million students globally
Both provide instant access to school rosters without manual setup.
**Key Benefits:**
- Zero teacher setup (IT manages access)
- Automatic roster sync
- Instant student login (no passwords)
- District-wide deployment capability
### Technical Requirements
**Clever Requirements:**
- OAuth 2.0 implementation
- REST API integration
- HTTPS endpoints
- District SSO certification
**ClassLink Requirements:**
- SAML 2.0 support (or OAuth/OpenID Connect)
- OneRoster API integration (for roster sync)
- ClassLink Management Console access
### Implementation Phases
#### Phase 1: Clever District SSO (4-5 weeks)
**Tasks:**
1. Register as Clever developer (dev.clever.com)
2. Implement OAuth 2.0 authorization grant flow
3. Add "Log in with Clever" button
4. Integrate Clever Data API v3.1
- Districts API
- Schools API
- Students/Teachers API
- Sections API (classes)
5. Build user account mapping (Clever ID → Internal ID)
6. Create development environment with sandbox district
7. Implement district admin dashboard
8. Apply for District SSO certification
**API Scopes Required:**
- `read:district_admins_basic`
- `read:school_admins_basic`
- `read:students_basic`
- `read:teachers_basic`
- `read:user_id`
- `read:sis` (for full roster data)
**Deliverables:**
- "Log in with Clever" SSO
- Automatic account creation
- District/school/class hierarchy sync
- Certified for live district connections
**Effort:** 100-120 hours
**Cost:** $0 (Clever is free for developers)
#### Phase 2: ClassLink SAML Integration (3-4 weeks)
**Tasks:**
1. Set up SAML 2.0 service provider
2. Register with ClassLink SSO Library
3. Implement SAML authentication flow
4. Add "Log in with ClassLink" button
5. Integrate OneRoster API for roster sync
6. Build automated roster update system
7. Create ClassLink admin dashboard
8. Submit to ClassLink SSO Library (6,000+ app directory)
**Deliverables:**
- "Log in with ClassLink" SSO
- OneRoster-based roster sync
- Listed in ClassLink SSO Library
**Effort:** 80-100 hours
**Cost:** $0
#### Phase 3: Advanced Features (2-3 weeks)
**Tasks:**
1. Implement shared device support (session override)
2. Add UTF-8 character support for names
3. Build district admin analytics dashboard
4. Create automated onboarding emails for admins
5. Add Clever/ClassLink badge to marketing site
6. Build compliance documentation (FERPA, COPPA)
**Deliverables:**
- Production-ready SSO for both platforms
- District admin tools
- Compliance documentation
**Effort:** 60-80 hours
**Cost:** $0
### Total Timeline: 9-12 weeks
### Total Effort: 240-300 hours
### Total Cost: $0
### Dependencies
- SAML library (e.g., `passport-saml` for Node.js)
- OAuth 2.0 implementation (can reuse from Google Classroom)
- Secure session management
- Database schema for district/school/class hierarchy
### Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Certification delays | High | Medium | Start certification process early |
| SAML complexity | Medium | Medium | Use established libraries (passport-saml) |
| District adoption | High | Medium | Partner with early adopter districts |
| Privacy regulations | Critical | Medium | Legal review of terms/privacy policy |
| Competing apps | Medium | High | Emphasize unique abacus/math focus |
### Success Metrics
- **Listings:** Published in both app directories
- **Certification:** Clever District SSO certified
- **Districts:** 10+ districts in first 6 months
- **Students:** 5,000+ student logins in first year
- **Retention:** 60%+ district renewal rate
---
## Platform #3: Game Distribution Portals
### Overview
Three major browser game portals with complementary audiences:
1. **CrazyGames:** Open platform, 35M+ monthly users, SDK monetization
2. **Poki:** Curated platform, 50M+ monthly users, 50/50 revenue share, web exclusivity
3. **Kongregate:** Legacy platform, 20M+ monthly users, now requires pre-approval
**Target Audience:** Casual gamers discovering educational games through play
### Platform Comparison
| Feature | CrazyGames | Poki | Kongregate |
|---------|------------|------|------------|
| **Selectivity** | Moderate | High (curated) | High (email approval) |
| **File Size** | Strict limits | <8MB initial | ~1100x700px max |
| **Exclusivity** | None | Web exclusive | None |
| **Revenue** | SDK ads | 50/50 split | Ads + Kreds |
| **SDK Required** | Yes | Yes | Yes |
| **Approval Time** | 1-2 weeks | 2-4 weeks | Varies |
### Technical Requirements (Common)
**All Platforms:**
- HTML5/WebGL build
- Save system (localStorage/cloud)
- Ad integration (SDK provided)
- Mobile responsive design
- HTTPS hosting
- No external monetization UI
**CrazyGames Specific:**
- CrazyGames SDK integration
- File size optimization
- Ad placements (banner, interstitial, rewarded)
- Leaderboard integration
**Poki Specific:**
- Poki SDK integration
- <8MB initial download (critical!)
- Auto-detect mobile devices
- Remove any IAP UI elements
- Web exclusivity agreement
**Kongregate Specific:**
- Pre-approval via BD@kongregate.com
- Kongregate API integration
- Badge system integration
- Kreds (virtual currency) support optional
- "initialized" stat tracking
### Implementation Phases
#### Phase 1: Game Optimization & SDK Prep (3-4 weeks)
**Tasks:**
1. Audit current build sizes (check all games)
2. Implement code splitting and lazy loading
3. Optimize assets (image compression, audio formats)
4. Create lightweight "portal build" configuration
5. Build asset CDN strategy
6. Implement progress save system (if not present)
7. Add mobile detection and responsive layouts
8. Create build pipeline for portal releases
**Target Build Sizes:**
- Initial load: <5MB (to meet Poki's <8MB requirement)
- Full game: <20MB
- Individual game assets: lazy loaded
**Deliverables:**
- Optimized builds for each game
- Build pipeline for portal releases
- Mobile-responsive UI for all games
**Effort:** 80-100 hours
**Cost:** $0
#### Phase 2: CrazyGames Integration (2-3 weeks)
**Tasks:**
1. Register at CrazyGames developer portal
2. Integrate CrazyGames SDK
- Ad placements (banner, interstitial, rewarded)
- Game analytics
- User progression tracking
3. Submit best-performing games:
- Matching Pairs Battle (most casual-friendly)
- Complement Race (fast-paced)
- Card Sorting (quick sessions)
4. Create game pages with descriptions/screenshots
5. Pass technical review and QA
6. Launch and monitor metrics
**Deliverables:**
- 3 games live on CrazyGames
- SDK integration complete
- Ad monetization active
**Effort:** 60-80 hours
**Cost:** $0
#### Phase 3: Poki Integration (3-4 weeks)
**Tasks:**
1. Submit application via Poki game submission form
2. Wait for curator review (2-4 weeks)
3. If approved, integrate Poki SDK
- Ad integration (Poki manages monetization)
- Analytics tracking
- Commerce API (if applicable)
4. Agree to web exclusivity terms
5. Optimize games to <8MB initial load (critical!)
6. Remove any competing monetization UI
7. Submit games for final review
8. Launch on Poki
**Deliverables:**
- 2-3 games live on Poki (start with best performers)
- Poki SDK integration
- Revenue share active (50/50 split)
**Effort:** 80-100 hours
**Cost:** $0
**Note:** Approval not guaranteed - Poki is highly selective
#### Phase 4: Kongregate Pre-Approval (2-3 weeks)
**Tasks:**
1. Email BD@kongregate.com with game portfolio
2. Prepare pitch deck:
- Game trailers/screenshots
- Unique value proposition (abacus education + multiplayer)
- Target audience data
- Existing player metrics
3. Wait for response (varies)
4. If approved, integrate Kongregate API
- Stats and achievements (badges)
- Optional: Kreds integration
5. Submit games for review
6. Launch on Kongregate
**Deliverables:**
- Pre-approval from Kongregate
- 1-2 games live on platform
- Badge achievements implemented
**Effort:** 60-80 hours
**Cost:** $0
**Note:** Approval required before development
### Total Timeline: 10-14 weeks
### Total Effort: 280-360 hours
### Total Cost: $0
### Game Prioritization for Submission
**Tier 1 (Submit first):**
1. **Matching Pairs Battle** - Most casual-friendly, clear mechanics
2. **Complement Race** - Fast-paced, competitive, good for short sessions
**Tier 2 (Submit if Tier 1 succeeds):**
3. **Card Sorting** - Simple, quick gameplay
4. **Memory Quiz** - Educational but approachable
**Tier 3 (Hold back):**
5. **Rithmomachia** - Too complex for casual portals, better for Steam
### Dependencies
- Current games must be playable without login (guest mode)
- Mobile responsive design
- Build optimization pipeline
- CDN for asset hosting
- Analytics integration
### Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Poki rejection | Medium | High | Focus on CrazyGames first, apply with best metrics |
| File size requirements | High | Medium | Aggressive asset optimization, code splitting |
| Ad integration conflicts | Medium | Medium | Separate builds per platform |
| Revenue lower than expected | Medium | High | Treat as marketing channel, not primary revenue |
| Web exclusivity limits Steam | High | Low | Don't submit Rithmomachia to Poki |
| Multiplayer sync issues | High | Medium | Add offline/single-player modes |
### Success Metrics
**CrazyGames:**
- **Plays:** 10,000+ plays/month per game
- **Retention:** 30%+ day-1 return rate
- **Revenue:** $200+ monthly from ads
**Poki:**
- **Plays:** 50,000+ plays/month per game
- **Revenue:** $500+ monthly (50/50 split)
- **Rating:** 4.0+ stars
**Kongregate:**
- **Plays:** 5,000+ plays/month
- **Badges:** 3+ achievements per game
- **Revenue:** $100+ monthly
---
## Platform #4: Steam Distribution
### Overview
**Why Steam for Educational Games:**
- 120+ million active users
- Strong social features (friends, achievements, leaderboards)
- Educational games perform well (Kerbal Space Program, Human Resource Machine, etc.)
- One-time purchase model fits premium educational content
- Community features drive multiplayer engagement
**Package Concept:** "Soroban Academy: Mathematical Strategy Games"
- Bundle all games into single Steam app
- Include exclusive Steam features (achievements, leaderboards, cloud saves)
- Highlight Rithmomachia as flagship historical strategy game
- Price point: $9.99-14.99
### Technical Requirements
**Platform:**
- Desktop application (Windows, macOS, Linux)
- No native browser game support
**Packaging Options:**
1. **Electron** (most common for web games)
- Pros: Easy conversion, full Chromium
- Cons: Large file size (~100-200MB), memory intensive
2. **Tauri** (modern alternative)
- Pros: Lightweight (~10MB), uses system WebView
- Cons: Newer, less Steam integration examples
3. **NW.js** (alternative to Electron)
- Pros: Similar to Electron, slightly smaller
- Cons: Less popular, smaller community
**Recommended:** Start with Electron (most proven path for HTML5 → Steam)
**Steamworks Integration:**
- Steam Authentication API
- Steam Achievements API
- Steam Leaderboards API
- Steam Cloud (save sync)
- Steam Friends API (invite friends to games)
- Steam Overlay support
### Implementation Phases
#### Phase 1: Desktop Packaging with Electron (4-5 weeks)
**Tasks:**
1. Set up Electron project
2. Package Next.js app for Electron
3. Configure build pipeline:
- Windows (x64)
- macOS (Apple Silicon + Intel)
- Linux (x64)
4. Handle offline mode and local Socket.io server
5. Implement native window controls
6. Add auto-updater support
7. Test on all platforms
8. Optimize bundle size (<500MB total)
**Deliverables:**
- Desktop app for Windows/macOS/Linux
- Standalone builds (no separate server needed)
- Native app experience
**Effort:** 120-150 hours
**Cost:** $0
#### Phase 2: Steamworks SDK Integration (3-4 weeks)
**Tasks:**
1. Register as Steamworks developer (pay $100 fee)
2. Create Steam app page
3. Integrate Steamworks SDK:
- Install Greenworks (Electron + Steamworks bridge)
- Implement Steam Authentication
- Replace internal auth with Steam accounts
4. Build achievement system:
- Define 20-30 achievements per game
- Integrate Steamworks Achievements API
- Create achievement icons (64x64px)
5. Implement Steam Leaderboards:
- Per-game leaderboards
- Global cross-game leaderboard
6. Add Steam Cloud saves:
- Sync game progress across devices
- Handle conflict resolution
7. Integrate Steam Friends API:
- "Invite friend to game room" button
- Show online friends in lobby
8. Test Steam Overlay functionality
**Deliverables:**
- Full Steamworks integration
- Achievement system (20-30 achievements)
- Steam Leaderboards
- Cloud saves
- Friend invites
**Effort:** 100-120 hours
**Cost:** $100 (Steam Direct fee, one-time, recoupable)
#### Phase 3: Store Page & Marketing Assets (2-3 weeks)
**Tasks:**
1. Create Steam store page:
- Title: "Soroban Academy: Mathematical Strategy Games"
- Capsule images (multiple sizes)
- Hero image / header
- 5-10 screenshots per game
- 60-90 second trailer (show all games)
- Detailed description with educational benefits
- System requirements
- Price: $9.99-14.99
2. Write compelling marketing copy
3. Create game trailer (video editing)
4. Design Steam library assets
5. Set up community hub
6. Configure Steam tags:
- Education, Strategy, Multiplayer, Casual, Math, Board Game
7. Prepare press kit and media
**Deliverables:**
- Complete Steam store page
- Marketing trailer
- Press kit
- Community hub setup
**Effort:** 60-80 hours
**Cost:** $0-300 (if hiring video editor)
#### Phase 4: Steam Review & Launch (2-3 weeks)
**Tasks:**
1. Upload build to Steamworks
2. Set release date (2+ weeks out)
3. Make store page public
4. Submit for Steam review (1-5 days)
5. Address any review feedback
6. Prepare launch marketing:
- Email existing users
- Social media campaign
- Press outreach
- Reddit/forum posts
- Product Hunt launch
7. Launch on Steam
8. Monitor reviews and feedback
9. Respond to community
10. Plan post-launch updates
**Deliverables:**
- Live on Steam
- Launch marketing campaign
- Community management process
**Effort:** 60-80 hours
**Cost:** $0-500 (marketing budget)
#### Phase 5: Post-Launch Support (Ongoing)
**Tasks:**
- Weekly bug fixes and patches
- Monthly content updates
- Seasonal events
- New achievements
- Community engagement
- Steam Sale participation
- User feedback integration
**Deliverables:**
- Regular updates
- Active community
- Growing review score
**Effort:** 20-40 hours/month ongoing
**Cost:** $0
### Total Timeline: 11-15 weeks
### Total Effort: 340-430 hours
### Total Cost: $100-900
### Dependencies
- Functioning multiplayer system (✓ exists)
- All games stable and bug-free
- Marketing materials (screenshots, videos)
- Legal documentation (EULA, privacy policy)
- Support email/forum setup
### Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Electron bundle too large | Medium | Medium | Optimize assets, use lazy loading |
| Steamworks integration bugs | High | Medium | Extensive testing, use Greenworks |
| Low sales | High | High | Strong marketing, community building |
| Review bombing | Medium | Low | Active community management |
| Multiplayer server costs | Medium | Medium | P2P option or Steam networking |
| Steam review rejection | High | Low | Follow guidelines strictly |
| Competition from free web version | High | High | Add Steam-exclusive features |
### Pricing Strategy
**Options:**
1. **Premium ($9.99-14.99):** Full game bundle with Steam features
2. **Freemium:** Free base + DLC for premium games
3. **Early Access ($7.99):** Launch at lower price, increase at 1.0
**Recommendation:** Premium $12.99 with frequent sales
- Perceived value: 6 games = ~$2 each
- Recoup $100 fee after 8 sales
- Sales can drive volume (50% off = $6.49)
### Marketing Angles for Steam
1. **Rithmomachia Focus:** "Play the 1,000-year-old medieval chess"
2. **Educational Value:** "Math learning disguised as strategy gaming"
3. **Multiplayer Fun:** "Challenge friends in mathematical duels"
4. **Historical Gaming:** "Rediscover forgotten board games"
5. **Family Friendly:** "Safe, educational gaming for all ages"
### Success Metrics
**Launch Goals:**
- **Wishlist:** 1,000+ before launch
- **Sales:** 500+ units in first month
- **Reviews:** 50+ reviews, 85%+ positive
- **Revenue:** $5,000+ in first quarter
**Long-term Goals:**
- **Sales:** 5,000+ units in first year
- **Reviews:** 200+ reviews, 90%+ positive
- **Revenue:** $30,000+ annually
- **Community:** Active Discord/forums with 1,000+ members
---
## Prioritization & Recommended Timeline
### Recommended Order of Execution
**Phase A: Quick Wins (First 3 months)**
1. **Google Classroom - Phase 1 (OAuth/SSO)** - Immediate teacher value
2. **CrazyGames submission** - Fast player discovery
3. **Google Classroom - Phase 2 (Class Import)** - Core teacher feature
**Phase B: Institution Access (Months 4-6)**
4. **Clever SSO integration** - Scale to districts
5. **ClassLink SSO integration** - Additional district reach
6. **Google Classroom - Phase 3 (Assignments)** - Deep integration
**Phase C: Casual Discovery (Months 6-9)**
7. **Poki submission** - Largest casual audience
8. **Kongregate pre-approval + submission** - Niche community
**Phase D: Premium Market (Months 9-12)**
9. **Steam development** - Desktop packaging + Steamworks
10. **Steam launch** - Premium educational game market
### Timeline Gantt Overview
```
Month 1-2: [Google OAuth/SSO] [CrazyGames Prep]
Month 2-3: [Google Class Import] [CrazyGames Launch]
Month 4-5: [Clever SSO] [ClassLink SSO]
Month 6-7: [Google Assignments] [Poki Prep]
Month 7-8: [Poki Launch] [Kongregate]
Month 9-10: [Steam Electron Build]
Month 10-11: [Steam Steamworks Integration]
Month 11-12: [Steam Store Page & Launch]
```
### Total Cost Summary
| Platform | One-time Cost | Ongoing Cost | Notes |
|----------|--------------|--------------|-------|
| Google Classroom | $0 | $0 | Free for education |
| Clever | $0 | $0 | Free for developers |
| ClassLink | $0 | $0 | Free for developers |
| CrazyGames | $0 | $0 | Revenue share via ads |
| Poki | $0 | $0 | 50/50 revenue share |
| Kongregate | $0 | $0 | Revenue share via ads |
| Steam | $100 | $0-200/mo | Server costs optional |
| **TOTAL** | **$100** | **$0-200/mo** | Very affordable |
### Resource Requirements
**Developer Time:**
- **Total effort:** 1,140-1,450 hours across all platforms
- **Timeline:** 9-12 months (with 1-2 developers)
- **Ongoing:** 20-60 hours/month maintenance
**External Costs:**
- Steam Direct fee: $100
- Optional video editing: $0-500
- Optional marketing budget: $0-1,000
- Server costs (if needed): $50-200/month
**Skills Needed:**
- OAuth/SAML implementation
- REST API integration
- Electron/desktop app packaging
- Steamworks SDK integration
- Marketing/community management
---
## Risk Management
### Top 5 Risks Across All Platforms
1. **Privacy Compliance (COPPA, FERPA, GDPR)**
- **Impact:** Critical (could block educational adoption)
- **Mitigation:** Legal review, implement consent systems, privacy policy updates
2. **Platform Rejection (Poki, Kongregate)**
- **Impact:** High (wasted development time)
- **Mitigation:** Apply early, start with less selective platforms, gather metrics
3. **Low Adoption/Sales**
- **Impact:** High (ROI concern)
- **Mitigation:** Strong marketing, community building, teacher outreach
4. **Technical Integration Complexity**
- **Impact:** Medium (timeline delays)
- **Mitigation:** Use established libraries, start simple, iterate
5. **Server Costs for Multiplayer**
- **Impact:** Medium (ongoing expenses)
- **Mitigation:** Optimize server efficiency, consider P2P, price accordingly
---
## Success Metrics Dashboard
### Key Performance Indicators (KPIs)
**User Acquisition:**
- New users per month (by source)
- Sign-up conversion rate
- Platform-specific downloads/plays
**Engagement:**
- Daily/Monthly Active Users (DAU/MAU)
- Average session length
- Games per session
- Return rate (D1, D7, D30)
**Education Impact:**
- Teachers registered
- Classes created
- Assignments completed
- Student progress metrics
**Revenue (Steam):**
- Units sold
- Revenue per month
- Refund rate
- Review score
**Platform-Specific:**
- **Google Classroom:** Classes imported, assignments created
- **Clever/ClassLink:** Districts connected, SSO logins
- **Game Portals:** Plays per game, ad revenue, ratings
- **Steam:** Sales, reviews, concurrent players
---
## Next Steps
### Immediate Actions (This Week)
1. **Decide on priority order** - Which platform to tackle first?
2. **Set up project tracking** - Dedicate repository/board for integration work
3. **Legal review** - Review privacy policy and terms for educational compliance
4. **Create developer accounts:**
- Google Cloud Platform
- Clever Developer Portal
- CrazyGames Developer Portal
- Poki submission prep
5. **Audit current codebase** - Check readiness for each integration
### Quick Wins to Start
**Option 1: Google Classroom OAuth (Fastest Impact)**
- 2-3 weeks to launch
- Immediate teacher value
- No approval needed
**Option 2: CrazyGames (Fastest Player Growth)**
- 3-4 weeks to launch
- Immediate player discovery
- Open platform (high acceptance rate)
**Recommendation:** Start both in parallel if resources allow
---
## Appendix
### Useful Links
**Google Classroom:**
- Developer Docs: https://developers.google.com/classroom
- API Reference: https://developers.google.com/classroom/reference
- OAuth Guide: https://developers.google.com/identity/protocols/oauth2
**Clever:**
- Developer Portal: https://dev.clever.com
- SSO Guide: https://dev.clever.com/docs/getting-started-with-clever-sso
- API Docs: https://dev.clever.com/docs/api-overview
**ClassLink:**
- SSO Library: https://www.classlink.com/resources/sso-search
- OneRoster: https://www.imsglobal.org/activity/onerosterlis
**Game Portals:**
- CrazyGames: https://docs.crazygames.com/
- Poki: https://developers.poki.com/
- Kongregate: BD@kongregate.com
**Steam:**
- Steamworks: https://partner.steamgames.com/
- Electron: https://www.electronjs.org/
- Greenworks: https://github.com/greenheartgames/greenworks
### Code Library Recommendations
**OAuth/SSO:**
- `next-auth` (already in use)
- `passport-saml` (for ClassLink SAML)
- `@googleapis/classroom` (official Google Classroom client)
**Electron:**
- `electron-builder` (packaging)
- `electron-updater` (auto-updates)
- `greenworks` (Steamworks bridge)
**Game Portal SDKs:**
- CrazyGames SDK (provided)
- Poki SDK (provided)
- Kongregate API (provided)
---
**Document End**
*For questions or updates to this roadmap, contact the development team.*

View File

@@ -1,390 +0,0 @@
# PlayingGuideModal - Complete Feature Specification
## Overview
Interactive, draggable, resizable modal for Rithmomachia game guide with i18n support and bust-out functionality.
## File Location
`src/arcade-games/rithmomachia/components/PlayingGuideModal.tsx`
## Dependencies
```typescript
import { useEffect, useState, useRef } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { useTranslation } from 'react-i18next'
import { css } from '../../../../styled-system/css'
import { Z_INDEX } from '@/constants/zIndex'
import { useAbacusSettings } from '@/hooks/useAbacusSettings'
import { PieceRenderer } from './PieceRenderer'
import { RithmomachiaBoard, type ExamplePiece } from './RithmomachiaBoard'
import type { PieceType, Color } from '../types'
import '../i18n/config' // Initialize i18n
```
## Props Interface
```typescript
interface PlayingGuideModalProps {
isOpen: boolean // Controls visibility
onClose: () => void // Called when modal closes
standalone?: boolean // True when opened in popup window (full-screen mode)
}
```
## State Management
### Required State
```typescript
const { t, i18n } = useTranslation()
const { data: abacusSettings } = useAbacusSettings()
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
const [activeSection, setActiveSection] = useState<Section>('overview')
const [position, setPosition] = useState({ x: 0, y: 0 })
const [size, setSize] = useState({ width: 450, height: 600 })
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const [isResizing, setIsResizing] = useState(false)
const [resizeDirection, setResizeDirection] = useState<string>('')
const [isHovered, setIsHovered] = useState(false)
const modalRef = useRef<HTMLDivElement>(null)
```
### Section Type
```typescript
type Section = 'overview' | 'pieces' | 'capture' | 'strategy' | 'harmony' | 'victory'
```
## Core Features
### 1. Radix Dialog Wrapper
**When NOT standalone:**
- Wrap entire modal in `<Dialog.Root open={isOpen} onOpenChange={onClose}>`
- Use `<Dialog.Portal>` for portal rendering
- Use `<Dialog.Overlay>` with backdrop styling
- Use `<Dialog.Content>` as container for draggable/resizable content
**Styling:**
- Overlay: semi-transparent black (`rgba(0, 0, 0, 0.5)`)
- Content: no default positioning (we control via position state)
- Z-index: Must be above game board - use `Z_INDEX.GAME.GUIDE_MODAL` or 15000+
**When standalone:**
- Skip Dialog wrapper entirely
- Render full-screen fixed container
### 2. Draggable Functionality
**Requirements:**
- Click and drag from header to move modal
- Disabled on mobile (`window.innerWidth < 768`)
- Cursor changes to 'move' when hovering header
- Position state tracks x, y coordinates
**Implementation:**
```typescript
const handleMouseDown = (e: React.MouseEvent) => {
if (window.innerWidth < 768) return
setIsDragging(true)
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y,
})
}
```
**Effects:**
- Global `mousemove` listener updates position while dragging
- Global `mouseup` listener stops dragging
- Cleanup listeners on unmount
### 3. Resizable Functionality
**Requirements:**
- 8 resize handles: N, S, E, W, NE, NW, SE, SW
- Handles visible only on hover (when `isHovered === true`)
- Disabled on mobile
- Min size: 450x600
- Max size: 90vw x 80vh
**Handle Positions & Cursors:**
- N (top): `cursor: 'ns-resize'`
- S (bottom): `cursor: 'ns-resize'`
- E (right): `cursor: 'ew-resize'`
- W (left): `cursor: 'ew-resize'`
- NE (top-right): `cursor: 'nesw-resize'`
- NW (top-left): `cursor: 'nwse-resize'`
- SE (bottom-right): `cursor: 'nwse-resize'`
- SW (bottom-left): `cursor: 'nesw-resize'`
**Handle Styling:**
- Width/height: 8px (invisible hit area)
- Visible border when hovered: 2px solid blue
- Positioned absolutely at edges/corners
**Implementation:**
```typescript
const handleResizeStart = (e: React.MouseEvent, direction: string) => {
if (window.innerWidth < 768) return
e.stopPropagation()
setIsResizing(true)
setResizeDirection(direction)
setDragStart({ x: e.clientX, y: e.clientY })
}
```
### 4. Bust-Out Button
**Location:** Header, right side (before close button)
**Icon:** ↗ or external link icon
**Functionality:**
```typescript
const handleBustOut = () => {
const url = window.location.origin + '/arcade/rithmomachia/guide'
const features = 'width=600,height=800,menubar=no,toolbar=no,location=no,status=no'
window.open(url, 'RithmomachiaGuide', features)
}
```
**Visibility:** Only show if NOT already standalone
**Route:** Must have a route at `/arcade/rithmomachia/guide` that renders:
```tsx
<PlayingGuideModal isOpen={true} onClose={() => window.close()} standalone={true} />
```
### 5. Internationalization
**Setup:**
- i18n config file: `src/arcade-games/rithmomachia/i18n/config.ts`
- Translation files in: `src/arcade-games/rithmomachia/i18n/locales/`
- Languages: en.json, de.json (minimum)
**Usage:**
- All text uses `t('guide.section.key')` format
- Language switcher in header with buttons for each language
**Header Language Switcher:**
```tsx
<div className={css({ display: 'flex', gap: '8px' })}>
{['en', 'de'].map((lang) => (
<button
key={lang}
onClick={() => i18n.changeLanguage(lang)}
className={css({
px: '8px',
py: '4px',
fontSize: '12px',
fontWeight: i18n.language === lang ? 'bold' : 'normal',
bg: i18n.language === lang ? '#3b82f6' : '#e5e7eb',
color: i18n.language === lang ? 'white' : '#374151',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
})}
>
{lang.toUpperCase()}
</button>
))}
</div>
```
### 6. Centering on Mount
**Effect:**
```typescript
useEffect(() => {
if (isOpen && modalRef.current && !standalone) {
const rect = modalRef.current.getBoundingClientRect()
setPosition({
x: (window.innerWidth - rect.width) / 2,
y: Math.max(50, (window.innerHeight - rect.height) / 2),
})
}
}, [isOpen, standalone])
```
**Standalone Mode:**
- If standalone, don't center - use full viewport
- Position: fixed, top: 0, left: 0, width: 100vw, height: 100vh
## Layout Structure
```
<Dialog.Root> (if not standalone)
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content asChild>
<div ref={modalRef} style={{ position: absolute, top: position.y, left: position.x }}>
{/* Resize handles (8 total, only if hovered and not mobile) */}
<div> {/* Main container */}
{/* Header */}
<div onMouseDown={handleMouseDown} style={{ cursor: isDragging ? 'grabbing' : 'grab' }}>
<h2>{t('guide.title')}</h2>
<div> {/* Language switcher */}
<button onClick={handleBustOut}> {/* Bust-out (if not standalone) */}
<button onClick={onClose}> {/* Close X */}
</div>
{/* Navigation tabs */}
<div> {/* Section buttons: Overview, Pieces, Capture, Strategy, Harmony, Victory */}
{/* Content area - scrollable */}
<div style={{ overflow: 'auto', maxHeight: size.height - headerHeight }}>
{activeSection === 'overview' && <OverviewSection />}
{activeSection === 'pieces' && <PiecesSection useNativeAbacusNumbers={useNativeAbacusNumbers} />}
{activeSection === 'capture' && <CaptureSection />}
{/* ... etc */}
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
```
## Styling Requirements
### Main Container
- Background: `#ffffff`
- Border radius: `12px`
- Box shadow: `0 20px 60px rgba(0, 0, 0, 0.3)`
- Border: `1px solid #e5e7eb`
- Position: `absolute` (controlled by position state)
- Width/height: from size state
### Header
- Background: `#f9fafb`
- Border bottom: `1px solid #e5e7eb`
- Padding: `16px`
- Display: flex, justify-between, align-items: center
- Cursor: `move` on desktop (when not standalone)
- Prevent text selection while dragging
### Navigation Tabs
- Display: flex, gap: `8px`
- Padding: `12px 16px`
- Background: `#ffffff`
- Border bottom: `1px solid #e5e7eb`
### Tab Buttons
- Active: bold, blue background, white text
- Inactive: normal weight, gray background, dark text
- Padding: `8px 16px`
- Border radius: `6px`
- Cursor: pointer
- Transition: all 0.2s
### Content Area
- Padding: `24px`
- Overflow: auto
- Max height: calculated (size.height - header - tabs)
- Color: `#374151`
- Line height: `1.6`
### Resize Handles
- Position: absolute
- Width/height: 8px
- Background: transparent
- Border: visible on hover (2px solid `#3b82f6`)
- Z-index: 1 (above content)
## Content Sections
### PiecesSection Component
**Must have its own useAbacusSettings hook:**
```typescript
function PiecesSection() {
const { data: abacusSettings } = useAbacusSettings()
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
// ... piece rendering with useNativeAbacusNumbers prop
}
```
### All RithmomachiaBoard Uses
- Must pass `useNativeAbacusNumbers={useNativeAbacusNumbers}` prop
- Boards show game positions with pieces
### All PieceRenderer Uses
- Must pass `useNativeAbacusNumbers={useNativeAbacusNumbers}` prop
- Renders individual piece icons in pieces section
## Translation Keys (Minimum Required)
```json
{
"guide": {
"title": "Rithmomachia Playing Guide",
"overview": {
"title": "Overview",
"content": "..."
},
"pieces": {
"title": "Your Pieces",
"circle": "Circle",
"triangle": "Triangle",
"square": "Square",
"pyramid": "Pyramid"
},
"capture": {
"title": "Capture Rules",
"equality": "Equality",
"multiple": "Multiple",
"ratio": "Ratio",
"sum": "Sum",
"difference": "Difference",
"product": "Product"
},
"strategy": {
"title": "Strategy Tips"
},
"harmony": {
"title": "Harmony (Progressions)"
},
"victory": {
"title": "Victory Conditions"
}
}
}
```
## Error Prevention
1. **Z-Index Issue:** Must be higher than game board (use `Z_INDEX.GAME.GUIDE_MODAL` or 15000+)
2. **Lost Work:** Never use `git checkout --` on working files without confirming stash/commit first
3. **Dialog Overlay:** Must render with high z-index to cover game
4. **Mobile:** Disable drag/resize on mobile, make responsive
5. **Standalone Route:** Must exist at `/arcade/rithmomachia/guide`
## Testing Checklist
- [ ] Modal opens and closes correctly
- [ ] Dragging works on desktop
- [ ] Resizing works on desktop (all 8 handles)
- [ ] Drag/resize disabled on mobile
- [ ] Language switcher changes content
- [ ] Bust-out button opens new window
- [ ] New window renders standalone mode correctly
- [ ] Modal appears above game board
- [ ] Close button works
- [ ] All sections render correctly
- [ ] Native abacus numbers toggle respected
- [ ] Translations load for all languages
- [ ] Modal centers on first open
- [ ] Position/size persists while open
- [ ] Cleanup happens on unmount
## Implementation Priority
1. Basic Dialog structure with standalone mode
2. Header with title, close, bust-out
3. Language switcher and i18n setup
4. Navigation tabs
5. Content sections (start with existing content)
6. Dragging functionality
7. Resizing functionality
8. Native abacus numbers integration
9. Translation files
10. Standalone route page

View File

@@ -104,71 +104,9 @@
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run \\(.databaseId)\"\"')",
"Bash(do ssh nas.home.network '/usr/local/bin/docker inspect soroban-abacus-flashcards --format=\"\"{{index .Config.Labels \\\"\"org.opencontainers.image.revision\\\"\"}}\"\"')",
"Bash(git rev-parse HEAD)",
"Bash(gh run watch --exit-status 18662351595)",
"WebFetch(domain:github.com)",
"WebSearch",
"WebFetch(domain:www.npmjs.com)",
"mcp__sqlite__list_tables",
"mcp__sqlite__describe_table",
"mcp__sqlite__read_query",
"Bash(git rebase:*)",
"Bash(gh run watch:*)",
"Bash(git reflog:*)",
"Bash(do echo -e \"\\n$hash:\")",
"Bash(git fsck:*)",
"Bash(do echo \"=== Stash @{$i} ===\")",
"Bash(git diff-tree:*)",
"Bash(git merge-base:*)",
"Bash(sed:*)",
"Bash(while read file)",
"Bash(do if git show HEAD:\"$file\")",
"Bash(/dev/null)",
"Bash(then echo \"✓ $file\")",
"Bash(git rev-parse:*)",
"Bash(node scripts/parseBoardCSV.js:*)",
"Bash(do echo \"=== HEAD~$i ===\")",
"Read(//private/tmp/**)",
"Bash(do echo \"=== $commit ===\")",
"Bash(do echo \"=== stash@{$i} ===\")",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(jq:*)",
"Bash(src/arcade-games/rithmomachia/components/guide-sections/OverviewSection.tsx )",
"Bash(src/arcade-games/rithmomachia/components/guide-sections/PiecesSection.tsx )",
"Bash(src/arcade-games/rithmomachia/components/guide-sections/CaptureSection.tsx )",
"Bash(src/arcade-games/rithmomachia/components/guide-sections/HarmonySection.tsx )",
"Bash(src/arcade-games/rithmomachia/components/guide-sections/VictorySection.tsx)",
"Bash(pnpm remove:*)",
"Bash(__NEW_LINE__ sed -n '68,73p' CaptureSection.tsx.bak)",
"WebFetch(domain:hub.docker.com)",
"Bash(gcloud auth:*)",
"Bash(gcloud config list:*)",
"WebFetch(domain:www.boardspace.net)",
"WebFetch(domain:www.gamecabinet.com)",
"WebFetch(domain:en.wikipedia.org)",
"Bash(pnpm search:*)",
"Bash(mkdir:*)",
"Bash(timeout 10 npx drizzle-kit generate:sqlite:*)",
"Bash(brew install:*)",
"Bash(sudo ln:*)",
"Bash(cd:*)",
"Bash(git clone:*)",
"Bash(git ls-remote:*)",
"Bash(openscad:*)",
"Bash(npx eslint:*)",
"Bash(env)",
"Bash(security find-generic-password -s 'Anthropic API Key' -w)",
"Bash(printenv:*)",
"Bash(typst:*)",
"Bash(npx tsx:*)",
"Bash(sort:*)",
"Bash(scp:*)"
"Bash(gh run watch --exit-status 18662351595)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
}
}

View File

@@ -1,437 +0,0 @@
/**
* @vitest-environment node
*/
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { db, schema } from '../src/db'
import { createRoom } from '../src/lib/arcade/room-manager'
import { createInvitation, getInvitation } from '../src/lib/arcade/room-invitations'
import { addRoomMember } from '../src/lib/arcade/room-membership'
/**
* Join Flow with Invitation Acceptance E2E Tests
*
* Tests the bug fix for invitation acceptance:
* - When a user joins a restricted room with an invitation
* - The invitation should be marked as "accepted"
* - This prevents the invitation from showing up again
*
* Regression test for the bug where invitations stayed "pending" forever.
*/
describe('Join Flow: Invitation Acceptance', () => {
let hostUserId: string
let guestUserId: string
let hostGuestId: string
let guestGuestId: string
let roomId: string
beforeEach(async () => {
// Create test users
hostGuestId = `test-host-${Date.now()}-${Math.random().toString(36).slice(2)}`
guestGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`
const [host] = await db.insert(schema.users).values({ guestId: hostGuestId }).returning()
const [guest] = await db.insert(schema.users).values({ guestId: guestGuestId }).returning()
hostUserId = host.id
guestUserId = guest.id
})
afterEach(async () => {
// Clean up invitations
if (roomId) {
await db.delete(schema.roomInvitations).where(eq(schema.roomInvitations.roomId, roomId))
}
// Clean up room
if (roomId) {
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomId))
}
// Clean up users
await db.delete(schema.users).where(eq(schema.users.id, hostUserId))
await db.delete(schema.users).where(eq(schema.users.id, guestUserId))
})
describe('BUG FIX: Invitation marked as accepted after join', () => {
it('marks invitation as accepted when guest joins restricted room', async () => {
// 1. Host creates a restricted room
const room = await createRoom({
name: 'Restricted Room',
createdBy: hostGuestId,
creatorName: 'Host User',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted', // Requires invitation
})
roomId = room.id
// 2. Host invites guest
const invitation = await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest User',
invitedBy: hostUserId,
invitedByName: 'Host User',
invitationType: 'manual',
})
// 3. Verify invitation is pending
expect(invitation.status).toBe('pending')
// 4. Guest joins the room (simulating the join API flow)
// In the real API, it checks the invitation and then adds the member
const invitationCheck = await getInvitation(roomId, guestUserId)
expect(invitationCheck?.status).toBe('pending')
// Simulate what the join API does: add member
await addRoomMember({
roomId,
userId: guestGuestId,
displayName: 'Guest User',
isCreator: false,
})
// 5. BUG: Before fix, invitation would still be "pending" here
// AFTER FIX: The join API now explicitly marks it as "accepted"
// Simulate the fix from join API
const { acceptInvitation } = await import('../src/lib/arcade/room-invitations')
await acceptInvitation(invitation.id)
// 6. Verify invitation is now marked as accepted
const updatedInvitation = await getInvitation(roomId, guestUserId)
expect(updatedInvitation?.status).toBe('accepted')
expect(updatedInvitation?.respondedAt).toBeDefined()
})
it('prevents showing the same invitation again after accepting', async () => {
// This tests the exact bug scenario from the issue:
// "even if I accept the invite and join the room,
// if I try to join room SFK3GD again, then I'm shown the same invite notice"
// 1. Create Room A and Room B
const roomA = await createRoom({
name: 'Room KHS3AE',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
const roomB = await createRoom({
name: 'Room SFK3GD',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'open', // Guest can join without invitation
})
roomId = roomA.id // For cleanup
// 2. Invite guest to Room A
const invitationA = await createInvitation({
roomId: roomA.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// 3. Guest sees invitation to Room A
const { getUserPendingInvitations } = await import('../src/lib/arcade/room-invitations')
let pendingInvites = await getUserPendingInvitations(guestUserId)
expect(pendingInvites).toHaveLength(1)
expect(pendingInvites[0].roomId).toBe(roomA.id)
// 4. Guest accepts and joins Room A
const { acceptInvitation } = await import('../src/lib/arcade/room-invitations')
await acceptInvitation(invitationA.id)
await addRoomMember({
roomId: roomA.id,
userId: guestGuestId,
displayName: 'Guest',
isCreator: false,
})
// 5. Guest tries to visit Room B link (/join/SFK3GD)
// BUG: Before fix, they'd see Room A invitation again because it's still "pending"
// FIX: Invitation is now "accepted", so it won't show in pending list
pendingInvites = await getUserPendingInvitations(guestUserId)
expect(pendingInvites).toHaveLength(0) // ✅ No longer shows Room A
// 6. Guest can successfully join Room B without being interrupted
await addRoomMember({
roomId: roomB.id,
userId: guestGuestId,
displayName: 'Guest',
isCreator: false,
})
// Clean up
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomB.id))
})
})
describe('Invitation flow with multiple rooms', () => {
it('only shows pending invitations, not accepted ones', async () => {
// Create 3 rooms, invite to all of them
const room1 = await createRoom({
name: 'Room 1',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
const room2 = await createRoom({
name: 'Room 2',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
const room3 = await createRoom({
name: 'Room 3',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
roomId = room1.id // For cleanup
// Invite to all 3
const inv1 = await createInvitation({
roomId: room1.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
const inv2 = await createInvitation({
roomId: room2.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
const inv3 = await createInvitation({
roomId: room3.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// All 3 should be pending
const { getUserPendingInvitations, acceptInvitation } = await import(
'../src/lib/arcade/room-invitations'
)
let pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(3)
// Accept invitation 1 and join
await acceptInvitation(inv1.id)
// Now only 2 should be pending
pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(2)
expect(pending.map((p) => p.roomId)).not.toContain(room1.id)
// Clean up
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id))
await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room3.id))
})
})
describe('Host re-joining their own restricted room', () => {
it('host can rejoin without invitation (no acceptance needed)', async () => {
// Create restricted room as host
const room = await createRoom({
name: 'Host Room',
createdBy: hostGuestId,
creatorName: 'Host User',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
roomId = room.id
// Host joins their own room
await addRoomMember({
roomId,
userId: hostGuestId,
displayName: 'Host User',
isCreator: true,
})
// No invitation needed, no acceptance
// This should not create any invitation records
const invitation = await getInvitation(roomId, hostUserId)
expect(invitation).toBeUndefined()
})
})
describe('Edge cases', () => {
it('handles multiple invitations from same host to same guest (updates, not duplicates)', async () => {
const room = await createRoom({
name: 'Test Room',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
roomId = room.id
// Send first invitation
const inv1 = await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
message: 'First message',
})
// Send second invitation (should update, not create new)
const inv2 = await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
message: 'Second message',
})
// Should be same invitation (same ID)
expect(inv1.id).toBe(inv2.id)
expect(inv2.message).toBe('Second message')
// Should only have 1 invitation in database
const allInvitations = await db
.select()
.from(schema.roomInvitations)
.where(eq(schema.roomInvitations.roomId, roomId))
expect(allInvitations).toHaveLength(1)
})
it('re-sends invitation after previous was declined', async () => {
const room = await createRoom({
name: 'Test Room',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'restricted',
})
roomId = room.id
// First invitation
const inv1 = await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// Guest declines
const { declineInvitation, getUserPendingInvitations } = await import(
'../src/lib/arcade/room-invitations'
)
await declineInvitation(inv1.id)
// Should not be in pending list
let pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(0)
// Host sends new invitation (should reset to pending)
await createInvitation({
roomId,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// Should now be in pending list again
pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(1)
expect(pending[0].status).toBe('pending')
})
it('accepts invitations to OPEN rooms (not just restricted)', async () => {
// This tests the root cause of the bug:
// Invitations to OPEN rooms were never being marked as accepted
const openRoom = await createRoom({
name: 'Open Room',
createdBy: hostGuestId,
creatorName: 'Host',
gameName: 'rithmomachia',
gameConfig: {},
accessMode: 'open', // Open access - no invitation required to join
})
roomId = openRoom.id
// Host sends invitation anyway (e.g., to notify guest about the room)
const inv = await createInvitation({
roomId: openRoom.id,
userId: guestUserId,
userName: 'Guest',
invitedBy: hostUserId,
invitedByName: 'Host',
invitationType: 'manual',
})
// Guest should see pending invitation
const { getUserPendingInvitations, acceptInvitation } = await import(
'../src/lib/arcade/room-invitations'
)
let pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(1)
// Guest joins the open room (invitation not required, but present)
await addRoomMember({
roomId: openRoom.id,
userId: guestGuestId,
displayName: 'Guest',
isCreator: false,
})
// Simulate the join API accepting the invitation
await acceptInvitation(inv.id)
// BUG FIX: Invitation should now be accepted, not stuck in pending
pending = await getUserPendingInvitations(guestUserId)
expect(pending).toHaveLength(0) // ✅ No longer pending
// Verify it's marked as accepted
const acceptedInv = await getInvitation(openRoom.id, guestUserId)
expect(acceptedInv?.status).toBe('accepted')
})
})
})

View File

@@ -1,3 +0,0 @@
-- Custom SQL migration file, put your code below! --
-- Add native_abacus_numbers column to abacus_settings table
ALTER TABLE `abacus_settings` ADD `native_abacus_numbers` integer DEFAULT 0 NOT NULL;

View File

@@ -1,17 +0,0 @@
-- Migration: Add player_stats table
-- Per-player game statistics tracking
CREATE TABLE `player_stats` (
`player_id` text PRIMARY KEY NOT NULL,
`games_played` integer DEFAULT 0 NOT NULL,
`total_wins` integer DEFAULT 0 NOT NULL,
`total_losses` integer DEFAULT 0 NOT NULL,
`best_time` integer,
`highest_accuracy` real DEFAULT 0 NOT NULL,
`favorite_game_type` text,
`game_stats` text DEFAULT '{}' NOT NULL,
`last_played_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`player_id`) REFERENCES `players`(`id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

View File

@@ -85,13 +85,6 @@
"when": 1760800000000,
"tag": "0011_add_room_game_configs",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1761939039939,
"tag": "0012_damp_mongoose",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,3 @@
const createNextIntlPlugin = require('next-intl/plugin')
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
@@ -67,4 +63,4 @@ const nextConfig = {
},
}
module.exports = withNextIntl(nextConfig)
module.exports = nextConfig

View File

@@ -46,9 +46,7 @@
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "^10.0.3",
"@react-three/drei": "^9.117.0",
"@react-three/fiber": "^8.17.0",
"@react-spring/web": "^10.0.2",
"@soroban/abacus-react": "workspace:*",
"@soroban/core": "workspace:*",
"@soroban/templates": "workspace:*",
@@ -59,31 +57,20 @@
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.4.1",
"drizzle-orm": "^0.44.6",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"emojibase-data": "^16.0.3",
"jose": "^6.1.0",
"js-yaml": "^4.1.0",
"lib0": "^0.2.114",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",
"nanoid": "^5.1.6",
"next": "^14.2.32",
"next-auth": "5.0.0-beta.29",
"next-intl": "^4.4.0",
"python-bridge": "^1.1.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable-layout": "^0.7.3",
"react-resizable-panels": "^3.0.6",
"react-textfit": "^1.1.1",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"three": "^0.169.0",
"y-protocols": "^1.0.6",
"y-websocket": "^3.0.0",
"yjs": "^13.6.27",
"zod": "^4.1.12"
},
"devDependencies": {
@@ -99,8 +86,6 @@
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-textfit": "^1.1.4",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.0.2",
"concurrently": "^8.0.0",
"drizzle-kit": "^0.31.5",

View File

@@ -1,39 +0,0 @@
include <BOSL2/std.scad>; // BOSL2 v2.0 or newer
// ---- USER CUSTOMIZABLE PARAMETERS ----
// These can be overridden via command line: -D 'columns=7' etc.
columns = 13; // Total number of columns (1-13, mirrored book design)
scale_factor = 1.5; // Overall size scale (preserves aspect ratio)
// -----------------------------------------
stl_path = "./simplified.abacus.stl";
// Calculate parameters based on column count
// The full STL has 13 columns. We want columns/2 per side (mirrored).
// The original bounding box intersection: scale([35/186, 1, 1])
// 35/186 ≈ 0.188 = ~2.44 columns, so 186 units ≈ 13 columns, ~14.3 units per column
total_columns_in_stl = 13;
columns_per_side = columns / 2;
width_scale = columns_per_side / total_columns_in_stl;
// Column spacing: distance between mirrored halves
// Original spacing of 69 for ~2.4 columns/side
// Calculate proportional spacing based on columns
units_per_column = 186 / total_columns_in_stl; // ~14.3 units per column
column_spacing = columns_per_side * units_per_column;
// --- actual model ---
module imported()
import(stl_path, convexity = 10);
module half_abacus() {
intersection() {
scale([width_scale, 1, 1]) bounding_box() imported();
imported();
}
}
scale([scale_factor, scale_factor, scale_factor]) {
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
half_abacus();
}

View File

@@ -398,8 +398,8 @@
<!-- Import Typst.ts and SVG Processor -->
<script>
// Global variables
const typstRenderer = null;
const flashcardsTemplate = null;
let typstRenderer = null;
let flashcardsTemplate = null;
// Initialize everything - use web app's API instead of direct Typst
async function initialize() {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,220 +0,0 @@
#!/usr/bin/env tsx
/**
* Generate icon.svg and og-image.svg from AbacusReact component
*
* This script renders AbacusReact server-side to produce the exact same
* SVG output as the interactive client-side version (without animations).
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { writeFileSync } from 'fs'
import { join } from 'path'
import { AbacusReact } from '@soroban/abacus-react'
// Extract just the SVG element content from rendered output
function extractSvgContent(markup: string): string {
// Find the opening <svg and closing </svg> tags
const svgMatch = markup.match(/<svg[^>]*>([\s\S]*?)<\/svg>/)
if (!svgMatch) {
throw new Error('No SVG element found in rendered output')
}
return svgMatch[1] // Return just the inner content
}
// Generate the favicon (icon.svg) - single column showing value 5
function generateFavicon(): string {
const abacusMarkup = renderToStaticMarkup(
<AbacusReact
value={5}
columns={1}
scaleFactor={1.0}
animated={false}
interactive={false}
showNumbers={false}
customStyles={{
heavenBeads: { fill: '#7c2d12', stroke: '#451a03', strokeWidth: 1 },
earthBeads: { fill: '#7c2d12', stroke: '#451a03', strokeWidth: 1 },
columnPosts: {
fill: '#451a03',
stroke: '#292524',
strokeWidth: 2,
},
reckoningBar: {
fill: '#292524',
stroke: '#292524',
strokeWidth: 3,
},
}}
/>
)
// Extract just the SVG content (without div wrapper)
let svgContent = extractSvgContent(abacusMarkup)
// Remove !important from CSS (production code policy)
svgContent = svgContent.replace(/\s*!important/g, '')
// Wrap in SVG with proper viewBox for favicon sizing
// AbacusReact with 1 column + scaleFactor 1.0 = ~25×120px
// Scale 0.7 = ~17.5×84px, centered in 100×100
return `<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle for better visibility -->
<circle cx="50" cy="50" r="48" fill="#fef3c7"/>
<!-- Abacus from @soroban/abacus-react -->
<g transform="translate(41, 8) scale(0.7)">
${svgContent}
</g>
</svg>
`
}
// Generate the Open Graph image (og-image.svg)
function generateOGImage(): string {
const abacusMarkup = renderToStaticMarkup(
<AbacusReact
value={1234}
columns={4}
scaleFactor={3.5}
animated={false}
interactive={false}
showNumbers={false}
customStyles={{
columnPosts: {
fill: 'rgb(255, 255, 255)',
stroke: 'rgb(200, 200, 200)',
strokeWidth: 2,
},
reckoningBar: {
fill: 'rgb(255, 255, 255)',
stroke: 'rgb(200, 200, 200)',
strokeWidth: 3,
},
columns: {
0: {
// Ones place (rightmost) - Blue
heavenBeads: { fill: '#60a5fa', stroke: '#3b82f6', strokeWidth: 1 },
earthBeads: { fill: '#60a5fa', stroke: '#3b82f6', strokeWidth: 1 },
},
1: {
// Tens place - Green
heavenBeads: { fill: '#4ade80', stroke: '#22c55e', strokeWidth: 1 },
earthBeads: { fill: '#4ade80', stroke: '#22c55e', strokeWidth: 1 },
},
2: {
// Hundreds place - Yellow/Gold
heavenBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 1 },
earthBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 1 },
},
3: {
// Thousands place (leftmost) - Purple
heavenBeads: { fill: '#c084fc', stroke: '#a855f7', strokeWidth: 1 },
earthBeads: { fill: '#c084fc', stroke: '#a855f7', strokeWidth: 1 },
},
},
}}
/>
)
// Extract just the SVG content (without div wrapper)
let svgContent = extractSvgContent(abacusMarkup)
// Remove !important from CSS (production code policy)
svgContent = svgContent.replace(/\s*!important/g, '')
return `<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<!-- Dark background like homepage -->
<rect width="1200" height="630" fill="#111827"/>
<!-- Subtle dot pattern background -->
<defs>
<pattern id="dots" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1" fill="rgba(255, 255, 255, 0.15)" />
</pattern>
</defs>
<rect width="1200" height="630" fill="url(#dots)" opacity="0.1"/>
<!-- Left decorative elements - Diamond shapes and math operators -->
<g opacity="0.4">
<!-- Purple diamond (thousands) -->
<polygon points="150,120 180,150 150,180 120,150" fill="#c084fc" />
<!-- Gold diamond (hundreds) -->
<polygon points="150,220 180,250 150,280 120,250" fill="#fbbf24" />
<!-- Green diamond (tens) -->
<polygon points="150,320 180,350 150,380 120,350" fill="#4ade80" />
<!-- Blue diamond (ones) -->
<polygon points="150,420 180,450 150,480 120,450" fill="#60a5fa" />
</g>
<!-- Left math operators -->
<g opacity="0.35" fill="rgba(255, 255, 255, 0.8)">
<text x="80" y="100" font-family="Arial, sans-serif" font-size="42" font-weight="300">+</text>
<text x="240" y="190" font-family="Arial, sans-serif" font-size="42" font-weight="300">×</text>
<text x="70" y="290" font-family="Arial, sans-serif" font-size="42" font-weight="300">=</text>
<text x="250" y="390" font-family="Arial, sans-serif" font-size="42" font-weight="300"></text>
</g>
<!-- Right decorative elements - Diamond shapes and math operators -->
<g opacity="0.4">
<!-- Purple diamond (thousands) -->
<polygon points="1050,120 1080,150 1050,180 1020,150" fill="#c084fc" />
<!-- Gold diamond (hundreds) -->
<polygon points="1050,220 1080,250 1050,280 1020,250" fill="#fbbf24" />
<!-- Green diamond (tens) -->
<polygon points="1050,320 1080,350 1050,380 1020,350" fill="#4ade80" />
<!-- Blue diamond (ones) -->
<polygon points="1050,420 1080,450 1050,480 1020,450" fill="#60a5fa" />
</g>
<!-- Right math operators -->
<g opacity="0.35" fill="rgba(255, 255, 255, 0.8)">
<text x="940" y="160" font-family="Arial, sans-serif" font-size="42" font-weight="300">÷</text>
<text x="1110" y="270" font-family="Arial, sans-serif" font-size="42" font-weight="300">+</text>
<text x="920" y="360" font-family="Arial, sans-serif" font-size="42" font-weight="300">×</text>
<text x="1120" y="480" font-family="Arial, sans-serif" font-size="42" font-weight="300">=</text>
</g>
<!-- Huge centered abacus from @soroban/abacus-react -->
<!-- AbacusReact 4 columns @ scale 3.5: width ~350px, height ~420px -->
<!-- Center horizontally: (1200-350)/2 = 425px -->
<!-- Center vertically in upper portion: abacus middle at ~225px, so start at 225-210 = 15px -->
<g transform="translate(425, 15)">
${svgContent}
</g>
<!-- Title at bottom, horizontally and vertically centered in lower portion -->
<!-- Position at y=520 for vertical centering in bottom half -->
<text x="600" y="520" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="url(#title-gradient)" text-anchor="middle">
Abaci One
</text>
<!-- Gold gradient for title -->
<defs>
<linearGradient id="title-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" />
<stop offset="50%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
</linearGradient>
</defs>
</svg>
`
}
// Main execution
const appDir = __dirname.replace('/scripts', '')
try {
console.log('Generating Open Graph image from AbacusReact...')
const ogImageSvg = generateOGImage()
writeFileSync(join(appDir, 'public', 'og-image.svg'), ogImageSvg)
console.log('✓ Generated public/og-image.svg')
console.log('\n✅ Icon generated successfully!')
console.log('\nNote: Day-of-month favicons are generated on-demand by src/app/icon/route.tsx')
console.log('which calls scripts/generateDayIcon.tsx as a subprocess.')
} catch (error) {
console.error('❌ Error generating icons:', error)
process.exit(1)
}

View File

@@ -1,35 +0,0 @@
#!/usr/bin/env tsx
/**
* Generate a simple abacus SVG (no customization for now - just get it working)
* Usage: npx tsx scripts/generateCalendarAbacus.tsx <value> <columns>
* Example: npx tsx scripts/generateCalendarAbacus.tsx 15 2
*
* Pattern copied directly from working generateDayIcon.tsx
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { AbacusReact } from '@soroban/abacus-react'
const value = parseInt(process.argv[2], 10)
const columns = parseInt(process.argv[3], 10)
if (isNaN(value) || isNaN(columns)) {
console.error('Usage: npx tsx scripts/generateCalendarAbacus.tsx <value> <columns>')
process.exit(1)
}
// Use exact same pattern as generateDayIcon - inline customStyles
const abacusMarkup = renderToStaticMarkup(
<AbacusReact
value={value}
columns={columns}
scaleFactor={1}
animated={false}
interactive={false}
showNumbers={false}
/>
)
process.stdout.write(abacusMarkup)

View File

@@ -1,166 +0,0 @@
#!/usr/bin/env tsx
/**
* Generate a single day-of-month favicon
* Usage: npx tsx scripts/generateDayIcon.tsx <day>
* Example: npx tsx scripts/generateDayIcon.tsx 15
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { AbacusReact } from '@soroban/abacus-react'
// Extract just the SVG element content from rendered output
function extractSvgContent(markup: string): string {
const svgMatch = markup.match(/<svg[^>]*>([\s\S]*?)<\/svg>/)
if (!svgMatch) {
throw new Error('No SVG element found in rendered output')
}
return svgMatch[1]
}
// Calculate bounding box that includes active beads AND structural elements (posts, bar)
interface BoundingBox {
minX: number
minY: number
maxX: number
maxY: number
}
function getAbacusBoundingBox(
svgContent: string,
scaleFactor: number,
columns: number
): BoundingBox {
// Parse column posts: <rect x="..." y="..." width="..." height="..." ... >
const postRegex = /<rect\s+x="([^"]+)"\s+y="([^"]+)"\s+width="([^"]+)"\s+height="([^"]+)"/g
const postMatches = [...svgContent.matchAll(postRegex)]
// Parse active bead transforms: <g class="abacus-bead active" transform="translate(x, y)">
const activeBeadRegex =
/<g\s+class="abacus-bead active[^"]*"\s+transform="translate\(([^,]+),\s*([^)]+)\)"/g
const beadMatches = [...svgContent.matchAll(activeBeadRegex)]
if (beadMatches.length === 0) {
// Fallback if no active beads found - show full abacus
return { minX: 0, minY: 0, maxX: 50 * scaleFactor, maxY: 120 * scaleFactor }
}
// Bead dimensions (diamond): width ≈ 30px * scaleFactor, height ≈ 21px * scaleFactor
const beadHeight = 21.6 * scaleFactor
// HORIZONTAL BOUNDS: Always show full width of both columns (fixed for all days)
let minX = Infinity
let maxX = -Infinity
for (const match of postMatches) {
const x = parseFloat(match[1])
const width = parseFloat(match[3])
minX = Math.min(minX, x)
maxX = Math.max(maxX, x + width)
}
// VERTICAL BOUNDS: Crop to active beads (dynamic based on which beads are active)
let minY = Infinity
let maxY = -Infinity
for (const match of beadMatches) {
const y = parseFloat(match[2])
// Top of topmost active bead to bottom of bottommost active bead
minY = Math.min(minY, y)
maxY = Math.max(maxY, y + beadHeight)
}
return { minX, minY, maxX, maxY }
}
// Get day from command line argument
const day = parseInt(process.argv[2], 10)
if (!day || day < 1 || day > 31) {
console.error('Usage: npx tsx scripts/generateDayIcon.tsx <day>')
console.error('Example: npx tsx scripts/generateDayIcon.tsx 15')
process.exit(1)
}
// Render 2-column abacus showing day of month
const abacusMarkup = renderToStaticMarkup(
<AbacusReact
value={day}
columns={2}
scaleFactor={1.8}
animated={false}
interactive={false}
showNumbers={false}
hideInactiveBeads={true}
customStyles={{
columnPosts: {
fill: '#1c1917',
stroke: '#0c0a09',
strokeWidth: 2,
},
reckoningBar: {
fill: '#1c1917',
stroke: '#0c0a09',
strokeWidth: 3,
},
columns: {
0: {
// Ones place - Gold (royal theme)
heavenBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 2 },
earthBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 2 },
},
1: {
// Tens place - Purple (royal theme)
heavenBeads: { fill: '#a855f7', stroke: '#7e22ce', strokeWidth: 2 },
earthBeads: { fill: '#a855f7', stroke: '#7e22ce', strokeWidth: 2 },
},
},
}}
/>
)
let svgContent = extractSvgContent(abacusMarkup)
// Remove !important from CSS (production code policy)
svgContent = svgContent.replace(/\s*!important/g, '')
// Calculate bounding box including posts, bar, and active beads
const bbox = getAbacusBoundingBox(svgContent, 1.8, 2)
// Add minimal padding around active beads (in abacus coordinates)
// Less padding below since we want to cut tight to the last bead
const paddingTop = 8
const paddingBottom = 2
const paddingSide = 5
const cropX = bbox.minX - paddingSide
const cropY = bbox.minY - paddingTop
const cropWidth = bbox.maxX - bbox.minX + paddingSide * 2
const cropHeight = bbox.maxY - bbox.minY + paddingTop + paddingBottom
// Calculate scale to fit cropped region into 96x96 (leaving room for border)
const targetSize = 96
const scale = Math.min(targetSize / cropWidth, targetSize / cropHeight)
// Center in 100x100 canvas
const scaledWidth = cropWidth * scale
const scaledHeight = cropHeight * scale
const offsetX = (100 - scaledWidth) / 2
const offsetY = (100 - scaledHeight) / 2
// Wrap in SVG with proper viewBox for favicon sizing
// Use nested SVG with viewBox to actually CROP the content, not just scale it
const svg = `<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Abacus showing day ${day.toString().padStart(2, '0')} (US Central Time) - cropped to active beads -->
<!-- Nested SVG with viewBox does the actual cropping -->
<svg x="${offsetX}" y="${offsetY}" width="${scaledWidth}" height="${scaledHeight}"
viewBox="${cropX} ${cropY} ${cropWidth} ${cropHeight}">
<g class="hide-inactive-mode">
${svgContent}
</g>
</svg>
</svg>
`
// Output to stdout so parent process can capture it
process.stdout.write(svg)

View File

@@ -1,206 +0,0 @@
#!/usr/bin/env node
/**
* Test script to parse the Rithmomachia board CSV and verify the layout.
*/
const fs = require('fs')
const path = require('path')
const csvPath = path.join(
process.env.HOME,
'Downloads',
'rithmomachia board setup - Sheet1 (1).csv'
)
function parseCSV(csvContent) {
const lines = csvContent.trim().split('\n')
const pieces = []
// Process in triplets (color, shape, number)
for (let rankIndex = 0; rankIndex < 16; rankIndex++) {
const colorRowIndex = rankIndex * 3
const shapeRowIndex = rankIndex * 3 + 1
const numberRowIndex = rankIndex * 3 + 2
if (numberRowIndex >= lines.length) break
const colorRow = lines[colorRowIndex].split(',')
const shapeRow = lines[shapeRowIndex].split(',')
const numberRow = lines[numberRowIndex].split(',')
// Process each column (8 total)
for (let colIndex = 0; colIndex < 8; colIndex++) {
const color = colorRow[colIndex]?.trim()
const shape = shapeRow[colIndex]?.trim()
const numberStr = numberRow[colIndex]?.trim()
// Skip empty cells (but allow empty number for Pyramids)
if (!color || !shape) continue
// Map CSV position to game square
// CSV column → game row (1-8)
// CSV rank → game column (A-P)
const gameRow = colIndex + 1 // CSV col 0 → row 1, col 7 → row 8
const gameCol = String.fromCharCode(65 + rankIndex) // rank 0 → A, rank 15 → P
const square = `${gameCol}${gameRow}`
// Parse color
const pieceColor = color.toLowerCase() === 'black' ? 'B' : 'W'
// Parse type
let pieceType
const shapeLower = shape.toLowerCase()
if (shapeLower === 'circle') pieceType = 'C'
else if (shapeLower === 'triangle' || shapeLower === 'traingle')
pieceType = 'T' // Handle typo
else if (shapeLower === 'square') pieceType = 'S'
else if (shapeLower === 'pyramid') pieceType = 'P'
else {
console.warn(`Unknown shape "${shape}" at ${square}`)
continue
}
// Parse value/pyramid faces
if (pieceType === 'P') {
// Pyramid - number cell should be empty, use default faces
pieces.push({
color: pieceColor,
type: pieceType,
pyramidFaces: pieceColor === 'B' ? [36, 25, 16, 4] : [64, 49, 36, 25],
square,
})
} else {
// Regular piece needs a number
if (!numberStr) {
console.warn(`Missing number for non-Pyramid ${shape} at ${square}`)
continue
}
const value = parseInt(numberStr, 10)
if (isNaN(value)) {
console.warn(`Invalid number "${numberStr}" at ${square}`)
continue
}
pieces.push({
color: pieceColor,
type: pieceType,
value,
square,
})
}
}
}
return pieces
}
function generateBoardDisplay(pieces) {
const lines = []
lines.push('\n=== Board Layout (Game Orientation) ===')
lines.push('BLACK (top)\n')
lines.push(
' A B C D E F G H I J K L M N O P'
)
for (let row = 8; row >= 1; row--) {
let line = `${row} `
for (let colCode = 65; colCode <= 80; colCode++) {
const col = String.fromCharCode(colCode)
const square = `${col}${row}`
const piece = pieces.find((p) => p.square === square)
if (piece) {
const val = piece.type === 'P' ? ' P' : piece.value.toString().padStart(3, ' ')
line += ` ${piece.color}${piece.type}${val} `
} else {
line += ' ---- '
}
}
lines.push(line)
}
lines.push('\nWHITE (bottom)\n')
return lines.join('\n')
}
function generateColumnSummaries(pieces) {
const lines = []
lines.push('\n=== Column-by-Column Summary ===\n')
for (let colCode = 65; colCode <= 80; colCode++) {
const col = String.fromCharCode(colCode)
const columnPieces = pieces
.filter((p) => p.square[0] === col)
.sort((a, b) => {
const rowA = parseInt(a.square.substring(1))
const rowB = parseInt(b.square.substring(1))
return rowA - rowB
})
if (columnPieces.length === 0) continue
const color = columnPieces[0].color === 'B' ? 'BLACK' : 'WHITE'
lines.push(`Column ${col} (${color}):`)
for (const piece of columnPieces) {
const val = piece.type === 'P' ? 'P[36,25,16,4]' : piece.value
lines.push(` ${piece.square}: ${piece.type}(${val})`)
}
lines.push('')
}
return lines.join('\n')
}
function countPieces(pieces) {
const blackPieces = pieces.filter((p) => p.color === 'B')
const whitePieces = pieces.filter((p) => p.color === 'W')
const countByType = (pieces) => {
const counts = { C: 0, T: 0, S: 0, P: 0 }
for (const p of pieces) counts[p.type]++
return counts
}
const blackCounts = countByType(blackPieces)
const whiteCounts = countByType(whitePieces)
console.log('\n=== Piece Counts ===')
console.log(
`Black: ${blackPieces.length} total (C:${blackCounts.C}, T:${blackCounts.T}, S:${blackCounts.S}, P:${blackCounts.P})`
)
console.log(
`White: ${whitePieces.length} total (C:${whiteCounts.C}, T:${whiteCounts.T}, S:${whiteCounts.S}, P:${whiteCounts.P})`
)
}
// Main
try {
const csvContent = fs.readFileSync(csvPath, 'utf-8')
const pieces = parseCSV(csvContent)
console.log(`\nParsed ${pieces.length} pieces from CSV`)
console.log(generateBoardDisplay(pieces))
console.log(generateColumnSummaries(pieces))
countPieces(pieces)
// Save parsed data
const outputPath = path.join(
__dirname,
'..',
'src',
'arcade-games',
'rithmomachia',
'utils',
'parsedBoard.json'
)
fs.writeFileSync(outputPath, JSON.stringify(pieces, null, 2))
console.log(`\n✅ Saved parsed board to: ${outputPath}`)
} catch (error) {
console.error('Error:', error.message)
process.exit(1)
}

View File

@@ -1,235 +0,0 @@
#!/bin/bash
# Google Classroom API Setup Script
# This script automates GCP project setup from the command line
#
# Prerequisites:
# - gcloud CLI installed (brew install google-cloud-sdk)
# - Valid Google account
#
# Usage:
# ./scripts/setup-google-classroom.sh
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Google Classroom API Setup${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Configuration
PROJECT_ID="soroban-abacus-$(date +%s)" # Unique project ID with timestamp
PROJECT_NAME="Soroban Abacus Flashcards"
BILLING_ACCOUNT="" # Will prompt user if needed
REDIRECT_URIS="http://localhost:3000/api/auth/callback/google,https://abaci.one/api/auth/callback/google"
echo -e "${YELLOW}Project ID:${NC} $PROJECT_ID"
echo ""
# Step 1: Check if gcloud is installed
echo -e "${BLUE}[1/9] Checking gcloud installation...${NC}"
if ! command -v gcloud &> /dev/null; then
echo -e "${RED}Error: gcloud CLI not found${NC}"
echo "Install it with: brew install google-cloud-sdk"
exit 1
fi
echo -e "${GREEN}✓ gcloud CLI found${NC}"
echo ""
# Step 2: Authenticate with Google
echo -e "${BLUE}[2/9] Authenticating with Google...${NC}"
CURRENT_ACCOUNT=$(gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null || echo "")
if [ -z "$CURRENT_ACCOUNT" ]; then
echo "No active account found. Opening browser to authenticate..."
gcloud auth login
CURRENT_ACCOUNT=$(gcloud auth list --filter=status:ACTIVE --format="value(account)")
fi
echo -e "${GREEN}✓ Authenticated as: $CURRENT_ACCOUNT${NC}"
echo ""
# Step 3: Create GCP project
echo -e "${BLUE}[3/9] Creating GCP project...${NC}"
echo "Creating project: $PROJECT_ID"
gcloud projects create "$PROJECT_ID" --name="$PROJECT_NAME" 2>/dev/null || {
echo -e "${YELLOW}Project might already exist, continuing...${NC}"
}
gcloud config set project "$PROJECT_ID"
echo -e "${GREEN}✓ Project created/selected: $PROJECT_ID${NC}"
echo ""
# Step 4: Check billing (required for APIs)
echo -e "${BLUE}[4/9] Checking billing account...${NC}"
echo -e "${YELLOW}Note: Google Classroom API requires a billing account, but it's FREE for educational use.${NC}"
echo -e "${YELLOW}You won't be charged unless you explicitly enable paid services.${NC}"
echo ""
# List available billing accounts
BILLING_ACCOUNTS=$(gcloud billing accounts list --format="value(name)" 2>/dev/null || echo "")
if [ -z "$BILLING_ACCOUNTS" ]; then
echo -e "${YELLOW}No billing accounts found.${NC}"
echo -e "${YELLOW}You'll need to create one at: https://console.cloud.google.com/billing${NC}"
echo -e "${YELLOW}Press Enter after creating a billing account, or Ctrl+C to exit${NC}"
read -r
BILLING_ACCOUNTS=$(gcloud billing accounts list --format="value(name)")
fi
# If multiple accounts, let user choose
BILLING_COUNT=$(echo "$BILLING_ACCOUNTS" | wc -l | tr -d ' ')
if [ "$BILLING_COUNT" -eq 1 ]; then
BILLING_ACCOUNT="$BILLING_ACCOUNTS"
else
echo "Available billing accounts:"
gcloud billing accounts list
echo ""
echo -n "Enter billing account ID (e.g., 012345-ABCDEF-678901): "
read -r BILLING_ACCOUNT
fi
# Link billing account to project
echo "Linking billing account to project..."
gcloud billing projects link "$PROJECT_ID" --billing-account="$BILLING_ACCOUNT"
echo -e "${GREEN}✓ Billing account linked${NC}"
echo ""
# Step 5: Enable required APIs
echo -e "${BLUE}[5/9] Enabling required APIs...${NC}"
echo "This may take 1-2 minutes..."
gcloud services enable classroom.googleapis.com --project="$PROJECT_ID"
gcloud services enable people.googleapis.com --project="$PROJECT_ID"
echo -e "${GREEN}✓ APIs enabled:${NC}"
echo " - Google Classroom API"
echo " - Google People API (for profile info)"
echo ""
# Step 6: Create OAuth 2.0 credentials
echo -e "${BLUE}[6/9] Creating OAuth 2.0 credentials...${NC}"
# First check if credentials already exist
EXISTING_CREDS=$(gcloud auth application-default print-access-token &>/dev/null && \
curl -s -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
"https://oauth2.googleapis.com/v1/projects/${PROJECT_ID}/oauthClients" 2>/dev/null | \
grep -c "clientId" || echo "0")
if [ "$EXISTING_CREDS" -gt 0 ]; then
echo -e "${YELLOW}OAuth credentials already exist for this project${NC}"
echo "Skipping credential creation..."
else
# Create OAuth client
# Note: This creates a "Web application" type OAuth client
echo "Creating OAuth 2.0 client..."
# Unfortunately, gcloud doesn't have a direct command for this
# We need to use the REST API
echo -e "${YELLOW}Note: OAuth client creation requires using the web console${NC}"
echo -e "${YELLOW}Opening the OAuth credentials page...${NC}"
echo ""
echo -e "${BLUE}Please follow these steps in the browser:${NC}"
echo "1. Click 'Create Credentials' → 'OAuth client ID'"
echo "2. Application type: 'Web application'"
echo "3. Name: 'Soroban Abacus Web'"
echo "4. Authorized JavaScript origins:"
echo " - http://localhost:3000"
echo " - https://abaci.one"
echo "5. Authorized redirect URIs:"
echo " - http://localhost:3000/api/auth/callback/google"
echo " - https://abaci.one/api/auth/callback/google"
echo "6. Click 'Create'"
echo "7. Copy the Client ID and Client Secret"
echo ""
# Open browser to credentials page
open "https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID" 2>/dev/null || \
echo "Open this URL: https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID"
echo -n "Press Enter after creating the OAuth client..."
read -r
fi
echo -e "${GREEN}✓ OAuth credentials configured${NC}"
echo ""
# Step 7: Configure OAuth Consent Screen
echo -e "${BLUE}[7/9] Configuring OAuth consent screen...${NC}"
echo -e "${YELLOW}The OAuth consent screen requires web console configuration${NC}"
echo ""
echo -e "${BLUE}Please follow these steps:${NC}"
echo "1. User Type: 'External' (unless you have Google Workspace)"
echo "2. App name: 'Soroban Abacus Flashcards'"
echo "3. User support email: Your email"
echo "4. Developer contact: Your email"
echo "5. Scopes: Click 'Add or Remove Scopes' and add:"
echo " - .../auth/userinfo.email"
echo " - .../auth/userinfo.profile"
echo " - .../auth/classroom.courses.readonly"
echo " - .../auth/classroom.rosters.readonly"
echo "6. Test users: Add your email for testing"
echo "7. Save and continue"
echo ""
# Open OAuth consent screen configuration
open "https://console.cloud.google.com/apis/credentials/consent?project=$PROJECT_ID" 2>/dev/null || \
echo "Open this URL: https://console.cloud.google.com/apis/credentials/consent?project=$PROJECT_ID"
echo -n "Press Enter after configuring the consent screen..."
read -r
echo -e "${GREEN}✓ OAuth consent screen configured${NC}"
echo ""
# Step 8: Create .env.local file
echo -e "${BLUE}[8/9] Creating environment configuration...${NC}"
echo ""
echo "Please enter your OAuth credentials from the previous step:"
echo -n "Client ID: "
read -r CLIENT_ID
echo -n "Client Secret: "
read -r -s CLIENT_SECRET
echo ""
# Create or update .env.local
ENV_FILE=".env.local"
if [ -f "$ENV_FILE" ]; then
echo "Backing up existing $ENV_FILE to ${ENV_FILE}.backup"
cp "$ENV_FILE" "${ENV_FILE}.backup"
fi
# Add Google OAuth credentials
echo "" >> "$ENV_FILE"
echo "# Google OAuth (Generated by setup-google-classroom.sh)" >> "$ENV_FILE"
echo "GOOGLE_CLIENT_ID=\"$CLIENT_ID\"" >> "$ENV_FILE"
echo "GOOGLE_CLIENT_SECRET=\"$CLIENT_SECRET\"" >> "$ENV_FILE"
echo "" >> "$ENV_FILE"
echo -e "${GREEN}✓ Environment variables added to $ENV_FILE${NC}"
echo ""
# Step 9: Summary
echo -e "${BLUE}[9/9] Setup Complete!${NC}"
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Setup Summary${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "Project ID: ${BLUE}$PROJECT_ID${NC}"
echo -e "Project Name: ${BLUE}$PROJECT_NAME${NC}"
echo -e "Billing Account: ${BLUE}$BILLING_ACCOUNT${NC}"
echo -e "APIs Enabled: ${BLUE}Classroom, People${NC}"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo "1. Add Google provider to NextAuth configuration"
echo "2. Test login with 'Sign in with Google'"
echo "3. Verify Classroom API access"
echo ""
echo -e "${BLUE}Useful Commands:${NC}"
echo " View project: gcloud projects describe $PROJECT_ID"
echo " List APIs: gcloud services list --enabled --project=$PROJECT_ID"
echo " View quota: gcloud quotas describe classroom.googleapis.com --project=$PROJECT_ID"
echo ""
echo -e "${GREEN}Setup script complete!${NC}"

View File

@@ -34,36 +34,10 @@ app.prepare().then(() => {
}
})
// Debug: Check upgrade handlers at each stage
console.log('📊 Stage 1 - After server creation:')
console.log(` Upgrade handlers: ${server.listeners('upgrade').length}`)
// Initialize Socket.IO
const { initializeSocketServer } = require('./dist/socket-server')
console.log('📊 Stage 2 - Before initializeSocketServer:')
console.log(` Upgrade handlers: ${server.listeners('upgrade').length}`)
initializeSocketServer(server)
console.log('📊 Stage 3 - After initializeSocketServer:')
const allHandlers = server.listeners('upgrade')
console.log(` Upgrade handlers: ${allHandlers.length}`)
allHandlers.forEach((handler, i) => {
console.log(` [${i}] ${handler.name || 'anonymous'} (length: ${handler.length} params)`)
})
// Log all upgrade requests to see handler execution order
const originalEmit = server.emit.bind(server)
server.emit = (event, ...args) => {
if (event === 'upgrade') {
const req = args[0]
console.log(`\n🔄 UPGRADE REQUEST: ${req.url}`)
console.log(` ${allHandlers.length} handlers will be called`)
}
return originalEmit(event, ...args)
}
server
.once('error', (err) => {
console.error(err)

View File

@@ -1,46 +0,0 @@
import { JobManager } from '@/lib/3d-printing/jobManager'
import { NextResponse } from 'next/server'
export async function GET(request: Request, { params }: { params: Promise<{ jobId: string }> }) {
try {
const { jobId } = await params
const job = JobManager.getJob(jobId)
if (!job) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
}
if (job.status !== 'completed') {
return NextResponse.json(
{ error: `Job is ${job.status}, not ready for download` },
{ status: 400 }
)
}
const fileBuffer = await JobManager.getJobOutput(jobId)
// Determine content type and filename
const contentTypes = {
stl: 'model/stl',
'3mf': 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml',
scad: 'text/plain',
}
const contentType = contentTypes[job.params.format]
const filename = `abacus.${job.params.format}`
// Convert Buffer to Uint8Array for NextResponse
const uint8Array = new Uint8Array(fileBuffer)
return new NextResponse(uint8Array, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': fileBuffer.length.toString(),
},
})
} catch (error) {
console.error('Error downloading job:', error)
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
}
}

View File

@@ -1,57 +0,0 @@
import { JobManager } from '@/lib/3d-printing/jobManager'
import type { AbacusParams } from '@/lib/3d-printing/jobManager'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
try {
const body = await request.json()
// Validate parameters
const columns = Number.parseInt(body.columns, 10)
const scaleFactor = Number.parseFloat(body.scaleFactor)
const widthMm = body.widthMm ? Number.parseFloat(body.widthMm) : undefined
const format = body.format
// Validation
if (Number.isNaN(columns) || columns < 1 || columns > 13) {
return NextResponse.json({ error: 'columns must be between 1 and 13' }, { status: 400 })
}
if (Number.isNaN(scaleFactor) || scaleFactor < 0.5 || scaleFactor > 3) {
return NextResponse.json({ error: 'scaleFactor must be between 0.5 and 3' }, { status: 400 })
}
if (widthMm !== undefined && (Number.isNaN(widthMm) || widthMm < 50 || widthMm > 500)) {
return NextResponse.json({ error: 'widthMm must be between 50 and 500' }, { status: 400 })
}
if (!['stl', '3mf', 'scad'].includes(format)) {
return NextResponse.json({ error: 'format must be stl, 3mf, or scad' }, { status: 400 })
}
const params: AbacusParams = {
columns,
scaleFactor,
widthMm,
format,
// 3MF colors (optional)
frameColor: body.frameColor,
heavenBeadColor: body.heavenBeadColor,
earthBeadColor: body.earthBeadColor,
decorationColor: body.decorationColor,
}
const jobId = await JobManager.createJob(params)
return NextResponse.json(
{
jobId,
message: 'Job created successfully',
},
{ status: 202 }
)
} catch (error) {
console.error('Error creating job:', error)
return NextResponse.json({ error: 'Failed to create job' }, { status: 500 })
}
}

View File

@@ -1,109 +0,0 @@
import { JobManager } from '@/lib/3d-printing/jobManager'
import type { AbacusParams } from '@/lib/3d-printing/jobManager'
import { NextResponse } from 'next/server'
// Allow up to 90 seconds for OpenSCAD rendering
export const maxDuration = 90
// Cache for preview STLs to avoid regenerating on every request
const previewCache = new Map<string, { buffer: Buffer; timestamp: number }>()
const CACHE_TTL = 300000 // 5 minutes
function getCacheKey(params: AbacusParams): string {
return `${params.columns}-${params.scaleFactor}`
}
export async function POST(request: Request) {
try {
const body = await request.json()
// Validate parameters
const columns = Number.parseInt(body.columns, 10)
const scaleFactor = Number.parseFloat(body.scaleFactor)
// Validation
if (Number.isNaN(columns) || columns < 1 || columns > 13) {
return NextResponse.json({ error: 'columns must be between 1 and 13' }, { status: 400 })
}
if (Number.isNaN(scaleFactor) || scaleFactor < 0.5 || scaleFactor > 3) {
return NextResponse.json({ error: 'scaleFactor must be between 0.5 and 3' }, { status: 400 })
}
const params: AbacusParams = {
columns,
scaleFactor,
format: 'stl', // Always STL for preview
}
// Check cache first
const cacheKey = getCacheKey(params)
const cached = previewCache.get(cacheKey)
const now = Date.now()
if (cached && now - cached.timestamp < CACHE_TTL) {
// Return cached preview
const uint8Array = new Uint8Array(cached.buffer)
return new NextResponse(uint8Array, {
headers: {
'Content-Type': 'model/stl',
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
},
})
}
// Generate new preview
const jobId = await JobManager.createJob(params)
// Wait for job to complete (with timeout)
const startTime = Date.now()
const timeout = 90000 // 90 seconds max wait (OpenSCAD can take 40-60s)
while (Date.now() - startTime < timeout) {
const job = JobManager.getJob(jobId)
if (!job) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
}
if (job.status === 'completed') {
const buffer = await JobManager.getJobOutput(jobId)
// Cache the result
previewCache.set(cacheKey, { buffer, timestamp: now })
// Clean up old cache entries
for (const [key, value] of previewCache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
previewCache.delete(key)
}
}
// Clean up the job
await JobManager.cleanupJob(jobId)
const uint8Array = new Uint8Array(buffer)
return new NextResponse(uint8Array, {
headers: {
'Content-Type': 'model/stl',
'Cache-Control': 'public, max-age=300',
},
})
}
if (job.status === 'failed') {
return NextResponse.json(
{ error: job.error || 'Preview generation failed' },
{ status: 500 }
)
}
// Wait 500ms before checking again
await new Promise((resolve) => setTimeout(resolve, 500))
}
return NextResponse.json({ error: 'Preview generation timeout' }, { status: 408 })
} catch (error) {
console.error('Error generating preview:', error)
return NextResponse.json({ error: 'Failed to generate preview' }, { status: 500 })
}
}

View File

@@ -1,25 +0,0 @@
import { JobManager } from '@/lib/3d-printing/jobManager'
import { NextResponse } from 'next/server'
export async function GET(request: Request, { params }: { params: Promise<{ jobId: string }> }) {
try {
const { jobId } = await params
const job = JobManager.getJob(jobId)
if (!job) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
}
return NextResponse.json({
id: job.id,
status: job.status,
progress: job.progress,
error: job.error,
createdAt: job.createdAt,
completedAt: job.completedAt,
})
} catch (error) {
console.error('Error fetching job status:', error)
return NextResponse.json({ error: 'Failed to fetch job status' }, { status: 500 })
}
}

View File

@@ -1,121 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getPlayer, getRoomActivePlayers, setPlayerActiveStatus } from '@/lib/arcade/player-manager'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/deactivate-player
* Deactivate a specific player in the room (host only)
* Body:
* - playerId: string - The player to deactivate
*/
export async function POST(req: NextRequest, context: RouteContext) {
console.log('[Deactivate Player API] POST request received')
try {
const { roomId } = await context.params
console.log('[Deactivate Player API] roomId:', roomId)
const viewerId = await getViewerId()
console.log('[Deactivate Player API] viewerId:', viewerId)
const body = await req.json()
console.log('[Deactivate Player API] body:', body)
// Validate required fields
if (!body.playerId) {
console.log('[Deactivate Player API] Missing playerId in body')
return NextResponse.json({ error: 'Missing required field: playerId' }, { status: 400 })
}
// Check if user is the host
console.log('[Deactivate Player API] Fetching room members for roomId:', roomId)
const members = await getRoomMembers(roomId)
console.log('[Deactivate Player API] members count:', members.length)
const currentMember = members.find((m) => m.userId === viewerId)
console.log('[Deactivate Player API] currentMember:', currentMember)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json({ error: 'Only the host can deactivate players' }, { status: 403 })
}
// Get the player
console.log('[Deactivate Player API] Looking up player with ID:', body.playerId)
const player = await getPlayer(body.playerId)
console.log('[Deactivate Player API] Player found:', player)
if (!player) {
console.log('[Deactivate Player API] Player not found in database')
return NextResponse.json({ error: 'Player not found' }, { status: 404 })
}
console.log('[Deactivate Player API] Player userId:', player.userId)
console.log(
'[Deactivate Player API] Room member userIds:',
members.map((m) => m.userId)
)
// Can't deactivate your own players (use the regular player controls for that)
if (player.userId === viewerId) {
console.log('[Deactivate Player API] ERROR: Cannot deactivate your own players')
return NextResponse.json(
{ error: 'Cannot deactivate your own players. Use the player controls in the nav.' },
{ status: 400 }
)
}
// Note: We don't check if the player belongs to a current room member
// because players from users who have left the room may still need to be cleaned up
console.log('[Deactivate Player API] Player validation passed, proceeding with deactivation')
// Deactivate the player
await setPlayerActiveStatus(body.playerId, false)
// Broadcast updates via socket
const io = await getSocketIO()
if (io) {
try {
// Get updated player list
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Notify everyone in the room about the player update
io.to(`room:${roomId}`).emit('player-deactivated', {
roomId,
playerId: body.playerId,
playerName: player.name,
deactivatedBy: currentMember.displayName,
memberPlayers: memberPlayersObj,
})
console.log(
`[Deactivate Player API] Player ${body.playerId} (${player.name}) deactivated by host in room ${roomId}`
)
} catch (socketError) {
console.error('[Deactivate Player API] Failed to broadcast deactivation:', socketError)
}
}
console.log('[Deactivate Player API] Success - returning 200')
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
console.error('[Deactivate Player API] ERROR:', error)
console.error('[Deactivate Player API] Error stack:', error.stack)
return NextResponse.json({ error: 'Failed to deactivate player' }, { status: 500 })
}
}

View File

@@ -1,7 +1,7 @@
import bcrypt from 'bcryptjs'
import { type NextRequest, NextResponse } from 'next/server'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getInvitation, acceptInvitation } from '@/lib/arcade/room-invitations'
import { getInvitation } from '@/lib/arcade/room-invitations'
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
@@ -26,19 +26,12 @@ export async function POST(req: NextRequest, context: RouteContext) {
const viewerId = await getViewerId()
const body = await req.json().catch(() => ({}))
console.log(`[Join API] User ${viewerId} attempting to join room ${roomId}`)
// Get room
const room = await getRoomById(roomId)
if (!room) {
console.log(`[Join API] Room ${roomId} not found`)
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
console.log(
`[Join API] Room ${roomId} found: name="${room.name}" accessMode="${room.accessMode}" game="${room.gameName}"`
)
// Check if user is banned
const banned = await isUserBanned(roomId, viewerId)
if (banned) {
@@ -50,20 +43,6 @@ export async function POST(req: NextRequest, context: RouteContext) {
const isExistingMember = members.some((m) => m.userId === viewerId)
const isRoomCreator = room.createdBy === viewerId
// Track invitation/join request to mark as accepted after successful join
let invitationToAccept: string | null = null
let joinRequestToAccept: string | null = null
// Check for pending invitation (regardless of access mode)
// This ensures invitations are marked as accepted when user joins ANY room type
const invitation = await getInvitation(roomId, viewerId)
if (invitation && invitation.status === 'pending') {
invitationToAccept = invitation.id
console.log(
`[Join API] Found pending invitation ${invitation.id} for user ${viewerId} in room ${roomId}`
)
}
// Validate access mode
switch (room.accessMode) {
case 'locked':
@@ -104,20 +83,16 @@ export async function POST(req: NextRequest, context: RouteContext) {
}
case 'restricted': {
console.log(`[Join API] Room is restricted, checking invitation for user ${viewerId}`)
// Room creator can always rejoin their own room
if (!isRoomCreator) {
// For restricted rooms, invitation is REQUIRED
if (!invitationToAccept) {
console.log(`[Join API] No valid pending invitation, rejecting join`)
// Check for valid pending invitation
const invitation = await getInvitation(roomId, viewerId)
if (!invitation || invitation.status !== 'pending') {
return NextResponse.json(
{ error: 'You need a valid invitation to join this room' },
{ status: 403 }
)
}
console.log(`[Join API] Valid invitation found, will accept after member added`)
} else {
console.log(`[Join API] User is room creator, skipping invitation check`)
}
break
}
@@ -133,9 +108,6 @@ export async function POST(req: NextRequest, context: RouteContext) {
{ status: 403 }
)
}
// Note: Join request stays in "approved" status after join
// (No need to update it - "approved" indicates they were allowed in)
joinRequestToAccept = joinRequest.id
}
break
}
@@ -163,13 +135,6 @@ export async function POST(req: NextRequest, context: RouteContext) {
isCreator: false,
})
// Mark invitation as accepted (if applicable)
if (invitationToAccept) {
await acceptInvitation(invitationToAccept)
console.log(`[Join API] Accepted invitation ${invitationToAccept} for user ${viewerId}`)
}
// Note: Join requests stay in "approved" status (no need to update)
// Fetch user's active players (these will participate in the game)
const activePlayers = await getActivePlayers(viewerId)
@@ -205,10 +170,6 @@ export async function POST(req: NextRequest, context: RouteContext) {
}
// Build response with auto-leave info if applicable
console.log(
`[Join API] Successfully added user ${viewerId} to room ${roomId} (invitation=${invitationToAccept ? 'accepted' : 'N/A'})`
)
return NextResponse.json(
{
member,

View File

@@ -17,17 +17,12 @@ type RouteContext = {
/**
* PATCH /api/arcade/rooms/:roomId/settings
* Update room settings
*
* Authorization:
* - gameConfig: Any room member can update
* - All other settings: Host only
*
* Update room settings (host only)
* Body:
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only' (host only)
* - password?: string (plain text, will be hashed) (host only)
* - gameName?: string | null (any game with a registered validator) (host only)
* - gameConfig?: object (game-specific settings) (any member)
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
* - password?: string (plain text, will be hashed)
* - gameName?: string | null (any game with a registered validator)
* - gameConfig?: object (game-specific settings)
*
* Note: gameName is validated at runtime against the validator registry.
* No need to update this file when adding new games!
@@ -68,7 +63,7 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
)
)
// Check if user is a room member
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
@@ -76,24 +71,8 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
// Determine which settings are being changed
const changingRoomSettings = !!(
body.accessMode !== undefined ||
body.password !== undefined ||
body.gameName !== undefined ||
body.name !== undefined ||
body.description !== undefined
)
// Only gameConfig can be changed by any member
// All other settings require host privileges
if (changingRoomSettings && !currentMember.isCreator) {
return NextResponse.json(
{
error: 'Only the host can change room settings (name, access mode, game selection, etc.)',
},
{ status: 403 }
)
if (!currentMember.isCreator) {
return NextResponse.json({ error: 'Only the host can change room settings' }, { status: 403 })
}
// Validate accessMode if provided

View File

@@ -1,117 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { writeFileSync, readFileSync, mkdirSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { execSync } from 'child_process'
import { generateMonthlyTypst, generateDailyTypst, getDaysInMonth } from '../utils/typstGenerator'
import type { AbacusConfig } from '@soroban/abacus-react'
interface CalendarRequest {
month: number
year: number
format: 'monthly' | 'daily'
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
abacusConfig?: AbacusConfig
}
export async function POST(request: NextRequest) {
let tempDir: string | null = null
try {
const body: CalendarRequest = await request.json()
const { month, year, format, paperSize, abacusConfig } = body
// Validate inputs
if (!month || month < 1 || month > 12 || !year || year < 1 || year > 9999) {
return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 })
}
// Create temp directory
tempDir = join(tmpdir(), `calendar-${Date.now()}-${Math.random()}`)
mkdirSync(tempDir, { recursive: true })
// Generate SVGs using script (avoids Next.js react-dom/server restriction)
const daysInMonth = getDaysInMonth(year, month)
const maxDay = format === 'daily' ? daysInMonth : 31 // For monthly, pre-generate all
const scriptPath = join(process.cwd(), 'scripts', 'generateCalendarAbacus.tsx')
// Generate day SVGs (1 to maxDay)
for (let day = 1; day <= maxDay; day++) {
const svg = execSync(`npx tsx "${scriptPath}" ${day} 2`, {
encoding: 'utf-8',
cwd: process.cwd(),
})
writeFileSync(join(tempDir, `day-${day}.svg`), svg)
}
// Generate year SVG
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
const yearSvg = execSync(`npx tsx "${scriptPath}" ${year} ${yearColumns}`, {
encoding: 'utf-8',
cwd: process.cwd(),
})
writeFileSync(join(tempDir, 'year.svg'), yearSvg)
// Generate Typst document
const typstContent =
format === 'monthly'
? generateMonthlyTypst({
month,
year,
paperSize,
tempDir,
daysInMonth,
})
: generateDailyTypst({
month,
year,
paperSize,
tempDir,
daysInMonth,
})
const typstPath = join(tempDir, 'calendar.typ')
writeFileSync(typstPath, typstContent)
// Compile with Typst
const pdfPath = join(tempDir, 'calendar.pdf')
try {
execSync(`typst compile "${typstPath}" "${pdfPath}"`, {
stdio: 'pipe',
})
} catch (error) {
console.error('Typst compilation error:', error)
return NextResponse.json(
{ error: 'Failed to compile PDF. Is Typst installed?' },
{ status: 500 }
)
}
// Read and return PDF
const pdfBuffer = readFileSync(pdfPath)
// Clean up temp directory
rmSync(tempDir, { recursive: true, force: true })
tempDir = null
return new NextResponse(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="calendar-${year}-${String(month).padStart(2, '0')}.pdf"`,
},
})
} catch (error) {
console.error('Error generating calendar:', error)
// Clean up temp directory if it exists
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true })
} catch (cleanupError) {
console.error('Failed to clean up temp directory:', cleanupError)
}
}
return NextResponse.json({ error: 'Failed to generate calendar' }, { status: 500 })
}
}

View File

@@ -1,176 +0,0 @@
interface TypstConfig {
month: number
year: number
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
tempDir: string
daysInMonth: number
}
const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
export function getDaysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
function getFirstDayOfWeek(year: number, month: number): number {
return new Date(year, month - 1, 1).getDay() // 0 = Sunday
}
function getDayOfWeek(year: number, month: number, day: number): string {
const date = new Date(year, month - 1, day)
return date.toLocaleDateString('en-US', { weekday: 'long' })
}
type PaperSize = 'us-letter' | 'a4' | 'a3' | 'tabloid'
interface PaperConfig {
typstName: string
marginX: string
marginY: string
}
function getPaperConfig(size: string): PaperConfig {
const configs: Record<PaperSize, PaperConfig> = {
'us-letter': { typstName: 'us-letter', marginX: '0.75in', marginY: '1in' },
a4: { typstName: 'a4', marginX: '2cm', marginY: '2.5cm' },
a3: { typstName: 'a3', marginX: '2cm', marginY: '2.5cm' },
tabloid: { typstName: 'us-tabloid', marginX: '1in', marginY: '1in' },
}
return configs[size as PaperSize] || configs['us-letter']
}
export function generateMonthlyTypst(config: TypstConfig): string {
const { month, year, paperSize, tempDir, daysInMonth } = config
const paperConfig = getPaperConfig(paperSize)
const firstDayOfWeek = getFirstDayOfWeek(year, month)
const monthName = MONTH_NAMES[month - 1]
// Generate calendar cells with proper empty cells before the first day
let cells = ''
// Empty cells before first day
for (let i = 0; i < firstDayOfWeek; i++) {
cells += ' [],\n'
}
// Day cells
for (let day = 1; day <= daysInMonth; day++) {
cells += ` [#image("${tempDir}/day-${day}.svg", width: 90%)],\n`
}
return `#set page(
paper: "${paperConfig.typstName}",
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
)
#set text(font: "Arial", size: 12pt)
// Title
#align(center)[
#text(size: 24pt, weight: "bold")[${monthName} ${year}]
#v(0.5em)
// Year as abacus
#image("${tempDir}/year.svg", width: 35%)
]
#v(1.5em)
// Calendar grid
#grid(
columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1fr),
gutter: 4pt,
// Weekday headers
[#align(center)[*Sun*]],
[#align(center)[*Mon*]],
[#align(center)[*Tue*]],
[#align(center)[*Wed*]],
[#align(center)[*Thu*]],
[#align(center)[*Fri*]],
[#align(center)[*Sat*]],
// Calendar days
${cells})
`
}
export function generateDailyTypst(config: TypstConfig): string {
const { month, year, paperSize, tempDir, daysInMonth } = config
const paperConfig = getPaperConfig(paperSize)
const monthName = MONTH_NAMES[month - 1]
let pages = ''
for (let day = 1; day <= daysInMonth; day++) {
const dayOfWeek = getDayOfWeek(year, month, day)
pages += `
#page(
paper: "${paperConfig.typstName}",
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
)[
// Header: Year
#align(center)[
#v(1em)
#image("${tempDir}/year.svg", width: 30%)
]
#v(2em)
// Main: Day number as large abacus
#align(center + horizon)[
#image("${tempDir}/day-${day}.svg", width: 50%)
]
#v(2em)
// Footer: Day of week and date
#align(center)[
#text(size: 18pt, weight: "bold")[${dayOfWeek}]
#v(0.5em)
#text(size: 14pt)[${monthName} ${day}, ${year}]
]
// Notes section
#v(3em)
#line(length: 100%, stroke: 0.5pt)
#v(0.5em)
#text(size: 10pt, fill: gray)[Notes:]
#v(0.5em)
#line(length: 100%, stroke: 0.5pt)
#v(1em)
#line(length: 100%, stroke: 0.5pt)
#v(1em)
#line(length: 100%, stroke: 0.5pt)
#v(1em)
#line(length: 100%, stroke: 0.5pt)
]
${day < daysInMonth ? '' : ''}`
if (day < daysInMonth) {
pages += '\n'
}
}
return `#set text(font: "Arial")
${pages}
`
}

View File

@@ -4,9 +4,6 @@ import { getActivePlayers } from '@/lib/arcade/player-manager'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
// Force dynamic rendering - this route uses headers()
export const dynamic = 'force-dynamic'
/**
* GET /api/debug/active-players
* Debug endpoint to check active players for current user

View File

@@ -1,111 +0,0 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { db } from '@/db'
import type { GameStatsBreakdown } from '@/db/schema/player-stats'
import { playerStats } from '@/db/schema/player-stats'
import { players } from '@/db/schema/players'
import type { GetPlayerStatsResponse, PlayerStatsData } from '@/lib/arcade/stats/types'
import { getViewerId } from '@/lib/viewer'
/**
* GET /api/player-stats/[playerId]
*
* Fetches stats for a specific player (must be owned by current user).
*/
export async function GET(_request: Request, { params }: { params: { playerId: string } }) {
try {
const { playerId } = params
// 1. Authenticate user
const viewerId = await getViewerId()
if (!viewerId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 2. Verify player belongs to user
const player = await db
.select()
.from(players)
.where(eq(players.id, playerId))
.limit(1)
.then((rows) => rows[0])
if (!player) {
return NextResponse.json({ error: 'Player not found' }, { status: 404 })
}
if (player.userId !== viewerId) {
return NextResponse.json(
{ error: 'Forbidden: player belongs to another user' },
{ status: 403 }
)
}
// 3. Fetch player stats
const stats = await db
.select()
.from(playerStats)
.where(eq(playerStats.playerId, playerId))
.limit(1)
.then((rows) => rows[0])
const playerStatsData: PlayerStatsData = stats
? convertToPlayerStatsData(stats)
: createDefaultPlayerStats(playerId)
// 4. Return response
const response: GetPlayerStatsResponse = {
stats: playerStatsData,
}
return NextResponse.json(response)
} catch (error) {
console.error('❌ Failed to fetch player stats:', error)
return NextResponse.json(
{
error: 'Failed to fetch player stats',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
)
}
}
/**
* Convert DB record to PlayerStatsData
*/
function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData {
return {
playerId: dbStats.playerId,
gamesPlayed: dbStats.gamesPlayed,
totalWins: dbStats.totalWins,
totalLosses: dbStats.totalLosses,
bestTime: dbStats.bestTime,
highestAccuracy: dbStats.highestAccuracy,
favoriteGameType: dbStats.favoriteGameType,
gameStats: (dbStats.gameStats as Record<string, GameStatsBreakdown>) || {},
lastPlayedAt: dbStats.lastPlayedAt,
createdAt: dbStats.createdAt,
updatedAt: dbStats.updatedAt,
}
}
/**
* Create default player stats for new player
*/
function createDefaultPlayerStats(playerId: string): PlayerStatsData {
const now = new Date()
return {
playerId,
gamesPlayed: 0,
totalWins: 0,
totalLosses: 0,
bestTime: null,
highestAccuracy: 0,
favoriteGameType: null,
gameStats: {},
lastPlayedAt: null,
createdAt: now,
updatedAt: now,
}
}

View File

@@ -1,277 +0,0 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { db } from '@/db'
import type { GameStatsBreakdown } from '@/db/schema/player-stats'
import { playerStats } from '@/db/schema/player-stats'
import type {
GameResult,
PlayerGameResult,
PlayerStatsData,
RecordGameRequest,
RecordGameResponse,
StatsUpdate,
} from '@/lib/arcade/stats/types'
import { getViewerId } from '@/lib/viewer'
/**
* POST /api/player-stats/record-game
*
* Records a game result and updates player stats for all participants.
* Supports cooperative games (team wins/losses) and competitive games.
*/
export async function POST(request: Request) {
try {
// 1. Authenticate user
const viewerId = await getViewerId()
if (!viewerId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 2. Parse and validate request
const body: RecordGameRequest = await request.json()
const { gameResult } = body
if (!gameResult || !gameResult.playerResults || gameResult.playerResults.length === 0) {
return NextResponse.json(
{ error: 'Invalid game result: playerResults required' },
{ status: 400 }
)
}
if (!gameResult.gameType) {
return NextResponse.json({ error: 'Invalid game result: gameType required' }, { status: 400 })
}
// 3. Process each player's result
const updates: StatsUpdate[] = []
for (const playerResult of gameResult.playerResults) {
const update = await recordPlayerResult(gameResult, playerResult)
updates.push(update)
}
// 4. Return success response
const response: RecordGameResponse = {
success: true,
updates,
}
return NextResponse.json(response)
} catch (error) {
console.error('❌ Failed to record game result:', error)
return NextResponse.json(
{
error: 'Failed to record game result',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
)
}
}
/**
* Records stats for a single player's game result
*/
async function recordPlayerResult(
gameResult: GameResult,
playerResult: PlayerGameResult
): Promise<StatsUpdate> {
const { playerId } = playerResult
// 1. Fetch or create player stats
const existingStats = await db
.select()
.from(playerStats)
.where(eq(playerStats.playerId, playerId))
.limit(1)
.then((rows) => rows[0])
const previousStats: PlayerStatsData = existingStats
? convertToPlayerStatsData(existingStats)
: createDefaultPlayerStats(playerId)
// 2. Calculate new stats
const newStats: PlayerStatsData = { ...previousStats }
// Always increment games played
newStats.gamesPlayed++
// Handle wins/losses (cooperative vs competitive)
if (gameResult.metadata?.isTeamVictory !== undefined) {
// Cooperative game: all players share outcome
if (playerResult.won) {
newStats.totalWins++
} else {
newStats.totalLosses++
}
} else {
// Competitive/Solo: individual outcome
if (playerResult.won) {
newStats.totalWins++
} else {
newStats.totalLosses++
}
}
// Update best time (if provided and improved)
if (playerResult.completionTime) {
if (!newStats.bestTime || playerResult.completionTime < newStats.bestTime) {
newStats.bestTime = playerResult.completionTime
}
}
// Update highest accuracy (if provided and improved)
if (playerResult.accuracy !== undefined && playerResult.accuracy > newStats.highestAccuracy) {
newStats.highestAccuracy = playerResult.accuracy
}
// Update per-game stats (JSON)
const gameType = gameResult.gameType
const currentGameStats: GameStatsBreakdown = newStats.gameStats[gameType] || {
gamesPlayed: 0,
wins: 0,
losses: 0,
bestTime: null,
highestAccuracy: 0,
averageScore: 0,
lastPlayed: 0,
}
currentGameStats.gamesPlayed++
if (playerResult.won) {
currentGameStats.wins++
} else {
currentGameStats.losses++
}
// Update game-specific best time
if (playerResult.completionTime) {
if (!currentGameStats.bestTime || playerResult.completionTime < currentGameStats.bestTime) {
currentGameStats.bestTime = playerResult.completionTime
}
}
// Update game-specific highest accuracy
if (
playerResult.accuracy !== undefined &&
playerResult.accuracy > currentGameStats.highestAccuracy
) {
currentGameStats.highestAccuracy = playerResult.accuracy
}
// Update average score
if (playerResult.score !== undefined) {
const previousTotal = currentGameStats.averageScore * (currentGameStats.gamesPlayed - 1)
currentGameStats.averageScore =
(previousTotal + playerResult.score) / currentGameStats.gamesPlayed
}
currentGameStats.lastPlayed = gameResult.completedAt
newStats.gameStats[gameType] = currentGameStats
// Update favorite game type (most played)
newStats.favoriteGameType = getMostPlayedGame(newStats.gameStats)
// Update timestamps
newStats.lastPlayedAt = new Date(gameResult.completedAt)
newStats.updatedAt = new Date()
// 3. Save to database
if (existingStats) {
// Update existing record
await db
.update(playerStats)
.set({
gamesPlayed: newStats.gamesPlayed,
totalWins: newStats.totalWins,
totalLosses: newStats.totalLosses,
bestTime: newStats.bestTime,
highestAccuracy: newStats.highestAccuracy,
favoriteGameType: newStats.favoriteGameType,
gameStats: newStats.gameStats as any, // Drizzle JSON type
lastPlayedAt: newStats.lastPlayedAt,
updatedAt: newStats.updatedAt,
})
.where(eq(playerStats.playerId, playerId))
} else {
// Insert new record
await db.insert(playerStats).values({
playerId: newStats.playerId,
gamesPlayed: newStats.gamesPlayed,
totalWins: newStats.totalWins,
totalLosses: newStats.totalLosses,
bestTime: newStats.bestTime,
highestAccuracy: newStats.highestAccuracy,
favoriteGameType: newStats.favoriteGameType,
gameStats: newStats.gameStats as any,
lastPlayedAt: newStats.lastPlayedAt,
createdAt: newStats.createdAt,
updatedAt: newStats.updatedAt,
})
}
// 4. Return update summary
return {
playerId,
previousStats,
newStats,
changes: {
gamesPlayed: newStats.gamesPlayed - previousStats.gamesPlayed,
wins: newStats.totalWins - previousStats.totalWins,
losses: newStats.totalLosses - previousStats.totalLosses,
},
}
}
/**
* Convert DB record to PlayerStatsData
*/
function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData {
return {
playerId: dbStats.playerId,
gamesPlayed: dbStats.gamesPlayed,
totalWins: dbStats.totalWins,
totalLosses: dbStats.totalLosses,
bestTime: dbStats.bestTime,
highestAccuracy: dbStats.highestAccuracy,
favoriteGameType: dbStats.favoriteGameType,
gameStats: (dbStats.gameStats as Record<string, GameStatsBreakdown>) || {},
lastPlayedAt: dbStats.lastPlayedAt,
createdAt: dbStats.createdAt,
updatedAt: dbStats.updatedAt,
}
}
/**
* Create default player stats for new player
*/
function createDefaultPlayerStats(playerId: string): PlayerStatsData {
const now = new Date()
return {
playerId,
gamesPlayed: 0,
totalWins: 0,
totalLosses: 0,
bestTime: null,
highestAccuracy: 0,
favoriteGameType: null,
gameStats: {},
lastPlayedAt: null,
createdAt: now,
updatedAt: now,
}
}
/**
* Determine most-played game from game stats
*/
function getMostPlayedGame(gameStats: Record<string, GameStatsBreakdown>): string | null {
const games = Object.entries(gameStats)
if (games.length === 0) return null
return games.reduce((mostPlayed, [gameType, stats]) => {
const mostPlayedStats = gameStats[mostPlayed]
return stats.gamesPlayed > (mostPlayedStats?.gamesPlayed || 0) ? gameType : mostPlayed
}, games[0][0])
}

View File

@@ -1,105 +0,0 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { db } from '@/db'
import type { GameStatsBreakdown } from '@/db/schema/player-stats'
import { playerStats } from '@/db/schema/player-stats'
import { players } from '@/db/schema/players'
import type { GetAllPlayerStatsResponse, PlayerStatsData } from '@/lib/arcade/stats/types'
import { getViewerId } from '@/lib/viewer'
// Force dynamic rendering - this route uses headers()
export const dynamic = 'force-dynamic'
/**
* GET /api/player-stats
*
* Fetches stats for all of the current user's players.
*/
export async function GET() {
try {
// 1. Authenticate user
const viewerId = await getViewerId()
if (!viewerId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 2. Fetch all user's players
const userPlayers = await db.select().from(players).where(eq(players.userId, viewerId))
const playerIds = userPlayers.map((p) => p.id)
// 3. Fetch stats for all players
const allStats: PlayerStatsData[] = []
for (const playerId of playerIds) {
const stats = await db
.select()
.from(playerStats)
.where(eq(playerStats.playerId, playerId))
.limit(1)
.then((rows) => rows[0])
if (stats) {
allStats.push(convertToPlayerStatsData(stats))
} else {
// Player exists but has no stats yet
allStats.push(createDefaultPlayerStats(playerId))
}
}
// 4. Return response
const response: GetAllPlayerStatsResponse = {
playerStats: allStats,
}
return NextResponse.json(response)
} catch (error) {
console.error('❌ Failed to fetch player stats:', error)
return NextResponse.json(
{
error: 'Failed to fetch player stats',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
)
}
}
/**
* Convert DB record to PlayerStatsData
*/
function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData {
return {
playerId: dbStats.playerId,
gamesPlayed: dbStats.gamesPlayed,
totalWins: dbStats.totalWins,
totalLosses: dbStats.totalLosses,
bestTime: dbStats.bestTime,
highestAccuracy: dbStats.highestAccuracy,
favoriteGameType: dbStats.favoriteGameType,
gameStats: (dbStats.gameStats as Record<string, GameStatsBreakdown>) || {},
lastPlayedAt: dbStats.lastPlayedAt,
createdAt: dbStats.createdAt,
updatedAt: dbStats.updatedAt,
}
}
/**
* Create default player stats for new player
*/
function createDefaultPlayerStats(playerId: string): PlayerStatsData {
const now = new Date()
return {
playerId,
gamesPlayed: 0,
totalWins: 0,
totalLosses: 0,
bestTime: null,
highestAccuracy: 0,
favoriteGameType: null,
gameStats: {},
lastPlayedAt: null,
createdAt: now,
updatedAt: now,
}
}

View File

@@ -2,16 +2,12 @@
import { animated, useSpring } from '@react-spring/web'
import { AbacusReact } from '@soroban/abacus-react'
import { useAbacusSettings } from '@/hooks/useAbacusSettings'
interface PressureGaugeProps {
pressure: number // 0-150 PSI
}
export function PressureGauge({ pressure }: PressureGaugeProps) {
// Get native abacus numbers setting
const { data: abacusSettings } = useAbacusSettings()
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
const maxPressure = 150
// Animate pressure value smoothly with spring physics
@@ -111,29 +107,17 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
lineHeight: 0,
}}
>
{useNativeAbacusNumbers ? (
<AbacusReact
value={psi}
columns={3}
interactive={false}
showNumbers={false}
hideInactiveBeads={false}
scaleFactor={0.6}
customStyles={{
columnPosts: { opacity: 0 },
}}
/>
) : (
<div
style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#1f2937',
}}
>
{psi}
</div>
)}
<AbacusReact
value={psi}
columns={3}
interactive={false}
showNumbers={false}
hideInactiveBeads={false}
scaleFactor={0.6}
customStyles={{
columnPosts: { opacity: 0 },
}}
/>
</div>
</foreignObject>
</g>
@@ -158,7 +142,7 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
/>
</svg>
{/* Pressure readout */}
{/* Abacus readout */}
<div
style={{
textAlign: 'center',
@@ -169,35 +153,27 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
minHeight: '32px',
}}
>
{useNativeAbacusNumbers ? (
<>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 0,
}}
>
<AbacusReact
value={Math.round(pressure)}
columns={3}
interactive={false}
showNumbers={false}
hideInactiveBeads={true}
scaleFactor={0.35}
customStyles={{
columnPosts: { opacity: 0 },
}}
/>
</div>
<span style={{ fontSize: '12px', color: '#6b7280', fontWeight: 'bold' }}>PSI</span>
</>
) : (
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1f2937' }}>
{Math.round(pressure)} <span style={{ fontSize: '12px', color: '#6b7280' }}>PSI</span>
</div>
)}
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 0,
}}
>
<AbacusReact
value={Math.round(pressure)}
columns={3}
interactive={false}
showNumbers={false}
hideInactiveBeads={true}
scaleFactor={0.35}
customStyles={{
columnPosts: { opacity: 0 },
}}
/>
</div>
<span style={{ fontSize: '12px', color: '#6b7280', fontWeight: 'bold' }}>PSI</span>
</div>
</div>
)

View File

@@ -89,7 +89,7 @@ export function GhostTrain({
y: locomotiveTarget?.y ?? 0,
rotation: locomotiveTarget?.rotation ?? 0,
opacity: locomotiveTarget?.opacity ?? 1,
config: { tension: 280, friction: 60 }, // Smooth but responsive
config: { tension: 600, friction: 35 }, // Fast/responsive to match local train
})
// Calculate target transforms for cars (used by spring animations)
@@ -133,7 +133,7 @@ export function GhostTrain({
y: target.y,
rotation: target.rotation,
opacity: target.opacity,
config: { tension: 280, friction: 60 },
config: { tension: 600, friction: 35 }, // Fast/responsive to match local train
}))
)

View File

@@ -1,21 +1,23 @@
'use client'
import { memo } from 'react'
import { animated, to } from '@react-spring/web'
import type { SpringValue } from '@react-spring/web'
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
import type { Passenger } from '@/arcade-games/complement-race/types'
interface TrainCarTransform {
x: number
y: number
rotation: number
position: number
opacity: number
x: SpringValue<number>
y: SpringValue<number>
rotation: SpringValue<number>
position: SpringValue<number>
opacity: SpringValue<number>
}
interface TrainTransform {
x: number
y: number
rotation: number
x: SpringValue<number>
y: SpringValue<number>
rotation: SpringValue<number>
}
interface TrainAndCarsProps {
@@ -30,7 +32,7 @@ interface TrainAndCarsProps {
trainCars: TrainCarTransform[]
boardedPassengers: Passenger[]
trainTransform: TrainTransform
locomotiveOpacity: number
locomotiveOpacity: SpringValue<number>
playerEmoji: string
momentum: number
}
@@ -72,14 +74,14 @@ export const TrainAndCars = memo(
const passenger = boardedPassengers[carIndex]
return (
<g
<animated.g
key={`train-car-${carIndex}`}
data-component="train-car"
transform={`translate(${carTransform.x}, ${carTransform.y}) rotate(${carTransform.rotation}) scale(-1, 1)`}
transform={to(
[carTransform.x, carTransform.y, carTransform.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
opacity={carTransform.opacity}
style={{
transition: 'opacity 0.5s ease-in',
}}
>
{/* Train car */}
<text
@@ -114,18 +116,18 @@ export const TrainAndCars = memo(
{passenger.avatar}
</text>
)}
</g>
</animated.g>
)
})}
{/* Locomotive - rendered last so it appears on top */}
<g
<animated.g
data-component="locomotive-group"
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
transform={to(
[trainTransform.x, trainTransform.y, trainTransform.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
opacity={locomotiveOpacity}
style={{
transition: 'opacity 0.5s ease-in',
}}
>
{/* Train locomotive */}
<text
@@ -191,7 +193,7 @@ export const TrainAndCars = memo(
}}
/>
))}
</g>
</animated.g>
</>
)
}

View File

@@ -1,233 +0,0 @@
/**
* Test to reproduce delivery thrashing bug
*
* The bug: When a car is at a station for multiple frames (50ms intervals),
* the delivery logic fires repeatedly before the optimistic state update propagates.
* This causes multiple DELIVER_PASSENGER moves to be sent to the server,
* which rejects all but the first one.
*/
import { describe, expect, test } from 'vitest'
interface Passenger {
id: string
name: string
claimedBy: string | null
deliveredBy: string | null
carIndex: number | null
destinationStationId: string
isUrgent: boolean
}
interface Station {
id: string
name: string
emoji: string
position: number
}
describe('useSteamJourney - Delivery Thrashing Reproduction', () => {
const CAR_SPACING = 7
/**
* Simulate the delivery logic from useSteamJourney
* Returns the number of delivery attempts made
*/
function simulateDeliveryAtPosition(
trainPosition: number,
passengers: Passenger[],
stations: Station[],
pendingDeliveryRef: Set<string>
): { deliveryAttempts: number; deliveredPassengerIds: string[] } {
let deliveryAttempts = 0
const deliveredPassengerIds: string[] = []
const currentBoardedPassengers = passengers.filter(
(p) => p.claimedBy !== null && p.deliveredBy === null
)
// PRIORITY 1: Process deliveries FIRST (dispatch DELIVER moves before BOARD moves)
currentBoardedPassengers.forEach((passenger) => {
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
// Skip if already has a pending delivery request
if (pendingDeliveryRef.has(passenger.id)) return
const station = stations.find((s) => s.id === passenger.destinationStationId)
if (!station) return
// 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) {
// Mark as pending BEFORE dispatch to prevent duplicate delivery attempts across frames
pendingDeliveryRef.add(passenger.id)
deliveryAttempts++
deliveredPassengerIds.push(passenger.id)
}
})
return { deliveryAttempts, deliveredPassengerIds }
}
test('WITHOUT fix: multiple frames at same position cause thrashing', () => {
const stations: Station[] = [
{ id: 's1', name: 'Start', emoji: '🏠', position: 20 },
{ id: 's2', name: 'Middle', emoji: '🏢', position: 40 },
{ id: 's3', name: 'End', emoji: '🏁', position: 80 },
]
// Passenger "Bob" is in Car 1, heading to station s2 at position 40
const passengers: Passenger[] = [
{
id: 'bob',
name: 'Bob',
claimedBy: 'player1',
deliveredBy: null, // Not yet delivered
carIndex: 1, // In car 1 (second car)
destinationStationId: 's2',
isUrgent: false,
},
]
// NO pending delivery tracking (simulating the bug)
const noPendingRef = new Set<string>()
// Train position where Car 1 is at station s2 (position 40)
// Car 1 position = trainPosition - (carIndex + 1) * CAR_SPACING
// Car 1 position = trainPosition - 2 * 7 = trainPosition - 14
// For Car 1 to be at position 40: trainPosition = 40 + 14 = 54
const trainPosition = 53.9
const carPosition = Math.max(0, trainPosition - (1 + 1) * CAR_SPACING)
console.log(`Train at ${trainPosition}, Car 1 at ${carPosition}, Station at 40`)
expect(Math.abs(carPosition - 40)).toBeLessThan(5) // Verify we're in delivery range
let totalAttempts = 0
// Simulate 10 frames (50ms each = 500ms total) at the same position
// This mimics what happens when the train is near/at a station
for (let frame = 0; frame < 10; frame++) {
const result = simulateDeliveryAtPosition(trainPosition, passengers, stations, noPendingRef)
totalAttempts += result.deliveryAttempts
// WITHOUT the pendingDeliveryRef fix, every frame triggers a delivery attempt
// because the optimistic update hasn't propagated yet
}
// Without the fix, we expect 10 delivery attempts (one per frame)
// because nothing prevents duplicate attempts
console.log(`Total delivery attempts without fix: ${totalAttempts}`)
expect(totalAttempts).toBe(10) // This demonstrates the bug!
})
test('WITH fix: pendingDeliveryRef prevents thrashing', () => {
const stations: Station[] = [
{ id: 's1', name: 'Start', emoji: '🏠', position: 20 },
{ id: 's2', name: 'Middle', emoji: '🏢', position: 40 },
{ id: 's3', name: 'End', emoji: '🏁', position: 80 },
]
const passengers: Passenger[] = [
{
id: 'bob',
name: 'Bob',
claimedBy: 'player1',
deliveredBy: null,
carIndex: 1,
destinationStationId: 's2',
isUrgent: false,
},
]
// WITH pending delivery tracking (the fix)
const pendingDeliveryRef = new Set<string>()
const trainPosition = 53.9
let totalAttempts = 0
// Simulate 10 frames at the same position
for (let frame = 0; frame < 10; frame++) {
const result = simulateDeliveryAtPosition(
trainPosition,
passengers,
stations,
pendingDeliveryRef
)
totalAttempts += result.deliveryAttempts
}
// With the fix, only the FIRST frame should attempt delivery
// All subsequent frames skip because passenger.id is in pendingDeliveryRef
console.log(`Total delivery attempts with fix: ${totalAttempts}`)
expect(totalAttempts).toBe(1) // Only one attempt! ✅
})
test('EDGE CASE: multiple passengers at same station', () => {
const stations: Station[] = [
{ id: 's1', name: 'Start', emoji: '🏠', position: 20 },
{ id: 's2', name: 'Middle', emoji: '🏢', position: 40 },
{ id: 's3', name: 'End', emoji: '🏁', position: 80 },
]
// Two passengers in different cars, both going to station s2
const passengers: Passenger[] = [
{
id: 'alice',
name: 'Alice',
claimedBy: 'player1',
deliveredBy: null,
carIndex: 0, // Car 0
destinationStationId: 's2',
isUrgent: false,
},
{
id: 'bob',
name: 'Bob',
claimedBy: 'player1',
deliveredBy: null,
carIndex: 1, // Car 1
destinationStationId: 's2',
isUrgent: false,
},
]
const pendingDeliveryRef = new Set<string>()
// Position where both cars are near station s2 (position 40)
// Car 0 at position 40: trainPosition = 40 + 7 = 47
// Car 1 at position 40: trainPosition = 40 + 14 = 54
// Let's use 50 so Car 0 is at 43 and Car 1 is at 36 (both within 5 of 40)
const trainPosition = 46.5
// Debug: Check car positions
const car0Pos = Math.max(0, trainPosition - (0 + 1) * CAR_SPACING)
const car1Pos = Math.max(0, trainPosition - (1 + 1) * CAR_SPACING)
console.log(
`Train at ${trainPosition}, Car 0 at ${car0Pos} (dist ${Math.abs(car0Pos - 40)}), Car 1 at ${car1Pos} (dist ${Math.abs(car1Pos - 40)})`
)
let totalAttempts = 0
// Simulate 5 frames
for (let frame = 0; frame < 5; frame++) {
const result = simulateDeliveryAtPosition(
trainPosition,
passengers,
stations,
pendingDeliveryRef
)
totalAttempts += result.deliveryAttempts
if (result.deliveryAttempts > 0) {
console.log(`Frame ${frame}: Delivered ${result.deliveredPassengerIds.join(', ')}`)
}
}
// Should deliver BOTH passengers exactly once (2 total attempts)
console.log(`Total delivery attempts for 2 passengers: ${totalAttempts}`)
expect(totalAttempts).toBe(2) // Alice once, Bob once ✅
})
})

View File

@@ -1,5 +1,4 @@
import { useCallback, useContext, useRef } from 'react'
import { PreviewModeContext } from '@/components/GamePreview'
import { useCallback, useRef } from 'react'
/**
* Web Audio API sound effects system
@@ -16,7 +15,6 @@ interface Note {
export function useSoundEffects() {
const audioContextsRef = useRef<AudioContext[]>([])
const previewMode = useContext(PreviewModeContext)
/**
* Helper function to play multi-note 90s arcade sounds
@@ -109,11 +107,6 @@ export function useSoundEffects() {
| 'steam_hiss',
volume: number = 0.15
) => {
// Disable all audio in preview mode
if (previewMode?.isPreview) {
return
}
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
@@ -445,7 +438,7 @@ export function useSoundEffects() {
console.log('🎵 Web Audio not supported - missing out on rad 90s sounds!')
}
},
[play90sSound, previewMode]
[play90sSound]
)
/**

View File

@@ -66,14 +66,16 @@ export function useSteamJourney() {
}
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
// Clean up pendingBoardingRef when passengers are claimed/delivered
// NOTE: We do NOT clean up pendingDeliveryRef here because delivery should only happen once per route
// Clean up pendingBoardingRef and pendingDeliveryRef when passengers are claimed/delivered or route changes
useEffect(() => {
// Remove passengers from pending boarding set if they've been claimed or delivered
// Remove passengers from pending sets if they've been claimed or delivered
state.passengers.forEach((passenger) => {
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
pendingBoardingRef.current.delete(passenger.id)
}
if (passenger.deliveredBy !== null) {
pendingDeliveryRef.current.delete(passenger.id)
}
})
}, [state.passengers])
@@ -162,7 +164,7 @@ export function useSteamJourney() {
currentBoardedPassengers.forEach((passenger) => {
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
// Skip if already has a pending delivery request
// Skip if delivery already dispatched (prevents render loop spam)
if (pendingDeliveryRef.current.has(passenger.id)) return
const station = state.stations.find((s) => s.id === passenger.destinationStationId)

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useSpring, useSprings } from '@react-spring/web'
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
interface TrainTransform {
@@ -27,22 +28,24 @@ export function useTrainTransforms({
maxCars,
carSpacing,
}: UseTrainTransformsParams) {
const [trainTransform, setTrainTransform] = useState<TrainTransform>({
x: 50,
y: 300,
rotation: 0,
})
// Update train position and rotation
useEffect(() => {
if (pathRef.current) {
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
setTrainTransform(transform)
// Calculate target locomotive transform
const locomotiveTarget = useMemo<TrainTransform>(() => {
if (!pathRef.current) {
return { x: 50, y: 300, rotation: 0 }
}
return trackGenerator.getTrainTransform(pathRef.current, trainPosition)
}, [trainPosition, trackGenerator, pathRef])
// Calculate train car transforms (each car follows behind the locomotive)
const trainCars = useMemo((): TrainCarTransform[] => {
// Animated spring for smooth locomotive movement
const trainTransform = useSpring({
x: locomotiveTarget.x,
y: locomotiveTarget.y,
rotation: locomotiveTarget.rotation,
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
})
// Calculate target transforms for train cars (each car follows behind the locomotive)
const carTargets = useMemo((): TrainCarTransform[] => {
if (!pathRef.current) {
return Array.from({ length: maxCars }, () => ({
x: 0,
@@ -86,8 +89,21 @@ export function useTrainTransforms({
})
}, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing])
// Calculate locomotive opacity (fade in/out through tunnels)
const locomotiveOpacity = useMemo(() => {
// Animated springs for smooth car movement
const trainCars = useSprings(
carTargets.length,
carTargets.map((target) => ({
x: target.x,
y: target.y,
rotation: target.rotation,
opacity: target.opacity,
position: target.position,
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
}))
)
// Calculate target locomotive opacity (fade in/out through tunnels)
const locomotiveOpacityTarget = useMemo(() => {
const fadeInStart = 3
const fadeInEnd = 8
const fadeOutStart = 92
@@ -109,9 +125,15 @@ export function useTrainTransforms({
return 1 // Default to fully visible
}, [trainPosition])
// Animated spring for smooth locomotive opacity
const locomotiveOpacity = useSpring({
opacity: locomotiveOpacityTarget,
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
})
return {
trainTransform,
trainCars,
locomotiveOpacity,
locomotiveOpacity: locomotiveOpacity.opacity,
}
}

View File

@@ -1,8 +1,8 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { useRoomData, useSetRoomGame, useCreateRoom } from '@/hooks/useRoomData'
import { useState } from 'react'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
@@ -17,8 +17,7 @@ import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
* Shows game selection when no game is set, then shows the game itself once selected.
* URL never changes - it's always /arcade regardless of selection, setup, or gameplay.
*
* Auto-creates a solo room if the user doesn't have one, ensuring they always have
* a context in which to play games.
* Note: We show a friendly message with a link if no room exists to avoid navigation loops.
*
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
* so we don't need to render it here.
@@ -28,38 +27,10 @@ export default function RoomPage() {
const { roomData, isLoading } = useRoomData()
const { data: viewerId } = useViewerId()
const { mutate: setRoomGame } = useSetRoomGame()
const { mutate: createRoom, isPending: isCreatingRoom } = useCreateRoom()
const [permissionError, setPermissionError] = useState<string | null>(null)
// Auto-create room when user has no room
// This happens when:
// 1. First time visiting /arcade
// 2. After leaving a room
useEffect(() => {
if (!isLoading && !roomData && viewerId && !isCreatingRoom) {
console.log('[RoomPage] No room found, auto-creating room for user:', viewerId)
createRoom(
{
name: 'My Room',
gameName: null, // No game selected yet
gameConfig: undefined, // No game config since no game selected
accessMode: 'open' as const, // Open by default - user can change settings later
},
{
onSuccess: (newRoom) => {
console.log('[RoomPage] Successfully created room:', newRoom.id)
},
onError: (error) => {
console.error('[RoomPage] Failed to auto-create room:', error)
},
}
)
}
}, [isLoading, roomData, viewerId, isCreatingRoom, createRoom])
// Show loading state (includes both initial load and room creation)
if (isLoading || isCreatingRoom) {
// Show loading state
if (isLoading) {
return (
<div
style={{
@@ -71,13 +42,12 @@ export default function RoomPage() {
color: '#666',
}}
>
{isCreatingRoom ? 'Creating solo room...' : 'Loading room...'}
Loading room...
</div>
)
}
// If still no room after loading and creation attempt, show fallback
// This should rarely happen (only if auto-creation fails)
// Show error if no room (instead of redirecting)
if (!roomData) {
return (
<div
@@ -92,8 +62,16 @@ export default function RoomPage() {
gap: '1rem',
}}
>
<div>Unable to create room</div>
<div style={{ fontSize: '14px', color: '#999' }}>Please try refreshing the page</div>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
)
}

View File

@@ -1,22 +0,0 @@
'use client'
import { useState } from 'react'
import { PlayingGuideModal } from '@/arcade-games/rithmomachia/components/PlayingGuideModal'
export default function RithmomachiaGuidePage() {
// Guide is always open in this standalone page
const [isOpen] = useState(true)
return (
<div
style={{
width: '100vw',
height: '100vh',
overflow: 'hidden',
background: '#f3f4f6',
}}
>
<PlayingGuideModal isOpen={isOpen} onClose={() => window.close()} standalone={true} />
</div>
)
}

View File

@@ -1,13 +0,0 @@
'use client'
import { rithmomachiaGame } from '@/arcade-games/rithmomachia'
const { Provider, GameComponent } = rithmomachiaGame
export default function RithmomachiaPage() {
return (
<Provider>
<GameComponent />
</Provider>
)
}

View File

@@ -1,574 +0,0 @@
'use client'
import { JobMonitor } from '@/components/3d-print/JobMonitor'
import { STLPreview } from '@/components/3d-print/STLPreview'
import { useState } from 'react'
import { css } from '../../../../styled-system/css'
export default function ThreeDPrintPage() {
// New unified parameter system
const [columns, setColumns] = useState(13)
const [scaleFactor, setScaleFactor] = useState(1.5)
const [widthMm, setWidthMm] = useState<number | undefined>(undefined)
const [format, setFormat] = useState<'stl' | '3mf' | 'scad'>('stl')
// 3MF color options
const [frameColor, setFrameColor] = useState('#8b7355')
const [heavenBeadColor, setHeavenBeadColor] = useState('#e8d5c4')
const [earthBeadColor, setEarthBeadColor] = useState('#6b5444')
const [decorationColor, setDecorationColor] = useState('#d4af37')
const [jobId, setJobId] = useState<string | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = async () => {
setIsGenerating(true)
setError(null)
setIsComplete(false)
try {
const response = await fetch('/api/abacus/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
columns,
scaleFactor,
widthMm,
format,
// Include 3MF colors if format is 3mf
...(format === '3mf' && {
frameColor,
heavenBeadColor,
earthBeadColor,
decorationColor,
}),
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to generate file')
}
const data = await response.json()
setJobId(data.jobId)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
setIsGenerating(false)
}
}
const handleJobComplete = () => {
setIsComplete(true)
setIsGenerating(false)
}
const handleDownload = () => {
if (!jobId) return
window.location.href = `/api/abacus/download/${jobId}`
}
return (
<div
data-component="3d-print-page"
className={css({
maxWidth: '1200px',
mx: 'auto',
p: 6,
})}
>
<h1
className={css({
fontSize: '3xl',
fontWeight: 'bold',
mb: 2,
})}
>
Customize Your 3D Printable Abacus
</h1>
<p className={css({ mb: 6, color: 'gray.600' })}>
Adjust the parameters below to customize your abacus, then generate and download the file
for 3D printing.
</p>
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: '1fr 1fr' },
gap: 8,
})}
>
{/* Left column: Controls */}
<div data-section="controls">
<div
className={css({
bg: 'white',
p: 6,
borderRadius: '8px',
boxShadow: 'md',
})}
>
<h2
className={css({
fontSize: 'xl',
fontWeight: 'bold',
mb: 4,
})}
>
Customization Parameters
</h2>
{/* Number of Columns */}
<div data-setting="columns" className={css({ mb: 4 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 2,
})}
>
Number of Columns: {columns}
</label>
<input
type="range"
min="1"
max="13"
step="1"
value={columns}
onChange={(e) => setColumns(Number.parseInt(e.target.value, 10))}
className={css({ width: '100%' })}
/>
<div
className={css({
fontSize: 'sm',
color: 'gray.500',
mt: 1,
})}
>
Total number of columns in the abacus (1-13)
</div>
</div>
{/* Scale Factor */}
<div data-setting="scale-factor" className={css({ mb: 4 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 2,
})}
>
Scale Factor: {scaleFactor.toFixed(1)}x
</label>
<input
type="range"
min="0.5"
max="3"
step="0.1"
value={scaleFactor}
onChange={(e) => setScaleFactor(Number.parseFloat(e.target.value))}
className={css({ width: '100%' })}
/>
<div
className={css({
fontSize: 'sm',
color: 'gray.500',
mt: 1,
})}
>
Overall size multiplier (preserves aspect ratio, larger values = bigger file size)
</div>
</div>
{/* Optional Width in mm */}
<div data-setting="width-mm" className={css({ mb: 4 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 2,
})}
>
Width in mm (optional)
</label>
<input
type="number"
min="50"
max="500"
step="1"
value={widthMm ?? ''}
onChange={(e) => {
const value = e.target.value
setWidthMm(value ? Number.parseFloat(value) : undefined)
}}
placeholder="Leave empty to use scale factor"
className={css({
width: '100%',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
_focus: {
outline: 'none',
borderColor: 'blue.500',
},
})}
/>
<div
className={css({
fontSize: 'sm',
color: 'gray.500',
mt: 1,
})}
>
Specify exact width in millimeters (overrides scale factor)
</div>
</div>
{/* Format Selection */}
<div data-setting="format" className={css({ mb: format === '3mf' ? 4 : 6 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 2,
})}
>
Output Format
</label>
<div className={css({ display: 'flex', gap: 2, flexWrap: 'wrap' })}>
<button
type="button"
onClick={() => setFormat('stl')}
className={css({
px: 4,
py: 2,
borderRadius: '4px',
border: '2px solid',
borderColor: format === 'stl' ? 'blue.600' : 'gray.300',
bg: format === 'stl' ? 'blue.50' : 'white',
color: format === 'stl' ? 'blue.700' : 'gray.700',
cursor: 'pointer',
fontWeight: format === 'stl' ? 'bold' : 'normal',
_hover: { bg: format === 'stl' ? 'blue.100' : 'gray.50' },
})}
>
STL
</button>
<button
type="button"
onClick={() => setFormat('3mf')}
className={css({
px: 4,
py: 2,
borderRadius: '4px',
border: '2px solid',
borderColor: format === '3mf' ? 'blue.600' : 'gray.300',
bg: format === '3mf' ? 'blue.50' : 'white',
color: format === '3mf' ? 'blue.700' : 'gray.700',
cursor: 'pointer',
fontWeight: format === '3mf' ? 'bold' : 'normal',
_hover: { bg: format === '3mf' ? 'blue.100' : 'gray.50' },
})}
>
3MF
</button>
<button
type="button"
onClick={() => setFormat('scad')}
className={css({
px: 4,
py: 2,
borderRadius: '4px',
border: '2px solid',
borderColor: format === 'scad' ? 'blue.600' : 'gray.300',
bg: format === 'scad' ? 'blue.50' : 'white',
color: format === 'scad' ? 'blue.700' : 'gray.700',
cursor: 'pointer',
fontWeight: format === 'scad' ? 'bold' : 'normal',
_hover: { bg: format === 'scad' ? 'blue.100' : 'gray.50' },
})}
>
OpenSCAD
</button>
</div>
</div>
{/* 3MF Color Options */}
{format === '3mf' && (
<div data-section="3mf-colors" className={css({ mb: 6 })}>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
mb: 3,
})}
>
3MF Color Customization
</h3>
{/* Frame Color */}
<div data-setting="frame-color" className={css({ mb: 3 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 1,
})}
>
Frame Color
</label>
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
<input
type="color"
value={frameColor}
onChange={(e) => setFrameColor(e.target.value)}
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
/>
<input
type="text"
value={frameColor}
onChange={(e) => setFrameColor(e.target.value)}
placeholder="#8b7355"
className={css({
flex: 1,
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
fontFamily: 'monospace',
})}
/>
</div>
</div>
{/* Heaven Bead Color */}
<div data-setting="heaven-bead-color" className={css({ mb: 3 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 1,
})}
>
Heaven Bead Color
</label>
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
<input
type="color"
value={heavenBeadColor}
onChange={(e) => setHeavenBeadColor(e.target.value)}
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
/>
<input
type="text"
value={heavenBeadColor}
onChange={(e) => setHeavenBeadColor(e.target.value)}
placeholder="#e8d5c4"
className={css({
flex: 1,
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
fontFamily: 'monospace',
})}
/>
</div>
</div>
{/* Earth Bead Color */}
<div data-setting="earth-bead-color" className={css({ mb: 3 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 1,
})}
>
Earth Bead Color
</label>
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
<input
type="color"
value={earthBeadColor}
onChange={(e) => setEarthBeadColor(e.target.value)}
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
/>
<input
type="text"
value={earthBeadColor}
onChange={(e) => setEarthBeadColor(e.target.value)}
placeholder="#6b5444"
className={css({
flex: 1,
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
fontFamily: 'monospace',
})}
/>
</div>
</div>
{/* Decoration Color */}
<div data-setting="decoration-color" className={css({ mb: 0 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 1,
})}
>
Decoration Color
</label>
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
<input
type="color"
value={decorationColor}
onChange={(e) => setDecorationColor(e.target.value)}
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
/>
<input
type="text"
value={decorationColor}
onChange={(e) => setDecorationColor(e.target.value)}
placeholder="#d4af37"
className={css({
flex: 1,
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
fontFamily: 'monospace',
})}
/>
</div>
</div>
</div>
)}
{/* Generate Button */}
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating}
data-action="generate"
className={css({
width: '100%',
px: 6,
py: 3,
bg: 'blue.600',
color: 'white',
borderRadius: '4px',
fontWeight: 'bold',
cursor: isGenerating ? 'not-allowed' : 'pointer',
opacity: isGenerating ? 0.6 : 1,
_hover: { bg: isGenerating ? 'blue.600' : 'blue.700' },
})}
>
{isGenerating ? 'Generating...' : 'Generate File'}
</button>
{/* Job Status */}
{jobId && !isComplete && (
<div className={css({ mt: 4 })}>
<JobMonitor jobId={jobId} onComplete={handleJobComplete} />
</div>
)}
{/* Download Button */}
{isComplete && (
<button
type="button"
onClick={handleDownload}
data-action="download"
className={css({
width: '100%',
mt: 4,
px: 6,
py: 3,
bg: 'green.600',
color: 'white',
borderRadius: '4px',
fontWeight: 'bold',
cursor: 'pointer',
_hover: { bg: 'green.700' },
})}
>
Download {format.toUpperCase()}
</button>
)}
{/* Error Message */}
{error && (
<div
data-status="error"
className={css({
mt: 4,
p: 4,
bg: 'red.100',
borderRadius: '4px',
color: 'red.700',
})}
>
{error}
</div>
)}
</div>
</div>
{/* Right column: Preview */}
<div data-section="preview">
<div
className={css({
bg: 'white',
p: 6,
borderRadius: '8px',
boxShadow: 'md',
})}
>
<h2
className={css({
fontSize: 'xl',
fontWeight: 'bold',
mb: 4,
})}
>
Preview
</h2>
<STLPreview columns={columns} scaleFactor={scaleFactor} />
<div
className={css({
mt: 4,
fontSize: 'sm',
color: 'gray.600',
})}
>
<p className={css({ mb: 2 })}>
<strong>Live Preview:</strong> The preview updates automatically as you adjust
parameters (with a 1-second delay). This shows the exact mirrored book-fold design
that will be generated.
</p>
<p className={css({ mb: 2 })}>
<strong>Note:</strong> Preview generation requires OpenSCAD. If you see an error,
the preview feature only works in production (Docker). The download functionality
will still work when deployed.
</p>
<p>Use your mouse to rotate and zoom the 3D model.</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,324 +0,0 @@
'use client'
import { css } from '../../../../../styled-system/css'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import Link from 'next/link'
interface CalendarConfigPanelProps {
month: number
year: number
format: 'monthly' | 'daily'
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
isGenerating: boolean
onMonthChange: (month: number) => void
onYearChange: (year: number) => void
onFormatChange: (format: 'monthly' | 'daily') => void
onPaperSizeChange: (size: 'us-letter' | 'a4' | 'a3' | 'tabloid') => void
onGenerate: () => void
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
export function CalendarConfigPanel({
month,
year,
format,
paperSize,
isGenerating,
onMonthChange,
onYearChange,
onFormatChange,
onPaperSizeChange,
onGenerate,
}: CalendarConfigPanelProps) {
const abacusConfig = useAbacusConfig()
return (
<div
data-component="calendar-config-panel"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
})}
>
{/* Format Selection */}
<fieldset
data-section="format-selection"
className={css({
border: 'none',
padding: '0',
margin: '0',
})}
>
<legend
className={css({
fontSize: '1.125rem',
fontWeight: '600',
marginBottom: '0.75rem',
color: 'yellow.400',
})}
>
Calendar Format
</legend>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
padding: '0.5rem',
borderRadius: '6px',
_hover: { bg: 'gray.700' },
})}
>
<input
type="radio"
value="monthly"
checked={format === 'monthly'}
onChange={(e) => onFormatChange(e.target.value as 'monthly' | 'daily')}
className={css({
cursor: 'pointer',
})}
/>
<span>Monthly Calendar (one page per month)</span>
</label>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
padding: '0.5rem',
borderRadius: '6px',
_hover: { bg: 'gray.700' },
})}
>
<input
type="radio"
value="daily"
checked={format === 'daily'}
onChange={(e) => onFormatChange(e.target.value as 'monthly' | 'daily')}
className={css({
cursor: 'pointer',
})}
/>
<span>Daily Calendar (one page per day)</span>
</label>
</div>
</fieldset>
{/* Date Selection */}
<fieldset
data-section="date-selection"
className={css({
border: 'none',
padding: '0',
margin: '0',
})}
>
<legend
className={css({
fontSize: '1.125rem',
fontWeight: '600',
marginBottom: '0.75rem',
color: 'yellow.400',
})}
>
Date
</legend>
<div
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
<select
data-element="month-select"
value={month}
onChange={(e) => onMonthChange(Number(e.target.value))}
className={css({
flex: '1',
padding: '0.5rem',
borderRadius: '6px',
bg: 'gray.700',
color: 'white',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
_hover: { borderColor: 'gray.500' },
})}
>
{MONTHS.map((monthName, index) => (
<option key={monthName} value={index + 1}>
{monthName}
</option>
))}
</select>
<input
type="number"
data-element="year-input"
value={year}
onChange={(e) => onYearChange(Number(e.target.value))}
min={1}
max={9999}
className={css({
width: '100px',
padding: '0.5rem',
borderRadius: '6px',
bg: 'gray.700',
color: 'white',
border: '1px solid',
borderColor: 'gray.600',
_hover: { borderColor: 'gray.500' },
})}
/>
</div>
</fieldset>
{/* Paper Size */}
<fieldset
data-section="paper-size"
className={css({
border: 'none',
padding: '0',
margin: '0',
})}
>
<legend
className={css({
fontSize: '1.125rem',
fontWeight: '600',
marginBottom: '0.75rem',
color: 'yellow.400',
})}
>
Paper Size
</legend>
<select
data-element="paper-size-select"
value={paperSize}
onChange={(e) =>
onPaperSizeChange(e.target.value as 'us-letter' | 'a4' | 'a3' | 'tabloid')
}
className={css({
width: '100%',
padding: '0.5rem',
borderRadius: '6px',
bg: 'gray.700',
color: 'white',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
_hover: { borderColor: 'gray.500' },
})}
>
<option value="us-letter">US Letter (8.5" × 11")</option>
<option value="a4">A4 (210mm × 297mm)</option>
<option value="a3">A3 (297mm × 420mm)</option>
<option value="tabloid">Tabloid (11" × 17")</option>
</select>
</fieldset>
{/* Abacus Styling Info */}
<div
data-section="styling-info"
className={css({
padding: '1rem',
bg: 'gray.700',
borderRadius: '8px',
})}
>
<p
className={css({
fontSize: '0.875rem',
marginBottom: '0.75rem',
color: 'gray.300',
})}
>
Using your saved abacus style:
</p>
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '0.75rem',
})}
>
<AbacusReact
value={12}
columns={2}
customStyles={abacusConfig.customStyles}
scaleFactor={0.5}
/>
</div>
<Link
href="/create"
data-action="edit-style"
className={css({
display: 'block',
textAlign: 'center',
fontSize: '0.875rem',
color: 'yellow.400',
textDecoration: 'underline',
_hover: { color: 'yellow.300' },
})}
>
Edit your abacus style
</Link>
</div>
{/* Generate Button */}
<button
type="button"
data-action="generate-calendar"
onClick={onGenerate}
disabled={isGenerating}
className={css({
padding: '1rem',
bg: 'yellow.500',
color: 'gray.900',
fontWeight: '600',
fontSize: '1.125rem',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'yellow.400',
},
_disabled: {
bg: 'gray.600',
color: 'gray.400',
cursor: 'not-allowed',
},
})}
>
{isGenerating ? 'Generating PDF...' : 'Generate PDF Calendar'}
</button>
</div>
)
}

View File

@@ -1,266 +0,0 @@
'use client'
import { css } from '../../../../../styled-system/css'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
interface CalendarPreviewProps {
month: number
year: number
format: 'monthly' | 'daily'
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
function getFirstDayOfWeek(year: number, month: number): number {
return new Date(year, month - 1, 1).getDay()
}
export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
const abacusConfig = useAbacusConfig()
const daysInMonth = getDaysInMonth(year, month)
const firstDayOfWeek = getFirstDayOfWeek(year, month)
if (format === 'daily') {
return (
<div
data-component="calendar-preview"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '600px',
})}
>
<p
className={css({
fontSize: '1.25rem',
color: 'gray.300',
marginBottom: '1.5rem',
textAlign: 'center',
})}
>
Daily format preview
</p>
<div
className={css({
bg: 'white',
padding: '3rem 2rem',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
maxWidth: '400px',
width: '100%',
})}
>
{/* Year at top */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '2rem',
})}
>
<AbacusReact
value={year}
columns={4}
customStyles={abacusConfig.customStyles}
scaleFactor={0.4}
/>
</div>
{/* Large day number */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '2rem',
})}
>
<AbacusReact
value={1}
columns={2}
customStyles={abacusConfig.customStyles}
scaleFactor={0.8}
/>
</div>
{/* Date text */}
<div
className={css({
textAlign: 'center',
color: 'gray.800',
})}
>
<div
className={css({
fontSize: '1.25rem',
fontWeight: '600',
marginBottom: '0.25rem',
})}
>
{new Date(year, month - 1, 1).toLocaleDateString('en-US', {
weekday: 'long',
})}
</div>
<div
className={css({
fontSize: '1rem',
color: 'gray.600',
})}
>
{MONTHS[month - 1]} 1, {year}
</div>
</div>
</div>
<p
className={css({
fontSize: '0.875rem',
color: 'gray.400',
marginTop: '1rem',
textAlign: 'center',
})}
>
Example of first day (1 page per day for all {daysInMonth} days)
</p>
</div>
)
}
// Monthly format
const calendarDays: (number | null)[] = []
// Add empty cells for days before the first day of month
for (let i = 0; i < firstDayOfWeek; i++) {
calendarDays.push(null)
}
// Add actual days
for (let day = 1; day <= daysInMonth; day++) {
calendarDays.push(day)
}
return (
<div
data-component="calendar-preview"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '2rem',
})}
>
<div
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h2
className={css({
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '1rem',
color: 'yellow.400',
})}
>
{MONTHS[month - 1]} {year}
</h2>
<div
className={css({
display: 'flex',
justifyContent: 'center',
})}
>
<AbacusReact
value={year}
columns={4}
customStyles={abacusConfig.customStyles}
scaleFactor={0.6}
/>
</div>
</div>
{/* Calendar Grid */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '0.5rem',
})}
>
{/* Weekday headers */}
{WEEKDAYS.map((day) => (
<div
key={day}
className={css({
textAlign: 'center',
fontWeight: '600',
padding: '0.5rem',
color: 'yellow.400',
fontSize: '0.875rem',
})}
>
{day}
</div>
))}
{/* Calendar days */}
{calendarDays.map((day, index) => (
<div
key={index}
className={css({
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: day ? 'gray.700' : 'transparent',
borderRadius: '6px',
padding: '0.25rem',
})}
>
{day && (
<AbacusReact
value={day}
columns={2}
customStyles={abacusConfig.customStyles}
scaleFactor={0.35}
/>
)}
</div>
))}
</div>
<p
className={css({
fontSize: '0.875rem',
color: 'gray.400',
marginTop: '1.5rem',
textAlign: 'center',
})}
>
Preview of monthly calendar layout (actual PDF will be optimized for printing)
</p>
</div>
)
}

View File

@@ -1,131 +0,0 @@
'use client'
import { useState } from 'react'
import { css } from '../../../../styled-system/css'
import { useAbacusConfig } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav'
import { CalendarConfigPanel } from './components/CalendarConfigPanel'
import { CalendarPreview } from './components/CalendarPreview'
export default function CalendarCreatorPage() {
const currentDate = new Date()
const abacusConfig = useAbacusConfig()
const [month, setMonth] = useState(currentDate.getMonth() + 1) // 1-12
const [year, setYear] = useState(currentDate.getFullYear())
const [format, setFormat] = useState<'monthly' | 'daily'>('monthly')
const [paperSize, setPaperSize] = useState<'us-letter' | 'a4' | 'a3' | 'tabloid'>('us-letter')
const [isGenerating, setIsGenerating] = useState(false)
const handleGenerate = async () => {
setIsGenerating(true)
try {
const response = await fetch('/api/create/calendar/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
month,
year,
format,
paperSize,
abacusConfig,
}),
})
if (!response.ok) {
throw new Error('Failed to generate calendar')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `calendar-${year}-${String(month).padStart(2, '0')}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Error generating calendar:', error)
alert('Failed to generate calendar. Please try again.')
} finally {
setIsGenerating(false)
}
}
return (
<PageWithNav navTitle="Create" navEmoji="📅">
<div
data-component="calendar-creator"
className={css({
minHeight: '100vh',
bg: 'gray.900',
color: 'white',
padding: '2rem',
})}
>
<div
className={css({
maxWidth: '1400px',
margin: '0 auto',
})}
>
{/* Header */}
<header
data-section="page-header"
className={css({
textAlign: 'center',
marginBottom: '3rem',
})}
>
<h1
className={css({
fontSize: '2.5rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: 'yellow.400',
})}
>
Create Abacus Calendar
</h1>
<p
className={css({
fontSize: '1.125rem',
color: 'gray.300',
})}
>
Generate printable calendars with abacus date numbers
</p>
</header>
{/* Main Content */}
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', lg: '350px 1fr' },
gap: '2rem',
})}
>
{/* Configuration Panel */}
<CalendarConfigPanel
month={month}
year={year}
format={format}
paperSize={paperSize}
isGenerating={isGenerating}
onMonthChange={setMonth}
onYearChange={setYear}
onFormatChange={setFormat}
onPaperSizeChange={setPaperSize}
onGenerate={handleGenerate}
/>
{/* Preview */}
<CalendarPreview month={month} year={year} format={format} />
</div>
</div>
</div>
</PageWithNav>
)
}

View File

@@ -1,411 +0,0 @@
'use client'
import { useAbacusConfig } from '@soroban/abacus-react'
import { useForm } from '@tanstack/react-form'
import { useState } from 'react'
import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationFormWithoutGenerate'
import { GenerationProgress } from '@/components/GenerationProgress'
import { LivePreview } from '@/components/LivePreview'
import { PageWithNav } from '@/components/PageWithNav'
import { StyleControls } from '@/components/StyleControls'
import { css } from '../../../../styled-system/css'
import { container, grid, hstack, stack } from '../../../../styled-system/patterns'
// Complete, validated configuration ready for generation
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' | 'html' | 'png' | 'svg'
}
// Partial form state during editing (may have undefined values)
export interface FlashcardFormState {
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' | 'html' | 'png' | 'svg'
}
// Validation function to convert form state to complete config
function validateAndCompleteConfig(formState: FlashcardFormState): FlashcardConfig {
return {
// Required fields with defaults
range: formState.range || '0-99',
// Optional fields with defaults
step: formState.step ?? 1,
cardsPerPage: formState.cardsPerPage ?? 6,
paperSize: formState.paperSize ?? 'us-letter',
orientation: formState.orientation ?? 'portrait',
gutter: formState.gutter ?? '5mm',
shuffle: formState.shuffle ?? false,
seed: formState.seed,
showCutMarks: formState.showCutMarks ?? false,
showRegistration: formState.showRegistration ?? false,
fontFamily: formState.fontFamily ?? 'DejaVu Sans',
fontSize: formState.fontSize ?? '48pt',
columns: formState.columns ?? 'auto',
showEmptyColumns: formState.showEmptyColumns ?? false,
hideInactiveBeads: formState.hideInactiveBeads ?? false,
beadShape: formState.beadShape ?? 'diamond',
colorScheme: formState.colorScheme ?? 'place-value',
coloredNumerals: formState.coloredNumerals ?? false,
scaleFactor: formState.scaleFactor ?? 0.9,
format: formState.format ?? 'pdf',
margins: formState.margins,
}
}
type GenerationStatus = 'idle' | 'generating' | 'error'
export default function CreatePage() {
const [generationStatus, setGenerationStatus] = useState<GenerationStatus>('idle')
const [error, setError] = useState<string | null>(null)
const globalConfig = useAbacusConfig()
const form = useForm<FlashcardFormState>({
defaultValues: {
range: '0-99',
step: 1,
cardsPerPage: 6,
paperSize: 'us-letter',
orientation: 'portrait',
gutter: '5mm',
shuffle: false,
showCutMarks: false,
showRegistration: false,
fontFamily: 'DejaVu Sans',
fontSize: '48pt',
columns: 'auto',
showEmptyColumns: false,
// Use global config for abacus display settings
hideInactiveBeads: globalConfig.hideInactiveBeads,
beadShape: globalConfig.beadShape,
colorScheme: globalConfig.colorScheme,
coloredNumerals: globalConfig.coloredNumerals,
scaleFactor: globalConfig.scaleFactor,
format: 'pdf',
},
})
const handleGenerate = async (formState: FlashcardFormState) => {
setGenerationStatus('generating')
setError(null)
try {
// Validate and complete the configuration
const config = validateAndCompleteConfig(formState)
const response = await fetch('/api/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
})
if (!response.ok) {
// Handle error response (should be JSON)
const errorResult = await response.json()
throw new Error(errorResult.error || 'Generation failed')
}
// Success - response is binary PDF data, trigger download
const blob = await response.blob()
const filename = `soroban-flashcards-${config.range || 'cards'}.pdf`
// Create download link and trigger download
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
setGenerationStatus('idle') // Reset to idle after successful download
} catch (err) {
console.error('Generation error:', err)
setError(err instanceof Error ? err.message : 'Unknown error occurred')
setGenerationStatus('error')
}
}
const handleNewGeneration = () => {
setGenerationStatus('idle')
setError(null)
}
return (
<PageWithNav navTitle="Create Flashcards" navEmoji="✨">
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
{/* Main Content */}
<div className={container({ maxW: '7xl', px: '4', py: '8' })}>
<div className={stack({ gap: '6', mb: '8' })}>
<div className={stack({ gap: '2', textAlign: 'center' })}>
<h1
className={css({
fontSize: '3xl',
fontWeight: 'bold',
color: 'gray.900',
})}
>
Create Your Flashcards
</h1>
<p
className={css({
fontSize: 'lg',
color: 'gray.600',
})}
>
Configure content and style, preview instantly, then generate your flashcards
</p>
</div>
</div>
{/* Configuration Interface */}
<div
className={grid({
columns: { base: 1, lg: 3 },
gap: '8',
alignItems: 'start',
})}
>
{/* Main Configuration Panel */}
<div
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '8',
})}
>
<ConfigurationFormWithoutGenerate form={form} />
</div>
{/* Style Controls Panel */}
<div
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '6',
})}
>
<div className={stack({ gap: '4' })}>
<div className={stack({ gap: '1' })}>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: 'gray.900',
})}
>
🎨 Visual Style
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
See changes instantly in the preview
</p>
</div>
<form.Subscribe
selector={(state) => state}
children={(_state) => <StyleControls form={form} />}
/>
</div>
</div>
{/* Live Preview Panel */}
<div
className={css({
bg: 'white',
rounded: '2xl',
shadow: 'card',
p: '6',
})}
>
<div className={stack({ gap: '6' })}>
<form.Subscribe
selector={(state) => state}
children={(state) => <LivePreview config={state.values} />}
/>
{/* Generate Button within Preview */}
<div
className={css({
borderTop: '1px solid',
borderColor: 'gray.200',
pt: '6',
})}
>
{/* Generation Status */}
{generationStatus === 'generating' && (
<div className={css({ mb: '4' })}>
<GenerationProgress config={form.state.values} />
</div>
)}
<button
onClick={() => handleGenerate(form.state.values)}
disabled={generationStatus === 'generating'}
className={css({
w: 'full',
px: '6',
py: '4',
bg: 'brand.600',
color: 'white',
fontSize: 'lg',
fontWeight: 'semibold',
rounded: 'xl',
shadow: 'card',
transition: 'all',
cursor: generationStatus === 'generating' ? 'not-allowed' : 'pointer',
opacity: generationStatus === 'generating' ? '0.7' : '1',
_hover:
generationStatus === 'generating'
? {}
: {
bg: 'brand.700',
transform: 'translateY(-1px)',
shadow: 'modal',
},
})}
>
<span className={hstack({ gap: '3', justify: 'center' })}>
{generationStatus === 'generating' ? (
<>
<div
className={css({
w: '5',
h: '5',
border: '2px solid',
borderColor: 'white',
borderTopColor: 'transparent',
rounded: 'full',
animation: 'spin 1s linear infinite',
})}
/>
Generating Your Flashcards...
</>
) : (
<>
<div className={css({ fontSize: 'xl' })}></div>
Generate Flashcards
</>
)}
</span>
</button>
</div>
</div>
</div>
</div>
{/* Error Display - moved to global level */}
{generationStatus === 'error' && error && (
<div
className={css({
bg: 'red.50',
border: '1px solid',
borderColor: 'red.200',
rounded: '2xl',
p: '8',
mt: '8',
})}
>
<div className={stack({ gap: '4' })}>
<div className={hstack({ gap: '3', alignItems: 'center' })}>
<div className={css({ fontSize: '2xl' })}></div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'semibold',
color: 'red.800',
})}
>
Generation Failed
</h3>
</div>
<p
className={css({
color: 'red.700',
lineHeight: 'relaxed',
})}
>
{error}
</p>
<button
onClick={handleNewGeneration}
className={css({
alignSelf: 'start',
px: '4',
py: '2',
bg: 'red.600',
color: 'white',
fontWeight: 'medium',
rounded: 'lg',
transition: 'all',
_hover: { bg: 'red.700' },
})}
>
Try Again
</button>
</div>
</div>
)}
</div>
</div>
</PageWithNav>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -45,13 +45,3 @@ body {
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}

View File

@@ -2,7 +2,6 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import Link from 'next/link'
import { useMessages, useTranslations } from 'next-intl'
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
import { getTutorialForEditor } from '@/utils/tutorialConverter'
import { css } from '../../../../styled-system/css'
@@ -10,8 +9,6 @@ import { grid } from '../../../../styled-system/patterns'
export function ArithmeticOperationsGuide() {
const appConfig = useAbacusConfig()
const messages = useMessages() as any
const t = useTranslations('guide.arithmetic')
return (
<div className={css({ maxW: '4xl', mx: 'auto' })}>
@@ -34,7 +31,7 @@ export function ArithmeticOperationsGuide() {
mb: '4',
})}
>
{t('title')}
🧮 Arithmetic Operations
</h2>
<p
className={css({
@@ -42,7 +39,7 @@ export function ArithmeticOperationsGuide() {
opacity: '0.9',
})}
>
{t('subtitle')}
Master addition, subtraction, multiplication, and division on the soroban
</p>
</div>
@@ -69,10 +66,13 @@ export function ArithmeticOperationsGuide() {
gap: '2',
})}
>
{t('addition.title')}
<span></span> Addition
</h3>
<p className={css({ mb: '6', color: 'gray.700' })}>{t('addition.description')}</p>
<p className={css({ mb: '6', color: 'gray.700' })}>
Addition on the soroban follows the principle of moving beads toward the bar to increase
values.
</p>
<div className={css({ mb: '6' })}>
<h4
@@ -83,7 +83,7 @@ export function ArithmeticOperationsGuide() {
color: 'green.600',
})}
>
{t('addition.basicSteps.title')}
Basic Steps:
</h4>
<ol
className={css({
@@ -92,11 +92,12 @@ export function ArithmeticOperationsGuide() {
color: 'gray.700',
})}
>
{(t.raw('addition.basicSteps.steps') as string[]).map((step, i) => (
<li key={i} className={css({ mb: i < 3 ? '2' : '0' })}>
{i + 1}. {step}
</li>
))}
<li className={css({ mb: '2' })}>1. Set the first number on the soroban</li>
<li className={css({ mb: '2' })}>
2. Add the second number by moving beads toward the bar
</li>
<li className={css({ mb: '2' })}>3. Handle carries when a column exceeds 9</li>
<li>4. Read the final result</li>
</ol>
</div>
@@ -117,13 +118,11 @@ export function ArithmeticOperationsGuide() {
mb: '2',
})}
>
{t('addition.example.title')}
Example: 3 + 4 = 7
</h5>
<div className={grid({ columns: 3, gap: '4', alignItems: 'center' })}>
<div className={css({ textAlign: 'center' })}>
<p className={css({ fontSize: 'sm', mb: '2', color: 'green.700' })}>
{t('addition.example.start')}
</p>
<p className={css({ fontSize: 'sm', mb: '2', color: 'green.700' })}>Start: 3</p>
<div
className={css({
width: '160px',
@@ -155,9 +154,7 @@ export function ArithmeticOperationsGuide() {
</div>
<div className={css({ textAlign: 'center', fontSize: '2xl' })}>+</div>
<div className={css({ textAlign: 'center' })}>
<p className={css({ fontSize: 'sm', mb: '2', color: 'green.700' })}>
{t('addition.example.result')}
</p>
<p className={css({ fontSize: 'sm', mb: '2', color: 'green.700' })}>Result: 7</p>
<div
className={css({
width: '160px',
@@ -214,10 +211,12 @@ export function ArithmeticOperationsGuide() {
gap: '2',
})}
>
{t('guidedTutorial.title')}
<span>🎯</span> Guided Addition Tutorial
</h3>
<p className={css({ mb: '6', color: 'gray.700' })}>{t('guidedTutorial.description')}</p>
<p className={css({ mb: '6', color: 'gray.700' })}>
Learn addition step-by-step with interactive guidance, tooltips, and error correction.
</p>
<div
className={css({
@@ -240,10 +239,10 @@ export function ArithmeticOperationsGuide() {
})}
>
<span></span>
<strong>{t('guidedTutorial.editableNote')}</strong>
<strong>This tutorial is now editable!</strong>
</p>
<p className={css({ fontSize: 'xs', color: 'blue.600' })}>
{t('guidedTutorial.editableDesc')}{' '}
You can customize this tutorial using our new tutorial editor system.{' '}
<a
href="/tutorial-editor"
className={css({
@@ -252,13 +251,13 @@ export function ArithmeticOperationsGuide() {
_hover: { color: 'blue.800' },
})}
>
{t('guidedTutorial.editableLink')}
Open in Editor
</a>
</p>
</div>
<TutorialPlayer
tutorial={getTutorialForEditor(messages.tutorial || {})}
tutorial={getTutorialForEditor()}
isDebugMode={false}
showDebugPanel={false}
/>
@@ -287,10 +286,12 @@ export function ArithmeticOperationsGuide() {
gap: '2',
})}
>
{t('subtraction.title')}
<span></span> Subtraction
</h3>
<p className={css({ mb: '6', color: 'gray.700' })}>{t('subtraction.description')}</p>
<p className={css({ mb: '6', color: 'gray.700' })}>
Subtraction involves moving beads away from the bar to decrease values.
</p>
<div className={css({ mb: '6' })}>
<h4
@@ -301,7 +302,7 @@ export function ArithmeticOperationsGuide() {
color: 'red.600',
})}
>
{t('subtraction.basicSteps.title')}
Basic Steps:
</h4>
<ol
className={css({
@@ -310,11 +311,10 @@ export function ArithmeticOperationsGuide() {
color: 'gray.700',
})}
>
{(t.raw('subtraction.basicSteps.steps') as string[]).map((step, i) => (
<li key={i} className={css({ mb: i < 3 ? '2' : '0' })}>
{i + 1}. {step}
</li>
))}
<li className={css({ mb: '2' })}>1. Set the minuend (first number) on the soroban</li>
<li className={css({ mb: '2' })}>2. Subtract by moving beads away from the bar</li>
<li className={css({ mb: '2' })}>3. Handle borrowing when needed</li>
<li>4. Read the final result</li>
</ol>
</div>
@@ -335,13 +335,11 @@ export function ArithmeticOperationsGuide() {
mb: '2',
})}
>
{t('subtraction.example.title')}
Example: 8 - 3 = 5
</h5>
<div className={grid({ columns: 3, gap: '4', alignItems: 'center' })}>
<div className={css({ textAlign: 'center' })}>
<p className={css({ fontSize: 'sm', mb: '2', color: 'red.700' })}>
{t('subtraction.example.start')}
</p>
<p className={css({ fontSize: 'sm', mb: '2', color: 'red.700' })}>Start: 8</p>
<div
className={css({
width: '160px',
@@ -373,9 +371,7 @@ export function ArithmeticOperationsGuide() {
</div>
<div className={css({ textAlign: 'center', fontSize: '2xl' })}>-</div>
<div className={css({ textAlign: 'center' })}>
<p className={css({ fontSize: 'sm', mb: '2', color: 'red.700' })}>
{t('subtraction.example.result')}
</p>
<p className={css({ fontSize: 'sm', mb: '2', color: 'red.700' })}>Result: 5</p>
<div
className={css({
width: '160px',
@@ -432,11 +428,11 @@ export function ArithmeticOperationsGuide() {
gap: '2',
})}
>
{t('multiplicationDivision.title')}
<span></span> Multiplication & Division
</h3>
<p className={css({ mb: '6', color: 'gray.700' })}>
{t('multiplicationDivision.description')}
Advanced operations that combine addition/subtraction with position shifting.
</p>
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
@@ -457,7 +453,7 @@ export function ArithmeticOperationsGuide() {
mb: '3',
})}
>
{t('multiplicationDivision.multiplication.title')}
Multiplication
</h4>
<ul
className={css({
@@ -466,13 +462,10 @@ export function ArithmeticOperationsGuide() {
pl: '4',
})}
>
{(t.raw('multiplicationDivision.multiplication.points') as string[]).map(
(point, i) => (
<li key={i} className={css({ mb: i < 3 ? '2' : '0' })}>
{point}
</li>
)
)}
<li className={css({ mb: '2' })}> Break down into repeated addition</li>
<li className={css({ mb: '2' })}> Use position shifts for place values</li>
<li className={css({ mb: '2' })}> Master multiplication tables</li>
<li> Practice with single digits first</li>
</ul>
</div>
@@ -493,7 +486,7 @@ export function ArithmeticOperationsGuide() {
mb: '3',
})}
>
{t('multiplicationDivision.division.title')}
Division
</h4>
<ul
className={css({
@@ -502,11 +495,10 @@ export function ArithmeticOperationsGuide() {
pl: '4',
})}
>
{(t.raw('multiplicationDivision.division.points') as string[]).map((point, i) => (
<li key={i} className={css({ mb: i < 3 ? '2' : '0' })}>
{point}
</li>
))}
<li className={css({ mb: '2' })}> Use repeated subtraction method</li>
<li className={css({ mb: '2' })}> Estimate quotients carefully</li>
<li className={css({ mb: '2' })}> Handle remainders properly</li>
<li> Check results by multiplication</li>
</ul>
</div>
</div>
@@ -531,7 +523,7 @@ export function ArithmeticOperationsGuide() {
mb: '3',
})}
>
{t('practiceTips.title')}
💡 Master the Fundamentals
</h4>
<p
className={css({
@@ -539,7 +531,7 @@ export function ArithmeticOperationsGuide() {
opacity: '0.9',
})}
>
{t('practiceTips.description')}
Start with simple problems and gradually increase complexity
</p>
<Link
href="/create"
@@ -556,7 +548,7 @@ export function ArithmeticOperationsGuide() {
_hover: { transform: 'translateY(-1px)', shadow: 'lg' },
})}
>
{t('practiceTips.button')}
Practice Arithmetic Operations
</Link>
</div>
</div>

View File

@@ -2,13 +2,11 @@
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { css } from '../../../../styled-system/css'
import { grid, hstack, stack } from '../../../../styled-system/patterns'
export function ReadingNumbersGuide() {
const appConfig = useAbacusConfig()
const t = useTranslations('guide.reading')
return (
<div className={stack({ gap: '12' })}>
@@ -22,7 +20,7 @@ export function ReadingNumbersGuide() {
mb: '4',
})}
>
{t('title')}
🔍 Learning to Read Soroban Numbers
</h2>
<p
className={css({
@@ -33,7 +31,8 @@ export function ReadingNumbersGuide() {
lineHeight: 'relaxed',
})}
>
{t('subtitle')}
Master the fundamentals of reading numbers on the soroban with step-by-step visual
tutorials
</p>
</div>
@@ -62,7 +61,7 @@ export function ReadingNumbersGuide() {
fontSize: 'lg',
})}
>
{t('structure.number')}
1
</div>
<h3
className={css({
@@ -71,7 +70,7 @@ export function ReadingNumbersGuide() {
color: 'gray.900',
})}
>
{t('structure.title')}
Understanding the Structure
</h3>
</div>
@@ -83,7 +82,8 @@ export function ReadingNumbersGuide() {
lineHeight: 'relaxed',
})}
>
{t('structure.description')}
The soroban consists of two main sections divided by a horizontal bar. Understanding
this structure is fundamental to reading any number.
</p>
<div className={grid({ columns: { base: 1, md: 2 }, gap: '8' })}>
@@ -104,7 +104,7 @@ export function ReadingNumbersGuide() {
mb: '3',
})}
>
{t('structure.heaven.title')}
🌅 Heaven Beads (Top)
</h4>
<ul
className={css({
@@ -114,11 +114,10 @@ export function ReadingNumbersGuide() {
pl: '4',
})}
>
{(t.raw('structure.heaven.points') as string[]).map((point, i) => (
<li key={i} className={css({ mb: i < 3 ? '2' : '0' })}>
{point}
</li>
))}
<li className={css({ mb: '2' })}> Located above the horizontal bar</li>
<li className={css({ mb: '2' })}> Each bead represents 5</li>
<li className={css({ mb: '2' })}> Only one bead per column</li>
<li> When pushed down = active/counted</li>
</ul>
</div>
@@ -139,7 +138,7 @@ export function ReadingNumbersGuide() {
mb: '3',
})}
>
{t('structure.earth.title')}
🌍 Earth Beads (Bottom)
</h4>
<ul
className={css({
@@ -149,11 +148,10 @@ export function ReadingNumbersGuide() {
pl: '4',
})}
>
{(t.raw('structure.earth.points') as string[]).map((point, i) => (
<li key={i} className={css({ mb: i < 3 ? '2' : '0' })}>
{point}
</li>
))}
<li className={css({ mb: '2' })}> Located below the horizontal bar</li>
<li className={css({ mb: '2' })}> Each bead represents 1</li>
<li className={css({ mb: '2' })}> Four beads per column</li>
<li> When pushed up = active/counted</li>
</ul>
</div>
</div>
@@ -175,7 +173,7 @@ export function ReadingNumbersGuide() {
fontWeight: 'medium',
})}
>
{t('structure.keyConcept')}
💡 Key Concept: Active beads are those touching the horizontal bar
</p>
</div>
</div>
@@ -207,7 +205,7 @@ export function ReadingNumbersGuide() {
fontSize: 'lg',
})}
>
{t('singleDigits.number')}
2
</div>
<h3
className={css({
@@ -216,7 +214,7 @@ export function ReadingNumbersGuide() {
color: 'gray.900',
})}
>
{t('singleDigits.title')}
Reading Single Digits (1-9)
</h3>
</div>
@@ -227,16 +225,17 @@ export function ReadingNumbersGuide() {
lineHeight: 'relaxed',
})}
>
{t('singleDigits.description')}
Let's learn to read single digits by understanding how heaven and earth beads combine to
represent numbers 1 through 9.
</p>
<div className={grid({ columns: { base: 1, lg: 5 }, gap: '6' })}>
{[
{ num: 0, descKey: '0' },
{ num: 1, descKey: '1' },
{ num: 3, descKey: '3' },
{ num: 5, descKey: '5' },
{ num: 7, descKey: '7' },
{ num: 0, desc: 'No beads active - all away from bar' },
{ num: 1, desc: 'One earth bead pushed up' },
{ num: 3, desc: 'Three earth beads pushed up' },
{ num: 5, desc: 'Heaven bead pushed down' },
{ num: 7, desc: 'Heaven bead + two earth beads' },
].map((example) => (
<div
key={example.num}
@@ -302,7 +301,7 @@ export function ReadingNumbersGuide() {
mt: 'auto',
})}
>
{t(`singleDigits.examples.${example.descKey}`)}
{example.desc}
</p>
</div>
))}
@@ -335,7 +334,7 @@ export function ReadingNumbersGuide() {
fontSize: 'lg',
})}
>
{t('multiDigit.number')}
3
</div>
<h3
className={css({
@@ -344,7 +343,7 @@ export function ReadingNumbersGuide() {
color: 'gray.900',
})}
>
{t('multiDigit.title')}
Multi-Digit Numbers
</h3>
</div>
@@ -355,7 +354,8 @@ export function ReadingNumbersGuide() {
lineHeight: 'relaxed',
})}
>
{t('multiDigit.description')}
Reading larger numbers is simply a matter of reading each column from left to right,
with each column representing a different place value.
</p>
<div
@@ -376,7 +376,7 @@ export function ReadingNumbersGuide() {
textAlign: 'center',
})}
>
{t('multiDigit.readingDirection.title')}
📍 Reading Direction & Place Values
</h4>
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
<div>
@@ -387,7 +387,7 @@ export function ReadingNumbersGuide() {
color: 'purple.800',
})}
>
{t('multiDigit.readingDirection.readingOrder.title')}
Reading Order:
</h5>
<ul
className={css({
@@ -396,13 +396,9 @@ export function ReadingNumbersGuide() {
pl: '4',
})}
>
{(t.raw('multiDigit.readingDirection.readingOrder.points') as string[]).map(
(point, i) => (
<li key={i} className={css({ mb: i < 2 ? '1' : '0' })}>
{point}
</li>
)
)}
<li className={css({ mb: '1' })}>• Always read from LEFT to RIGHT</li>
<li className={css({ mb: '1' })}>• Each column is one digit</li>
<li>• Combine digits to form the complete number</li>
</ul>
</div>
<div>
@@ -413,7 +409,7 @@ export function ReadingNumbersGuide() {
color: 'purple.800',
})}
>
{t('multiDigit.readingDirection.placeValues.title')}
Place Values:
</h5>
<ul
className={css({
@@ -422,13 +418,9 @@ export function ReadingNumbersGuide() {
pl: '4',
})}
>
{(t.raw('multiDigit.readingDirection.placeValues.points') as string[]).map(
(point, i) => (
<li key={i} className={css({ mb: i < 2 ? '1' : '0' })}>
{point}
</li>
)
)}
<li className={css({ mb: '1' })}>• Rightmost = Ones (1s)</li>
<li className={css({ mb: '1' })}>• Next left = Tens (10s)</li>
<li>• Continue for hundreds, thousands, etc.</li>
</ul>
</div>
</div>
@@ -453,14 +445,20 @@ export function ReadingNumbersGuide() {
textAlign: 'center',
})}
>
{t('multiDigit.examples.title')}
🔢 Multi-Digit Examples
</h4>
<div className={grid({ columns: { base: 1, md: 3 }, gap: '8' })}>
{[
{ num: 23, descKey: '23' },
{ num: 58, descKey: '58' },
{ num: 147, descKey: '147' },
{
num: 23,
desc: 'Two-digit: 2 in tens place + 3 in ones place',
},
{
num: 58,
desc: 'Heaven bead in tens (5) + heaven + earth beads in ones (8)',
},
{ num: 147, desc: 'Three-digit: 1 hundred + 4 tens + 7 ones' },
].map((example) => (
<div
key={example.num}
@@ -525,7 +523,7 @@ export function ReadingNumbersGuide() {
textAlign: 'center',
})}
>
{t(`multiDigit.examples.${example.descKey}`)}
{example.desc}
</p>
</div>
))}
@@ -559,7 +557,7 @@ export function ReadingNumbersGuide() {
fontSize: 'lg',
})}
>
{t('practice.number')}
4
</div>
<h3
className={css({
@@ -568,7 +566,7 @@ export function ReadingNumbersGuide() {
color: 'gray.900',
})}
>
{t('practice.title')}
Practice Strategy
</h3>
</div>
@@ -590,7 +588,7 @@ export function ReadingNumbersGuide() {
mb: '4',
})}
>
{t('practice.learningTips.title')}
🎯 Learning Tips
</h4>
<ul
className={css({
@@ -600,11 +598,12 @@ export function ReadingNumbersGuide() {
pl: '4',
})}
>
{(t.raw('practice.learningTips.points') as string[]).map((point, i) => (
<li key={i} className={css({ mb: i < 3 ? '2' : '0' })}>
{point}
</li>
))}
<li className={css({ mb: '2' })}>• Start with single digits (0-9)</li>
<li className={css({ mb: '2' })}>
• Practice identifying active vs. inactive beads
</li>
<li className={css({ mb: '2' })}>• Work on speed recognition</li>
<li>• Progress to multi-digit numbers gradually</li>
</ul>
</div>
@@ -625,7 +624,7 @@ export function ReadingNumbersGuide() {
mb: '4',
})}
>
{t('practice.quickRecognition.title')}
⚡ Quick Recognition
</h4>
<ul
className={css({
@@ -635,11 +634,10 @@ export function ReadingNumbersGuide() {
pl: '4',
})}
>
{(t.raw('practice.quickRecognition.points') as string[]).map((point, i) => (
<li key={i} className={css({ mb: i < 3 ? '2' : '0' })}>
{point}
</li>
))}
<li className={css({ mb: '2' })}>• Numbers 1-4: Only earth beads</li>
<li className={css({ mb: '2' })}>• Number 5: Only heaven bead</li>
<li className={css({ mb: '2' })}>• Numbers 6-9: Heaven + earth beads</li>
<li>• Zero: All beads away from bar</li>
</ul>
</div>
</div>
@@ -660,7 +658,7 @@ export function ReadingNumbersGuide() {
mb: '3',
})}
>
{t('practice.readyToPractice.title')}
🚀 Ready to Practice?
</h4>
<p
className={css({
@@ -668,7 +666,7 @@ export function ReadingNumbersGuide() {
opacity: '0.9',
})}
>
{t('practice.readyToPractice.description')}
Test your newfound knowledge with interactive flashcards
</p>
<Link
href="/create"
@@ -685,7 +683,7 @@ export function ReadingNumbersGuide() {
_hover: { transform: 'translateY(-1px)', shadow: 'lg' },
})}
>
{t('practice.readyToPractice.button')}
Create Practice Flashcards →
</Link>
</div>
</div>
@@ -716,7 +714,7 @@ export function ReadingNumbersGuide() {
fontSize: 'lg',
})}
>
{t('interactive.number')}
5
</div>
<h3
className={css({
@@ -725,7 +723,7 @@ export function ReadingNumbersGuide() {
color: 'gray.900',
})}
>
{t('interactive.title')}
Interactive Practice
</h3>
</div>
@@ -736,7 +734,8 @@ export function ReadingNumbersGuide() {
lineHeight: 'relaxed',
})}
>
{t('interactive.description')}
Try the interactive abacus below! Click on the beads to activate them and watch the
number change in real-time.
</p>
<div
@@ -757,7 +756,7 @@ export function ReadingNumbersGuide() {
textAlign: 'center',
})}
>
{t('interactive.howToUse.title')}
🎮 How to Use the Interactive Abacus
</h4>
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
<div>
@@ -768,7 +767,7 @@ export function ReadingNumbersGuide() {
color: 'orange.800',
})}
>
{t('interactive.howToUse.heaven.title')}
Heaven Beads (Top):
</h5>
<ul
className={css({
@@ -777,11 +776,9 @@ export function ReadingNumbersGuide() {
pl: '4',
})}
>
{(t.raw('interactive.howToUse.heaven.points') as string[]).map((point, i) => (
<li key={i} className={css({ mb: i < 2 ? '1' : '0' })}>
{point}
</li>
))}
<li className={css({ mb: '1' })}>• Worth 5 points each</li>
<li className={css({ mb: '1' })}>• Click to toggle on/off</li>
<li>• Blue when active, gray when inactive</li>
</ul>
</div>
<div>
@@ -792,7 +789,7 @@ export function ReadingNumbersGuide() {
color: 'orange.800',
})}
>
{t('interactive.howToUse.earth.title')}
Earth Beads (Bottom):
</h5>
<ul
className={css({
@@ -801,11 +798,9 @@ export function ReadingNumbersGuide() {
pl: '4',
})}
>
{(t.raw('interactive.howToUse.earth.points') as string[]).map((point, i) => (
<li key={i} className={css({ mb: i < 2 ? '1' : '0' })}>
{point}
</li>
))}
<li className={css({ mb: '1' })}>• Worth 1 point each</li>
<li className={css({ mb: '1' })}>• Click to activate groups</li>
<li>• Green when active, gray when inactive</li>
</ul>
</div>
</div>
@@ -854,7 +849,7 @@ export function ReadingNumbersGuide() {
mb: '3',
})}
>
{t('interactive.readyToPractice.title')}
🚀 Ready to Practice?
</h4>
<p
className={css({
@@ -862,7 +857,7 @@ export function ReadingNumbersGuide() {
opacity: '0.9',
})}
>
{t('interactive.readyToPractice.description')}
Test your newfound knowledge with interactive flashcards
</p>
<Link
href="/create"
@@ -879,7 +874,7 @@ export function ReadingNumbersGuide() {
_hover: { transform: 'translateY(-1px)', shadow: 'lg' },
})}
>
{t('interactive.readyToPractice.button')}
Create Practice Flashcards
</Link>
</div>
</div>

View File

@@ -1,7 +1,6 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../styled-system/css'
import { container, hstack } from '../../../styled-system/patterns'
@@ -11,11 +10,10 @@ import { ReadingNumbersGuide } from './components/ReadingNumbersGuide'
type TabType = 'reading' | 'arithmetic'
export default function GuidePage() {
const t = useTranslations('guide.page')
const [activeTab, setActiveTab] = useState<TabType>('reading')
return (
<PageWithNav navTitle={t('navTitle')} navEmoji="📖">
<PageWithNav navTitle="Interactive Guide" navEmoji="📖">
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
{/* Hero Section */}
<div
@@ -35,7 +33,7 @@ export default function GuidePage() {
textShadow: '0 4px 20px rgba(0,0,0,0.3)',
})}
>
{t('hero.title')}
📚 Complete Soroban Mastery Guide
</h1>
<p
className={css({
@@ -46,7 +44,8 @@ export default function GuidePage() {
lineHeight: 'relaxed',
})}
>
{t('hero.subtitle')}
From basic reading to advanced arithmetic - everything you need to master the Japanese
abacus
</p>
</div>
</div>
@@ -79,7 +78,7 @@ export default function GuidePage() {
},
})}
>
{t('tabs.reading')}
📖 Reading Numbers
</button>
<button
onClick={() => setActiveTab('arithmetic')}
@@ -99,7 +98,7 @@ export default function GuidePage() {
},
})}
>
{t('tabs.arithmetic')}
🧮 Arithmetic Operations
</button>
</div>
</div>

View File

@@ -1,55 +0,0 @@
import { execSync } from 'child_process'
import { join } from 'path'
export const runtime = 'nodejs'
// In-memory cache: { day: svg }
const iconCache = new Map<number, string>()
// Get current day of month in US Central Time
function getDayOfMonth(): number {
const now = new Date()
// Get date in America/Chicago timezone
const centralDate = new Date(now.toLocaleString('en-US', { timeZone: 'America/Chicago' }))
return centralDate.getDate()
}
// Generate icon by calling script that uses react-dom/server
function generateDayIcon(day: number): string {
// Call the generation script as a subprocess
// Scripts can use react-dom/server, route handlers cannot
const scriptPath = join(process.cwd(), 'scripts', 'generateDayIcon.tsx')
const svg = execSync(`npx tsx "${scriptPath}" ${day}`, {
encoding: 'utf-8',
cwd: process.cwd(),
})
return svg
}
export async function GET() {
const dayOfMonth = getDayOfMonth()
// Check cache first
let svg = iconCache.get(dayOfMonth)
if (!svg) {
// Generate and cache
svg = generateDayIcon(dayOfMonth)
iconCache.set(dayOfMonth, svg)
// Clear old cache entries (keep only current day)
for (const [cachedDay] of iconCache) {
if (cachedDay !== dayOfMonth) {
iconCache.delete(cachedDay)
}
}
}
return new Response(svg, {
headers: {
'Content-Type': 'image/svg+xml',
// Cache for 1 hour so it updates throughout the day
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
})
}

View File

@@ -1,75 +1,11 @@
import type { Metadata, Viewport } from 'next'
import './globals.css'
import { ClientProviders } from '@/components/ClientProviders'
import { getRequestLocale } from '@/i18n/request'
import { getMessages } from '@/i18n/messages'
export const metadata: Metadata = {
metadataBase: new URL('https://abaci.one'),
title: {
default: 'Abaci.One - Interactive Soroban Learning',
template: '%s | Abaci.One',
},
title: 'Soroban Flashcard Generator',
description:
'Master the Japanese abacus (soroban) with interactive tutorials, arcade-style math games, and beautiful flashcards. Learn arithmetic through play with Rithmomachia, Complement Race, and more.',
keywords: [
'soroban',
'abacus',
'Japanese abacus',
'mental arithmetic',
'math games',
'abacus tutorial',
'soroban learning',
'arithmetic practice',
'educational games',
'Rithmomachia',
'number bonds',
'complement training',
],
authors: [{ name: 'Abaci.One' }],
creator: 'Abaci.One',
publisher: 'Abaci.One',
// Open Graph
openGraph: {
type: 'website',
locale: 'en_US',
alternateLocale: ['de_DE', 'ja_JP', 'hi_IN', 'es_ES', 'la'],
url: 'https://abaci.one',
title: 'Abaci.One - Interactive Soroban Learning',
description: 'Master the Japanese abacus through interactive games, tutorials, and practice',
siteName: 'Abaci.One',
},
// Twitter
twitter: {
card: 'summary_large_image',
title: 'Abaci.One - Interactive Soroban Learning',
description: 'Master the Japanese abacus through games and practice',
},
// Icons
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
{ url: '/icon', type: 'image/svg+xml' },
],
apple: '/apple-touch-icon.png',
},
// Manifest
manifest: '/manifest.json',
// App-specific
applicationName: 'Abaci.One',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Abaci.One',
},
// Category
category: 'education',
'Create beautiful, educational soroban flashcards with authentic Japanese abacus representations',
}
export const viewport: Viewport = {
@@ -79,16 +15,11 @@ export const viewport: Viewport = {
userScalable: false,
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getRequestLocale()
const messages = await getMessages(locale)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang={locale}>
<html lang="en">
<body>
<ClientProviders initialLocale={locale} initialMessages={messages}>
{children}
</ClientProviders>
<ClientProviders>{children}</ClientProviders>
</body>
</html>
)

View File

@@ -1,116 +0,0 @@
import { ImageResponse } from 'next/og'
import { readFileSync } from 'fs'
import { join } from 'path'
// Route segment config
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
// Image metadata
export const alt = 'Abaci.One - Interactive Soroban Learning Platform'
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
// Extract just the abacus SVG content from the pre-generated og-image.svg
// This SVG is generated by scripts/generateAbacusIcons.tsx using AbacusReact
function getAbacusSVGContent(): string {
const svgPath = join(process.cwd(), 'public', 'og-image.svg')
const svgContent = readFileSync(svgPath, 'utf-8')
// Extract just the abacus <g> element (contains the AbacusReact output)
const abacusMatch = svgContent.match(
/<!-- Left side - Abacus from @soroban\/abacus-react -->\s*<g[^>]*>([\s\S]*?)<\/g>/
)
if (!abacusMatch) {
throw new Error('Could not extract abacus content from og-image.svg')
}
return abacusMatch[0] // Return the full <g>...</g> block with AbacusReact output
}
// Image generation
// Note: Uses pre-generated SVG from og-image.svg which is rendered by AbacusReact
// This avoids importing react-dom/server in this file (Next.js restriction)
export default async function Image() {
const abacusSVG = getAbacusSVGContent()
return new ImageResponse(
<div
style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fcd34d 100%)',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '80px',
}}
>
{/* Left side - Abacus from pre-generated og-image.svg (AbacusReact output) */}
<div
style={{
display: 'flex',
width: '40%',
}}
dangerouslySetInnerHTML={{
__html: abacusSVG,
}}
/>
{/* Right side - Text content */}
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '55%',
gap: '30px',
}}
>
<h1
style={{
fontSize: '80px',
fontWeight: 'bold',
color: '#7c2d12',
margin: 0,
lineHeight: 1,
}}
>
Abaci.One
</h1>
<p
style={{
fontSize: '40px',
fontWeight: 600,
color: '#92400e',
margin: 0,
lineHeight: 1.2,
}}
>
Learn Soroban Through Play
</p>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '15px',
fontSize: '32px',
color: '#78350f',
}}
>
<div style={{ display: 'flex', alignItems: 'center' }}> Interactive Games</div>
<div style={{ display: 'flex', alignItems: 'center' }}> Tutorials</div>
<div style={{ display: 'flex', alignItems: 'center' }}> Practice Tools</div>
</div>
</div>
</div>,
{
...size,
}
)
}

View File

@@ -1,10 +1,10 @@
'use client'
import Link from 'next/link'
import { useEffect, useState, useRef } from 'react'
import { useTranslations, useMessages } from 'next-intl'
import { useEffect, useState } from 'react'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { useHomeHero } from '@/contexts/HomeHeroContext'
import { HeroAbacus } from '@/components/HeroAbacus'
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
import { PageWithNav } from '@/components/PageWithNav'
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
import { getTutorialForEditor } from '@/utils/tutorialConverter'
@@ -14,135 +14,6 @@ import { LevelSliderDisplay } from '@/components/LevelSliderDisplay'
import { css } from '../../styled-system/css'
import { container, grid, hstack, stack } from '../../styled-system/patterns'
// Hero section placeholder - the actual abacus is rendered by MyAbacus component
function HeroSection() {
const { subtitle, setIsHeroVisible, isSubtitleLoaded } = useHomeHero()
const heroRef = useRef<HTMLDivElement>(null)
// Detect when hero scrolls out of view
useEffect(() => {
if (!heroRef.current) return
const observer = new IntersectionObserver(
([entry]) => {
setIsHeroVisible(entry.intersectionRatio > 0.2)
},
{
threshold: [0, 0.2, 0.5, 1],
}
)
observer.observe(heroRef.current)
return () => observer.disconnect()
}, [setIsHeroVisible])
return (
<div
ref={heroRef}
className={css({
height: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
bg: 'gray.900',
position: 'relative',
overflow: 'hidden',
px: '4',
py: '12',
})}
>
{/* Background pattern */}
<div
className={css({
position: 'absolute',
inset: 0,
opacity: 0.1,
backgroundImage:
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
backgroundSize: '40px 40px',
})}
/>
{/* Title and Subtitle */}
<div
className={css({
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2',
zIndex: 10,
})}
>
<h1
className={css({
fontSize: { base: '4xl', md: '6xl', lg: '7xl' },
fontWeight: 'bold',
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
backgroundClip: 'text',
color: 'transparent',
})}
>
Abaci One
</h1>
<p
className={css({
fontSize: { base: 'xl', md: '2xl' },
fontWeight: 'medium',
color: 'purple.300',
fontStyle: 'italic',
marginBottom: '8',
opacity: isSubtitleLoaded ? 1 : 0,
transition: 'opacity 0.5s ease-in-out',
})}
>
{subtitle.text}
</p>
</div>
{/* Space for abacus - rendered by MyAbacus component in hero mode */}
<div className={css({ flex: 1 })} />
{/* Scroll hint */}
<div
className={css({
position: 'relative',
fontSize: 'sm',
color: 'gray.400',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2',
animation: 'bounce 2s ease-in-out infinite',
zIndex: 10,
})}
>
<span>Scroll to explore</span>
<span></span>
</div>
{/* Keyframes for bounce animation */}
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes bounce {
0%, 100% {
transform: translateY(0);
opacity: 0.7;
}
50% {
transform: translateY(-10px);
opacity: 1;
}
}
`,
}}
/>
</div>
)
}
// Mini abacus that cycles through a sequence of values
function MiniAbacus({
values,
@@ -203,10 +74,8 @@ function MiniAbacus({
}
export default function HomePage() {
const t = useTranslations('home')
const messages = useMessages() as any
const [selectedSkillIndex, setSelectedSkillIndex] = useState(1) // Default to "Friends techniques"
const fullTutorial = getTutorialForEditor(messages.tutorial || {})
const fullTutorial = getTutorialForEditor()
// Create different tutorials for each skill level
const skillTutorials = [
@@ -214,32 +83,32 @@ export default function HomePage() {
{
...fullTutorial,
id: 'read-numbers-demo',
title: t('skills.readNumbers.tutorialTitle'),
description: t('skills.readNumbers.tutorialDesc'),
title: 'Read and Set Numbers',
description: 'Master abacus number representation from zero to thousands',
steps: fullTutorial.steps.filter((step) => step.id.startsWith('basic-')),
},
// Skill 1: Friends techniques (5 = 2+3)
{
...fullTutorial,
id: 'friends-of-5-demo',
title: t('skills.friends.tutorialTitle'),
description: t('skills.friends.tutorialDesc'),
title: 'Friends of 5',
description: 'Add and subtract using complement pairs: 5 = 2+3',
steps: fullTutorial.steps.filter((step) => step.id === 'complement-2'),
},
// Skill 2: Multiply & divide (12×34)
{
...fullTutorial,
id: 'multiply-demo',
title: t('skills.multiply.tutorialTitle'),
description: t('skills.multiply.tutorialDesc'),
title: 'Multiplication',
description: 'Fluent multi-digit calculations with advanced techniques',
steps: fullTutorial.steps.filter((step) => step.id.includes('complement')).slice(0, 3),
},
// Skill 3: Mental calculation (Speed math)
{
...fullTutorial,
id: 'mental-calc-demo',
title: t('skills.mental.tutorialTitle'),
description: t('skills.mental.tutorialDesc'),
title: 'Mental Calculation',
description: 'Visualize and compute without the physical tool (Anzan)',
steps: fullTutorial.steps.slice(-3),
},
]
@@ -247,246 +116,14 @@ export default function HomePage() {
const selectedTutorial = skillTutorials[selectedSkillIndex]
return (
<PageWithNav>
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
{/* Hero Section - abacus rendered by MyAbacus in hero mode */}
<HeroSection />
<HomeHeroProvider>
<PageWithNav>
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
{/* Hero Section with Large Interactive Abacus */}
<HeroAbacus />
{/* Learn by Doing Section - with inline tutorial demo */}
<section className={stack({ gap: '8', mb: '16', px: '4', py: '12' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('learnByDoing.title')}
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
{t('learnByDoing.subtitle')}
</p>
</div>
{/* Live demo and learning objectives */}
<div
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
rounded: 'xl',
p: '8',
border: '1px solid',
borderColor: 'gray.700',
shadow: 'lg',
minW: { base: '100%', xl: '1400px' },
mx: 'auto',
})}
>
<div
className={css({
display: 'flex',
flexDirection: { base: 'column', xl: 'row' },
gap: '8',
alignItems: { base: 'center', xl: 'flex-start' },
})}
>
{/* Tutorial on the left */}
<div
className={css({
flex: '1',
minW: { base: '100%', xl: '500px' },
maxW: { base: '100%', xl: '500px' },
})}
>
<TutorialPlayer
key={selectedTutorial.id}
tutorial={selectedTutorial}
isDebugMode={false}
showDebugPanel={false}
hideNavigation={true}
hideTooltip={true}
silentErrors={true}
abacusColumns={1}
theme="dark"
/>
</div>
{/* What you'll learn on the right */}
<div
className={css({
flex: '0 0 auto',
w: { base: '100%', lg: '800px' },
})}
>
<h3
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'white',
mb: '6',
})}
>
{t('whatYouLearn.title')}
</h3>
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '5' })}>
{[
{
title: t('skills.readNumbers.title'),
desc: t('skills.readNumbers.desc'),
example: t('skills.readNumbers.example'),
badge: t('skills.readNumbers.badge'),
values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999],
columns: 3,
},
{
title: t('skills.friends.title'),
desc: t('skills.friends.desc'),
example: t('skills.friends.example'),
badge: t('skills.friends.badge'),
values: [2, 5, 3],
columns: 1,
},
{
title: t('skills.multiply.title'),
desc: t('skills.multiply.desc'),
example: t('skills.multiply.example'),
badge: t('skills.multiply.badge'),
values: [12, 24, 36, 48],
columns: 2,
},
{
title: t('skills.mental.title'),
desc: t('skills.mental.desc'),
example: t('skills.mental.example'),
badge: t('skills.mental.badge'),
values: [7, 14, 21, 28, 35],
columns: 2,
},
].map((skill, i) => {
const isSelected = i === selectedSkillIndex
return (
<div
key={i}
onClick={() => setSelectedSkillIndex(i)}
className={css({
bg: isSelected
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.15), rgba(250, 204, 21, 0.08))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
borderRadius: 'xl',
p: { base: '4', lg: '5' },
border: '1px solid',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.4)'
: 'rgba(255, 255, 255, 0.15)',
boxShadow: isSelected
? '0 6px 16px rgba(250, 204, 21, 0.2)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
transition: 'all 0.2s',
cursor: 'pointer',
_hover: {
bg: isSelected
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(250, 204, 21, 0.12))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.5)'
: 'rgba(255, 255, 255, 0.25)',
transform: 'translateY(-2px)',
boxShadow: isSelected
? '0 8px 20px rgba(250, 204, 21, 0.3)'
: '0 6px 16px rgba(0, 0, 0, 0.4)',
},
})}
>
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
<div
className={css({
fontSize: '3xl',
width: { base: '120px', lg: '150px' },
minHeight: { base: '115px', lg: '140px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
bg: isSelected
? 'rgba(250, 204, 21, 0.15)'
: 'rgba(255, 255, 255, 0.1)',
borderRadius: 'lg',
})}
>
<MiniAbacus values={skill.values} columns={skill.columns} />
</div>
<div className={stack({ gap: '2', flex: '1', minWidth: '0' })}>
<div
className={hstack({
gap: '2',
alignItems: 'center',
flexWrap: 'wrap',
})}
>
<div
className={css({
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
})}
>
{skill.title}
</div>
<div
className={css({
bg: 'rgba(250, 204, 21, 0.2)',
color: 'yellow.400',
fontSize: '2xs',
fontWeight: 'semibold',
px: '2',
py: '0.5',
borderRadius: 'md',
})}
>
{skill.badge}
</div>
</div>
<div
className={css({
color: 'gray.300',
fontSize: 'xs',
lineHeight: '1.5',
})}
>
{skill.desc}
</div>
<div
className={css({
color: 'yellow.400',
fontSize: 'xs',
fontFamily: 'mono',
fontWeight: 'semibold',
mt: '1',
bg: 'rgba(250, 204, 21, 0.1)',
px: '2',
py: '1',
borderRadius: 'md',
w: 'fit-content',
})}
>
{skill.example}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</section>
{/* Main content container */}
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
{/* Current Offerings Section */}
<section className={stack({ gap: '6', mb: '16' })}>
{/* Learn by Doing Section - with inline tutorial demo */}
<section className={stack({ gap: '8', mb: '16', px: '4', py: '12' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
@@ -496,161 +133,402 @@ export default function HomePage() {
mb: '2',
})}
>
{t('arcade.title')}
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md' })}>{t('arcade.subtitle')}</p>
</div>
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
{getAvailableGames().map((game) => {
const playersText =
game.manifest.maxPlayers === 1
? t('arcade.soloChallenge')
: t('arcade.playersCount', { min: 1, max: game.manifest.maxPlayers })
return (
<GameCard
key={game.manifest.name}
icon={game.manifest.icon}
title={game.manifest.displayName}
description={game.manifest.description}
players={playersText}
tags={game.manifest.chips}
gradient={game.manifest.gradient}
href="/games"
/>
)
})}
</div>
</section>
{/* Progression Visualization */}
<section className={stack({ gap: '6', mb: '16', overflow: 'hidden' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('journey.title')}
</h2>
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>{t('journey.subtitle')}</p>
</div>
<LevelSliderDisplay />
</section>
{/* Flashcard Generator Section */}
<section className={stack({ gap: '8', mb: '16' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('flashcards.title')}
Learn by Doing
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
{t('flashcards.subtitle')}
Interactive tutorials teach you step-by-step. Try this example right now:
</p>
</div>
{/* Combined interactive display and CTA */}
{/* Live demo and learning objectives */}
<div
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
rounded: 'xl',
p: { base: '6', md: '8' },
p: '8',
border: '1px solid',
borderColor: 'gray.700',
shadow: 'lg',
maxW: '1200px',
minW: { base: '100%', xl: '1400px' },
mx: 'auto',
})}
>
{/* Interactive Flashcards Display */}
<div className={css({ mb: '8' })}>
<InteractiveFlashcards />
</div>
{/* Features */}
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
{[
{
icon: t('flashcards.features.formats.icon'),
title: t('flashcards.features.formats.title'),
desc: t('flashcards.features.formats.desc'),
},
{
icon: t('flashcards.features.customizable.icon'),
title: t('flashcards.features.customizable.title'),
desc: t('flashcards.features.customizable.desc'),
},
{
icon: t('flashcards.features.paperSizes.icon'),
title: t('flashcards.features.paperSizes.title'),
desc: t('flashcards.features.paperSizes.desc'),
},
].map((feature, i) => (
<div
key={i}
className={css({
textAlign: 'center',
p: '4',
rounded: 'lg',
bg: 'rgba(255, 255, 255, 0.05)',
})}
>
<div className={css({ fontSize: '2xl', mb: '2' })}>{feature.icon}</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'white',
mb: '1',
})}
>
{feature.title}
</div>
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>{feature.desc}</div>
</div>
))}
</div>
{/* CTA Button */}
<div className={css({ textAlign: 'center' })}>
<Link
href="/create"
<div
className={css({
display: 'flex',
flexDirection: { base: 'column', xl: 'row' },
gap: '8',
alignItems: { base: 'center', xl: 'flex-start' },
})}
>
{/* Tutorial on the left */}
<div
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
bg: 'blue.600',
color: 'white',
px: '6',
py: '3',
rounded: 'lg',
fontWeight: 'semibold',
transition: 'all 0.2s',
_hover: {
bg: 'blue.500',
},
flex: '1',
minW: { base: '100%', xl: '500px' },
maxW: { base: '100%', xl: '500px' },
})}
>
<span>{t('flashcards.cta')}</span>
<span></span>
</Link>
<TutorialPlayer
key={selectedTutorial.id}
tutorial={selectedTutorial}
isDebugMode={false}
showDebugPanel={false}
hideNavigation={true}
hideTooltip={true}
silentErrors={true}
abacusColumns={1}
theme="dark"
/>
</div>
{/* What you'll learn on the right */}
<div
className={css({
flex: '0 0 auto',
w: { base: '100%', lg: '800px' },
})}
>
<h3
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'white',
mb: '6',
})}
>
What You'll Learn
</h3>
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '5' })}>
{[
{
title: '📖 Read and set numbers',
desc: 'Master abacus number representation from zero to thousands',
example: '0-9999',
badge: 'Foundation',
values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999],
columns: 3,
},
{
title: '🤝 Friends techniques',
desc: 'Add and subtract using complement pairs and mental shortcuts',
example: '5 = 2+3',
badge: 'Core',
values: [2, 5, 3],
columns: 1,
},
{
title: ' Multiply & divide',
desc: 'Fluent multi-digit calculations with advanced techniques',
example: '12×34',
badge: 'Advanced',
values: [12, 24, 36, 48],
columns: 2,
},
{
title: '🧠 Mental calculation',
desc: 'Visualize and compute without the physical tool (Anzan)',
example: 'Speed math',
badge: 'Expert',
values: [7, 14, 21, 28, 35],
columns: 2,
},
].map((skill, i) => {
const isSelected = i === selectedSkillIndex
return (
<div
key={i}
onClick={() => setSelectedSkillIndex(i)}
className={css({
bg: isSelected
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.15), rgba(250, 204, 21, 0.08))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
borderRadius: 'xl',
p: { base: '4', lg: '5' },
border: '1px solid',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.4)'
: 'rgba(255, 255, 255, 0.15)',
boxShadow: isSelected
? '0 6px 16px rgba(250, 204, 21, 0.2)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
transition: 'all 0.2s',
cursor: 'pointer',
_hover: {
bg: isSelected
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(250, 204, 21, 0.12))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.5)'
: 'rgba(255, 255, 255, 0.25)',
transform: 'translateY(-2px)',
boxShadow: isSelected
? '0 8px 20px rgba(250, 204, 21, 0.3)'
: '0 6px 16px rgba(0, 0, 0, 0.4)',
},
})}
>
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
<div
className={css({
fontSize: '3xl',
width: { base: '120px', lg: '150px' },
minHeight: { base: '115px', lg: '140px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
bg: isSelected
? 'rgba(250, 204, 21, 0.15)'
: 'rgba(255, 255, 255, 0.1)',
borderRadius: 'lg',
})}
>
<MiniAbacus values={skill.values} columns={skill.columns} />
</div>
<div className={stack({ gap: '2', flex: '1', minWidth: '0' })}>
<div
className={hstack({
gap: '2',
alignItems: 'center',
flexWrap: 'wrap',
})}
>
<div
className={css({
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
})}
>
{skill.title}
</div>
<div
className={css({
bg: 'rgba(250, 204, 21, 0.2)',
color: 'yellow.400',
fontSize: '2xs',
fontWeight: 'semibold',
px: '2',
py: '0.5',
borderRadius: 'md',
})}
>
{skill.badge}
</div>
</div>
<div
className={css({
color: 'gray.300',
fontSize: 'xs',
lineHeight: '1.5',
})}
>
{skill.desc}
</div>
<div
className={css({
color: 'yellow.400',
fontSize: 'xs',
fontFamily: 'mono',
fontWeight: 'semibold',
mt: '1',
bg: 'rgba(250, 204, 21, 0.1)',
px: '2',
py: '1',
borderRadius: 'md',
w: 'fit-content',
})}
>
{skill.example}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</section>
{/* Main content container */}
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
{/* Current Offerings Section */}
<section className={stack({ gap: '6', mb: '16' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
The Arcade
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md' })}>
Single-player challenges and multiplayer battles in networked rooms. Invite
friends to play or watch live.
</p>
</div>
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
{getAvailableGames().map((game) => {
const playersText =
game.manifest.maxPlayers === 1
? 'Solo challenge'
: `1-${game.manifest.maxPlayers} players`
return (
<GameCard
key={game.manifest.name}
icon={game.manifest.icon}
title={game.manifest.displayName}
description={game.manifest.description}
players={playersText}
tags={game.manifest.chips}
gradient={game.manifest.gradient}
href="/games"
/>
)
})}
</div>
</section>
{/* Progression Visualization */}
<section className={stack({ gap: '6', mb: '16', overflow: 'hidden' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
Your Journey
</h2>
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>
Progress from beginner to master
</p>
</div>
<LevelSliderDisplay />
</section>
{/* Flashcard Generator Section */}
<section className={stack({ gap: '8', mb: '16' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
Create Custom Flashcards
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
Design beautiful flashcards for learning and practice
</p>
</div>
{/* Combined interactive display and CTA */}
<div
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
rounded: 'xl',
p: { base: '6', md: '8' },
border: '1px solid',
borderColor: 'gray.700',
shadow: 'lg',
maxW: '1200px',
mx: 'auto',
})}
>
{/* Interactive Flashcards Display */}
<div className={css({ mb: '8' })}>
<InteractiveFlashcards />
</div>
{/* Features */}
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
{[
{
icon: '📄',
title: 'Multiple Formats',
desc: 'PDF, PNG, SVG, HTML',
},
{
icon: '🎨',
title: 'Customizable',
desc: 'Bead shapes, colors, layouts',
},
{
icon: '📐',
title: 'All Paper Sizes',
desc: 'A3, A4, A5, US Letter',
},
].map((feature, i) => (
<div
key={i}
className={css({
textAlign: 'center',
p: '4',
rounded: 'lg',
bg: 'rgba(255, 255, 255, 0.05)',
})}
>
<div className={css({ fontSize: '2xl', mb: '2' })}>{feature.icon}</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'white',
mb: '1',
})}
>
{feature.title}
</div>
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>
{feature.desc}
</div>
</div>
))}
</div>
{/* CTA Button */}
<div className={css({ textAlign: 'center' })}>
<Link
href="/create"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
bg: 'blue.600',
color: 'white',
px: '6',
py: '3',
rounded: 'lg',
fontWeight: 'semibold',
transition: 'all 0.2s',
_hover: {
bg: 'blue.500',
},
})}
>
<span>Create Flashcards</span>
<span>→</span>
</Link>
</div>
</div>
</section>
</div>
</div>
</div>
</PageWithNav>
</PageWithNav>
</HomeHeroProvider>
)
}

View File

@@ -1,12 +0,0 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/test/', '/_next/'],
},
sitemap: 'https://abaci.one/sitemap.xml',
}
}

View File

@@ -1,37 +0,0 @@
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://abaci.one'
// Main pages
const routes = ['', '/arcade', '/create', '/guide', '/about'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: route === '' ? 1 : 0.8,
}))
// Arcade games
const games = [
'/arcade/rithmomachia',
'/arcade/complement-race',
'/arcade/matching',
'/arcade/memory-quiz',
'/arcade/card-sorting',
].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.6,
}))
// Guide pages
const guides = ['/arcade/rithmomachia/guide'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.5,
}))
return [...routes, ...games, ...guides]
}

View File

@@ -8,13 +8,7 @@ import { buildPlayerMetadata as buildPlayerMetadataUtil } from '@/lib/arcade/pla
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '@/contexts/GameModeContext'
import { generateRandomCards, shuffleCards } from './utils/cardGeneration'
import type {
CardSortingState,
CardSortingMove,
SortingCard,
CardSortingConfig,
CardPosition,
} from './types'
import type { CardSortingState, CardSortingMove, SortingCard, CardSortingConfig } from './types'
// Context value interface
interface CardSortingContextValue {
@@ -24,11 +18,11 @@ interface CardSortingContextValue {
placeCard: (cardId: string, position: number) => void
insertCard: (cardId: string, insertPosition: number) => void
removeCard: (position: number) => void
checkSolution: (finalSequence?: SortingCard[]) => void
checkSolution: () => void
revealNumbers: () => void
goToSetup: () => void
resumeGame: () => void
setConfig: (field: 'cardCount' | 'timeLimit' | 'gameMode', value: unknown) => void
updateCardPositions: (positions: CardPosition[]) => void
setConfig: (field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => void
exitSession: () => void
// Computed
canCheckSolution: boolean
@@ -42,8 +36,6 @@ interface CardSortingContextValue {
// Spectator mode
localPlayerId: string | undefined
isSpectating: boolean
// Multiplayer
players: Map<string, { id: string; name: string; emoji: string }> // All room players
}
// Create context
@@ -52,8 +44,8 @@ const CardSortingContext = createContext<CardSortingContextValue | null>(null)
// Initial state matching validator's getInitialState
const createInitialState = (config: Partial<CardSortingConfig>): CardSortingState => ({
cardCount: config.cardCount ?? 8,
showNumbers: config.showNumbers ?? true,
timeLimit: config.timeLimit ?? null,
gameMode: config.gameMode ?? 'solo',
gamePhase: 'setup',
playerId: '',
playerMetadata: {
@@ -62,17 +54,14 @@ const createInitialState = (config: Partial<CardSortingConfig>): CardSortingStat
emoji: '',
userId: '',
},
activePlayers: [],
allPlayerMetadata: new Map(),
gameStartTime: null,
gameEndTime: null,
selectedCards: [],
correctOrder: [],
availableCards: [],
placedCards: new Array(config.cardCount ?? 8).fill(null),
cardPositions: [],
cursorPositions: new Map(),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
})
@@ -92,19 +81,17 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
gamePhase: 'playing',
playerId: typedMove.playerId,
playerMetadata: typedMove.data.playerMetadata,
activePlayers: [typedMove.playerId],
allPlayerMetadata: new Map([[typedMove.playerId, typedMove.data.playerMetadata]]),
gameStartTime: Date.now(),
selectedCards,
correctOrder,
// Use cards in the order they were sent (already shuffled by initiating client)
availableCards: selectedCards,
availableCards: shuffleCards(selectedCards),
placedCards: new Array(state.cardCount).fill(null),
numbersRevealed: false,
// Save original config for pause/resume
originalConfig: {
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
gameMode: state.gameMode,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
@@ -139,9 +126,7 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
case 'INSERT_CARD': {
const { cardId, insertPosition } = typedMove.data
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) {
return state
}
if (!card) return state
// Insert with shift and compact (no gaps)
const newPlaced = new Array(state.cardCount).fill(null)
@@ -208,9 +193,20 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
}
}
case 'REVEAL_NUMBERS': {
return {
...state,
numbersRevealed: true,
}
}
case 'CHECK_SOLUTION': {
// Don't apply optimistic update - wait for server to calculate and return score
return state
// Server will calculate score - just transition to results optimistically
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
case 'GO_TO_SETUP': {
@@ -219,8 +215,8 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
return {
...createInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
gameMode: state.gameMode,
}),
// Save paused state if coming from active game
originalConfig: state.originalConfig,
@@ -230,8 +226,8 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
selectedCards: state.selectedCards,
availableCards: state.availableCards,
placedCards: state.placedCards,
cardPositions: state.cardPositions,
gameStartTime: state.gameStartTime || Date.now(),
numbersRevealed: state.numbersRevealed,
}
: undefined,
}
@@ -273,20 +269,13 @@ function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardS
correctOrder,
availableCards: state.pausedGameState.availableCards,
placedCards: state.pausedGameState.placedCards,
cardPositions: state.pausedGameState.cardPositions,
gameStartTime: state.pausedGameState.gameStartTime,
numbersRevealed: state.pausedGameState.numbersRevealed,
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
case 'UPDATE_CARD_POSITIONS': {
return {
...state,
cardPositions: typedMove.data.positions,
}
}
default:
return state
}
@@ -375,10 +364,10 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
if (!state.originalConfig) return false
return (
state.cardCount !== state.originalConfig.cardCount ||
state.timeLimit !== state.originalConfig.timeLimit ||
state.gameMode !== state.originalConfig.gameMode
state.showNumbers !== state.originalConfig.showNumbers ||
state.timeLimit !== state.originalConfig.timeLimit
)
}, [state.cardCount, state.timeLimit, state.gameMode, state.originalConfig])
}, [state.cardCount, state.showNumbers, state.timeLimit, state.originalConfig])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
@@ -387,19 +376,12 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
// Action creators
const startGame = useCallback(() => {
if (!localPlayerId) {
return
}
// Prevent rapid double-sends within 500ms to avoid duplicate game starts
const now = Date.now()
const justStarted = state.gameStartTime && now - state.gameStartTime < 500
if (justStarted) {
console.error('[CardSortingProvider] No local player available')
return
}
const playerMetadata = buildPlayerMetadata()
const selectedCards = shuffleCards(generateRandomCards(state.cardCount))
const selectedCards = generateRandomCards(state.cardCount)
sendMove({
type: 'START_GAME',
@@ -410,15 +392,7 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
selectedCards,
},
})
}, [
localPlayerId,
state.cardCount,
state.gamePhase,
state.gameStartTime,
buildPlayerMetadata,
sendMove,
viewerId,
])
}, [localPlayerId, state.cardCount, buildPlayerMetadata, sendMove, viewerId])
const placeCard = useCallback(
(cardId: string, position: number) => {
@@ -468,26 +442,31 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
[localPlayerId, sendMove, viewerId]
)
const checkSolution = useCallback(
(finalSequence?: SortingCard[]) => {
if (!localPlayerId) return
const checkSolution = useCallback(() => {
if (!localPlayerId) return
if (!canCheckSolution) {
console.warn('[CardSortingProvider] Cannot check - not all cards placed')
return
}
// If finalSequence provided, use it. Otherwise check current placedCards
if (!finalSequence && !canCheckSolution) {
return
}
sendMove({
type: 'CHECK_SOLUTION',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canCheckSolution, sendMove, viewerId])
sendMove({
type: 'CHECK_SOLUTION',
playerId: localPlayerId,
userId: viewerId || '',
data: {
finalSequence,
},
})
},
[localPlayerId, canCheckSolution, sendMove, viewerId]
)
const revealNumbers = useCallback(() => {
if (!localPlayerId) return
sendMove({
type: 'REVEAL_NUMBERS',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, sendMove, viewerId])
const goToSetup = useCallback(() => {
if (!localPlayerId) return
@@ -515,7 +494,7 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
}, [localPlayerId, canResumeGame, sendMove, viewerId])
const setConfig = useCallback(
(field: 'cardCount' | 'timeLimit' | 'gameMode', value: unknown) => {
(field: 'cardCount' | 'showNumbers' | 'timeLimit', value: unknown) => {
if (!localPlayerId) return
sendMove({
@@ -548,20 +527,6 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
[localPlayerId, sendMove, viewerId, roomData, updateGameConfig]
)
const updateCardPositions = useCallback(
(positions: CardPosition[]) => {
if (!localPlayerId) return
sendMove({
type: 'UPDATE_CARD_POSITIONS',
playerId: localPlayerId,
userId: viewerId || '',
data: { positions },
})
},
[localPlayerId, sendMove, viewerId]
)
const contextValue: CardSortingContextValue = {
state,
// Actions
@@ -570,10 +535,10 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
insertCard,
removeCard,
checkSolution,
revealNumbers,
goToSetup,
resumeGame,
setConfig,
updateCardPositions,
exitSession,
// Computed
canCheckSolution,
@@ -587,8 +552,6 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
// Spectator mode
localPlayerId,
isSpectating: !localPlayerId,
// Multiplayer
players,
}
return <CardSortingContext.Provider value={contextValue}>{children}</CardSortingContext.Provider>

View File

@@ -3,7 +3,7 @@ import type {
ValidationContext,
ValidationResult,
} from '@/lib/arcade/validation/types'
import type { CardSortingConfig, CardSortingMove, CardSortingState, CardPosition } from './types'
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
import { calculateScore } from './utils/scoringAlgorithm'
import { placeCardAtPosition, insertCardAtPosition, removeCardAtPosition } from './utils/validation'
@@ -22,16 +22,16 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
return this.validateInsertCard(state, move.data.cardId, move.data.insertPosition)
case 'REMOVE_CARD':
return this.validateRemoveCard(state, move.data.position)
case 'REVEAL_NUMBERS':
return this.validateRevealNumbers(state)
case 'CHECK_SOLUTION':
return this.validateCheckSolution(state, move.data.finalSequence)
return this.validateCheckSolution(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'RESUME_GAME':
return this.validateResumeGame(state)
case 'UPDATE_CARD_POSITIONS':
return this.validateUpdateCardPositions(state, move.data.positions)
default:
return {
valid: false,
@@ -45,7 +45,13 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
data: { playerMetadata: unknown; selectedCards: unknown },
playerId: string
): ValidationResult {
// Allow starting a new game from any phase (for "Play Again" button)
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only start game from setup phase',
}
}
// Validate selectedCards
if (!Array.isArray(data.selectedCards)) {
@@ -76,13 +82,11 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
playerId,
playerMetadata: data.playerMetadata,
gameStartTime: Date.now(),
gameEndTime: null,
selectedCards: selectedCards as typeof state.selectedCards,
correctOrder: correctOrder as typeof state.correctOrder,
availableCards: selectedCards as typeof state.availableCards,
placedCards: new Array(state.cardCount).fill(null),
cardPositions: [], // Will be set by first position update
scoreBreakdown: null,
numbersRevealed: false,
},
}
}
@@ -231,10 +235,35 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
}
}
private validateCheckSolution(
state: CardSortingState,
finalSequence?: typeof state.selectedCards
): ValidationResult {
private validateRevealNumbers(state: CardSortingState): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only reveal numbers during playing phase',
}
}
// Must be enabled in config
if (!state.showNumbers) {
return { valid: false, error: 'Reveal numbers is not enabled' }
}
// Already revealed
if (state.numbersRevealed) {
return { valid: false, error: 'Numbers already revealed' }
}
return {
valid: true,
newState: {
...state,
numbersRevealed: true,
},
}
}
private validateCheckSolution(state: CardSortingState): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
@@ -243,31 +272,22 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
}
}
// Use finalSequence if provided, otherwise use placedCards
const userCards =
finalSequence ||
state.placedCards.filter((c): c is (typeof state.selectedCards)[0] => c !== null)
// Must have all cards
if (userCards.length !== state.cardCount) {
// All slots must be filled
if (state.placedCards.some((c) => c === null)) {
return { valid: false, error: 'Must place all cards before checking' }
}
// Calculate score using scoring algorithms
const userSequence = userCards.map((c) => c.number)
const userSequence = state.placedCards.map((c) => c!.number)
const correctSequence = state.correctOrder.map((c) => c.number)
const scoreBreakdown = calculateScore(
userSequence,
correctSequence,
state.gameStartTime || Date.now()
state.gameStartTime || Date.now(),
state.numbersRevealed
)
// If finalSequence was provided, update placedCards with it
const newPlacedCards = finalSequence
? [...userCards, ...new Array(state.cardCount - userCards.length).fill(null)]
: state.placedCards
return {
valid: true,
newState: {
@@ -275,8 +295,6 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
gamePhase: 'results',
gameEndTime: Date.now(),
scoreBreakdown,
placedCards: newPlacedCards,
availableCards: [], // All cards are now placed
},
}
}
@@ -289,21 +307,21 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
newState: {
...this.getInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
gameMode: state.gameMode,
}),
originalConfig: {
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
gameMode: state.gameMode,
},
pausedGamePhase: 'playing',
pausedGameState: {
selectedCards: state.selectedCards,
availableCards: state.availableCards,
placedCards: state.placedCards,
cardPositions: state.cardPositions,
gameStartTime: state.gameStartTime || Date.now(),
numbersRevealed: state.numbersRevealed,
},
},
}
@@ -314,8 +332,8 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
valid: true,
newState: this.getInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
gameMode: state.gameMode,
}),
}
}
@@ -348,6 +366,21 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
},
}
case 'showNumbers':
if (typeof value !== 'boolean') {
return { valid: false, error: 'showNumbers must be a boolean' }
}
return {
valid: true,
newState: {
...state,
showNumbers: value,
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
case 'timeLimit':
if (value !== null && (typeof value !== 'number' || value < 30)) {
return {
@@ -366,24 +399,6 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
},
}
case 'gameMode':
if (!['solo', 'collaborative', 'competitive', 'relay'].includes(value as string)) {
return {
valid: false,
error: 'gameMode must be solo, collaborative, competitive, or relay',
}
}
return {
valid: true,
newState: {
...state,
gameMode: value as 'solo' | 'collaborative' | 'competitive' | 'relay',
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
@@ -410,56 +425,14 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
correctOrder: [...state.pausedGameState.selectedCards].sort((a, b) => a.number - b.number),
availableCards: state.pausedGameState.availableCards,
placedCards: state.pausedGameState.placedCards,
cardPositions: state.pausedGameState.cardPositions,
gameStartTime: state.pausedGameState.gameStartTime,
numbersRevealed: state.pausedGameState.numbersRevealed,
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
}
private validateUpdateCardPositions(
state: CardSortingState,
positions: CardPosition[]
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only update positions during playing phase' }
}
// Validate positions array
if (!Array.isArray(positions)) {
return { valid: false, error: 'positions must be an array' }
}
// Basic validation of position values
for (const pos of positions) {
if (typeof pos.x !== 'number' || pos.x < 0 || pos.x > 100) {
return { valid: false, error: 'x must be between 0 and 100' }
}
if (typeof pos.y !== 'number' || pos.y < 0 || pos.y > 100) {
return { valid: false, error: 'y must be between 0 and 100' }
}
if (typeof pos.rotation !== 'number') {
return { valid: false, error: 'rotation must be a number' }
}
if (typeof pos.zIndex !== 'number') {
return { valid: false, error: 'zIndex must be a number' }
}
if (typeof pos.cardId !== 'string') {
return { valid: false, error: 'cardId must be a string' }
}
}
return {
valid: true,
newState: {
...state,
cardPositions: positions,
},
}
}
isGameComplete(state: CardSortingState): boolean {
return state.gamePhase === 'results'
}
@@ -467,8 +440,8 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
getInitialState(config: CardSortingConfig): CardSortingState {
return {
cardCount: config.cardCount,
showNumbers: config.showNumbers,
timeLimit: config.timeLimit,
gameMode: config.gameMode,
gamePhase: 'setup',
playerId: '',
playerMetadata: {
@@ -477,17 +450,14 @@ export class CardSortingValidator implements GameValidator<CardSortingState, Car
emoji: '',
userId: '',
},
activePlayers: [],
allPlayerMetadata: new Map(),
gameStartTime: null,
gameEndTime: null,
selectedCards: [],
correctOrder: [],
availableCards: [],
placedCards: new Array(config.cardCount).fill(null),
cardPositions: [],
cursorPositions: new Map(),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
}
}

View File

@@ -8,7 +8,7 @@ import { StandardGameLayout } from '@/components/StandardGameLayout'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { useCardSorting } from '../Provider'
import { SetupPhase } from './SetupPhase'
import { PlayingPhaseDrag } from './PlayingPhaseDrag'
import { PlayingPhase } from './PlayingPhase'
import { ResultsPhase } from './ResultsPhase'
export function GameComponent() {
@@ -49,16 +49,16 @@ export function GameComponent() {
ref={gameRef}
className={css({
flex: 1,
padding: { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
overflow: 'hidden',
// Remove all padding/margins for playing phase
padding: state.gamePhase === 'playing' ? '0' : { base: '12px', sm: '16px', md: '20px' },
overflow: 'auto',
})}
>
{/* Spectator Mode Banner - only show in setup/results */}
{isSpectating && state.gamePhase !== 'setup' && state.gamePhase !== 'playing' && (
{/* Spectator Mode Banner */}
{isSpectating && state.gamePhase !== 'setup' && (
<div
className={css({
width: '100%',
@@ -76,7 +76,6 @@ export function GameComponent() {
fontWeight: '600',
color: '#92400e',
textAlign: 'center',
alignSelf: 'center',
})}
>
<span role="img" aria-label="watching">
@@ -86,29 +85,24 @@ export function GameComponent() {
</div>
)}
{/* For playing phase, render full viewport. For setup/results, use container */}
{state.gamePhase === 'playing' ? (
<PlayingPhaseDrag />
) : (
<main
className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
alignSelf: 'center',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
)}
<main
className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
</div>
</StandardGameLayout>
</PageWithNav>

View File

@@ -13,6 +13,7 @@ export function PlayingPhase() {
insertCard,
removeCard,
checkSolution,
revealNumbers,
goToSetup,
canCheckSolution,
placedCount,
@@ -180,9 +181,32 @@ export function PlayingPhase() {
</div>
<div className={css({ display: 'flex', gap: '0.5rem' })}>
{state.showNumbers && !state.numbersRevealed && (
<button
type="button"
onClick={revealNumbers}
disabled={isSpectating}
className={css({
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
background: isSpectating ? 'gray.300' : 'orange.500',
color: 'white',
fontSize: 'sm',
fontWeight: '600',
border: 'none',
cursor: isSpectating ? 'not-allowed' : 'pointer',
opacity: isSpectating ? 0.5 : 1,
_hover: {
background: isSpectating ? 'gray.300' : 'orange.600',
},
})}
>
Reveal Numbers
</button>
)}
<button
type="button"
onClick={() => checkSolution()}
onClick={checkSolution}
disabled={!canCheckSolution || isSpectating}
className={css({
padding: '0.5rem 1rem',
@@ -314,6 +338,23 @@ export function PlayingPhase() {
},
})}
/>
{state.numbersRevealed && (
<div
className={css({
position: 'absolute',
top: '5px',
right: '5px',
background: '#ffc107',
color: '#333',
borderRadius: '4px',
padding: '2px 8px',
fontSize: '14px',
fontWeight: 'bold',
})}
>
{card.number}
</div>
)}
</div>
))}
</div>

View File

@@ -1,348 +1,145 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
import { css } from '../../../../styled-system/css'
import { useCardSorting } from '../Provider'
// Add animations
const animations = `
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
@keyframes shimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-10px) rotate(2deg);
}
}
`
// Inject animation styles
if (typeof document !== 'undefined' && !document.getElementById('card-sorting-animations')) {
const style = document.createElement('style')
style.id = 'card-sorting-animations'
style.textContent = animations
document.head.appendChild(style)
}
export function SetupPhase() {
const { state, setConfig, startGame, resumeGame, canResumeGame } = useCardSorting()
const getButtonStyles = (isSelected: boolean) => {
return css({
border: 'none',
borderRadius: { base: '16px', md: '20px' },
padding: { base: '16px', md: '20px' },
fontSize: { base: '14px', md: '16px' },
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
textAlign: 'center' as const,
position: 'relative' as const,
overflow: 'hidden' as const,
background: isSelected
? 'linear-gradient(135deg, #14b8a6, #0d9488, #0f766e)'
: 'linear-gradient(135deg, #ffffff, #f1f5f9)',
color: isSelected ? 'white' : '#334155',
boxShadow: isSelected
? '0 10px 30px rgba(20, 184, 166, 0.4), inset 0 2px 0 rgba(255,255,255,0.2)'
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
textShadow: isSelected ? '0 1px 2px rgba(0,0,0,0.2)' : 'none',
_hover: {
transform: 'translateY(-4px) scale(1.03)',
boxShadow: isSelected
? '0 15px 40px rgba(20, 184, 166, 0.6), inset 0 2px 0 rgba(255,255,255,0.2)'
: '0 10px 30px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
},
_active: {
transform: 'translateY(-2px) scale(1.01)',
},
})
}
const cardCountInfo = {
5: {
icon: '🌱',
label: 'Gentle',
description: 'Perfect to start',
emoji: '🟢',
difficulty: 'Easy',
},
8: {
icon: '⚡',
label: 'Swift',
description: 'Nice challenge',
emoji: '🟡',
difficulty: 'Medium',
},
12: {
icon: '🔥',
label: 'Intense',
description: 'Test your memory',
emoji: '🟠',
difficulty: 'Hard',
},
15: {
icon: '💎',
label: 'Master',
description: 'Ultimate test',
emoji: '🔴',
difficulty: 'Expert',
},
}
return (
<div
className={css({
textAlign: 'center',
padding: { base: '12px', md: '20px' },
maxWidth: '900px',
margin: '0 auto',
display: 'flex',
flexDirection: 'column',
gap: { base: '16px', md: '24px' },
minHeight: 0,
overflow: 'auto',
alignItems: 'center',
gap: '2rem',
padding: '2rem',
})}
>
{/* Hero Section */}
<div
className={css({
background: 'linear-gradient(135deg, #0f766e, #14b8a6, #2dd4bf)',
borderRadius: { base: '16px', md: '20px' },
padding: { base: '20px 16px 28px', md: '24px 24px 36px' },
boxShadow: '0 20px 60px rgba(20, 184, 166, 0.3)',
position: 'relative',
overflow: 'visible',
minHeight: { base: '240px', md: '260px' },
})}
>
{/* Animated background pattern */}
<div
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: 0.1,
background:
'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,0.1) 10px, rgba(255,255,255,0.1) 20px)',
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
marginBottom: '0.5rem',
})}
/>
<div className={css({ position: 'relative', zIndex: 1 })}>
<h1
className={css({
fontSize: { base: '28px', sm: '32px', md: '40px' },
fontWeight: 'black',
color: 'white',
marginBottom: '8px',
textShadow: '0 4px 12px rgba(0,0,0,0.3)',
letterSpacing: '-0.02em',
})}
>
🎴 Card Sorting Challenge
</h1>
<p
className={css({
fontSize: { base: '14px', md: '16px' },
color: 'rgba(255,255,255,0.95)',
maxWidth: '600px',
margin: '0 auto',
lineHeight: 1.5,
fontWeight: '500',
})}
>
Arrange abacus cards in order using <strong>only visual patterns</strong> no numbers
shown!
</p>
{/* Sample cards preview */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: { base: '6px', md: '8px' },
marginTop: '12px',
flexWrap: 'wrap',
})}
>
{[3, 7, 12].map((value, idx) => (
<div
key={value}
className={css({
background: 'white',
borderRadius: '8px',
padding: { base: '6px', md: '8px' },
boxShadow: '0 6px 16px rgba(0,0,0,0.2)',
transform: `rotate(${(idx - 1) * 3}deg)`,
animation: 'float 3s ease-in-out infinite',
animationDelay: `${idx * 0.3}s`,
width: { base: '60px', md: '70px' },
height: { base: '75px', md: '85px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<div className={css({ transform: 'scale(0.35)', transformOrigin: 'center' })}>
<AbacusReact value={value} columns={2} showNumbers={false} />
</div>
</div>
))}
</div>
</div>
>
Card Sorting Challenge
</h2>
<p
className={css({
fontSize: { base: 'md', md: 'lg' },
color: 'gray.600',
})}
>
Arrange abacus cards in order using only visual patterns
</p>
</div>
{/* Card Count Selection */}
<div>
<div
<div className={css({ width: '100%', maxWidth: '400px' })}>
<label
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
marginBottom: { base: '12px', md: '16px' },
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '0.5rem',
color: 'gray.700',
})}
>
<span
className={css({
fontSize: { base: '24px', md: '28px' },
})}
>
🎯
</span>
<h2
className={css({
fontSize: { base: '20px', md: '24px' },
fontWeight: 'bold',
color: 'gray.800',
margin: 0,
})}
>
Choose Your Challenge
</h2>
</div>
Number of Cards
</label>
<div
className={css({
display: 'grid',
gridTemplateColumns: {
base: 'repeat(2, 1fr)',
sm: 'repeat(4, 1fr)',
},
gap: { base: '12px', md: '16px' },
gridTemplateColumns: '4',
gap: '0.5rem',
})}
>
{([5, 8, 12, 15] as const).map((count) => {
const info = cardCountInfo[count]
return (
<button
key={count}
type="button"
onClick={() => setConfig('cardCount', count)}
className={getButtonStyles(state.cardCount === count)}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: { base: '6px', md: '8px' },
})}
>
<div
className={css({
fontSize: { base: '36px', md: '44px' },
lineHeight: 1,
})}
>
{info.icon}
</div>
<div
className={css({
fontSize: { base: '24px', md: '28px' },
fontWeight: 'black',
lineHeight: 1,
})}
>
{count}
</div>
<div
className={css({
fontSize: { base: '13px', md: '15px' },
fontWeight: 'bold',
opacity: 0.9,
})}
>
{info.label}
</div>
<div
className={css({
fontSize: { base: '11px', md: '12px' },
opacity: 0.8,
display: { base: 'none', sm: 'block' },
})}
>
{info.description}
</div>
</div>
</button>
)
})}
</div>
<div
className={css({
marginTop: '12px',
padding: { base: '12px', md: '14px' },
background: 'linear-gradient(135deg, #f0fdfa, #ccfbf1)',
borderRadius: '10px',
border: '2px solid',
borderColor: 'teal.200',
})}
>
<p
className={css({
fontSize: { base: '13px', md: '15px' },
color: 'teal.800',
margin: 0,
fontWeight: '600',
})}
>
{cardCountInfo[state.cardCount].emoji} <strong>{state.cardCount} cards</strong> {' '}
{cardCountInfo[state.cardCount].difficulty} difficulty {' '}
{cardCountInfo[state.cardCount].description}
</p>
{([5, 8, 12, 15] as const).map((count) => (
<button
key={count}
type="button"
onClick={() => setConfig('cardCount', count)}
className={css({
padding: '0.75rem',
borderRadius: '0.5rem',
border: '2px solid',
borderColor: state.cardCount === count ? 'teal.500' : 'gray.300',
background: state.cardCount === count ? 'teal.50' : 'white',
color: state.cardCount === count ? 'teal.700' : 'gray.700',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'teal.400',
background: 'teal.50',
},
})}
>
{count}
</button>
))}
</div>
</div>
{/* Start Button */}
{/* Show Numbers Toggle */}
<div className={css({ width: '100%', maxWidth: '400px' })}>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '1rem',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '0.5rem',
cursor: 'pointer',
_hover: {
background: 'gray.50',
},
})}
>
<input
type="checkbox"
checked={state.showNumbers}
onChange={(e) => setConfig('showNumbers', e.target.checked)}
className={css({
width: '1.25rem',
height: '1.25rem',
cursor: 'pointer',
})}
/>
<div>
<div
className={css({
fontWeight: '600',
color: 'gray.700',
})}
>
Allow "Reveal Numbers" button
</div>
<div
className={css({
fontSize: 'sm',
color: 'gray.500',
})}
>
Show numeric values during gameplay
</div>
</div>
</label>
</div>
{/* Action Buttons */}
<div
className={css({
marginTop: 'auto',
paddingTop: { base: '8px', md: '12px' },
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
width: '100%',
maxWidth: '400px',
marginTop: '1rem',
})}
>
{canResumeGame && (
@@ -350,146 +147,47 @@ export function SetupPhase() {
type="button"
onClick={resumeGame}
className={css({
width: '100%',
background: 'linear-gradient(135deg, #10b981 0%, #059669 50%, #34d399 100%)',
padding: '1rem',
borderRadius: '0.5rem',
background: 'teal.600',
color: 'white',
border: 'none',
borderRadius: { base: '16px', md: '20px' },
padding: { base: '16px', md: '20px' },
fontSize: { base: '18px', md: '22px' },
fontWeight: 'black',
fontWeight: '600',
fontSize: 'lg',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 10px 30px rgba(16, 185, 129, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
marginBottom: '12px',
border: 'none',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow:
'0 15px 40px rgba(16, 185, 129, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
},
_active: {
transform: 'translateY(-2px) scale(1.01)',
background: 'teal.700',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
justifyContent: 'center',
})}
>
<span
className={css({
fontSize: { base: '24px', md: '28px' },
animation: 'bounce 2s infinite',
})}
>
</span>
<span>RESUME GAME</span>
<span
className={css({
fontSize: { base: '24px', md: '28px' },
animation: 'bounce 2s infinite',
animationDelay: '0.5s',
})}
>
🎮
</span>
</div>
Resume Game
</button>
)}
<button
type="button"
onClick={startGame}
className={css({
width: '100%',
background: canResumeGame
? 'linear-gradient(135deg, #64748b, #475569)'
: 'linear-gradient(135deg, #14b8a6 0%, #0d9488 50%, #5eead4 100%)',
padding: '1rem',
borderRadius: '0.5rem',
background: canResumeGame ? 'gray.600' : 'teal.600',
color: 'white',
border: 'none',
borderRadius: { base: '16px', md: '20px' },
padding: { base: '14px', md: '18px' },
fontSize: { base: '18px', md: '20px' },
fontWeight: 'black',
fontWeight: '600',
fontSize: 'lg',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: canResumeGame
? '0 8px 20px rgba(100, 116, 139, 0.4), inset 0 2px 0 rgba(255,255,255,0.2)'
: '0 10px 30px rgba(20, 184, 166, 0.5), inset 0 2px 0 rgba(255,255,255,0.3)',
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: '-200%',
width: '200%',
height: '100%',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
backgroundSize: '200% 100%',
},
border: 'none',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: canResumeGame
? '0 12px 35px rgba(100, 116, 139, 0.6), inset 0 2px 0 rgba(255,255,255,0.2)'
: '0 15px 40px rgba(20, 184, 166, 0.7), inset 0 2px 0 rgba(255,255,255,0.3)',
_before: {
animation: 'shimmer 1.5s ease-in-out',
},
},
_active: {
transform: 'translateY(-2px) scale(1.01)',
background: canResumeGame ? 'gray.700' : 'teal.700',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '8px', md: '10px' },
justifyContent: 'center',
})}
>
<span
className={css({
fontSize: { base: '22px', md: '26px' },
animation: canResumeGame ? 'none' : 'bounce 2s infinite',
})}
>
🚀
</span>
<span>{canResumeGame ? 'START NEW GAME' : 'START GAME'}</span>
<span
className={css({
fontSize: { base: '22px', md: '26px' },
animation: canResumeGame ? 'none' : 'bounce 2s infinite',
animationDelay: '0.5s',
})}
>
🎴
</span>
</div>
{canResumeGame ? 'Start New Game' : 'Start Game'}
</button>
{!canResumeGame && (
<p
className={css({
fontSize: { base: '12px', md: '13px' },
color: 'gray.500',
marginTop: '8px',
fontStyle: 'italic',
})}
>
💡 Tip: Look for patterns in the beads focus on positions, not numbers!
</p>
)}
</div>
</div>
)

View File

@@ -32,7 +32,6 @@ const defaultConfig: CardSortingConfig = {
cardCount: 8,
showNumbers: true,
timeLimit: null,
gameMode: 'solo',
}
// Config validation function
@@ -60,13 +59,6 @@ function validateCardSortingConfig(config: unknown): config is CardSortingConfig
}
}
// Validate gameMode (optional, defaults to 'solo')
if ('gameMode' in c) {
if (!['solo', 'collaborative', 'competitive', 'relay'].includes(c.gameMode as string)) {
return false
}
}
return true
}

View File

@@ -15,12 +15,10 @@ export interface PlayerMetadata {
// Configuration
// ============================================================================
export type GameMode = 'solo' | 'collaborative' | 'competitive' | 'relay'
export interface CardSortingConfig extends GameConfig {
cardCount: 5 | 8 | 12 | 15 // Difficulty (number of cards)
showNumbers: boolean // Allow reveal numbers button
timeLimit: number | null // Optional time limit (seconds), null = unlimited
gameMode: GameMode // Game mode (solo, collaborative, competitive, relay)
}
// ============================================================================
@@ -35,16 +33,6 @@ export interface SortingCard {
svgContent: string // Serialized AbacusReact SVG
}
export interface CardPosition {
cardId: string
x: number // % of viewport width (0-100)
y: number // % of viewport height (0-100)
rotation: number // degrees (-15 to 15)
zIndex: number
draggedByPlayerId?: string // ID of player currently dragging this card
draggedByWindowId?: string // ID of specific window/tab doing the drag
}
export interface PlacedCard {
card: SortingCard // The card data
position: number // Which slot it's in (0-indexed)
@@ -59,6 +47,7 @@ export interface ScoreBreakdown {
exactPositionScore: number // 0-100 based on exact matches
inversionScore: number // 0-100 based on inversions
elapsedTime: number // Seconds taken
numbersRevealed: boolean // Whether player used reveal
}
// ============================================================================
@@ -68,17 +57,15 @@ export interface ScoreBreakdown {
export interface CardSortingState extends GameState {
// Configuration
cardCount: 5 | 8 | 12 | 15
showNumbers: boolean
timeLimit: number | null
gameMode: GameMode
// Game phase
gamePhase: GamePhase
// Player & timing
playerId: string // Single player ID (primary player in solo/collaborative)
playerId: string // Single player ID
playerMetadata: PlayerMetadata // Player display info
activePlayers: string[] // All active player IDs (for collaborative mode)
allPlayerMetadata: Map<string, PlayerMetadata> // Metadata for all players
gameStartTime: number | null
gameEndTime: number | null
@@ -87,13 +74,10 @@ export interface CardSortingState extends GameState {
correctOrder: SortingCard[] // Sorted by number (answer key)
availableCards: SortingCard[] // Cards not yet placed
placedCards: (SortingCard | null)[] // Array of N slots (null = empty)
cardPositions: CardPosition[] // Viewport-relative positions for all cards
// Multiplayer cursors (collaborative mode)
cursorPositions: Map<string, { x: number; y: number }> // Player ID -> cursor position
// UI state (client-only, not in server state)
selectedCardId: string | null // Currently selected card
numbersRevealed: boolean // If player revealed numbers
// Results
scoreBreakdown: ScoreBreakdown | null // Final score details
@@ -105,8 +89,8 @@ export interface CardSortingState extends GameState {
selectedCards: SortingCard[]
availableCards: SortingCard[]
placedCards: (SortingCard | null)[]
cardPositions: CardPosition[]
gameStartTime: number
numbersRevealed: boolean
}
}
@@ -154,14 +138,19 @@ export type CardSortingMove =
position: number // Which slot to remove from
}
}
| {
type: 'REVEAL_NUMBERS'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'CHECK_SOLUTION'
playerId: string
userId: string
timestamp: number
data: {
finalSequence?: SortingCard[] // Optional - if provided, use this as the final placement
}
data: Record<string, never>
}
| {
type: 'GO_TO_SETUP'
@@ -176,7 +165,7 @@ export type CardSortingMove =
userId: string
timestamp: number
data: {
field: 'cardCount' | 'timeLimit' | 'gameMode'
field: 'cardCount' | 'showNumbers' | 'timeLimit'
value: unknown
}
}
@@ -187,41 +176,6 @@ export type CardSortingMove =
timestamp: number
data: Record<string, never>
}
| {
type: 'UPDATE_CARD_POSITIONS'
playerId: string
userId: string
timestamp: number
data: {
positions: CardPosition[]
}
}
| {
type: 'JOIN_COLLABORATIVE_GAME'
playerId: string
userId: string
timestamp: number
data: {
playerMetadata: PlayerMetadata
}
}
| {
type: 'LEAVE_COLLABORATIVE_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'UPDATE_CURSOR_POSITION'
playerId: string
userId: string
timestamp: number
data: {
x: number // % of viewport width (0-100)
y: number // % of viewport height (0-100)
}
}
// ============================================================================
// Component Props
@@ -233,6 +187,7 @@ export interface SortingCardProps {
isPlaced: boolean
isCorrect?: boolean // After checking solution
onClick: () => void
showNumber: boolean // If revealed
}
export interface PositionSlotProps {

View File

@@ -57,7 +57,8 @@ export function countInversions(userSeq: number[], correctSeq: number[]): number
export function calculateScore(
userSequence: number[],
correctSequence: number[],
startTime: number
startTime: number,
numbersRevealed: boolean
): ScoreBreakdown {
// LCS-based score (relative order)
const lcsLength = longestCommonSubsequence(userSequence, correctSequence)
@@ -94,5 +95,6 @@ export function calculateScore(
exactPositionScore: Math.round(exactPositionScore),
inversionScore: Math.round(inversionScore),
elapsedTime: Math.floor((Date.now() - startTime) / 1000),
numbersRevealed,
}
}

View File

@@ -1,19 +1,15 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { css } from '../../../../styled-system/css'
import { useGameMode } from '@/contexts/GameModeContext'
import { useMatching } from '../Provider'
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
import { useRecordGameResult } from '@/hooks/useRecordGameResult'
import type { GameResult } from '@/lib/arcade/stats/types'
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
const { mutate: recordGameResult } = useRecordGameResult()
// Get active player data array
const activePlayerData = Array.from(activePlayerIds)
@@ -32,45 +28,6 @@ export function ResultsPhase() {
const multiplayerResult =
gameMode === 'multiplayer' ? getMultiplayerWinner(state, activePlayers) : null
// Record game stats when results are shown
useEffect(() => {
if (!state.gameEndTime || !state.gameStartTime) return
// Build game result
const gameResult: GameResult = {
gameType: 'matching',
playerResults: activePlayerData.map((player) => {
const isWinner = gameMode === 'single' || multiplayerResult?.winners.includes(player.id)
const score =
gameMode === 'multiplayer'
? multiplayerResult?.scores[player.id] || 0
: state.matchedPairs
return {
playerId: player.id,
won: isWinner || false,
score,
accuracy: analysis.statistics.accuracy / 100, // Convert percentage to 0-1
completionTime: gameTime,
metrics: {
moves: state.moves,
matchedPairs: state.matchedPairs,
},
}
}),
completedAt: state.gameEndTime,
duration: gameTime,
metadata: {
gameMode,
starRating: analysis.starRating,
grade: analysis.grade,
},
}
console.log('📊 Recording matching game result:', gameResult)
recordGameResult(gameResult)
}, []) // Empty deps - only record once when component mounts
return (
<div
className={css({

View File

@@ -1,12 +1,10 @@
import { useCallback, useEffect, useState } from 'react'
import { isPrefix } from '@/lib/memory-quiz-utils'
import { useMemoryQuiz } from '../Provider'
import { useViewport } from '@/contexts/ViewportContext'
import { CardGrid } from './CardGrid'
export function InputPhase() {
const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = useMemoryQuiz()
const viewport = useViewport()
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>(
'neutral'
)
@@ -58,7 +56,7 @@ export function InputPhase() {
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
// Method 3: Check viewport characteristics for mobile devices
const isMobileViewport = viewport.width <= 768 && viewport.height <= 1024
const isMobileViewport = window.innerWidth <= 768 && window.innerHeight <= 1024
// Combined heuristic: assume no physical keyboard if:
// - It's a touch device AND has mobile viewport AND lacks precise pointer

View File

@@ -1,423 +0,0 @@
# Rithmomachia Implementation Audit Report
**Date:** 2025-01-30
**Auditor:** Claude Code
**Scope:** Complete implementation vs SPEC.md v1
---
## Executive Summary
**Overall Assessment:** ⚠️ **MOSTLY COMPLIANT with CRITICAL ISSUES**
The implementation is **93% compliant** with the specification, with all major game mechanics correctly implemented. However, there are **3 critical issues** that violate SPEC requirements and **2 medium-priority gaps** that should be addressed.
**Files Audited:** 11 implementation files + 1 spec (33,500+ lines)
---
## 🔴 CRITICAL ISSUES (Must Fix)
### 1. **BigInt Requirement Violation** ⚠️ CRITICAL
**SPEC Requirement (§10, §13.2):**
> Use bigints (JS `BigInt`) for relation math to avoid overflow with large powers.
**Implementation:** `relationEngine.ts` uses `number` type for all arithmetic
```typescript
// SPEC says this should be BigInt
export function checkProduct(a: number, b: number, h: number): RelationCheckResult {
const product = a * h // Can overflow with large values!
// ...
}
```
**Impact:**
- **HIGH SEVERITY** - With traditional piece values (361, 289, 225, etc.), multiplication can overflow
- Example: `361 * 289 = 104,329` (safe)
- But with higher values or accumulated products, overflow risk increases
- SPEC explicitly requires BigInt for "large powers"
**Evidence:**
- File: `utils/relationEngine.ts` lines 18-296
- Comment on line 5 claims "All arithmetic uses BigInt" but all functions use `number`
- `formatValue()` function (line 296) has JSDoc saying "Format a BigInt value" but accepts `number`
**Recommendation:**
```typescript
// Convert all relation functions to use bigint
export function checkProduct(a: bigint, b: bigint, h: bigint): RelationCheckResult {
const product = a * h
if (product === b || b * h === a) {
return { valid: true, relation: 'PRODUCT' }
}
// ...
}
```
---
### 2. **Pyramid as Helper - Unclear Implementation** ⚠️ MEDIUM-CRITICAL
**SPEC Requirement (§13.2):**
> If you allow Pyramid as helper, require explicit `helperFaceUsed` in payload and store it.
**Implementation:** `validateCapture()` in `Validator.ts` (lines 276-371) **does not check if helper is a Pyramid**
```typescript
// Current code (lines 302-318)
if (helperPieceId) {
try {
helperPiece = getPieceById(state.pieces, helperPieceId)
} catch (e) {
return { valid: false, error: `Helper piece not found: ${helperPieceId}` }
}
// Check helper is friendly
if (helperPiece.color !== mover.color) {
return { valid: false, error: 'Helper must be friendly' }
}
// Get helper value
helperValue = getEffectiveValue(helperPiece)
// ⚠️ getEffectiveValue() returns null for Pyramid without activePyramidFace
// ⚠️ No validation for helperFaceUsed in capture data!
}
```
**Gap:** SPEC says helpers **do not switch faces** (§13.2), but:
- No check if helper is a Pyramid
- No `helperFaceUsed` field in `CaptureContext` type
- `getEffectiveValue()` returns `null` for Pyramids without `activePyramidFace` set
**Impact:**
- If a Pyramid is used as helper, capture will fail (helperValue = null)
- No way to specify helper face in move data
- Ambiguous behavior: should Pyramids be allowed as helpers or not?
**Recommendation:** SPEC says Pyramids "do not switch faces" for helpers. Two options:
**Option A (Explicit):** Add `helperFaceUsed` to capture data:
```typescript
interface CaptureContext {
relation: RelationKind;
moverPieceId: string;
targetPieceId: string;
helperPieceId?: string;
helperFaceUsed?: number | null; // ← Add this
moverFaceUsed?: number | null;
}
```
**Option B (Simple):** Disallow Pyramids as helpers:
```typescript
if (helperPiece.type === 'P') {
return { valid: false, error: 'Pyramids cannot be used as helpers' }
}
```
---
### 3. **Harmony Params Type Mismatch** ⚠️ MEDIUM
**SPEC Requirement (§11.4):**
```typescript
interface HarmonyDeclaration {
// ...
params: { v?: string; d?: string; r?: string }; // store as strings for bigints
}
```
**Implementation (types.ts line 52-57):**
```typescript
export interface HarmonyDeclaration {
// ...
params: {
a?: string // first value in proportion (A-M-B structure)
m?: string // middle value in proportion
b?: string // last value in proportion
}
}
```
**Issue:** Param names changed from SPEC's `{ v, d, r }` to `{ a, m, b }` but SPEC not updated
**Impact:** LOW - Internal inconsistency, but both work. Implementation is actually **better** (more descriptive for A-M-B structure)
**Recommendation:** Update SPEC §11.4 to match implementation's `{ a, m, b }` structure
---
## 🟡 MEDIUM PRIORITY GAPS
### 4. **No Test Files Found** ⚠️ MEDIUM
**SPEC Requirement (§15):**
> Test cases (goldens) - 10 test scenarios provided
**Implementation:** **ZERO test files** found in `src/arcade-games/rithmomachia/`
**Gap:**
- No `*.test.ts` or `*.spec.ts` files
- No unit tests for validators
- No integration tests for game scenarios
- SPEC provides 10 specific test cases that should be automated
**Impact:**
- No automated regression testing
- Changes could break game logic undetected
- Manual testing burden on developer
**Recommendation:** Create test suite covering SPEC §15 test cases:
```
src/arcade-games/rithmomachia/__tests__/
├── relationEngine.test.ts # Test all 7 capture relations
├── harmonyValidator.test.ts # Test AP, GP, HP validation
├── pathValidator.test.ts # Test movement rules
├── pieceSetup.test.ts # Test initial board
└── Validator.integration.test.ts # Test full game scenarios
```
---
### 5. **Time Controls Not Enforced** ⚠️ LOW
**SPEC Requirement (§11.2):**
```typescript
clocks?: { Wms: number; Bms: number } | null; // optional timers
```
**Implementation:**
- `timeControlMs` config field exists (types.ts line 88)
- Stored in state but **never enforced**
- No clock countdown logic
- No time-out handling
**Gap:** Config accepts `timeControlMs` but has no effect
**Impact:** LOW - Marked as "not implemented in v1" per SPEC comment
**Status:** **ACCEPTABLE** - Documented as future feature
---
## ✅ COMPLIANT AREAS (Working Correctly)
### Board & Setup ✅
- ✅ 8 rows × 16 columns (A-P, 1-8)
- ✅ Traditional 25-piece setup per side
- ✅ Correct piece values and types
- ✅ Proper initial placement (verified against reference image)
- ✅ Piece IDs follow naming convention
### Movement & Geometry ✅
- ✅ Circle: Diagonal (bishop-like)
- ✅ Triangle: Orthogonal (rook-like)
- ✅ Square: Queen-like (diagonal + orthogonal)
- ✅ Pyramid: King-like (1 step any direction)
- ✅ Path clearance validation
- ✅ No jumping enforced
### Capture Relations (7 types) ✅
- ✅ EQUAL: `a == b`
- ✅ MULTIPLE: `a % b == 0`
- ✅ DIVISOR: `b % a == 0`
- ✅ SUM: `a + h == b` (with helper)
- ✅ DIFF: `|a - h| == b` (with helper)
- ✅ PRODUCT: `a * h == b` (with helper)
- ✅ RATIO: `a * r == b` (with helper)
**Note:** Logic correct but should use BigInt (see Critical Issue #1)
### Ambush Captures ✅
- ✅ Requires 2 friendly helpers
- ✅ Validates relation with enemy piece
- ✅ Post-move declaration
- ✅ Resets no-progress counter
### Harmony Victories ✅
- ✅ Three-piece proportions (A-M-B structure)
- ✅ Arithmetic: `2M = A + B`
- ✅ Geometric: `M² = A · B`
- ✅ Harmonic: `2AB = M(A + B)`
- ✅ Collinearity requirement
- ✅ Middle piece detection
- ✅ Layout modes (adjacent, equalSpacing, collinear)
- ✅ Persistence checking (survives opponent's turn)
-`allowAnySetOnRecheck` config respected
### Other Victory Conditions ✅
- ✅ Exhaustion (no legal moves)
- ✅ Resignation
- ✅ Point victory (optional, C=1, T=2, S=3, P=5)
- ✅ 30-point threshold
### Draw Conditions ✅
- ✅ Threefold repetition (using Zobrist hashing)
- ✅ 50-move rule (no captures/no harmony)
- ✅ Mutual agreement (offer/accept)
### Configuration ✅
- ✅ All 8 config fields implemented
- ✅ Player assignment (whitePlayerId, blackPlayerId)
- ✅ Point win toggle
- ✅ Rule toggles (repetition, fifty-move)
- ✅ Config persistence in database
### State Management ✅
- ✅ Immutable state updates
- ✅ Provider pattern with context
- ✅ Move history tracking
- ✅ Pending harmony tracking
- ✅ Captured pieces by color
- ✅ Turn management
### Validation ✅
- ✅ Server-side validation via Validator class
- ✅ Turn ownership checks
- ✅ Piece existence checks
- ✅ Path clearance
- ✅ Relation validation
- ✅ Helper validation (friendly, alive, not mover)
- ✅ Pyramid face validation
### UI Components ✅
- ✅ Full game board rendering
- ✅ Drag-and-drop movement
- ✅ Click-to-select movement
- ✅ Legal move highlighting
- ✅ Capture relation selection modal
- ✅ Ambush declaration UI
- ✅ Harmony declaration UI
- ✅ Setup phase with player assignment
- ✅ Results phase with victory display
- ✅ Move history panel
- ✅ Captured pieces display
- ✅ Error notifications
### Socket Protocol ✅
- ✅ Uses arcade SDK generic session handling
- ✅ No game-specific socket code needed
- ✅ Move validation server-side
- ✅ State synchronization
---
## 📊 Compliance Score
| Category | Score | Notes |
|----------|-------|-------|
| **Core Rules** | 95% | All rules implemented, BigInt issue only |
| **Data Models** | 100% | All types match SPEC |
| **Validation** | 90% | Missing helper Pyramid validation |
| **Victory Conditions** | 100% | All 6 conditions working |
| **UI/UX** | 95% | Excellent, missing math inspector |
| **Testing** | 0% | No test files |
| **Documentation** | 100% | SPEC is comprehensive |
**Overall:** 93% compliant
---
## 🎯 Priority Action Items
### High Priority (Fix before release)
1. **Implement BigInt arithmetic** in `relationEngine.ts` (Critical Issue #1)
2. **Decide on Pyramid helper policy** and implement validation (Critical Issue #2)
### Medium Priority (Fix in next sprint)
3. **Create test suite** covering SPEC §15 test cases (Medium Issue #4)
4. **Update SPEC** to match harmony params structure (Medium Issue #3)
### Low Priority (Future enhancement)
5. **Implement time controls** if needed for competitive play (Low Issue #5)
6. **Add math inspector UI** (SPEC §14 suggestion)
7. **Add harmony builder UI** (SPEC §14 suggestion)
---
## 🔍 Detailed Code Review Notes
### Validator.ts (895 lines)
- **Excellent:** Comprehensive move validation
- **Excellent:** Proper state immutability
- **Excellent:** Harmony persistence logic correct
- **Good:** Helper validation exists
- **Gap:** No check if helper is Pyramid
- **Gap:** No `helperFaceUsed` handling
### relationEngine.ts (296 lines)
- **Critical:** Uses `number` instead of `bigint`
- **Good:** All 7 relations correctly implemented
- **Good:** Bidirectional checks (a→b and b→a)
- **Good:** Helper validation structure
### harmonyValidator.ts (364 lines)
- **Excellent:** Three-piece structure correct
- **Excellent:** Collinearity logic solid
- **Excellent:** Middle piece detection accurate
- **Excellent:** Integer formulas (no division)
- **Good:** Layout modes implemented
### pathValidator.ts (210 lines)
- **Excellent:** All movement geometries correct
- **Excellent:** Path clearance algorithm
- **Good:** getLegalMoves() utility
### pieceSetup.ts (234 lines)
- **Excellent:** Traditional setup matches reference
- **Excellent:** All 50 pieces correctly placed
- **Good:** Utility functions comprehensive
### Provider.tsx (730 lines)
- **Excellent:** Player assignment logic
- **Excellent:** Observer mode detection
- **Excellent:** Config persistence
- **Good:** Error handling with toasts
### RithmomachiaGame.tsx (30,000+ lines)
- **Excellent:** Comprehensive UI
- **Excellent:** Drag-and-drop + click movement
- **Good:** Relation selection modal
- **Note:** Very large file, consider splitting
### PieceRenderer.tsx (200 lines)
- **Excellent:** Clean SVG rendering
- **Good:** Color gradients
- **Good:** Responsive sizing
### types.ts (318 lines)
- **Excellent:** Complete type definitions
- **Good:** Helper utilities (parseSquare, etc.)
- **Minor:** Harmony params naming differs from SPEC
### zobristHash.ts (180 lines)
- **Excellent:** Deterministic hashing
- **Good:** Uses BigInt internally
- **Good:** Repetition detection
---
## 📚 References
- **SPEC:** `src/arcade-games/rithmomachia/SPEC.md`
- **Implementation Root:** `src/arcade-games/rithmomachia/`
- **Audit Date:** 2025-01-30
- **Lines Audited:** ~33,500 lines
---
## ✍️ Auditor Notes
This is an **impressively thorough implementation** of a complex medieval board game. The code quality is high, with proper separation of concerns, immutable state management, and comprehensive validation logic.
The **BigInt issue is the only truly critical flaw** that could cause real bugs with large piece values. The Pyramid helper ambiguity is more of a spec clarification issue.
The **lack of tests is concerning** for a game with this much mathematical complexity. I strongly recommend adding test coverage for the relation engine and harmony validator before considering this production-ready.
Overall: **Excellent work, with 3 fixable issues preventing a 100% compliance score.**
---
**END OF AUDIT REPORT**

View File

@@ -1,134 +0,0 @@
# Rithmomachia Quick Start Guide
**The Philosopher's Game** — Win by using math to capture pieces and build harmonies in enemy territory.
---
## Goal
Arrange 3 of your pieces in enemy territory to form a **mathematical progression**, survive one opponent turn, and win.
---
## The Board
- **8 rows × 16 columns** (columns A-P, rows 1-8)
- **Your half:** Black controls rows 5-8, White controls rows 1-4
- **Enemy territory:** Where you need to build your winning progression
---
## Your Pieces (24 total)
Each piece has a **number value** and moves differently:
| Shape | Symbol | Movement | Count |
|-------|--------|----------|-------|
| **Circle** | ○ | Diagonal (like a bishop) | 8 |
| **Triangle** | △ | Straight lines (like a rook) | 8 |
| **Square** | □ | Any direction (like a queen) | 7 |
| **Pyramid** | ◇ | One step any way (like a king) | 1 |
**Pyramids are special:** They have 4 face values. When capturing, you choose which face to use.
---
## How to Move
1. **Click your piece** to select it
2. **Click destination** to move
3. Pieces cannot jump over others — path must be clear
4. You can only move to an empty square OR capture an enemy
---
## How to Capture
You can capture an enemy piece **only if your piece's value relates mathematically** to theirs:
### Simple Relations (no helper needed):
- **Equal:** Your 25 captures their 25
- **Multiple/Divisor:** Your 64 captures their 16 (64 ÷ 16 = 4)
### Advanced Relations (need one helper piece):
- **Sum:** Your 9 + helper 16 = enemy 25
- **Difference:** Your 30 - helper 10 = enemy 20
- **Product:** Your 5 × helper 5 = enemy 25
**Helpers** are your other pieces still on the board — they don't move, just provide their value for the math.
The game will show you valid captures when you select a piece.
---
## How to Win
### Victory Condition #1: Harmony (Progression)
Get **3 of your pieces into enemy territory** forming one of these progressions:
- **Arithmetic:** Middle value is the average
- Example: 6, 9, 12 (because 9 = (6+12)/2)
- **Geometric:** Middle value is geometric mean
- Example: 4, 8, 16 (because 8² = 4×16)
- **Harmonic:** Special proportion (formula: 2AB = M(A+B))
- Example: 6, 8, 12 (because 2×6×12 = 8×(6+12))
**Important:** Your 3 pieces must be in a straight line (row, column, or diagonal), and all 3 must be in enemy territory.
When you form a harmony, your opponent gets **one turn to break it**. If it survives, you win!
### Victory Condition #2: Exhaustion
If your opponent has no legal moves, they lose.
---
## Key Rules
1. **White moves first**
2. **No jumping** — paths must be clear
3. **Can't capture your own pieces**
4. **Helpers can be anywhere** on the board (not just adjacent)
5. **Harmonies must survive one turn** to win
---
## Quick Strategy Tips
- **Control the center** — easier to invade enemy territory
- **Small pieces are fast** — circles (3, 5, 7, 9) can slip into enemy half quickly
- **Large pieces are powerful** — harder to capture due to their size
- **Watch for harmony threats** — don't let opponent get 3 pieces deep in your territory
- **Pyramids are flexible** — choose the right face value for each situation
---
## Common Progressions to Know
**Arithmetic** (easiest to spot):
- 4, 6, 8
- 6, 9, 12
- 5, 7, 9
**Geometric** (same ratio between values):
- 4, 8, 16
- 9, 27, 81
**Harmonic** (trickiest):
- 6, 8, 12
- 3, 4, 6
Practice spotting these patterns in your pieces!
---
## Ready to Play?
1. Start by moving pieces toward the center
2. Look for capture opportunities using the math relations
3. Push into enemy territory (rows 1-4 for Black, rows 5-8 for White)
4. Watch for harmony opportunities with your forward pieces
5. Win by forming a progression that survives one turn!
**Remember:** This is a game of mathematical warfare. Every number matters!

View File

@@ -1,731 +0,0 @@
'use client'
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import {
TEAM_MOVE,
useArcadeSession,
useRoomData,
useUpdateGameConfig,
useViewerId,
} from '@/lib/arcade/game-sdk'
import type {
AmbushContext,
Color,
HarmonyType,
RelationKind,
RithmomachiaConfig,
RithmomachiaState,
} from './types'
import { useToast } from '@/components/common/ToastContext'
import {
parseError,
shouldShowToast,
getToastType,
getMoveActionName,
type EnhancedError,
type RetryState,
} from '@/lib/arcade/error-handling'
/**
* Context value for Rithmomachia game.
*/
export type RithmomachiaRosterStatus =
| { status: 'ok'; activePlayerCount: number; localPlayerCount: number }
| {
status: 'tooFew'
activePlayerCount: number
localPlayerCount: number
missingWhite: boolean
missingBlack: boolean
}
| {
status: 'noLocalControl'
activePlayerCount: number
localPlayerCount: number
}
interface RithmomachiaContextValue {
// State
state: RithmomachiaState
lastError: string | null
retryState: RetryState
// Player info
viewerId: string | null
playerColor: Color | null
isMyTurn: boolean
rosterStatus: RithmomachiaRosterStatus
localActivePlayerIds: string[]
whitePlayerId: string | null
blackPlayerId: string | null
localTurnPlayerId: string | null
isSpectating: boolean
localPlayerColor: Color | null
// Game actions
startGame: () => void
makeMove: (
from: string,
to: string,
pieceId: string,
pyramidFace?: number,
capture?: CaptureData,
ambush?: AmbushContext
) => void
declareHarmony: (
pieceIds: string[],
harmonyType: HarmonyType,
params: Record<string, string>
) => void
resign: () => void
offerDraw: () => void
acceptDraw: () => void
claimRepetition: () => void
claimFiftyMove: () => void
// Config actions
setConfig: (field: keyof RithmomachiaConfig, value: any) => void
// Player assignment actions
assignWhitePlayer: (playerId: string | null) => void
assignBlackPlayer: (playerId: string | null) => void
swapSides: () => void
// Game control actions
resetGame: () => void
goToSetup: () => void
exitSession: () => void
// Error handling
clearError: () => void
}
interface CaptureData {
relation: RelationKind
targetPieceId: string
helperPieceId?: string
}
const RithmomachiaContext = createContext<RithmomachiaContextValue | null>(null)
/**
* Hook to access Rithmomachia game context.
*/
export function useRithmomachia(): RithmomachiaContextValue {
const context = useContext(RithmomachiaContext)
if (!context) {
throw new Error('useRithmomachia must be used within RithmomachiaProvider')
}
return context
}
/**
* Provider for Rithmomachia game state and actions.
*/
export function RithmomachiaProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
const { showToast } = useToast()
const activePlayerList = useMemo(() => Array.from(activePlayerIds), [activePlayerIds])
const localActivePlayerIds = useMemo(
() =>
activePlayerList.filter((id) => {
const player = players.get(id)
return player?.isLocal !== false
}),
[activePlayerList, players]
)
// Merge saved config from room data
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null
const savedConfig = gameConfig?.rithmomachia as Partial<RithmomachiaConfig> | undefined
// Use validator to create initial state with config
const config: RithmomachiaConfig = {
pointWinEnabled: savedConfig?.pointWinEnabled ?? false,
pointWinThreshold: savedConfig?.pointWinThreshold ?? 30,
repetitionRule: savedConfig?.repetitionRule ?? true,
fiftyMoveRule: savedConfig?.fiftyMoveRule ?? true,
allowAnySetOnRecheck: savedConfig?.allowAnySetOnRecheck ?? true,
timeControlMs: savedConfig?.timeControlMs ?? null,
whitePlayerId: savedConfig?.whitePlayerId ?? null,
blackPlayerId: savedConfig?.blackPlayerId ?? null,
}
// Import validator dynamically to get initial state
return {
...require('./Validator').rithmomachiaValidator.getInitialState(config),
}
}, [roomData?.gameConfig])
// Use arcade session hook
const { state, sendMove, lastError, clearError, retryState } =
useArcadeSession<RithmomachiaState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState: mergedInitialState,
applyMove: (state) => state, // No optimistic updates for v1 - rely on server validation
})
// Get player assignments from config (with fallback to auto-assignment)
const whitePlayerId = useMemo(() => {
const configWhite = state.whitePlayerId
// If explicitly set in config and still valid, use it
if (configWhite !== undefined && configWhite !== null) {
return activePlayerList.includes(configWhite) ? configWhite : null
}
// Fallback to auto-assignment: first active player
return activePlayerList[0] ?? null
}, [state.whitePlayerId, activePlayerList])
const blackPlayerId = useMemo(() => {
const configBlack = state.blackPlayerId
// If explicitly set in config and still valid, use it
if (configBlack !== undefined && configBlack !== null) {
return activePlayerList.includes(configBlack) ? configBlack : null
}
// Fallback to auto-assignment: second active player
return activePlayerList[1] ?? null
}, [state.blackPlayerId, activePlayerList])
// Compute roster status based on white/black assignments (not player count)
const rosterStatus = useMemo<RithmomachiaRosterStatus>(() => {
const activeCount = activePlayerList.length
const localCount = localActivePlayerIds.length
// Check if white and black are assigned
const hasWhitePlayer = whitePlayerId !== null
const hasBlackPlayer = blackPlayerId !== null
// Status is 'tooFew' only if white or black is missing
if (!hasWhitePlayer || !hasBlackPlayer) {
return {
status: 'tooFew',
activePlayerCount: activeCount,
localPlayerCount: localCount,
missingWhite: !hasWhitePlayer,
missingBlack: !hasBlackPlayer,
}
}
// Check if current user has control over either white or black
const localControlsWhite = localActivePlayerIds.includes(whitePlayerId)
const localControlsBlack = localActivePlayerIds.includes(blackPlayerId)
if (!localControlsWhite && !localControlsBlack) {
return {
status: 'noLocalControl', // Observer mode
activePlayerCount: activeCount,
localPlayerCount: localCount,
}
}
// All good - white and black assigned, and user controls at least one
return { status: 'ok', activePlayerCount: activeCount, localPlayerCount: localCount }
}, [activePlayerList.length, localActivePlayerIds, whitePlayerId, blackPlayerId])
const localTurnPlayerId = useMemo(() => {
const currentId = state.turn === 'W' ? whitePlayerId : blackPlayerId
if (!currentId) return null
return localActivePlayerIds.includes(currentId) ? currentId : null
}, [state.turn, whitePlayerId, blackPlayerId, localActivePlayerIds])
const playerColor = useMemo((): Color | null => {
if (localTurnPlayerId) {
return state.turn
}
if (localActivePlayerIds.length === 1) {
const soleLocalId = localActivePlayerIds[0]
if (soleLocalId === whitePlayerId) return 'W'
if (soleLocalId === blackPlayerId) return 'B'
}
return null
}, [localTurnPlayerId, localActivePlayerIds, whitePlayerId, blackPlayerId, state.turn])
// Check if it's my turn
const isMyTurn = useMemo(() => {
if (rosterStatus.status !== 'ok') return false
return localTurnPlayerId !== null
}, [rosterStatus.status, localTurnPlayerId])
// Action: Start game
const startGame = useCallback(() => {
// Block observers from starting game
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'START_GAME',
playerId: localTurnPlayerId,
userId: viewerId,
data: {
playerColor: playerColor || 'W',
activePlayers: activePlayerList,
},
})
}, [
sendMove,
viewerId,
localTurnPlayerId,
playerColor,
activePlayerList,
whitePlayerId,
blackPlayerId,
localActivePlayerIds,
])
// Action: Make a move
const makeMove = useCallback(
(
from: string,
to: string,
pieceId: string,
pyramidFace?: number,
capture?: CaptureData,
ambush?: AmbushContext
) => {
// Block observers from making moves
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'MOVE',
playerId: localTurnPlayerId,
userId: viewerId,
data: {
from,
to,
pieceId,
pyramidFaceUsed: pyramidFace ?? null,
capture: capture
? {
relation: capture.relation,
targetPieceId: capture.targetPieceId,
helperPieceId: capture.helperPieceId,
}
: undefined,
ambush,
},
})
},
[sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds]
)
// Action: Declare harmony
const declareHarmony = useCallback(
(pieceIds: string[], harmonyType: HarmonyType, params: Record<string, string>) => {
// Block observers from declaring harmony
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'DECLARE_HARMONY',
playerId: localTurnPlayerId,
userId: viewerId,
data: {
pieceIds,
harmonyType,
params,
},
})
},
[sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds]
)
// Action: Resign
const resign = useCallback(() => {
// Block observers from resigning
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'RESIGN',
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
// Action: Offer draw
const offerDraw = useCallback(() => {
// Block observers from offering draw
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'OFFER_DRAW',
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
// Action: Accept draw
const acceptDraw = useCallback(() => {
// Block observers from accepting draw
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'ACCEPT_DRAW',
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
// Action: Claim repetition
const claimRepetition = useCallback(() => {
// Block observers from claiming repetition
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'CLAIM_REPETITION',
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
// Action: Claim fifty-move rule
const claimFiftyMove = useCallback(() => {
// Block observers from claiming fifty-move
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
if (!viewerId || !localTurnPlayerId) return
sendMove({
type: 'CLAIM_FIFTY_MOVE',
playerId: localTurnPlayerId,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId, localTurnPlayerId, whitePlayerId, blackPlayerId, localActivePlayerIds])
// Action: Set config
const setConfig = useCallback(
(field: keyof RithmomachiaConfig, value: any) => {
// During gameplay, restrict config changes
if (state.gamePhase === 'playing') {
// Allow host to change player assignments at any time
const isHost = roomData?.members.some((m) => m.userId === viewerId && m.isCreator)
const isPlayerAssignment = field === 'whitePlayerId' || field === 'blackPlayerId'
if (isPlayerAssignment && isHost) {
// Host can always reassign players
} else {
// Other config changes require being an active player
const localColor =
whitePlayerId && localActivePlayerIds.includes(whitePlayerId)
? 'W'
: blackPlayerId && localActivePlayerIds.includes(blackPlayerId)
? 'B'
: null
if (!localColor) return
}
}
// Send move to update state immediately
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Persist to database (room mode only)
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, any>) || {}
const currentConfig = (currentGameConfig.rithmomachia as Record<string, any>) || {}
updateGameConfig(
{
roomId: roomData.id,
gameConfig: {
...currentGameConfig,
rithmomachia: {
...currentConfig,
[field]: value,
},
},
},
{
onError: (error) => {
console.error('[Rithmomachia] Failed to update game config:', error)
// Surface 403 errors specifically
if (error.message.includes('Only the host can change')) {
console.warn('[Rithmomachia] 403 Forbidden: Only host can change room settings')
// The error will be visible in console - in the future, we could add toast notifications
}
},
}
)
}
},
[
viewerId,
sendMove,
roomData,
updateGameConfig,
state.gamePhase,
whitePlayerId,
blackPlayerId,
localActivePlayerIds,
]
)
// Action: Reset game (start new game with same config)
const resetGame = useCallback(() => {
if (!viewerId) return
sendMove({
type: 'RESET_GAME',
playerId: TEAM_MOVE,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId])
// Action: Go to setup (return to setup phase)
const goToSetup = useCallback(() => {
if (!viewerId) return
sendMove({
type: 'GO_TO_SETUP',
playerId: TEAM_MOVE,
userId: viewerId,
data: {},
})
}, [sendMove, viewerId])
// Action: Exit session (no-op for now, handled by PageWithNav)
const exitSession = useCallback(() => {
// PageWithNav handles the actual navigation
// This is here for API compatibility
}, [])
// Action: Assign white player
const assignWhitePlayer = useCallback(
(playerId: string | null) => {
setConfig('whitePlayerId', playerId)
},
[setConfig]
)
// Action: Assign black player
const assignBlackPlayer = useCallback(
(playerId: string | null) => {
setConfig('blackPlayerId', playerId)
},
[setConfig]
)
// Action: Swap white and black assignments
const swapSides = useCallback(() => {
const currentWhite = whitePlayerId
const currentBlack = blackPlayerId
setConfig('whitePlayerId', currentBlack)
setConfig('blackPlayerId', currentWhite)
}, [whitePlayerId, blackPlayerId, setConfig])
// Observer detection
const isSpectating = useMemo(() => {
return rosterStatus.status === 'noLocalControl'
}, [rosterStatus.status])
const localPlayerColor = useMemo<Color | null>(() => {
if (!whitePlayerId || !blackPlayerId) return null
if (localActivePlayerIds.includes(whitePlayerId)) return 'W'
if (localActivePlayerIds.includes(blackPlayerId)) return 'B'
return null
}, [localActivePlayerIds, whitePlayerId, blackPlayerId])
// Auto-assign players when they join and a color is missing
useEffect(() => {
// Only auto-assign if we have active players
if (activePlayerList.length === 0) return
// Check if we're missing white or black
const missingWhite = !whitePlayerId
const missingBlack = !blackPlayerId
// Only auto-assign if at least one color is missing
if (!missingWhite && !missingBlack) return
if (missingWhite && missingBlack) {
// Both missing - auto-assign first two players
if (activePlayerList.length >= 2) {
// Assign both at once to avoid double render
setConfig('whitePlayerId', activePlayerList[0])
// Use setTimeout to batch the second assignment
setTimeout(() => setConfig('blackPlayerId', activePlayerList[1]), 0)
} else if (activePlayerList.length === 1) {
// Only one player - assign to white by default
setConfig('whitePlayerId', activePlayerList[0])
}
return
}
// One color is missing - find an unassigned player
const assignedPlayers = [whitePlayerId, blackPlayerId].filter(Boolean) as string[]
const unassignedPlayer = activePlayerList.find((id) => !assignedPlayers.includes(id))
if (unassignedPlayer) {
if (missingWhite) {
setConfig('whitePlayerId', unassignedPlayer)
} else {
setConfig('blackPlayerId', unassignedPlayer)
}
}
}, [activePlayerList, whitePlayerId, blackPlayerId])
// Note: setConfig is intentionally NOT in dependencies to avoid infinite loop
// setConfig is stable (defined with useCallback) so this is safe
// Toast notifications for errors
useEffect(() => {
if (!lastError) return
// Parse the error to get enhanced information
const enhancedError: EnhancedError = parseError(
lastError,
retryState.move ?? undefined,
retryState.retryCount
)
// Show toast if appropriate
if (shouldShowToast(enhancedError)) {
const toastType = getToastType(enhancedError.severity)
const actionName = retryState.move ? getMoveActionName(retryState.move) : 'performing action'
showToast({
type: toastType,
title: enhancedError.userMessage,
description: enhancedError.suggestion
? `${enhancedError.suggestion} (${actionName})`
: `Error while ${actionName}`,
duration: enhancedError.severity === 'fatal' ? 10000 : 7000,
})
}
}, [lastError, retryState, showToast])
// Toast for retry state changes (progressive feedback)
useEffect(() => {
if (!retryState.isRetrying || !retryState.move) return
// Parse the error as a version conflict
const enhancedError: EnhancedError = parseError(
'version conflict',
retryState.move,
retryState.retryCount
)
// Show toast for 3+ retries (progressive disclosure)
if (retryState.retryCount >= 3 && shouldShowToast(enhancedError)) {
const actionName = getMoveActionName(retryState.move)
showToast({
type: 'info',
title: enhancedError.userMessage,
description: `Retrying ${actionName}... (attempt ${retryState.retryCount})`,
duration: 3000,
})
}
}, [retryState, showToast])
const value: RithmomachiaContextValue = {
state,
lastError,
retryState,
viewerId: viewerId ?? null,
playerColor,
isMyTurn,
rosterStatus,
localActivePlayerIds,
whitePlayerId,
blackPlayerId,
localTurnPlayerId,
isSpectating,
localPlayerColor,
startGame,
makeMove,
declareHarmony,
resign,
offerDraw,
acceptDraw,
claimRepetition,
claimFiftyMove,
setConfig,
assignWhitePlayer,
assignBlackPlayer,
swapSides,
resetGame,
goToSetup,
exitSession,
clearError,
}
return <RithmomachiaContext.Provider value={value}>{children}</RithmomachiaContext.Provider>
}

View File

@@ -1,572 +0,0 @@
# Rithmomachia (Implementation Spec v1)
## 0) High-level goals
* Two players ("White" and "Black") play on a rectangular grid.
* Pieces carry **positive integers** called **values**.
* You move pieces like chess (clear paths, legal geometries).
* You **capture** via **mathematical relations** (equality, sum, difference, multiple, divisor, product, ratio).
* You may also win by building a **Harmony** (a progression) inside enemy territory.
This spec aims for: fully deterministic setup, no ambiguities, consistent networking, and easy future extensions.
---
## 1) Board
* **Dimensions:** `8 rows × 16 columns`
* **Coordinates:** Columns `A…P` (left→right), Rows `1…8` (bottom→top from White's perspective)
* Bottom rank (Row 1) is White's back rank.
* Top rank (Row 8) is Black's back rank.
* **Halves:**
* **White half:** Rows `14`
* **Black half:** Rows `58`
---
## 2) Pieces and movement
Each side has **24 pieces**:
* **8 Circles (C)** — "light" pieces
Movement: **diagonal, any distance**, no jumping (like a bishop).
* **8 Triangles (T)** — "medium" pieces
Movement: **orthogonal, any distance**, no jumping (like a rook).
* **7 Squares (S)** — "heavy" pieces
Movement: **queen-like, any distance**, no jumping (orthogonal or diagonal).
* **1 Pyramid (P)** — "royal" piece
Movement: **king-like, 1 step** in any direction (8-neighborhood).
> Note: Movement is purely geometric. Numeric relations are only for captures and victory checks.
---
## 3) Values and piece philosophy
This is the **traditional Rithmomachia** ("The Philosophers' Game") setup, where numbers encode **arithmetical, geometrical, and harmonical progressions**.
### 3.1 Piece types and their numerical associations
* **Circles** → Units and squares (low geometric bases); move **diagonally** like bishops
* **Triangles** → Triangular numbers and figurates; move **orthogonally** like rooks
* **Squares** → Square numbers and composites; move like **queens** (orthogonal + diagonal)
* **Pyramids** → Composite/sum pieces with multiple faces; move like **kings** (1 step any direction)
### 3.2 Black values (traditional layout)
**Total: 24 pieces**
* **Squares (7):** `28` (×2), `66` (×2), `120`, `225` (15²), `361` (19²)
* **Triangles (8):** `12`, `16` (4²), `30`, `36` (6²), `56`, `64` (8²), `90`, `100` (10²)
* **Circles (8):** `3`, `5`, `7`, `9` (×2), `25` (5²), `49` (7²), `81` (9²)
* **Pyramid (1):** `[36, 25, 16, 4]` (faces: 6², 5², 4², 2²)
### 3.3 White values (traditional layout)
**Total: 24 pieces**
* **Squares (7):** `15`, `25` (5²), `45`, `81` (9²), `153`, `169` (13²), `289` (17²)
* **Triangles (8):** `6`, `9`, `20` (×2), `25` (5²), `72`, `81` (9²) (note: one T with value 72 appears twice in column O)
* **Circles (8):** `2`, `4` (×2), `6`, `8`, `16` (×2), `64` (8²)
* **Pyramid (1):** `[64, 49, 36, 25]` (faces: 8², 7², 6², 5²)
> **Philosophical note:** The initial layout visually encodes proportionality—large composite figurates on outer edges, smaller simple numbers inside. Numbers on each side form progressions that enable arithmetical, geometrical, and harmonical victories. For relations and Pyramid captures, the Pyramid's **face value** is chosen by the owner at capture time.
---
## 4) Initial setup — Traditional formation
**SYMMETRIC VERTICAL LAYOUT** — The board is **8 rows × 16 columns** with:
- **BLACK (left side)**: Columns **A, B, C, D**
- **WHITE (right side)**: Columns **M, N, O, P**
- **Battlefield (middle)**: Columns **E through L** (8 empty columns)
This is the **classical symmetric formation** from authoritative historical sources. The layout places larger values on outer edges (columns A and P) with smaller values toward the interior, encoding mathematical progressions that enable harmony victories.
### BLACK Setup (Left side — columns A, B, C, D)
**Column A** (Outer edge — Sparse squares):
```
A1: S(28) A2: S(66) A3: empty A4: empty
A5: empty A6: empty A7: S(225) A8: S(361)
```
**Column B** (Mixed with Pyramid at B8):
```
B1: S(28) B2: S(66) B3: T(36) B4: T(30)
B5: T(56) B6: T(64) B7: S(120) B8: P[36,25,16,4]
```
**Column C** (Triangles and circles):
```
C1: T(16) C2: T(12) C3: C(9) C4: C(25)
C5: C(49) C6: C(81) C7: T(90) C8: T(100)
```
**Column D** (Inner edge — Small circles, sparse):
```
D1: empty D2: empty D3: C(3) D4: C(5)
D5: C(7) D6: C(9) D7: empty D8: empty
```
### WHITE Setup (Right side — columns M, N, O, P)
**Column M** (Inner edge — Small circles, sparse):
```
M1: empty M2: empty M3: C(8) M4: C(6)
M5: C(4) M6: C(2) M7: empty M8: empty
```
**Column N** (Triangles and circles):
```
N1: T(81) N2: T(72) N3: C(64) N4: C(16)
N5: C(16) N6: C(4) N7: T(6) N8: T(9)
```
**Column O** (Mixed with Pyramid at O2):
```
O1: S(153) O2: P[64,49,36,25] O3: T(72) O4: T(20)
O5: T(20) O6: T(25) O7: S(45) O8: S(15)
```
**Column P** (Outer edge — Sparse squares):
```
P1: S(289) P2: S(169) P3: empty P4: empty
P5: empty P6: empty P7: S(81) P8: S(25)
```
### Piece Count Summary
**BLACK**: 7 Squares, 8 Triangles, 8 Circles, 1 Pyramid = **24 pieces**
**WHITE**: 7 Squares, 8 Triangles, 8 Circles, 1 Pyramid = **24 pieces**
### Strategic layout philosophy
* **Outer edges (A and P)**: Heavy squares (361, 289, 225, 169, etc.) command the flanks with sparse placement
* **Secondary columns (B and O)**: Dense formations with Pyramids (royal pieces) at B8 (Black) and O2 (White)
* **Tertiary columns (C and N)**: Full ranks of mixed triangles and circles
* **Inner edges (D and M)**: Small circles (29) for tactical infiltration, sparse placement
* **Central battlefield (EL)**: 8 empty columns provide space for mathematical maneuvering
Some pieces appear with duplicate values (e.g., A1 and B1 both have S(28)), reflecting the traditional layout's mathematical symmetries. White moves first.
---
## 5) Turn structure
* **White moves first.**
* A **turn** consists of:
1. **One movement** of a single piece (legal geometry, empty path).
2. Optional **Capture Resolution** (if the destination contains an enemy piece or you declare a relation capture; see §6).
3. Optional **Harmony Declaration** (if achieved; see §7).
* No en passant, no jumps, no castling; Pyramid is not a king (you don't lose on "check"), but see victory (§7, §8).
---
## 6) Captures (mathematical relations)
There are two categories:
### 6.1 Direct capture by **landing** (standard)
If you **move onto a square occupied by an enemy**, the capture **succeeds only if** **at least one** of the following relations between your **moved piece's value** (or Pyramid face) and the **enemy piece's value** is true:
* **Equality:** `a == b`
* **Multiple / Divisor:** `a % b == 0` or `b % a == 0` (strictly positive integers)
* **Sum (with an on-board friendly helper):** `a + h == b` or `b + h == a`
* **Difference (with helper):** `|a - h| == b` or `|b - h| == a`
* **Product (with helper):** `a * h == b` or `b * h == a`
* **Ratio (with helper):** `a * r == b` or `b * r == a`, where `r` equals the exact value of **some friendly helper** on the board.
**Helpers**:
* Are **any one** of your other pieces **already on the board** (they do **not** move).
* You must **name** the helper (piece ID) during capture resolution (for determinism).
* Only **one** helper may be used per capture.
* Helpers may be anywhere (not required to be adjacent).
**Pyramid face choice**:
* If your mover is a **Pyramid**, at capture time you may **choose one** of its faces (e.g., `8` or `27` or `64` or `1`) to be `a`. Record this in the move log.
If **none** of the relations hold, your landing **fails**: the move is illegal.
### 6.2 **Ambush capture** (no landing)
If, **after your movement**, an **enemy piece** sits on a square such that a relation holds **between that enemy's value** and **two of your unmoved pieces** simultaneously (think "pincer by numbers"), you may declare an ambush and remove the enemy. Use the same relations as above, but both friendly pieces are **helpers**; neither moves. You must specify **which two** and which relation. Ambush is optional and can only be declared **immediately** after your move.
> Tip for implementers: Model ambush as a post-move **relation scan** limited to enemies adjacent to some "relation context". Since helpers can be anywhere, you only need to check relations involving declared IDs; do not try to scan all pairs in large boards—let the client propose an ambush with (ids, relation) and the server validate.
---
## 7) Harmony (progression) victory
**Harmony** is both the theme of Rithmomachia and a special way to win. On your turn (after movement/captures), you may **declare Harmony** if you arrange three of your pieces in the **opponent's half** (White in rows 58, Black in rows 14) so their **values stand in a classical proportion**.
### 7.1 Three types of harmony (three-piece structure: AMB)
All harmonies use **three pieces** where M is the middle piece (spatially between A and B on the board):
* **Arithmetic Proportion (AP)**: the middle is the arithmetic mean
- **Condition:** `2M = A + B`
- **Example:** 6, 9, 12 (since 2·9 = 6 + 12 = 18)
* **Geometric Proportion (GP)**: the middle is the geometric mean
- **Condition:** `M² = A · B`
- **Example:** 6, 12, 24 (since 12² = 6·24 = 144)
* **Harmonic Proportion (HP)**: the middle is the harmonic mean
- **Condition:** `2AB = M(A + B)` (equivalently, 1/A, 1/M, 1/B forms an AP)
- **Examples:**
- 6, 8, 12 (since 2·6·12 = 8·(6+12) = 144)
- 10, 12, 15 (since 2·10·15 = 12·(10+15) = 300)
- 8, 12, 24 (since 2·8·24 = 12·(8+24) = 384)
> **Tip:** Use these integer equalities for validation—no division or rounding needed!
### 7.2 Board layout constraints
The three pieces must be arranged in a **straight line** (row, column, or diagonal) with one of these spacing rules:
1. **Straight & adjacent** (default): Three consecutive squares in order AMB
2. **Straight with equal spacing**: Same as above, but one empty square between each neighbor (still collinear)
3. **Collinear anywhere**: Pieces on the same line in correct numeric order, with any spacing
**Default for this implementation:** Straight & adjacent (option 1)
### 7.3 Common harmony triads (for reference)
**Arithmetic:**
- (6, 9, 12), (8, 12, 16), (5, 7, 9), (4, 6, 8)
**Geometric:**
- (4, 8, 16), (3, 9, 27), (2, 8, 32), (5, 25, 125)
**Harmonic:**
- (3, 4, 6), (4, 6, 12), (6, 8, 12), (10, 12, 15), (8, 12, 24), (6, 10, 15)
### 7.4 Declaring and winning
**Rules:**
* Pieces must be **distinct** and on **distinct squares**
* All three must be **entirely within opponent's half**
* **Pyramid face**: When a Pyramid is included, you must **fix** a face value for the duration of the check
* **Persistence:** Your declared Harmony must **survive the opponent's next full turn** (they can try to break it by moving/capturing). If, when your next turn begins, the Harmony still exists (same set or **any valid set** of ≥3), **you win immediately**
**Procedure:**
1. On your turn, complete the arrangement (by moving one piece)
2. **Announce** the proportion (e.g., "harmonic 6812 on column D")
3. Opponent verifies the numeric relation and board condition
4. If valid, harmony is **pending**—opponent gets one turn to break it
5. If still valid at start of your next turn, you **win**
> **Implementation:** On declare, snapshot the **set of piece IDs**, **proportion type**, and **parameters**. On the declarer's next turn start, **re-validate** either the same set OR allow **any** new valid harmony (we choose **any valid set** to reward dynamic play).
---
## 8) Other victory conditions
* **Exhaustion:** If a player has **no legal moves** at the start of their turn, they **lose**.
* **Resignation:** A player may resign at any time.
* **Point victory (optional toggle):** Track point values for pieces (C=1, T=2, S=3, P=5). If a player reaches **30 points captured**, they may declare a **Point Win** at the end of their turn. (Off by default; enable for ladders.)
---
## 9) Draws
* **Threefold repetition** (same full state, same player to move) → draw on claim.
* **50-move rule** (no capture, no Harmony declaration) → draw on claim.
* **Mutual agreement** → draw.
---
## 10) Illegal states / edge cases
* **No zero or negative values.** All values are positive integers.
* **No jumping** ever.
* **Self-capture** forbidden.
* **Helper identity** must be a currently alive friendly piece, not the mover (unless the relation allows using the mover's own value on both sides, which it shouldn't—disallow self as helper).
* **Division/ratio** must be exact in integers—no rounding.
* **Overflow**: Use bigints (JS `BigInt`) for relation math to avoid overflow with large powers.
---
## 11) Data model (authoritative server)
### 11.1 Piece
```ts
type PieceType = 'C' | 'T' | 'S' | 'P';
type Color = 'W' | 'B';
interface Piece {
id: string; // stable UUID
color: Color;
type: PieceType;
value?: number; // for C/T/S always present
pyramidFaces?: number[]; // for P only (length 4)
activePyramidFace?: number | null; // last chosen face for logging/captures
square: string; // "A1".."P8"
captured: boolean;
}
```
### 11.2 Game state
```ts
interface GameState {
id: string;
boardCols: number; // 16
boardRows: number; // 8
turn: Color; // 'W' or 'B'
pieces: Record<string, Piece>;
history: MoveRecord[];
pendingHarmony?: HarmonyDeclaration | null; // if declared last turn
rules: {
pointWinEnabled: boolean;
repetitionRule: boolean;
fiftyMoveRule: boolean;
allowAnySetOnRecheck: boolean; // true per §7
};
halfBoundaries: { whiteHalfRows: [1,2,3,4], blackHalfRows: [5,6,7,8] };
clocks?: { Wms: number; Bms: number } | null; // optional timers
}
```
### 11.3 Move + capture records
```ts
type RelationKind = 'EQUAL' | 'MULTIPLE' | 'DIVISOR' | 'SUM' | 'DIFF' | 'PRODUCT' | 'RATIO';
interface CaptureContext {
relation: RelationKind;
moverPieceId: string;
targetPieceId: string;
helperPieceId?: string; // required for SUM/DIFF/PRODUCT/RATIO
moverFaceUsed?: number | null; // if mover was a Pyramid
}
interface AmbushContext {
relation: RelationKind;
enemyPieceId: string;
helper1Id: string;
helper2Id: string; // two helpers for ambush
}
interface MoveRecord {
ply: number;
color: Color;
from: string; // e.g., "C2"
to: string; // e.g., "C6"
pieceId: string;
pyramidFaceUsed?: number | null;
capture?: CaptureContext | null;
ambush?: AmbushContext | null;
harmonyDeclared?: HarmonyDeclaration | null;
pointsCapturedThisMove?: number; // if point scoring is on
fenLikeHash?: string; // for repetition detection
noProgressCount?: number; // for 50-move rule
resultAfter?: 'ONGOING' | 'WINS_W' | 'WINS_B' | 'DRAW';
}
```
### 11.4 Harmony declaration
```ts
type HarmonyType = 'ARITH' | 'GEOM' | 'HARM';
interface HarmonyDeclaration {
by: Color;
pieceIds: string[]; // ≥3
type: HarmonyType;
params: { v?: string; d?: string; r?: string }; // store as strings for bigints if needed
declaredAtPly: number;
}
```
---
## 12) Server protocol (WebSocket)
### 12.1 Messages (client → server)
```jsonc
// Join a room
{ "type": "join_room", "roomId": "rith-123", "playerToken": "..." }
// Ask for current state (idempotent)
{ "type": "get_state", "roomId": "rith-123" }
// Propose a move (with optional capture or ambush info)
{
"type": "move_request",
"roomId": "rith-123",
"payload": {
"pieceId": "W_C_06",
"from": "C2",
"to": "H7",
"pyramidFaceUsed": 27, // if mover is Pyramid (optional)
"capture": {
"relation": "SUM", // if landing capture
"targetPieceId": "B_T_03",
"helperPieceId": "W_S_02"
},
"ambush": {
"relation": "PRODUCT", // if declaring ambush after movement
"enemyPieceId": "B_S_05",
"helper1Id": "W_T_01",
"helper2Id": "W_S_03"
},
"harmony": {
"type": "GEOM",
"pieceIds": ["W_C_02","W_T_02","W_S_02"],
"params": { "v": "2", "r": "2" }
}
}
}
// Resign
{ "type": "resign", "roomId": "rith-123" }
```
### 12.2 Messages (server → client)
```jsonc
// Room joined / spectator assigned
{ "type": "room_joined", "seat": "W" | "B" | "SPECTATOR", "state": { /* GameState */ } }
// State update after validated move
{ "type": "state_update", "state": { /* GameState */ } }
// Move rejected with reason
{ "type": "move_rejected", "reason": "ILLEGAL_MOVE|ILLEGAL_CAPTURE|RELATION_FAIL|TURN|NOT_OWNER|PATH_BLOCKED|BAD_HELPER|HARMONY_INVALID" }
// Game ended
{ "type": "game_over", "result": "WINS_W|WINS_B|DRAW", "by": "HARMONY|EXHAUSTION|RESIGNATION|POINTS|AGREEMENT|REPETITION|FIFTY" }
```
---
## 13) Validation logic (server)
### 13.1 Movement
* Check turn ownership.
* Check piece exists, not captured.
* Validate geometry for type (C diag; T ortho; S queen; P king).
* Validate clear path (grid ray-cast).
* If destination is empty:
* Allow **non-capture** move.
* After move, you may **declare ambush** (if valid).
* If destination occupied by enemy:
* Move only allowed if **landing capture** relations validate (with declared helper if required).
* Otherwise reject.
### 13.2 Relation checks
* All arithmetic in **bigints**.
* Equality is trivial.
* Multiple/Divisor: simple modulo checks; reject zeros.
* Sum/Diff/Product/Ratio require **helper** piece ID. Validate that helper:
* is friendly, alive, not the mover,
* has a well-defined value (Pyramid has implicit four candidates, but **helpers do not switch faces**; they are not pyramids here in our v1; if you allow Pyramid as helper, require explicit `helperFaceUsed` in payload and store it).
* For **Pyramid mover**, allow `pyramidFaceUsed` and use that as `a`.
### 13.3 Ambush
* The mover's landing square can be empty or enemy (if enemy, you must pass landing-capture first).
* Ambush uses **two helpers**; both must be friendly, alive, distinct, not the mover.
* Validate relation against the **enemy piece value** and the two helpers per the declared relation (server recomputes).
### 13.4 Harmony
* Validate ≥3 friendly pieces **on enemy half**.
* Extract their effective values (Pyramids must fix a face for the check; store it inside the HarmonyDeclaration).
* Validate strict progression per type.
* Store a pending declaration tied to `declaredAtPly`.
* On the declarer's next turn start: if **any** valid ≥3 set exists (per `allowAnySetOnRecheck`), award win; otherwise clear pending.
---
## 14) UI/UX suggestions (client)
* Hover a destination to see **all legal relation captures** (auto-suggest helpers).
* Toggle **"math inspector"** to show factors, multiples, candidate sums/diffs.
* **Harmony builder** UI: click pieces on enemy half; client proposes arithmetic/geometric/harmonic fits.
* Log every move with human-readable math, e.g.:
`W: T(27) C2→C7 captures B S(125) by RATIO 27×(125/27)=125 [helper W S(125)? nope; example only]`.
---
## 15) Test cases (goldens)
1. **Simple equality capture**
Move `W C(6)` onto `B C(6)` → valid by `EQUAL`.
2. **Sum capture**
`W T(9)` lands on `B C(15)` using helper `W C(6)``9 + 6 = 15`.
3. **Divisor capture**
`W S(64)` lands on `B T(2048)` → divisor (`2048 % 64 == 0`).
4. **Pyramid face**
`W P[8,27,64,1]` chooses face `64` to land-capture `B S(64)` by `EQUAL`.
5. **Ambush**
After moving any piece, declare ambush vs `B S(125)` using helpers `W T(5)` and `W S(25)` by `PRODUCT` (5×25=125). (Adjust helper identities to real IDs in your setup.)
6. **Harmony (GEOM)**
White occupies enemy half with values 4, 16, 64 → geometric (v=4, r=4). Declare; if it persists one full Black turn, White wins.
---
## 16) Optional rule toggles (versioning)
* **Strict Pyramid faces:** Allow Pyramid as **helper** only if face is declared similarly to mover.
* **Helper adjacency:** Require helpers to be **adjacent** to enemy for SUM/DIFF/PRODUCT/RATIO (reduces global scans).
* **Any-set vs same-set on recheck:** We chose **any-set**. Switchable.
---
## 17) Dev notes
* Use **Zobrist hashing** (or similar) for `fenLikeHash` to detect repetitions.
* Keep a **no-progress counter** (reset on any capture or harmony declaration).
* Use **BigInt** end-to-end for piece values and relation math.
* Build a **deterministic PRNG** only if you later add random presets—current spec is deterministic.
---
## Implementation Status
Last updated: 2025-10-31
The current implementation in `src/arcade-games/rithmomachia/` follows this spec:
- **Board setup**: ✅ VERTICAL layout (§4) - BLACK on left (columns A-D), WHITE on right (columns M-P)
- Authoritative CSV-derived layout (parsed from historical sources)
- 24 pieces per side (7 Squares, 8 Triangles, 8 Circles, 1 Pyramid)
- Black Pyramid at B8, White Pyramid at O2
- **Piece rendering**: ✅ SVG-based with precise color control (PieceRenderer.tsx)
- BLACK pieces: Dark fill (#1a1a1a) with black stroke
- WHITE pieces: Light fill (#ffffff) with gray stroke
- **Piece values**: ✅ Match authoritative CSV exactly (§3.2, §3.3)
- **Movement validation**: ✅ Implemented in `Validator.ts` following geometric rules
- **Capture system**: ✅ Relation-based captures per §6
- **Harmony system**: ✅ Progression detection and validation per §7
- **Data types**: ✅ All types use `number` (not `bigint`) for JSON serialization
- **Game controls**: ✅ Settings UI with rule toggles, New Game, Setup
- **UI**: ✅ Click-to-select, click-to-move piece interaction
**Remaining features (future enhancement):**
1. Math inspector UI (show legal captures with auto-suggested helpers)
2. Harmony builder UI (visual progression detector)
3. Move history display with human-readable math notation
4. Ambush capture UI (currently only basic movement implemented)
5. Enhanced piece highlighting for available moves

View File

@@ -1,954 +0,0 @@
import type { GameValidator, ValidationContext, ValidationResult } from '@/lib/arcade/game-sdk'
import type {
AmbushContext,
CaptureContext,
Color,
HarmonyDeclaration,
MoveRecord,
Piece,
RithmomachiaConfig,
RithmomachiaMove,
RithmomachiaState,
} from './types'
import { opponentColor } from './types'
import { hasAnyValidHarmony, isHarmonyStillValid, validateHarmony } from './utils/harmonyValidator'
import { validateMove } from './utils/pathValidator'
import {
clonePieces,
createInitialBoard,
getEffectiveValue,
getLivePiecesForColor,
getPieceAt,
getPieceById,
} from './utils/pieceSetup'
import { checkRelation } from './utils/relationEngine'
import { computeZobristHash, isThreefoldRepetition } from './utils/zobristHash'
/**
* Validator for Rithmomachia game logic.
* Implements all rules: movement, captures, harmony, victory conditions.
*/
export class RithmomachiaValidator implements GameValidator<RithmomachiaState, RithmomachiaMove> {
/**
* Get initial game state from config.
*/
getInitialState(config: RithmomachiaConfig): RithmomachiaState {
const pieces = createInitialBoard()
const initialHash = computeZobristHash(pieces, 'W')
const state: RithmomachiaState = {
// Configuration (stored in state per arcade pattern)
pointWinEnabled: config.pointWinEnabled,
pointWinThreshold: config.pointWinThreshold,
repetitionRule: config.repetitionRule,
fiftyMoveRule: config.fiftyMoveRule,
allowAnySetOnRecheck: config.allowAnySetOnRecheck,
timeControlMs: config.timeControlMs ?? null,
whitePlayerId: config.whitePlayerId ?? null,
blackPlayerId: config.blackPlayerId ?? null,
// Game phase
gamePhase: 'setup',
// Board setup
boardCols: 16,
boardRows: 8,
turn: 'W',
pieces,
capturedPieces: { W: [], B: [] },
history: [],
pendingHarmony: null,
noProgressCount: 0,
stateHashes: [initialHash],
winner: null,
winCondition: null,
}
// Add point tracking if enabled by config
if (config.pointWinEnabled) {
state.pointsCaptured = { W: 0, B: 0 }
}
return state
}
/**
* Validate a move and return the updated state if valid.
*/
validateMove(
state: RithmomachiaState,
move: RithmomachiaMove,
context?: ValidationContext
): ValidationResult {
// Allow SET_CONFIG in any phase
if (move.type === 'SET_CONFIG') {
return this.handleSetConfig(state, move, context)
}
// Allow RESET_GAME in any phase
if (move.type === 'RESET_GAME') {
return this.handleResetGame(state, move, context)
}
// Allow GO_TO_SETUP from results phase
if (move.type === 'GO_TO_SETUP') {
return this.handleGoToSetup(state, move, context)
}
// Game must be in playing phase for game moves
if (state.gamePhase === 'setup') {
if (move.type === 'START_GAME') {
return this.handleStartGame(state, move, context)
}
return { valid: false, error: 'Game not started' }
}
if (state.gamePhase === 'results') {
return { valid: false, error: 'Game already ended' }
}
// Check for existing winner
if (state.winner) {
return { valid: false, error: 'Game already has a winner' }
}
switch (move.type) {
case 'MOVE':
return this.handleMove(state, move, context)
case 'DECLARE_HARMONY':
return this.handleDeclareHarmony(state, move, context)
case 'RESIGN':
return this.handleResign(state, move, context)
case 'OFFER_DRAW':
case 'ACCEPT_DRAW':
return this.handleDraw(state, move, context)
case 'CLAIM_REPETITION':
return this.handleClaimRepetition(state, move, context)
case 'CLAIM_FIFTY_MOVE':
return this.handleClaimFiftyMove(state, move, context)
default:
return { valid: false, error: 'Unknown move type' }
}
}
/**
* Check if the game is complete.
*/
isGameComplete(state: RithmomachiaState): boolean {
return state.winner !== null || state.gamePhase === 'results'
}
// ==================== MOVE HANDLERS ====================
/**
* Handle START_GAME move.
*/
private handleStartGame(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'START_GAME' }>,
context?: ValidationContext
): ValidationResult {
const newState = {
...state,
gamePhase: 'playing' as const,
}
return { valid: true, newState }
}
/**
* Handle MOVE action (piece movement with optional capture/ambush).
*/
private handleMove(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'MOVE' }>,
context?: ValidationContext
): ValidationResult {
const { from, to, pieceId, pyramidFaceUsed, capture, ambush } = move.data
// Get the piece
let piece: Piece
try {
piece = getPieceById(state.pieces, pieceId)
} catch (e) {
return { valid: false, error: `Piece not found: ${pieceId}` }
}
// Check ownership (turn must match piece color)
if (piece.color !== state.turn) {
return { valid: false, error: `Not ${piece.color}'s turn` }
}
// Check piece is not captured
if (piece.captured) {
return { valid: false, error: 'Piece already captured' }
}
// Check from square matches piece location
if (piece.square !== from) {
return { valid: false, error: `Piece is not at ${from}, it's at ${piece.square}` }
}
// Validate movement geometry and path
const moveValidation = validateMove(piece, from, to, state.pieces)
if (!moveValidation.valid) {
return { valid: false, error: moveValidation.reason }
}
// Check destination
const targetPiece = getPieceAt(state.pieces, to)
// If destination is empty
if (!targetPiece) {
// No capture possible, just move
if (capture) {
return { valid: false, error: 'Cannot capture on empty square' }
}
// Process the move
const newState = this.applyMove(
state,
piece,
from,
to,
pyramidFaceUsed,
null,
ambush,
context
)
return { valid: true, newState }
}
// Destination is occupied
// Cannot capture own piece
if (targetPiece.color === piece.color) {
return { valid: false, error: 'Cannot capture own piece' }
}
// Must have a capture declaration if landing on enemy
if (!capture) {
return { valid: false, error: 'Must declare capture relation when landing on enemy piece' }
}
// Validate the capture relation
const captureValidation = this.validateCapture(
state,
piece,
targetPiece,
capture,
pyramidFaceUsed
)
if (!captureValidation.valid) {
return { valid: false, error: captureValidation.error }
}
// Process the move with capture
const captureContext: CaptureContext = {
relation: capture.relation,
moverPieceId: pieceId,
targetPieceId: capture.targetPieceId,
helperPieceId: capture.helperPieceId,
moverFaceUsed: pyramidFaceUsed ?? null,
}
const newState = this.applyMove(
state,
piece,
from,
to,
pyramidFaceUsed,
captureContext,
ambush,
context
)
return { valid: true, newState }
}
/**
* Validate a capture relation.
*/
private validateCapture(
state: RithmomachiaState,
mover: Piece,
target: Piece,
capture: NonNullable<Extract<RithmomachiaMove, { type: 'MOVE' }>['data']['capture']>,
pyramidFaceUsed?: number | null
): ValidationResult {
// Get target value
const targetValue = getEffectiveValue(target)
if (targetValue === null) {
return { valid: false, error: 'Target has no value' }
}
// Get helper value (if required)
let helperValue: number | undefined
if (capture.helperPieceId) {
let helperPiece: Piece
try {
helperPiece = getPieceById(state.pieces, capture.helperPieceId)
} catch (e) {
return { valid: false, error: `Helper piece not found: ${capture.helperPieceId}` }
}
// Helper must be friendly
if (helperPiece.color !== mover.color) {
return { valid: false, error: 'Helper must be friendly' }
}
// Helper must not be captured
if (helperPiece.captured) {
return { valid: false, error: 'Helper is captured' }
}
// Helper cannot be the mover
if (helperPiece.id === mover.id) {
return { valid: false, error: 'Helper cannot be the mover itself' }
}
// Helper cannot be a Pyramid (v1 simplification)
if (helperPiece.type === 'P') {
return { valid: false, error: 'Pyramids cannot be helpers in v1' }
}
helperValue = getEffectiveValue(helperPiece) ?? undefined
}
// Get mover value(s) - for pyramids, try all faces if not specified
if (mover.type === 'P') {
if (pyramidFaceUsed) {
// Specific face provided - validate it
if (!mover.pyramidFaces?.some((f) => f === pyramidFaceUsed)) {
return { valid: false, error: 'Invalid pyramid face' }
}
const relationCheck = checkRelation(
capture.relation,
pyramidFaceUsed,
targetValue,
helperValue
)
if (!relationCheck.valid) {
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
}
return { valid: true }
} else {
// No face specified - try all faces and accept if ANY works
if (!mover.pyramidFaces || mover.pyramidFaces.length !== 4) {
return { valid: false, error: 'Pyramid must have 4 faces' }
}
for (const faceValue of mover.pyramidFaces) {
const relationCheck = checkRelation(capture.relation, faceValue, targetValue, helperValue)
if (relationCheck.valid) {
// At least one face works - capture is valid
return { valid: true }
}
}
// None of the faces worked
return { valid: false, error: 'No pyramid face satisfies the relation' }
}
} else {
// Non-pyramid piece
const moverValue = mover.value!
const relationCheck = checkRelation(capture.relation, moverValue, targetValue, helperValue)
if (!relationCheck.valid) {
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
}
return { valid: true }
}
}
/**
* Apply a move to the state (mutates and returns new state).
*/
private applyMove(
state: RithmomachiaState,
piece: Piece,
from: string,
to: string,
pyramidFaceUsed: number | null | undefined,
capture: CaptureContext | null,
ambush: AmbushContext | undefined,
context?: ValidationContext
): RithmomachiaState {
// Clone state
const newState = { ...state }
newState.pieces = clonePieces(state.pieces)
newState.capturedPieces = {
W: [...state.capturedPieces.W],
B: [...state.capturedPieces.B],
}
newState.history = [...state.history]
newState.stateHashes = [...state.stateHashes]
// Move the piece
newState.pieces[piece.id].square = to
// Set pyramid face if used
if (pyramidFaceUsed && piece.type === 'P') {
newState.pieces[piece.id].activePyramidFace = pyramidFaceUsed
}
// Handle capture
let capturedPiece: Piece | null = null
if (capture) {
const targetPiece = newState.pieces[capture.targetPieceId]
targetPiece.captured = true
newState.capturedPieces[opponentColor(piece.color)].push(targetPiece)
capturedPiece = targetPiece
// Reset no-progress counter
newState.noProgressCount = 0
// Update points if enabled
if (newState.pointsCaptured) {
const points = this.getPiecePoints(targetPiece)
newState.pointsCaptured[piece.color] += points
}
} else {
// No capture = increment no-progress counter
newState.noProgressCount += 1
}
// Handle ambush (if declared)
if (ambush) {
const ambushValidation = this.validateAmbush(newState, piece.color, ambush)
if (ambushValidation.valid) {
const enemyPiece = newState.pieces[ambush.enemyPieceId]
enemyPiece.captured = true
newState.capturedPieces[opponentColor(piece.color)].push(enemyPiece)
// Update points if enabled
if (newState.pointsCaptured) {
const points = this.getPiecePoints(enemyPiece)
newState.pointsCaptured[piece.color] += points
}
// Reset no-progress counter
newState.noProgressCount = 0
}
}
// Compute new hash
const newHash = computeZobristHash(newState.pieces, opponentColor(piece.color))
newState.stateHashes.push(newHash)
// Create move record
const moveRecord: MoveRecord = {
ply: newState.history.length + 1,
color: piece.color,
from,
to,
pieceId: piece.id,
pyramidFaceUsed: pyramidFaceUsed ?? null,
capture: capture ?? null,
ambush: ambush ?? null,
harmonyDeclared: null,
fenLikeHash: newHash,
noProgressCount: newState.noProgressCount,
resultAfter: 'ONGOING',
}
newState.history.push(moveRecord)
// Switch turn
newState.turn = opponentColor(piece.color)
// Check for pending harmony validation
if (newState.pendingHarmony && newState.pendingHarmony.by === newState.turn) {
// It's now the declarer's turn again - check if harmony still exists
const config = this.getConfigFromState(newState)
if (config.allowAnySetOnRecheck) {
// Check for ANY valid harmony
if (hasAnyValidHarmony(newState.pieces, newState.pendingHarmony.by)) {
// Harmony persisted! Victory!
newState.winner = newState.pendingHarmony.by
newState.winCondition = 'HARMONY'
newState.gamePhase = 'results'
moveRecord.resultAfter = newState.winner === 'W' ? 'WINS_W' : 'WINS_B'
} else {
// Harmony broken
newState.pendingHarmony = null
}
} else {
// Check if the SAME harmony still exists
if (isHarmonyStillValid(newState.pieces, newState.pendingHarmony)) {
newState.winner = newState.pendingHarmony.by
newState.winCondition = 'HARMONY'
newState.gamePhase = 'results'
moveRecord.resultAfter = newState.winner === 'W' ? 'WINS_W' : 'WINS_B'
} else {
newState.pendingHarmony = null
}
}
}
// Check for point victory (if enabled)
if (newState.pointsCaptured && context) {
const config = this.getConfigFromState(newState)
if (config.pointWinEnabled) {
const capturedByMover = newState.pointsCaptured[piece.color]
if (capturedByMover >= config.pointWinThreshold) {
newState.winner = piece.color
newState.winCondition = 'POINTS'
newState.gamePhase = 'results'
moveRecord.resultAfter = newState.winner === 'W' ? 'WINS_W' : 'WINS_B'
}
}
}
// Check for exhaustion (opponent has no legal moves)
const opponentHasMoves = this.hasLegalMoves(newState, newState.turn)
if (!opponentHasMoves) {
newState.winner = opponentColor(newState.turn)
newState.winCondition = 'EXHAUSTION'
newState.gamePhase = 'results'
moveRecord.resultAfter = newState.winner === 'W' ? 'WINS_W' : 'WINS_B'
}
return newState
}
/**
* Validate an ambush capture.
*/
private validateAmbush(
state: RithmomachiaState,
color: Color,
ambush: AmbushContext
): ValidationResult {
// Get the enemy piece
let enemyPiece: Piece
try {
enemyPiece = getPieceById(state.pieces, ambush.enemyPieceId)
} catch (e) {
return { valid: false, error: `Enemy piece not found: ${ambush.enemyPieceId}` }
}
// Must be enemy
if (enemyPiece.color === color) {
return { valid: false, error: 'Ambush target must be enemy' }
}
// Get helpers
let helper1: Piece
let helper2: Piece
try {
helper1 = getPieceById(state.pieces, ambush.helper1Id)
helper2 = getPieceById(state.pieces, ambush.helper2Id)
} catch (e) {
return { valid: false, error: 'Helper not found' }
}
// Helpers must be friendly
if (helper1.color !== color || helper2.color !== color) {
return { valid: false, error: 'Helpers must be friendly' }
}
// Helpers must be alive
if (helper1.captured || helper2.captured) {
return { valid: false, error: 'Helper is captured' }
}
// Helpers must be distinct
if (helper1.id === helper2.id) {
return { valid: false, error: 'Helpers must be distinct' }
}
// Helpers cannot be Pyramids (v1)
if (helper1.type === 'P' || helper2.type === 'P') {
return { valid: false, error: 'Pyramids cannot be helpers in v1' }
}
// Get values
const enemyValue = getEffectiveValue(enemyPiece)
const helper1Value = getEffectiveValue(helper1)
const helper2Value = getEffectiveValue(helper2)
if (enemyValue === null || helper1Value === null || helper2Value === null) {
return { valid: false, error: 'Piece has no value' }
}
// Check the relation using the TWO helpers
// For ambush, we interpret the relation as: helper1 and helper2 combine to match enemy
// For example: SUM means helper1 + helper2 = enemy
const relationCheck = checkRelation(ambush.relation, helper1Value, enemyValue, helper2Value)
if (!relationCheck.valid) {
return { valid: false, error: relationCheck.explanation || 'Ambush relation failed' }
}
return { valid: true }
}
/**
* Handle DECLARE_HARMONY action.
*/
private handleDeclareHarmony(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'DECLARE_HARMONY' }>,
context?: ValidationContext
): ValidationResult {
const { pieceIds, harmonyType, params } = move.data
// Must be declaring player's turn
// (We need to get the player's color from context)
// For now, assume it's the current turn's player
const declaringColor = state.turn
// Get the pieces
const pieces = pieceIds
.map((id) => {
try {
return getPieceById(state.pieces, id)
} catch (e) {
return null
}
})
.filter((p): p is Piece => p !== null)
if (pieces.length !== pieceIds.length) {
return { valid: false, error: 'Some pieces not found' }
}
// Validate the harmony
const validation = validateHarmony(pieces, declaringColor)
if (!validation.valid) {
return { valid: false, error: validation.reason }
}
// Check type matches
if (validation.type !== harmonyType) {
return { valid: false, error: `Expected ${harmonyType} but found ${validation.type}` }
}
// Create harmony declaration
const harmony: HarmonyDeclaration = {
by: declaringColor,
pieceIds,
type: harmonyType,
params,
declaredAtPly: state.history.length,
}
// Clone state
const newState = {
...state,
pendingHarmony: harmony,
history: [...state.history],
}
// Add to history
const moveRecord: MoveRecord = {
ply: newState.history.length + 1,
color: declaringColor,
from: '',
to: '',
pieceId: '',
harmonyDeclared: harmony,
fenLikeHash: state.stateHashes[state.stateHashes.length - 1],
noProgressCount: state.noProgressCount,
resultAfter: 'ONGOING',
}
newState.history.push(moveRecord)
// Do NOT switch turn - harmony declaration is free
return { valid: true, newState }
}
/**
* Handle RESIGN action.
*/
private handleResign(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'RESIGN' }>,
context?: ValidationContext
): ValidationResult {
const resigningColor = state.turn
const winner = opponentColor(resigningColor)
const newState = {
...state,
winner,
winCondition: 'RESIGNATION' as const,
gamePhase: 'results' as const,
}
return { valid: true, newState }
}
/**
* Handle draw offers/accepts.
*/
private handleDraw(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'OFFER_DRAW' | 'ACCEPT_DRAW' }>,
context?: ValidationContext
): ValidationResult {
// For simplicity, accept any draw (we'd need to track offers in state for proper implementation)
const newState = {
...state,
winner: null,
winCondition: 'AGREEMENT' as const,
gamePhase: 'results' as const,
}
return { valid: true, newState }
}
/**
* Handle repetition claim.
*/
private handleClaimRepetition(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'CLAIM_REPETITION' }>,
context?: ValidationContext
): ValidationResult {
const config = this.getConfigFromState(state)
if (!config.repetitionRule) {
return { valid: false, error: 'Repetition rule not enabled' }
}
if (isThreefoldRepetition(state.stateHashes)) {
const newState = {
...state,
winner: null,
winCondition: 'REPETITION' as const,
gamePhase: 'results' as const,
}
return { valid: true, newState }
}
return { valid: false, error: 'No threefold repetition detected' }
}
/**
* Handle fifty-move rule claim.
*/
private handleClaimFiftyMove(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'CLAIM_FIFTY_MOVE' }>,
context?: ValidationContext
): ValidationResult {
const config = this.getConfigFromState(state)
if (!config.fiftyMoveRule) {
return { valid: false, error: 'Fifty-move rule not enabled' }
}
if (state.noProgressCount >= 50) {
const newState = {
...state,
winner: null,
winCondition: 'FIFTY' as const,
gamePhase: 'results' as const,
}
return { valid: true, newState }
}
return {
valid: false,
error: `Only ${state.noProgressCount} moves without progress (need 50)`,
}
}
/**
* Handle SET_CONFIG action.
* Updates a single config field in the state.
*/
private handleSetConfig(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'SET_CONFIG' }>,
context?: ValidationContext
): ValidationResult {
const { field, value } = move.data
// Validate the field exists in config
const validFields: Array<keyof RithmomachiaConfig> = [
'pointWinEnabled',
'pointWinThreshold',
'repetitionRule',
'fiftyMoveRule',
'allowAnySetOnRecheck',
'timeControlMs',
'whitePlayerId',
'blackPlayerId',
]
if (!validFields.includes(field as keyof RithmomachiaConfig)) {
return { valid: false, error: `Invalid config field: ${field}` }
}
// Basic type validation
if (
field === 'pointWinEnabled' ||
field === 'repetitionRule' ||
field === 'fiftyMoveRule' ||
field === 'allowAnySetOnRecheck'
) {
if (typeof value !== 'boolean') {
return { valid: false, error: `${field} must be a boolean` }
}
}
if (field === 'pointWinThreshold') {
if (typeof value !== 'number' || value < 1) {
return { valid: false, error: 'pointWinThreshold must be a positive number' }
}
}
if (field === 'timeControlMs') {
if (value !== null && (typeof value !== 'number' || value < 0)) {
return { valid: false, error: 'timeControlMs must be null or a non-negative number' }
}
}
if (field === 'whitePlayerId' || field === 'blackPlayerId') {
if (value !== null && typeof value !== 'string') {
return { valid: false, error: `${field} must be a string or null` }
}
}
// Create new state with updated config field
const newState = {
...state,
[field]: value,
}
// If enabling point tracking and it doesn't exist, initialize it
if (field === 'pointWinEnabled' && value === true && !state.pointsCaptured) {
newState.pointsCaptured = { W: 0, B: 0 }
}
return { valid: true, newState }
}
/**
* Handle RESET_GAME action.
* Creates a fresh game state with the current config and immediately starts playing.
*/
private handleResetGame(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'RESET_GAME' }>,
context?: ValidationContext
): ValidationResult {
// Extract current config from state
const config = this.getConfigFromState(state)
// Get fresh initial state with current config
const newState = this.getInitialState(config)
// Immediately transition to playing phase (skip setup)
newState.gamePhase = 'playing'
return { valid: true, newState }
}
/**
* Handle GO_TO_SETUP action.
* Returns to setup phase, preserving config but resetting game state.
*/
private handleGoToSetup(
state: RithmomachiaState,
move: Extract<RithmomachiaMove, { type: 'GO_TO_SETUP' }>,
context?: ValidationContext
): ValidationResult {
// Extract current config from state
const config = this.getConfigFromState(state)
// Get fresh initial state (which starts in setup phase) with current config
const newState = this.getInitialState(config)
return { valid: true, newState }
}
// ==================== HELPER METHODS ====================
/**
* Check if a player has any legal moves.
*/
private hasLegalMoves(state: RithmomachiaState, color: Color): boolean {
const pieces = getLivePiecesForColor(state.pieces, color)
for (const piece of pieces) {
// Check all possible destinations
for (let file = 0; file < 16; file++) {
for (let rank = 1; rank <= 8; rank++) {
const to = `${String.fromCharCode(65 + file)}${rank}`
// Skip same square
if (to === piece.square) continue
// Check if move is geometrically legal
const validation = validateMove(piece, piece.square, to, state.pieces)
if (validation.valid) {
// Check if destination is empty or has enemy that can be captured
const targetPiece = getPieceAt(state.pieces, to)
if (!targetPiece) {
// Empty square = legal move
return true
}
if (targetPiece.color !== color) {
// Enemy piece - check if any capture relation exists
// (We'll simplify and say yes if any no-helper relation works)
const moverValue = getEffectiveValue(piece)
const targetValue = getEffectiveValue(targetPiece)
if (moverValue && targetValue) {
// Check for simple relations (no helper required)
const simpleRelations = ['EQUAL', 'MULTIPLE', 'DIVISOR'] as const
for (const relation of simpleRelations) {
const check = checkRelation(relation, moverValue, targetValue)
if (check.valid) {
return true
}
}
// Could also check with helpers, but that's expensive
// For now, we assume if simple capture fails, move is not legal
}
}
}
}
}
}
return false
}
/**
* Get piece point value.
*/
private getPiecePoints(piece: Piece): number {
const POINTS: Record<typeof piece.type, number> = {
C: 1,
T: 2,
S: 3,
P: 5,
}
return POINTS[piece.type]
}
/**
* Get config from state (config is stored in state following arcade pattern).
*/
private getConfigFromState(state: RithmomachiaState): RithmomachiaConfig {
return {
pointWinEnabled: state.pointWinEnabled,
pointWinThreshold: state.pointWinThreshold,
repetitionRule: state.repetitionRule,
fiftyMoveRule: state.fiftyMoveRule,
allowAnySetOnRecheck: state.allowAnySetOnRecheck,
timeControlMs: state.timeControlMs,
whitePlayerId: state.whitePlayerId ?? null,
blackPlayerId: state.blackPlayerId ?? null,
}
}
}
export const rithmomachiaValidator = new RithmomachiaValidator()

View File

@@ -1,492 +0,0 @@
import { useSpring, animated } from '@react-spring/web'
import { AbacusReact, useAbacusDisplay } from '@soroban/abacus-react'
import type { Color, PieceType } from '../types'
interface PieceRendererProps {
type: PieceType
color: Color
value: number | string
size?: number
useNativeAbacusNumbers?: boolean
selected?: boolean
pyramidFaces?: number[]
shouldRotate?: boolean
}
/**
* SVG-based piece renderer with enhanced visual treatment.
* BLACK pieces: dark gradient fill with light border, point RIGHT (towards white)
* WHITE pieces: light gradient fill with dark border, point LEFT (towards black)
*/
export function PieceRenderer({
type,
color,
value,
size = 48,
useNativeAbacusNumbers = false,
selected = false,
pyramidFaces = [],
shouldRotate = false,
}: PieceRendererProps) {
const isDark = color === 'B'
const { config } = useAbacusDisplay()
// Subtle animation for pyramid face numbers
const pyramidNumbersSpring = useSpring({
from: { opacity: 0, scale: 0.8 },
to: {
opacity: type === 'P' && selected && pyramidFaces.length === 4 ? 1 : 0,
scale: type === 'P' && selected && pyramidFaces.length === 4 ? 1 : 0.8,
},
config: { tension: 200, friction: 20 },
})
// Gradient IDs
const gradientId = `gradient-${type}-${color}-${size}`
const shadowId = `shadow-${type}-${color}-${size}`
// Enhanced colors with gradients
const gradientStart = isDark ? '#2d2d2d' : '#ffffff'
const gradientEnd = isDark ? '#0a0a0a' : '#d0d0d0'
const strokeColor = isDark ? '#ffffff' : '#1a1a1a'
const textColor = isDark ? '#ffffff' : '#000000'
// Calculate responsive font size based on value length
const valueStr = value.toString()
const baseSize = type === 'P' ? size * 0.18 : size * 0.35
let fontSize = baseSize
if (valueStr.length >= 3) {
fontSize = baseSize * 0.65 // 3+ digits: smaller
} else if (valueStr.length === 2) {
fontSize = baseSize * 0.8 // 2 digits: slightly smaller
}
const renderShape = () => {
switch (type) {
case 'C': // Circle
return (
<g>
<circle
cx={size / 2}
cy={size / 2}
r={size * 0.38}
fill={`url(#${gradientId})`}
filter={`url(#${shadowId})`}
/>
<circle
cx={size / 2}
cy={size / 2}
r={size * 0.38}
fill="none"
stroke={strokeColor}
strokeWidth={3}
opacity={0.9}
/>
</g>
)
case 'T': // Triangle - BLACK points RIGHT, WHITE points LEFT
if (isDark) {
// Black triangle points RIGHT (towards white)
return (
<g>
<polygon
points={`${size * 0.15},${size * 0.15} ${size * 0.85},${size / 2} ${size * 0.15},${size * 0.85}`}
fill={`url(#${gradientId})`}
filter={`url(#${shadowId})`}
/>
<polygon
points={`${size * 0.15},${size * 0.15} ${size * 0.85},${size / 2} ${size * 0.15},${size * 0.85}`}
fill="none"
stroke={strokeColor}
strokeWidth={3}
opacity={0.9}
/>
</g>
)
} else {
// White triangle points LEFT (towards black)
return (
<g>
<polygon
points={`${size * 0.85},${size * 0.15} ${size * 0.15},${size / 2} ${size * 0.85},${size * 0.85}`}
fill={`url(#${gradientId})`}
filter={`url(#${shadowId})`}
/>
<polygon
points={`${size * 0.85},${size * 0.15} ${size * 0.15},${size / 2} ${size * 0.85},${size * 0.85}`}
fill="none"
stroke={strokeColor}
strokeWidth={3}
opacity={0.9}
/>
</g>
)
}
case 'S': // Square
return (
<g>
<rect
x={size * 0.15}
y={size * 0.15}
width={size * 0.7}
height={size * 0.7}
fill={`url(#${gradientId})`}
filter={`url(#${shadowId})`}
/>
<rect
x={size * 0.15}
y={size * 0.15}
width={size * 0.7}
height={size * 0.7}
fill="none"
stroke={strokeColor}
strokeWidth={3}
opacity={0.9}
/>
</g>
)
case 'P': {
// Pyramid - rotated 90° to point at opponent
// Create centered pyramid, then rotate: BLACK→right (90°), WHITE→left (-90°)
const rotation = isDark ? 90 : -90
return (
<g transform={`rotate(${rotation}, ${size / 2}, ${size / 2})`}>
{/* Top/smallest bar - centered */}
<rect
x={size * 0.35}
y={size * 0.1}
width={size * 0.3}
height={size * 0.15}
fill={`url(#${gradientId})`}
stroke={strokeColor}
strokeWidth={2}
opacity={0.9}
filter={`url(#${shadowId})`}
/>
{/* Second bar */}
<rect
x={size * 0.25}
y={size * 0.3}
width={size * 0.5}
height={size * 0.15}
fill={`url(#${gradientId})`}
stroke={strokeColor}
strokeWidth={2}
opacity={0.9}
filter={`url(#${shadowId})`}
/>
{/* Third bar */}
<rect
x={size * 0.15}
y={size * 0.5}
width={size * 0.7}
height={size * 0.15}
fill={`url(#${gradientId})`}
stroke={strokeColor}
strokeWidth={2}
opacity={0.9}
filter={`url(#${shadowId})`}
/>
{/* Bottom/largest bar */}
<rect
x={size * 0.05}
y={size * 0.7}
width={size * 0.9}
height={size * 0.15}
fill={`url(#${gradientId})`}
stroke={strokeColor}
strokeWidth={2}
opacity={0.9}
filter={`url(#${shadowId})`}
/>
</g>
)
}
default:
return null
}
}
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<defs>
{/* Gradient definition */}
{type === 'C' ? (
<radialGradient id={gradientId}>
<stop offset="0%" stopColor={gradientStart} />
<stop offset="100%" stopColor={gradientEnd} />
</radialGradient>
) : (
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={gradientStart} />
<stop offset="100%" stopColor={gradientEnd} />
</linearGradient>
)}
{/* Shadow filter */}
<filter id={shadowId} x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodOpacity="0.4" />
</filter>
{/* Text shadow for dark pieces */}
{isDark && (
<filter id={`text-shadow-${color}`} x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodOpacity="0.6" />
</filter>
)}
</defs>
{renderShape()}
{/* Pyramid face numbers - show when selected */}
{type === 'P' && selected && pyramidFaces.length === 4 && (
<g transform={shouldRotate ? `rotate(90, ${size / 2}, ${size / 2})` : undefined}>
<animated.g
style={{
opacity: pyramidNumbersSpring.opacity,
transform: pyramidNumbersSpring.scale.to((s) => `scale(${s})`),
transformOrigin: 'center',
}}
>
{/* Filter for strong drop shadow */}
<defs>
<filter id={`face-shadow-${color}`} x="-100%" y="-100%" width="300%" height="300%">
<feDropShadow
dx="0"
dy="0"
stdDeviation="3"
floodColor="#000000"
floodOpacity="0.9"
/>
</filter>
</defs>
{/* Top face */}
{/* Outline/stroke for contrast */}
<text
x={size / 2}
y={size * 0.12}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[0]}
</text>
{/* Main text with shadow and vibrant color */}
<text
x={size / 2}
y={size * 0.12}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[0]}
</text>
{/* Right face */}
<text
x={size * 0.88}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[1]}
</text>
<text
x={size * 0.88}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[1]}
</text>
{/* Bottom face */}
<text
x={size / 2}
y={size * 0.88}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[2]}
</text>
<text
x={size / 2}
y={size * 0.88}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[2]}
</text>
{/* Left face */}
<text
x={size * 0.12}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="none"
stroke={isDark ? '#000000' : '#ffffff'}
strokeWidth={size * 0.05}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
>
{pyramidFaces[3]}
</text>
<text
x={size * 0.12}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fill={isDark ? '#fbbf24' : '#b45309'}
fontSize={size * 0.35}
fontWeight="900"
fontFamily="Arial Black, Arial, sans-serif"
filter={`url(#face-shadow-${color})`}
style={{ transition: 'all 0.2s ease' }}
>
{pyramidFaces[3]}
</text>
</animated.g>
</g>
)}
{/* Other pieces show numbers normally */}
{type !== 'P' && (
<g transform={shouldRotate ? `rotate(90, ${size / 2}, ${size / 2})` : undefined}>
{useNativeAbacusNumbers && typeof value === 'number' ? (
// Render mini abacus
<foreignObject
x={size * 0.1}
y={size * 0.1}
width={size * 0.8}
height={size * 0.8}
style={{ overflow: 'visible' }}
>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<AbacusReact
value={value}
columns={Math.max(1, Math.ceil(Math.log10(value + 1)))}
scaleFactor={0.35}
showNumbers={false}
beadShape={config.beadShape}
colorScheme={config.colorScheme}
hideInactiveBeads={config.hideInactiveBeads}
customStyles={{
columnPosts: {
fill: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
stroke: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)',
strokeWidth: 1,
},
reckoningBar: {
fill: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.2)',
stroke: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
strokeWidth: 1,
},
}}
/>
</div>
</foreignObject>
) : (
// Render traditional text number
<g>
{/* Outer glow/shadow for emphasis */}
{isDark ? (
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill="none"
stroke="rgba(255, 255, 255, 0.4)"
strokeWidth={fontSize * 0.2}
fontSize={fontSize}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
>
{value}
</text>
) : (
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill="none"
stroke="rgba(255, 255, 255, 0.95)"
strokeWidth={fontSize * 0.25}
fontSize={fontSize}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
>
{value}
</text>
)}
{/* Main text */}
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="central"
fill={textColor}
fontSize={fontSize}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
filter={isDark ? `url(#text-shadow-${color})` : undefined}
>
{value}
</text>
</g>
)}
</g>
)}
</svg>
)
}

View File

@@ -1,912 +0,0 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Textfit } from 'react-textfit'
import { css } from '../../../../styled-system/css'
import { Z_INDEX } from '@/constants/zIndex'
import { useAbacusSettings } from '@/hooks/useAbacusSettings'
import { useViewport } from '@/contexts/ViewportContext'
import { OverviewSection } from './guide-sections/OverviewSection'
import { PiecesSection } from './guide-sections/PiecesSection'
import { CaptureSection } from './guide-sections/CaptureSection'
import { StrategySection } from './guide-sections/StrategySection'
import { HarmonySection } from './guide-sections/HarmonySection'
import { VictorySection } from './guide-sections/VictorySection'
interface PlayingGuideModalProps {
isOpen: boolean
onClose: () => void
standalone?: boolean // True when opened in popup window
docked?: boolean // True when docked to side
onDock?: (side: 'left' | 'right') => void
onUndock?: () => void
onDockPreview?: (side: 'left' | 'right' | null) => void // Preview docking without committing
}
type Section = 'overview' | 'pieces' | 'capture' | 'strategy' | 'harmony' | 'victory'
export function PlayingGuideModal({
isOpen,
onClose,
standalone = false,
docked = false,
onDock,
onUndock,
onDockPreview,
}: PlayingGuideModalProps) {
const t = useTranslations('rithmomachia.guide')
const { data: abacusSettings } = useAbacusSettings()
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
const viewport = useViewport()
const [activeSection, setActiveSection] = useState<Section>('overview')
// Load saved position and size from localStorage
const [position, setPosition] = useState<{ x: number; y: number }>(() => {
if (typeof window === 'undefined') return { x: 0, y: 0 }
const saved = localStorage.getItem('rithmomachia-guide-position')
if (saved) {
try {
return JSON.parse(saved)
} catch {
return { x: 0, y: 0 }
}
}
return { x: 0, y: 0 }
})
const [size, setSize] = useState<{ width: number; height: number }>(() => {
if (typeof window === 'undefined') return { width: 800, height: 600 }
const saved = localStorage.getItem('rithmomachia-guide-size')
if (saved) {
try {
return JSON.parse(saved)
} catch {
return { width: 800, height: 600 }
}
}
return { width: 800, height: 600 }
})
const [isDragging, setIsDragging] = useState(false)
const [windowWidth, setWindowWidth] = useState(
typeof window !== 'undefined' ? viewport.width : 800
)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const [isResizing, setIsResizing] = useState(false)
const [resizeDirection, setResizeDirection] = useState<string>('')
const [resizeStart, setResizeStart] = useState({ width: 0, height: 0, x: 0, y: 0 })
const [isHovered, setIsHovered] = useState(false)
const [dockPreview, setDockPreview] = useState<'left' | 'right' | null>(null)
const modalRef = useRef<HTMLDivElement>(null)
const hasUndockedRef = useRef(false) // Track if we've undocked during current drag
const undockPositionRef = useRef<{ x: number; y: number } | null>(null) // Position at moment of undocking
const [dragTransform, setDragTransform] = useState<{ x: number; y: number } | null>(null) // Visual transform while dragging from dock
// Save position to localStorage whenever it changes
useEffect(() => {
if (!docked && !standalone) {
localStorage.setItem('rithmomachia-guide-position', JSON.stringify(position))
}
}, [position, docked, standalone])
// Save size to localStorage whenever it changes
useEffect(() => {
if (!docked && !standalone) {
localStorage.setItem('rithmomachia-guide-size', JSON.stringify(size))
}
}, [size, docked, standalone])
// Track window width for responsive behavior
useEffect(() => {
setWindowWidth(viewport.width)
}, [viewport.width])
// Center modal on mount (not in standalone mode)
useEffect(() => {
if (isOpen && modalRef.current && !standalone) {
const rect = modalRef.current.getBoundingClientRect()
setPosition({
x: (viewport.width - rect.width) / 2,
y: Math.max(50, (viewport.height - rect.height) / 2),
})
}
}, [isOpen, standalone])
// Handle dragging
const handleMouseDown = (e: React.MouseEvent) => {
console.log(
'[GUIDE_DRAG] === MOUSE DOWN === windowWidth: ' +
viewport.width +
', standalone: ' +
standalone +
', docked: ' +
docked
)
if (viewport.width < 768 || standalone) {
console.log('[GUIDE_DRAG] Skipping drag - mobile or standalone')
return // No dragging on mobile or standalone
}
console.log(
'[GUIDE_DRAG] Starting drag - docked: ' +
docked +
', position: ' +
JSON.stringify(position) +
', size: ' +
JSON.stringify(size)
)
setIsDragging(true)
hasUndockedRef.current = false // Reset undock tracking for new drag
undockPositionRef.current = null // Clear undock position
setDragTransform(null) // Clear any previous transform
// When docked, we need to track the initial mouse position for undocking
if (docked) {
console.log(
'[GUIDE_DRAG] Docked - setting dragStart to clientX: ' +
e.clientX +
', clientY: ' +
e.clientY
)
setDragStart({
x: e.clientX,
y: e.clientY,
})
} else {
console.log(
'[GUIDE_DRAG] Not docked - setting dragStart offset: ' +
(e.clientX - position.x) +
', ' +
(e.clientY - position.y)
)
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y,
})
}
}
// Handle resize start
const handleResizeStart = (e: React.MouseEvent, direction: string) => {
if (viewport.width < 768 || standalone) return
e.stopPropagation()
setIsResizing(true)
setResizeDirection(direction)
setDragStart({ x: e.clientX, y: e.clientY })
// Save initial dimensions and position for resize calculation
setResizeStart({ width: size.width, height: size.height, x: position.x, y: position.y })
}
// Bust-out button handler
const handleBustOut = () => {
const url = `${window.location.origin}/arcade/rithmomachia/guide`
const features = 'width=600,height=800,menubar=no,toolbar=no,location=no,status=no'
window.open(url, 'RithmomachiaGuide', features)
onClose() // Close the modal version after opening in new window
}
// Mouse move effect for dragging and resizing
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
console.log(
'[GUIDE_DRAG] Mouse move - clientX: ' +
e.clientX +
', clientY: ' +
e.clientY +
', docked: ' +
docked +
', hasUndocked: ' +
hasUndockedRef.current
)
// When docked and haven't undocked yet, check if we've dragged far enough away to undock
if (docked && onUndock && !hasUndockedRef.current) {
const UNDOCK_THRESHOLD = 50 // pixels to drag before undocking
const dragDistance = Math.sqrt(
(e.clientX - dragStart.x) ** 2 + (e.clientY - dragStart.y) ** 2
)
console.log(
'[GUIDE_DRAG] Checking threshold - distance: ' +
dragDistance +
', threshold: ' +
UNDOCK_THRESHOLD +
', dragStart: ' +
JSON.stringify(dragStart)
)
if (dragDistance > UNDOCK_THRESHOLD) {
console.log('[GUIDE_DRAG] === THRESHOLD EXCEEDED === Marking as virtually undocked')
hasUndockedRef.current = true
// Don't call onUndock() yet - wait until mouse up to avoid unmounting during drag
// After undocking, set dragStart as offset from position to cursor for smooth continued dragging
if (modalRef.current) {
const rect = modalRef.current.getBoundingClientRect()
console.log(
'[GUIDE_DRAG] Modal rect - left: ' +
rect.left +
', top: ' +
rect.top +
', width: ' +
rect.width +
', height: ' +
rect.height
)
// Store the undock position in ref for immediate access
undockPositionRef.current = {
x: rect.left,
y: rect.top,
}
console.log(
'[GUIDE_DRAG] Stored undock position in ref: ' +
JSON.stringify(undockPositionRef.current)
)
// Set dragStart as offset from current position to cursor
const newDragStartX = e.clientX - rect.left
const newDragStartY = e.clientY - rect.top
console.log(
'[GUIDE_DRAG] New dragStart offset: ' + newDragStartX + ', ' + newDragStartY
)
setDragStart({
x: newDragStartX,
y: newDragStartY,
})
// Also store the position for state (used when actually undocking)
setPosition({
x: rect.left,
y: rect.top,
})
}
} else {
console.log('[GUIDE_DRAG] Below threshold - returning early')
// Still below threshold - don't apply any transform yet
return
}
}
// Virtually undocked or already floating - update position
if (hasUndockedRef.current || !docked) {
const newX = e.clientX - dragStart.x
const newY = e.clientY - dragStart.y
console.log(
'[GUIDE_DRAG] Calculating position - newX: ' +
newX +
', newY: ' +
newY +
', dragStart: ' +
JSON.stringify(dragStart)
)
if (hasUndockedRef.current && docked) {
// Still docked but virtually undocked - use transform for visual movement
// Use undockPositionRef instead of position state to avoid stale closure
if (undockPositionRef.current) {
const transformX = newX - undockPositionRef.current.x
const transformY = newY - undockPositionRef.current.y
console.log(
'[GUIDE_DRAG] === SETTING TRANSFORM === x: ' +
transformX +
', y: ' +
transformY +
', undockPosition: ' +
JSON.stringify(undockPositionRef.current)
)
setDragTransform({ x: transformX, y: transformY })
}
} else {
// Actually floating - use position
console.log('[GUIDE_DRAG] Floating - setting position: ' + newX + ', ' + newY)
setPosition({
x: newX,
y: newY,
})
}
// Check if we're near edges for docking preview (works for floating or virtually undocked)
console.log(
'[GUIDE_DRAG] Checking docking preview - onDock: ' +
(onDock ? 'defined' : 'undefined') +
', onDockPreview: ' +
(onDockPreview ? 'defined' : 'undefined') +
', docked: ' +
docked +
', hasUndocked: ' +
hasUndockedRef.current
)
if (onDock && onDockPreview && (!docked || hasUndockedRef.current)) {
const DOCK_THRESHOLD = 100
console.log(
'[GUIDE_DRAG] Docking preview condition passed - checking edges, clientX: ' +
e.clientX
)
if (e.clientX < DOCK_THRESHOLD) {
setDockPreview('left')
onDockPreview('left')
} else if (e.clientX > viewport.width - DOCK_THRESHOLD) {
setDockPreview('right')
onDockPreview('right')
} else {
setDockPreview(null)
onDockPreview(null)
}
}
}
} else if (isResizing) {
// Calculate delta from initial resize start position
const deltaX = e.clientX - dragStart.x
const deltaY = e.clientY - dragStart.y
let newWidth = resizeStart.width
let newHeight = resizeStart.height
let newX = resizeStart.x
let newY = resizeStart.y
// Handle different resize directions - calculate from initial state
// Ultra-flexible minimum width for narrow layouts
const minWidth = 150
const minHeight = 300
if (resizeDirection.includes('e')) {
newWidth = Math.max(minWidth, Math.min(viewport.width * 0.9, resizeStart.width + deltaX))
}
if (resizeDirection.includes('w')) {
const desiredWidth = resizeStart.width - deltaX
newWidth = Math.max(minWidth, Math.min(viewport.width * 0.9, desiredWidth))
// Move left edge by the amount we actually changed width
newX = resizeStart.x + (resizeStart.width - newWidth)
}
if (resizeDirection.includes('s')) {
newHeight = Math.max(
minHeight,
Math.min(viewport.height * 0.9, resizeStart.height + deltaY)
)
}
if (resizeDirection.includes('n')) {
const desiredHeight = resizeStart.height - deltaY
newHeight = Math.max(minHeight, Math.min(viewport.height * 0.9, desiredHeight))
// Move top edge by the amount we actually changed height
newY = resizeStart.y + (resizeStart.height - newHeight)
}
setSize({ width: newWidth, height: newHeight })
setPosition({ x: newX, y: newY })
}
}
const handleMouseUp = (e: MouseEvent) => {
console.log(
'[GUIDE_DRAG] === MOUSE UP === clientX: ' +
e.clientX +
', docked: ' +
docked +
', hasUndocked: ' +
hasUndockedRef.current +
', isDragging: ' +
isDragging +
', onDock: ' +
(onDock ? 'defined' : 'undefined')
)
// Check for docking when releasing drag (works for floating or virtually undocked)
if (isDragging && onDock && (!docked || hasUndockedRef.current)) {
const DOCK_THRESHOLD = 100 // pixels from edge to trigger docking
console.log(
'[GUIDE_DRAG] Checking for dock - clientX: ' +
e.clientX +
', threshold: ' +
DOCK_THRESHOLD +
', windowWidth: ' +
viewport.width
)
if (e.clientX < DOCK_THRESHOLD) {
console.log('[GUIDE_DRAG] Mouse up - near left edge, calling onDock(left)')
onDock('left')
// Don't call onUndock if we're re-docking
setIsDragging(false)
setIsResizing(false)
setResizeDirection('')
setDockPreview(null)
setDragTransform(null)
if (onDockPreview) {
onDockPreview(null)
}
console.log('[GUIDE_DRAG] Cleared state after re-dock to left')
return
} else if (e.clientX > viewport.width - DOCK_THRESHOLD) {
console.log('[GUIDE_DRAG] Mouse up - near right edge, calling onDock(right)')
onDock('right')
// Don't call onUndock if we're re-docking
setIsDragging(false)
setIsResizing(false)
setResizeDirection('')
setDockPreview(null)
setDragTransform(null)
if (onDockPreview) {
onDockPreview(null)
}
console.log('[GUIDE_DRAG] Cleared state after re-dock to right')
return
}
}
// If we virtually undocked during this drag and didn't re-dock, now actually undock
if (hasUndockedRef.current && docked && onUndock) {
console.log(
'[GUIDE_DRAG] Mouse up - calling deferred onUndock() with final position: ' +
JSON.stringify(position)
)
onUndock()
}
console.log('[GUIDE_DRAG] Mouse up - clearing all drag state')
setIsDragging(false)
setIsResizing(false)
setResizeDirection('')
setDockPreview(null) // Clear dock preview when drag ends
setDragTransform(null) // Clear drag transform
if (onDockPreview) {
onDockPreview(null) // Clear parent preview state
}
}
if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [
isDragging,
isResizing,
dragStart,
resizeDirection,
resizeStart,
docked,
onUndock,
onDock,
onDockPreview,
])
if (!isOpen && !standalone && !docked) return null
const sections: { id: Section; label: string; icon: string }[] = [
{ id: 'overview', label: t('sections.overview'), icon: '🎯' },
{ id: 'pieces', label: t('sections.pieces'), icon: '♟️' },
{ id: 'capture', label: t('sections.capture'), icon: '⚔️' },
{ id: 'strategy', label: t('sections.strategy'), icon: '🧠' },
{ id: 'harmony', label: t('sections.harmony'), icon: '🎵' },
{ id: 'victory', label: t('sections.victory'), icon: '👑' },
]
// Determine layout mode based on modal width (or window width if standalone)
const effectiveWidth = standalone ? windowWidth : size.width
const isVeryNarrow = effectiveWidth < 250
const isNarrow = effectiveWidth < 400
const isMedium = effectiveWidth < 600
const renderResizeHandles = () => {
if (!isHovered || viewport.width < 768 || standalone) return null
const handleStyle = {
position: 'absolute' as const,
bg: 'transparent',
zIndex: 1,
_hover: { borderColor: '#3b82f6' },
}
return (
<>
{/* North */}
<div
data-element="resize-n"
className={css({
...handleStyle,
top: 0,
left: '8px',
right: '8px',
height: '8px',
cursor: 'ns-resize',
borderTop: '2px solid transparent',
})}
onMouseDown={(e) => handleResizeStart(e, 'n')}
/>
{/* South */}
<div
data-element="resize-s"
className={css({
...handleStyle,
bottom: 0,
left: '8px',
right: '8px',
height: '8px',
cursor: 'ns-resize',
borderBottom: '2px solid transparent',
})}
onMouseDown={(e) => handleResizeStart(e, 's')}
/>
{/* East */}
<div
data-element="resize-e"
className={css({
...handleStyle,
right: 0,
top: '8px',
bottom: '8px',
width: '8px',
cursor: 'ew-resize',
borderRight: '2px solid transparent',
})}
onMouseDown={(e) => handleResizeStart(e, 'e')}
/>
{/* West */}
<div
data-element="resize-w"
className={css({
...handleStyle,
left: 0,
top: '8px',
bottom: '8px',
width: '8px',
cursor: 'ew-resize',
borderLeft: '2px solid transparent',
})}
onMouseDown={(e) => handleResizeStart(e, 'w')}
/>
{/* NorthEast */}
<div
data-element="resize-ne"
className={css({
...handleStyle,
top: 0,
right: 0,
width: '8px',
height: '8px',
cursor: 'nesw-resize',
border: '2px solid transparent',
})}
onMouseDown={(e) => handleResizeStart(e, 'ne')}
/>
{/* NorthWest */}
<div
data-element="resize-nw"
className={css({
...handleStyle,
top: 0,
left: 0,
width: '8px',
height: '8px',
cursor: 'nwse-resize',
border: '2px solid transparent',
})}
onMouseDown={(e) => handleResizeStart(e, 'nw')}
/>
{/* SouthEast */}
<div
data-element="resize-se"
className={css({
...handleStyle,
bottom: 0,
right: 0,
width: '8px',
height: '8px',
cursor: 'nwse-resize',
border: '2px solid transparent',
})}
onMouseDown={(e) => handleResizeStart(e, 'se')}
/>
{/* SouthWest */}
<div
data-element="resize-sw"
className={css({
...handleStyle,
bottom: 0,
left: 0,
width: '8px',
height: '8px',
cursor: 'nesw-resize',
border: '2px solid transparent',
})}
onMouseDown={(e) => handleResizeStart(e, 'sw')}
/>
</>
)
}
const modalContent = (() => {
const styleConfig: React.CSSProperties = {
// When virtually undocked (dragTransform present), use fixed positioning to break out of Panel
position: dragTransform || !docked ? 'fixed' : 'relative',
background: 'white',
borderRadius: standalone || (docked && !dragTransform) ? 0 : isVeryNarrow ? '8px' : '12px',
boxShadow:
standalone || (docked && !dragTransform) ? 'none' : '0 20px 60px rgba(0, 0, 0, 0.3)',
border: standalone || (docked && !dragTransform) ? 'none' : '1px solid #e5e7eb',
display: 'flex',
flexDirection: 'column' as const,
overflow: 'hidden',
...(dragTransform && undockPositionRef.current
? // Virtually undocked - show at drag position using ref position
{
left: `${undockPositionRef.current.x + dragTransform.x}px`,
top: `${undockPositionRef.current.y + dragTransform.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: Z_INDEX.MODAL,
}
: docked
? // Still docked
{ width: '100%', height: '100%' }
: standalone
? // Standalone mode
{ top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 1 }
: // Actually floating
{
left: `${position.x}px`,
top: `${position.y}px`,
width: `${size.width}px`,
height: `${size.height}px`,
zIndex: Z_INDEX.MODAL,
}),
// 80% opacity when showing dock preview or when not hovered on desktop
opacity:
dockPreview !== null
? 0.8
: !standalone && !docked && viewport.width >= 768 && !isHovered
? 0.8
: 1,
transition: 'opacity 0.2s ease',
}
console.log(
'[GUIDE_DRAG] Rendering with style - position: ' +
styleConfig.position +
', left: ' +
(styleConfig.left ?? 'none') +
', top: ' +
(styleConfig.top ?? 'none') +
', dragTransform: ' +
JSON.stringify(dragTransform) +
', docked: ' +
docked
)
return (
<div
ref={modalRef}
data-component="playing-guide-modal"
style={styleConfig}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{!docked && renderResizeHandles()}
{/* Header */}
<div
data-element="modal-header"
className={css({
bg: '#f9fafb',
borderBottom: '1px solid #e5e7eb',
userSelect: 'none',
flexShrink: 0,
position: 'relative',
})}
style={{
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
cursor: isDragging
? 'grabbing'
: !standalone && viewport.width >= 768
? 'grab'
: 'default',
}}
onMouseDown={handleMouseDown}
>
{/* Close and utility buttons - top right */}
<div
style={{
position: 'absolute',
top: isVeryNarrow ? '4px' : '8px',
right: isVeryNarrow ? '4px' : '8px',
display: 'flex',
alignItems: 'center',
gap: isVeryNarrow ? '4px' : '8px',
}}
>
{/* Bust-out button (only if not already standalone/docked and not very narrow) */}
{!standalone && !docked && !isVeryNarrow && (
<button
type="button"
data-action="bust-out-guide"
onClick={handleBustOut}
style={{
background: '#e5e7eb',
color: '#374151',
border: 'none',
borderRadius: isVeryNarrow ? '4px' : '6px',
width: isVeryNarrow ? '24px' : '32px',
height: isVeryNarrow ? '24px' : '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: isVeryNarrow ? '12px' : '16px',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
title={t('bustOut')}
>
</button>
)}
{/* Close button */}
<button
type="button"
data-action="close-guide"
onClick={onClose}
style={{
background: '#e5e7eb',
color: '#374151',
border: 'none',
borderRadius: isVeryNarrow ? '4px' : '6px',
width: isVeryNarrow ? '24px' : '32px',
height: isVeryNarrow ? '24px' : '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: isVeryNarrow ? '14px' : '18px',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
>
</button>
</div>
{/* Centered title and subtitle - hide when very narrow */}
{!isVeryNarrow && (
<div style={{ textAlign: 'center' }}>
<h1
style={{
fontSize: isNarrow ? '16px' : isMedium ? '20px' : '28px',
fontWeight: 'bold',
color: '#111827',
marginBottom: isNarrow ? '4px' : '8px',
lineHeight: 1.2,
}}
>
{t('title')}
</h1>
{!isNarrow && (
<p
style={{
fontSize: isMedium ? '12px' : '16px',
color: '#6b7280',
marginBottom: isMedium ? '8px' : '16px',
lineHeight: 1.3,
}}
>
{t('subtitle')}
</p>
)}
</div>
)}
</div>
{/* Navigation Tabs - fully responsive, always fit in available width */}
<div
data-element="guide-nav"
style={{
display: 'flex',
flexDirection: 'row',
borderBottom: '2px solid #e5e7eb',
background: '#f9fafb',
flexShrink: 0,
}}
>
{sections.map((section) => (
<button
key={section.id}
type="button"
data-action={`navigate-${section.id}`}
onClick={() => setActiveSection(section.id)}
style={{
flex: '1 1 0', // Equal width tabs
minWidth: 0, // Allow shrinking below content size
padding: isVeryNarrow ? '10px 6px' : isNarrow ? '10px 8px' : '14px 20px',
fontSize: isVeryNarrow ? '16px' : isNarrow ? '12px' : '14px',
fontWeight: activeSection === section.id ? 'bold' : '500',
color: activeSection === section.id ? '#7c2d12' : '#6b7280',
background: activeSection === section.id ? 'white' : 'transparent',
cursor: 'pointer',
transition: 'all 0.2s',
border: 'none',
borderBottom: `3px solid ${activeSection === section.id ? '#7c2d12' : 'transparent'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: isVeryNarrow ? '0' : isNarrow ? '4px' : '6px',
lineHeight: 1,
overflow: 'hidden',
}}
onMouseEnter={(e) => {
if (activeSection !== section.id) {
e.currentTarget.style.background = '#f3f4f6'
}
}}
onMouseLeave={(e) => {
if (activeSection !== section.id) {
e.currentTarget.style.background = 'transparent'
}
}}
title={section.label}
>
<span style={{ fontSize: isVeryNarrow ? '18px' : 'inherit', flexShrink: 0 }}>
{section.icon}
</span>
{!isVeryNarrow && (
<Textfit
mode="single"
min={8}
max={isNarrow ? 12 : 14}
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}
>
{section.label}
</Textfit>
)}
</button>
))}
</div>
{/* Content */}
<div
data-element="guide-content"
style={{
flex: 1,
overflow: 'auto',
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
fontSize: isVeryNarrow ? '12px' : isNarrow ? '13px' : '14px',
lineHeight: 1.5,
}}
>
{activeSection === 'overview' && (
<OverviewSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'pieces' && (
<PiecesSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'capture' && (
<CaptureSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'strategy' && (
<StrategySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'harmony' && (
<HarmonySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
{activeSection === 'victory' && (
<VictorySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
)}
</div>
</div>
)
})() // Invoke the IIFE
// If standalone, just render the content without Dialog wrapper
if (standalone) {
return modalContent
}
// Otherwise, just render the modal (parent will handle preview rendering)
return modalContent
}

View File

@@ -1,200 +0,0 @@
import { css } from '../../../../styled-system/css'
import { PieceRenderer } from './PieceRenderer'
import type { Color, PieceType } from '../types'
/**
* Simplified piece for board examples
*/
export interface ExamplePiece {
square: string // e.g. "A1", "B2"
type: PieceType
color: Color
value: number
}
interface CropArea {
// Support both naming conventions for backwards compatibility
minCol?: number // 0-15 (A=0, P=15)
maxCol?: number // 0-15
minRow?: number // 1-8
maxRow?: number // 1-8
startCol?: number // Alternative: 0-15
endCol?: number // Alternative: 0-15
startRow?: number // Alternative: 1-8
endRow?: number // Alternative: 1-8
}
interface RithmomachiaBoardProps {
pieces: ExamplePiece[]
highlightSquares?: string[] // Squares to highlight (e.g. for harmony examples)
scale?: number // Scale factor for the board size
showLabels?: boolean // Show rank/file labels
cropArea?: CropArea // Crop to show only a rectangular subsection
useNativeAbacusNumbers?: boolean // Display numbers as mini abaci
}
/**
* Reusable board component for displaying Rithmomachia positions.
* Used in the guide and for board examples.
*/
export function RithmomachiaBoard({
pieces,
highlightSquares = [],
scale = 0.5,
showLabels = true, // Default to true for proper board labels
cropArea,
useNativeAbacusNumbers = false,
}: RithmomachiaBoardProps) {
// Board dimensions
const cellSize = 100 // SVG units per cell
const gap = 2
const padding = 10
const labelMargin = showLabels ? 30 : 0 // Space for row/column labels
// Determine the area to display (support both naming conventions)
const minCol = cropArea?.minCol ?? cropArea?.startCol ?? 0
const maxCol = cropArea?.maxCol ?? cropArea?.endCol ?? 15
const minRow = cropArea?.minRow ?? cropArea?.startRow ?? 1
const maxRow = cropArea?.maxRow ?? cropArea?.endRow ?? 8
const displayCols = maxCol - minCol + 1
const displayRows = maxRow - minRow + 1
// Calculate cropped board dimensions (including label margins)
const boardInnerWidth = displayCols * cellSize + (displayCols - 1) * gap
const boardInnerHeight = displayRows * cellSize + (displayRows - 1) * gap
const boardWidth = boardInnerWidth + padding * 2 + labelMargin
const boardHeight = boardInnerHeight + padding * 2 + labelMargin
return (
<div
data-component="rithmomachia-board-example"
className={css({
width: '100%',
maxWidth: `${boardWidth * scale}px`,
margin: '0 auto',
})}
>
<svg
viewBox={`0 0 ${boardWidth} ${boardHeight}`}
className={css({
width: '100%',
height: 'auto',
})}
>
{/* Board background */}
<rect x={0} y={0} width={boardWidth} height={boardHeight} fill="#d1d5db" rx={8} />
{/* Board squares */}
{Array.from({ length: displayRows }, (_, displayRow) => {
const actualRank = maxRow - displayRow
return Array.from({ length: displayCols }, (_, displayCol) => {
const actualCol = minCol + displayCol
const square = `${String.fromCharCode(65 + actualCol)}${actualRank}`
const isLight = (actualCol + actualRank) % 2 === 0
const isHighlighted = highlightSquares.includes(square)
const x = labelMargin + padding + displayCol * (cellSize + gap)
const y = padding + displayRow * (cellSize + gap)
return (
<g key={square}>
<rect
x={x}
y={y}
width={cellSize}
height={cellSize}
fill={isHighlighted ? '#fde047' : isLight ? '#f3f4f6' : '#e5e7eb'}
stroke={isHighlighted ? '#f59e0b' : 'none'}
strokeWidth={isHighlighted ? 3 : 0}
/>
</g>
)
})
})}
{/* Column labels (A-P) at the bottom */}
{showLabels &&
Array.from({ length: displayCols }, (_, displayCol) => {
const actualCol = minCol + displayCol
const colLabel = String.fromCharCode(65 + actualCol)
const x = labelMargin + padding + displayCol * (cellSize + gap) + cellSize / 2
const y = boardHeight - 10
return (
<text
key={`col-${colLabel}`}
x={x}
y={y}
fontSize="20"
fontWeight="bold"
fill="#374151"
fontFamily="sans-serif"
textAnchor="middle"
dominantBaseline="middle"
>
{colLabel}
</text>
)
})}
{/* Row labels (1-8) on the left */}
{showLabels &&
Array.from({ length: displayRows }, (_, displayRow) => {
const actualRank = maxRow - displayRow
const x = 15
const y = padding + displayRow * (cellSize + gap) + cellSize / 2
return (
<text
key={`row-${actualRank}`}
x={x}
y={y}
fontSize="20"
fontWeight="bold"
fill="#374151"
fontFamily="sans-serif"
textAnchor="middle"
dominantBaseline="middle"
>
{actualRank}
</text>
)
})}
{/* Pieces */}
{pieces
.filter((piece) => {
const file = piece.square.charCodeAt(0) - 65
const rank = Number.parseInt(piece.square.slice(1), 10)
return file >= minCol && file <= maxCol && rank >= minRow && rank <= maxRow
})
.map((piece, idx) => {
const file = piece.square.charCodeAt(0) - 65
const rank = Number.parseInt(piece.square.slice(1), 10)
// Calculate position relative to the crop area
const displayCol = file - minCol
const displayRow = maxRow - rank
const x = labelMargin + padding + displayCol * (cellSize + gap) + cellSize / 2
const y = padding + displayRow * (cellSize + gap) + cellSize / 2
return (
<g key={`${piece.square}-${idx}`} transform={`translate(${x}, ${y})`}>
<g transform={`translate(${-cellSize / 2}, ${-cellSize / 2})`}>
<PieceRenderer
type={piece.type}
color={piece.color}
value={piece.value}
size={cellSize}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</g>
</g>
)
})}
</svg>
</div>
)
}

View File

@@ -1,320 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
import type { PlayerBadge } from '@/components/nav/types'
import { PageWithNav } from '@/components/PageWithNav'
import { StandardGameLayout } from '@/components/StandardGameLayout'
import { useFullscreen } from '@/contexts/FullscreenContext'
import { useAbacusSettings } from '@/hooks/useAbacusSettings'
import { css } from '../../../../styled-system/css'
import { useRosterWarning } from '../hooks/useRosterWarning'
import { useRithmomachia } from '../Provider'
import { PlayingGuideModal } from './PlayingGuideModal'
import { PlayingPhase } from './phases/PlayingPhase'
import { ResultsPhase } from './phases/ResultsPhase'
import { SetupPhase } from './phases/SetupPhase'
/**
* Main Rithmomachia game component.
* Orchestrates the game phases and UI.
*/
export function RithmomachiaGame() {
const router = useRouter()
const {
state,
resetGame,
goToSetup,
whitePlayerId,
blackPlayerId,
assignWhitePlayer,
assignBlackPlayer,
} = useRithmomachia()
// Get abacus settings for native abacus numbers
const { data: abacusSettings } = useAbacusSettings()
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
const { setFullscreenElement } = useFullscreen()
const gameRef = useRef<HTMLDivElement>(null)
const rosterWarning = useRosterWarning(state.gamePhase === 'setup' ? 'setup' : 'playing')
// Load saved guide preferences from localStorage
const [guideDocked, setGuideDocked] = useState<boolean>(() => {
if (typeof window === 'undefined') return false
const saved = localStorage.getItem('rithmomachia-guide-docked')
return saved === 'true'
})
const [guideDockSide, setGuideDockSide] = useState<'left' | 'right'>(() => {
if (typeof window === 'undefined') return 'right'
const saved = localStorage.getItem('rithmomachia-guide-dock-side')
return saved === 'left' || saved === 'right' ? saved : 'right'
})
const [isGuideOpen, setIsGuideOpen] = useState(false)
const [dockPreviewSide, setDockPreviewSide] = useState<'left' | 'right' | null>(null)
useEffect(() => {
// Register this component's main div as the fullscreen element
if (gameRef.current) {
setFullscreenElement(gameRef.current)
}
}, [setFullscreenElement])
// Save guide docked state to localStorage
useEffect(() => {
localStorage.setItem('rithmomachia-guide-docked', String(guideDocked))
}, [guideDocked])
// Save guide dock side to localStorage
useEffect(() => {
localStorage.setItem('rithmomachia-guide-dock-side', guideDockSide)
}, [guideDockSide])
// Debug logging for state changes
useEffect(() => {
console.log('[RithmomachiaGame] State changed', {
isGuideOpen,
guideDocked,
guideDockSide,
})
}, [isGuideOpen, guideDocked, guideDockSide])
const currentPlayerId = useMemo(() => {
if (state.turn === 'W') {
return whitePlayerId ?? undefined
}
if (state.turn === 'B') {
return blackPlayerId ?? undefined
}
return undefined
}, [state.turn, whitePlayerId, blackPlayerId])
const playerBadges = useMemo<Record<string, PlayerBadge>>(() => {
const badges: Record<string, PlayerBadge> = {}
if (whitePlayerId) {
badges[whitePlayerId] = {
label: 'White',
icon: '⚪',
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.95), rgba(226, 232, 240, 0.9))',
color: '#0f172a',
borderColor: 'rgba(226, 232, 240, 0.8)',
shadowColor: 'rgba(148, 163, 184, 0.35)',
}
}
if (blackPlayerId) {
badges[blackPlayerId] = {
label: 'Black',
icon: '⚫',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.92), rgba(15, 23, 42, 0.94))',
color: '#f8fafc',
borderColor: 'rgba(30, 41, 59, 0.9)',
shadowColor: 'rgba(15, 23, 42, 0.45)',
}
}
return badges
}, [whitePlayerId, blackPlayerId])
const handleOpenGuide = () => {
console.log('[RithmomachiaGame] handleOpenGuide called')
setIsGuideOpen(true)
// Use saved preferences if available, otherwise default to docked on right
if (typeof window !== 'undefined') {
const savedDocked = localStorage.getItem('rithmomachia-guide-docked')
const savedSide = localStorage.getItem('rithmomachia-guide-dock-side')
if (savedDocked !== null) {
const isDocked = savedDocked === 'true'
setGuideDocked(isDocked)
if (isDocked && savedSide) {
setGuideDockSide(savedSide === 'left' || savedSide === 'right' ? savedSide : 'right')
}
console.log('[RithmomachiaGame] Guide opened with saved preferences', {
docked: isDocked,
side: savedSide,
})
} else {
// First time opening - default to docked on right
setGuideDocked(true)
setGuideDockSide('right')
console.log('[RithmomachiaGame] Guide opened in default docked right position')
}
}
}
const handleDock = (side: 'left' | 'right') => {
console.log('[RithmomachiaGame] handleDock called', { side })
setGuideDockSide(side)
setGuideDocked(true)
setDockPreviewSide(null) // Clear preview when committing to dock
console.log('[RithmomachiaGame] Docked state updated', {
guideDocked: true,
guideDockSide: side,
})
}
const handleUndock = () => {
console.log('[RithmomachiaGame] handleUndock called')
setGuideDocked(false)
console.log('[RithmomachiaGame] Undocked state updated', { guideDocked: false })
}
const handleDockPreview = (side: 'left' | 'right' | null) => {
console.log('[RithmomachiaGame] handleDockPreview called', { side })
setDockPreviewSide(side)
}
const gameContent = (
<div
className={css({
flex: 1,
padding: guideDocked ? 0 : { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: guideDocked ? 'stretch' : 'center',
position: 'relative',
overflow: 'auto',
// When docked, ensure we fill panel height
height: guideDocked ? '100%' : 'auto',
})}
>
<main
className={css({
width: '100%',
maxWidth: guideDocked ? 'none' : '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: guideDocked ? 0 : { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: guideDocked ? 'none' : '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
position: 'relative',
// Ensure main fills parent height
minHeight: 0,
})}
>
{state.gamePhase === 'setup' && (
<SetupPhase onOpenGuide={handleOpenGuide} isGuideOpen={isGuideOpen} />
)}
{state.gamePhase === 'playing' && (
<PlayingPhase onOpenGuide={handleOpenGuide} isGuideOpen={isGuideOpen} />
)}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
</div>
)
return (
<PageWithNav
navTitle="Rithmomachia"
navEmoji="🎲"
emphasizePlayerSelection={state.gamePhase === 'setup'}
onExitSession={() => {
router.push('/arcade')
}}
onNewGame={resetGame}
onSetup={goToSetup}
currentPlayerId={currentPlayerId}
playerBadges={playerBadges}
rosterWarning={rosterWarning}
whitePlayerId={whitePlayerId}
blackPlayerId={blackPlayerId}
onAssignWhitePlayer={assignWhitePlayer}
onAssignBlackPlayer={assignBlackPlayer}
gamePhase={state.gamePhase}
>
<StandardGameLayout>
<div
ref={gameRef}
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
})}
>
{(guideDocked || dockPreviewSide) && isGuideOpen ? (
<PanelGroup direction="horizontal" style={{ flex: 1 }}>
{(guideDocked ? guideDockSide : dockPreviewSide) === 'left' && (
<>
<Panel defaultSize={35} minSize={20} maxSize={50}>
<PlayingGuideModal
isOpen={true}
onClose={() => setIsGuideOpen(false)}
docked={true} // Always render as docked when in panel
onUndock={guideDocked ? handleUndock : undefined} // Only show undock button when truly docked
onDock={handleDock} // Allow re-docking during virtual undock
onDockPreview={handleDockPreview}
/>
</Panel>
<PanelResizeHandle
className={css({
width: '2px',
background: '#e5e7eb',
cursor: 'col-resize',
transition: 'all 0.2s',
_hover: {
background: '#9ca3af',
width: '3px',
},
})}
/>
<Panel minSize={50}>{gameContent}</Panel>
</>
)}
{(guideDocked ? guideDockSide : dockPreviewSide) === 'right' && (
<>
<Panel minSize={50}>{gameContent}</Panel>
<PanelResizeHandle
className={css({
width: '2px',
background: '#e5e7eb',
cursor: 'col-resize',
transition: 'all 0.2s',
_hover: {
background: '#9ca3af',
width: '3px',
},
})}
/>
<Panel defaultSize={35} minSize={20} maxSize={50}>
<PlayingGuideModal
isOpen={true}
onClose={() => setIsGuideOpen(false)}
docked={true} // Always render as docked when in panel
onUndock={guideDocked ? handleUndock : undefined} // Only show undock button when truly docked
onDock={handleDock} // Allow re-docking during virtual undock
onDockPreview={handleDockPreview}
/>
</Panel>
</>
)}
</PanelGroup>
) : (
gameContent
)}
</div>
</StandardGameLayout>
{/* Playing Guide Modal - only show when not docked */}
{!guideDocked && (
<PlayingGuideModal
isOpen={isGuideOpen}
onClose={() => setIsGuideOpen(false)}
docked={false}
onDock={handleDock}
onDockPreview={handleDockPreview}
/>
)}
</PageWithNav>
)
}
/**
* Setup phase: game configuration and start button.
*/

View File

@@ -1,70 +0,0 @@
'use client'
import { animated, useSpring } from '@react-spring/web'
import type { Piece } from '../../types'
import { PieceRenderer } from '../PieceRenderer'
export interface SvgPieceProps {
piece: Piece
cellSize: number
padding: number
labelMargin?: number
opacity?: number
useNativeAbacusNumbers?: boolean
selected?: boolean
shouldRotate?: boolean
}
export function SvgPiece({
piece,
cellSize,
padding,
labelMargin = 0,
opacity = 1,
useNativeAbacusNumbers = false,
selected = false,
shouldRotate = false,
}: SvgPieceProps) {
const file = piece.square.charCodeAt(0) - 65 // A=0
const rank = Number.parseInt(piece.square.slice(1), 10) // 1-8
const row = 8 - rank // Invert for display
const x = labelMargin + padding + file * cellSize
const y = padding + row * cellSize
const spring = useSpring({
x,
y,
config: { tension: 280, friction: 60 },
})
const pieceSize = cellSize * 0.85
return (
<animated.g transform={spring.x.to((xVal) => `translate(${xVal}, ${spring.y.get()})`)}>
<foreignObject x={0} y={0} width={cellSize} height={cellSize}>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity,
}}
>
<PieceRenderer
type={piece.type}
color={piece.color}
value={piece.type === 'P' ? piece.pyramidFaces?.[0] || 0 : piece.value || 0}
size={pieceSize}
useNativeAbacusNumbers={useNativeAbacusNumbers}
selected={selected}
pyramidFaces={piece.type === 'P' ? piece.pyramidFaces : undefined}
shouldRotate={shouldRotate}
/>
</div>
</foreignObject>
</animated.g>
)
}

View File

@@ -1,85 +0,0 @@
'use client'
import { animated, to, useSpring } from '@react-spring/web'
import type { Piece } from '../../types'
import { getEffectiveValue } from '../../utils/pieceSetup'
import { PieceRenderer } from '../PieceRenderer'
interface AnimatedHelperPieceProps {
piece: Piece
boardPos: { x: number; y: number }
ringX: number
ringY: number
cellSize: number
onSelectHelper: (pieceId: string) => void
closing: boolean
onHover?: (pieceId: string | null) => void
useNativeAbacusNumbers?: boolean
}
export function AnimatedHelperPiece({
piece,
boardPos,
ringX,
ringY,
cellSize,
onSelectHelper,
closing,
onHover,
useNativeAbacusNumbers = false,
}: AnimatedHelperPieceProps) {
console.log(
`[AnimatedHelperPiece] Rendering piece ${piece.id}: boardPos=(${boardPos.x}, ${boardPos.y}), ringPos=(${ringX}, ${ringY}), closing=${closing}`
)
// Animate from board position to ring position
const spring = useSpring({
from: { x: boardPos.x, y: boardPos.y, opacity: 0 },
x: closing ? boardPos.x : ringX,
y: closing ? boardPos.y : ringY,
opacity: closing ? 0 : 1,
config: { tension: 280, friction: 20 },
})
console.log(
`[AnimatedHelperPiece] Spring config for ${piece.id}: from=(${boardPos.x}, ${boardPos.y}), to=(${closing ? boardPos.x : ringX}, ${closing ? boardPos.y : ringY})`
)
const value = getEffectiveValue(piece)
if (value === undefined || value === null) return null
return (
<animated.g
style={{
opacity: spring.opacity,
cursor: 'pointer',
}}
transform={to([spring.x, spring.y], (x, y) => `translate(${x}, ${y})`)}
onClick={(e) => {
e.stopPropagation()
onSelectHelper(piece.id)
}}
onMouseEnter={() => onHover?.(piece.id)}
onMouseLeave={() => onHover?.(null)}
>
{/* Render the actual piece with a highlight ring */}
<circle
cx={0}
cy={0}
r={cellSize * 0.5}
fill="rgba(250, 204, 21, 0.3)"
stroke="rgba(250, 204, 21, 0.9)"
strokeWidth={4}
/>
<g transform={`translate(${-cellSize / 2}, ${-cellSize / 2})`}>
<PieceRenderer
type={piece.type}
color={piece.color}
value={value}
size={cellSize}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</g>
</animated.g>
)
}

View File

@@ -1,63 +0,0 @@
import { animated, to, useSpring } from '@react-spring/web'
import { useCaptureContext } from '../../contexts/CaptureContext'
/**
* Error notification when no capture is possible
*/
export function CaptureErrorDialog() {
const { layout, closing } = useCaptureContext()
const { targetPos, cellSize } = layout
const entranceSpring = useSpring({
from: { opacity: 0, y: -20 },
opacity: closing ? 0 : 1,
y: closing ? -20 : 0,
config: { tension: 300, friction: 25 },
})
return (
<animated.g
style={{
opacity: entranceSpring.opacity,
}}
transform={to([entranceSpring.y], (y) => `translate(${targetPos.x}, ${targetPos.y + y})`)}
>
<foreignObject
x={-cellSize * 1.8}
y={-cellSize * 0.5}
width={cellSize * 3.6}
height={cellSize}
style={{ overflow: 'visible' }}
>
<div
style={{
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
color: '#f1f5f9',
padding: `${cellSize * 0.12}px ${cellSize * 0.18}px`,
borderRadius: `${cellSize * 0.12}px`,
fontSize: `${cellSize * 0.16}px`,
fontWeight: 500,
textAlign: 'center',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: `${cellSize * 0.1}px`,
backdropFilter: 'blur(8px)',
letterSpacing: '0.01em',
pointerEvents: 'none',
}}
>
<span
style={{
fontSize: `${cellSize * 0.2}px`,
opacity: 0.7,
}}
>
</span>
<span>No valid relation</span>
</div>
</foreignObject>
</animated.g>
)
}

View File

@@ -1,427 +0,0 @@
'use client'
import * as Tooltip from '@radix-ui/react-tooltip'
import { animated, useSpring } from '@react-spring/web'
import { useEffect, useState } from 'react'
import { getRelationColor, getRelationOperator } from '../../constants/captureRelations'
import type { RelationKind } from '../../types'
import { useCaptureContext } from '../../contexts/CaptureContext'
import { getEffectiveValue } from '../../utils/pieceSetup'
import { getSquarePosition } from '../../utils/boardCoordinates'
interface CaptureRelationOptionsProps {
availableRelations: RelationKind[]
}
/**
* Animated floating capture relation options with number bond preview on hover
*/
export function CaptureRelationOptions({ availableRelations }: CaptureRelationOptionsProps) {
const {
layout,
pieces,
closing,
allPieces,
pyramidFaceValues,
findValidHelpers,
selectRelation,
} = useCaptureContext()
const { targetPos, cellSize, gap, padding } = layout
const { mover: moverPiece, target: targetPiece } = pieces
const [hoveredRelation, setHoveredRelation] = useState<RelationKind | null>(null)
const [currentHelperIndex, setCurrentHelperIndex] = useState(0)
// Get mover value - either from pyramidFaceValues map (for pyramids) or from piece directly
const getMoverValue = (relation: RelationKind): number | null => {
if (pyramidFaceValues && pyramidFaceValues.has(relation)) {
return pyramidFaceValues.get(relation) || null
}
return getEffectiveValue(moverPiece)
}
// Cycle through valid helpers every 1.5 seconds when hovering
useEffect(() => {
if (!hoveredRelation) {
setCurrentHelperIndex(0)
return
}
const moverValue = getMoverValue(hoveredRelation)
const targetValue = getEffectiveValue(targetPiece)
if (
moverValue === undefined ||
moverValue === null ||
targetValue === undefined ||
targetValue === null
) {
return
}
const validHelpers = findValidHelpers(moverValue, targetValue, hoveredRelation)
if (validHelpers.length <= 1) {
// No need to cycle if only one or zero helpers
setCurrentHelperIndex(0)
return
}
// Cycle through helpers every 1.5 seconds
const interval = setInterval(() => {
setCurrentHelperIndex((prev) => (prev + 1) % validHelpers.length)
}, 1500)
return () => clearInterval(interval)
}, [hoveredRelation, pyramidFaceValues, targetPiece, findValidHelpers])
// Generate tooltip text with actual numbers for the currently displayed helper
const getTooltipText = (relation: RelationKind): string => {
if (relation !== hoveredRelation) {
// Not hovered, use generic text
const genericMap: Record<RelationKind, string> = {
EQUAL: 'Equality: a = b',
MULTIPLE: 'Multiple: b is multiple of a',
DIVISOR: 'Divisor: a divides b',
SUM: 'Sum: a + h = b (helper)',
DIFF: 'Difference: |a - h| = b (helper)',
PRODUCT: 'Product: a × h = b (helper)',
RATIO: 'Ratio: a/h = b/h (helper)',
}
return genericMap[relation] || relation
}
const moverValue = getMoverValue(relation)
const targetValue = getEffectiveValue(targetPiece)
if (
moverValue === undefined ||
moverValue === null ||
targetValue === undefined ||
targetValue === null
) {
return relation
}
// Relations that don't need helpers - show equation with just mover and target
const helperRelations: RelationKind[] = ['SUM', 'DIFF', 'PRODUCT', 'RATIO']
const needsHelper = helperRelations.includes(relation)
if (!needsHelper) {
// Generate equation with just mover and target values
switch (relation) {
case 'EQUAL':
return `${moverValue} = ${targetValue}`
case 'MULTIPLE':
return `${targetValue} is multiple of ${moverValue}`
case 'DIVISOR':
return `${moverValue} divides ${targetValue}`
default:
return relation
}
}
// Relations that need helpers
const validHelpers = findValidHelpers(moverValue, targetValue, relation)
if (validHelpers.length === 0) {
return `${relation}: No valid helpers`
}
const currentHelper = validHelpers[currentHelperIndex]
const helperValue = getEffectiveValue(currentHelper)
if (helperValue === undefined || helperValue === null) {
return relation
}
// Generate equation with actual numbers including helper
switch (relation) {
case 'SUM':
return `${moverValue} + ${helperValue} = ${targetValue}`
case 'DIFF':
return `|${moverValue} - ${helperValue}| = ${targetValue}`
case 'PRODUCT':
return `${moverValue} × ${helperValue} = ${targetValue}`
case 'RATIO':
return `${moverValue}/${helperValue} = ${targetValue}/${helperValue}`
default:
return relation
}
}
const allRelations = [
{ relation: 'EQUAL', label: '=', angle: 0, color: '#8b5cf6' },
{
relation: 'MULTIPLE',
label: '×n',
angle: 51.4,
color: '#a855f7',
},
{
relation: 'DIVISOR',
label: '÷',
angle: 102.8,
color: '#c084fc',
},
{
relation: 'SUM',
label: '+',
angle: 154.3,
color: '#3b82f6',
},
{
relation: 'DIFF',
label: '',
angle: 205.7,
color: '#06b6d4',
},
{
relation: 'PRODUCT',
label: '×',
angle: 257.1,
color: '#10b981',
},
{
relation: 'RATIO',
label: '÷÷',
angle: 308.6,
color: '#f59e0b',
},
]
// Filter to only available relations and redistribute angles evenly
const availableRelationDefs = allRelations.filter((r) =>
availableRelations.includes(r.relation as RelationKind)
)
const angleStep = availableRelationDefs.length > 1 ? 360 / availableRelationDefs.length : 0
const relations = availableRelationDefs.map((r, index) => ({
...r,
angle: index * angleStep,
}))
const maxRadius = cellSize * 1.2
const buttonSize = 64
// Animate all buttons simultaneously - reverse animation when closing
const spring = useSpring({
from: { radius: 0, opacity: 0 },
radius: closing ? 0 : maxRadius,
opacity: closing ? 0 : 0.85,
config: { tension: 280, friction: 20 },
})
return (
<Tooltip.Provider delayDuration={0} disableHoverableContent>
<g>
{relations.map(({ relation, label, angle, color }) => {
const rad = (angle * Math.PI) / 180
return (
<animated.g
key={relation}
transform={spring.radius.to(
(r) =>
`translate(${targetPos.x + Math.cos(rad) * r}, ${targetPos.y + Math.sin(rad) * r})`
)}
>
<foreignObject
x={-buttonSize / 2}
y={-buttonSize / 2}
width={buttonSize}
height={buttonSize}
style={{ overflow: 'visible' }}
>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<animated.button
onClick={(e) => {
e.stopPropagation()
selectRelation(relation as RelationKind)
}}
style={{
width: buttonSize,
height: buttonSize,
borderRadius: '50%',
border: '3px solid rgba(255, 255, 255, 0.9)',
backgroundColor: color,
color: 'white',
fontSize: '28px',
fontWeight: 'bold',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: spring.opacity,
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
textShadow: '0 2px 4px rgba(0, 0, 0, 0.5)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.15)'
e.currentTarget.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.4)'
setHoveredRelation(relation as RelationKind)
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'
setHoveredRelation(null)
}}
>
{label}
</animated.button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content asChild sideOffset={8}>
<div
style={{
background: 'rgba(0,0,0,0.95)',
color: 'white',
padding: '8px 16px',
borderRadius: '8px',
fontSize: '14px',
fontWeight: 600,
maxWidth: '240px',
zIndex: 10000,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
pointerEvents: 'none',
}}
>
{getTooltipText(relation as RelationKind)}
<Tooltip.Arrow
style={{
fill: 'rgba(0,0,0,0.95)',
}}
/>
</div>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</foreignObject>
</animated.g>
)
})}
{/* Number bond preview when hovering over a relation - cycle through valid helpers */}
{hoveredRelation &&
(() => {
const moverValue = getMoverValue(hoveredRelation)
const targetValue = getEffectiveValue(targetPiece)
if (
moverValue === undefined ||
moverValue === null ||
targetValue === undefined ||
targetValue === null
) {
return null
}
const validHelpers = findValidHelpers(moverValue, targetValue, hoveredRelation)
if (validHelpers.length === 0) {
return null
}
// Show only the current helper
const currentHelper = validHelpers[currentHelperIndex]
const color = getRelationColor(hoveredRelation)
const operator = getRelationOperator(hoveredRelation)
// Calculate piece positions on board
const layout = { cellSize, gap, padding }
const moverPos = getSquarePosition(moverPiece.square, layout)
const targetBoardPos = getSquarePosition(targetPiece.square, layout)
const helperPos = getSquarePosition(currentHelper.square, layout)
return (
<g key={currentHelper.id}>
{/* Triangle connecting lines */}
<g opacity={0.5}>
<line
x1={moverPos.x}
y1={moverPos.y}
x2={helperPos.x}
y2={helperPos.y}
stroke={color}
strokeWidth={4}
/>
<line
x1={moverPos.x}
y1={moverPos.y}
x2={targetBoardPos.x}
y2={targetBoardPos.y}
stroke={color}
strokeWidth={4}
/>
<line
x1={helperPos.x}
y1={helperPos.y}
x2={targetBoardPos.x}
y2={targetBoardPos.y}
stroke={color}
strokeWidth={4}
/>
</g>
{/* Operator symbol - smart placement to avoid collinear collapse */}
{(() => {
// Calculate center of triangle
const centerX = (moverPos.x + helperPos.x + targetBoardPos.x) / 3
const centerY = (moverPos.y + helperPos.y + targetBoardPos.y) / 3
// Check if pieces are nearly collinear using cross product
// Vector from mover to helper
const v1x = helperPos.x - moverPos.x
const v1y = helperPos.y - moverPos.y
// Vector from mover to target
const v2x = targetBoardPos.x - moverPos.x
const v2y = targetBoardPos.y - moverPos.y
// Cross product magnitude (2D)
const crossProduct = Math.abs(v1x * v2y - v1y * v2x)
// If cross product is small, pieces are nearly collinear
const minTriangleArea = cellSize * cellSize * 0.5 // Minimum triangle area threshold
const isCollinear = crossProduct < minTriangleArea
let operatorX = centerX
let operatorY = centerY
if (isCollinear) {
// Find the line connecting the three points (use mover to target as reference)
const lineLength = Math.sqrt(v2x * v2x + v2y * v2y)
if (lineLength > 0) {
// Perpendicular direction (rotate 90 degrees)
const perpX = -v2y / lineLength
const perpY = v2x / lineLength
// Offset operator perpendicular to the line
const offsetDistance = cellSize * 0.8
operatorX = centerX + perpX * offsetDistance
operatorY = centerY + perpY * offsetDistance
}
}
return (
<text
x={operatorX}
y={operatorY}
textAnchor="middle"
dominantBaseline="central"
fill={color}
fontSize={cellSize * 0.8}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
opacity={0.9}
>
{operator}
</text>
)
})()}
</g>
)
})()}
</g>
</Tooltip.Provider>
)
}

View File

@@ -1,162 +0,0 @@
'use client'
import { useState } from 'react'
import { useAbacusSettings } from '@/hooks/useAbacusSettings'
import { useCaptureContext } from '../../contexts/CaptureContext'
import { getRelationColor, getRelationOperator } from '../../constants/captureRelations'
import type { Piece } from '../../types'
import { AnimatedHelperPiece } from './AnimatedHelperPiece'
interface HelperSelectionOptionsProps {
helpers: Array<{ piece: Piece; boardPos: { x: number; y: number } }>
}
/**
* Helper piece selection - pieces fly from board to selection ring
* Hovering over a helper shows a preview of the number bond
*/
export function HelperSelectionOptions({ helpers }: HelperSelectionOptionsProps) {
const { layout, pieces, selectedRelation, closing, selectHelper } = useCaptureContext()
const { targetPos, cellSize, gap, padding } = layout
const { mover: moverPiece, target: targetPiece } = pieces
const relation = selectedRelation!
// Get abacus settings
const { data: abacusSettings } = useAbacusSettings()
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
const [hoveredHelperId, setHoveredHelperId] = useState<string | null>(null)
const maxRadius = cellSize * 1.2
const angleStep = helpers.length > 1 ? 360 / helpers.length : 0
console.log('[HelperSelectionOptions] targetPos:', targetPos)
console.log('[HelperSelectionOptions] cellSize:', cellSize)
console.log('[HelperSelectionOptions] maxRadius:', maxRadius)
console.log('[HelperSelectionOptions] angleStep:', angleStep)
console.log('[HelperSelectionOptions] helpers.length:', helpers.length)
// Find the hovered helper and its ring position
const hoveredHelperData = helpers.find((h) => h.piece.id === hoveredHelperId)
const hoveredHelperIndex = helpers.findIndex((h) => h.piece.id === hoveredHelperId)
let hoveredHelperRingPos = null
if (hoveredHelperIndex !== -1) {
const angle = hoveredHelperIndex * angleStep
const rad = (angle * Math.PI) / 180
hoveredHelperRingPos = {
x: targetPos.x + Math.cos(rad) * maxRadius,
y: targetPos.y + Math.sin(rad) * maxRadius,
}
}
const color = getRelationColor(relation)
const operator = getRelationOperator(relation)
return (
<g>
{helpers.map(({ piece, boardPos }, index) => {
const angle = index * angleStep
const rad = (angle * Math.PI) / 180
// Target position in ring
const ringX = targetPos.x + Math.cos(rad) * maxRadius
const ringY = targetPos.y + Math.sin(rad) * maxRadius
console.log(
`[HelperSelectionOptions] piece ${piece.id} (${piece.square}): index=${index}, angle=${angle}°, boardPos=(${boardPos.x}, ${boardPos.y}), ringPos=(${ringX}, ${ringY})`
)
return (
<AnimatedHelperPiece
key={piece.id}
piece={piece}
boardPos={boardPos}
ringX={ringX}
ringY={ringY}
cellSize={cellSize}
onSelectHelper={selectHelper}
closing={closing}
useNativeAbacusNumbers={useNativeAbacusNumbers}
onHover={setHoveredHelperId}
/>
)
})}
{/* Show number bond preview when hovering over a helper - draw triangle between actual pieces */}
{hoveredHelperData && hoveredHelperRingPos && (
<g>
{(() => {
// Use actual positions of all three pieces
const helperPos = hoveredHelperRingPos // Helper is in the ring
const moverBoardPos = hoveredHelperData.boardPos // Mover is on the board at its current position
const targetBoardPos = targetPos // Target is on the board at capture position
// Calculate positions from square coordinates
const file = moverPiece.square.charCodeAt(0) - 65
const rank = Number.parseInt(moverPiece.square.slice(1), 10)
const row = 8 - rank
const moverPos = {
x: padding + file * (cellSize + gap) + cellSize / 2,
y: padding + row * (cellSize + gap) + cellSize / 2,
}
const targetFile = targetPiece.square.charCodeAt(0) - 65
const targetRank = Number.parseInt(targetPiece.square.slice(1), 10)
const targetRow = 8 - targetRank
const targetBoardPosition = {
x: padding + targetFile * (cellSize + gap) + cellSize / 2,
y: padding + targetRow * (cellSize + gap) + cellSize / 2,
}
return (
<>
{/* Triangle connecting lines between actual piece positions */}
<g opacity={0.5}>
<line
x1={moverPos.x}
y1={moverPos.y}
x2={helperPos.x}
y2={helperPos.y}
stroke={color}
strokeWidth={4}
/>
<line
x1={moverPos.x}
y1={moverPos.y}
x2={targetBoardPosition.x}
y2={targetBoardPosition.y}
stroke={color}
strokeWidth={4}
/>
<line
x1={helperPos.x}
y1={helperPos.y}
x2={targetBoardPosition.x}
y2={targetBoardPosition.y}
stroke={color}
strokeWidth={4}
/>
</g>
{/* Operator symbol in center of triangle */}
<text
x={(moverPos.x + helperPos.x + targetBoardPosition.x) / 3}
y={(moverPos.y + helperPos.y + targetBoardPosition.y) / 3}
textAnchor="middle"
dominantBaseline="central"
fill={color}
fontSize={cellSize * 0.8}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
opacity={0.9}
>
{operator}
</text>
{/* No cloned pieces - using actual pieces already on board/ring */}
</>
)
})()}
</g>
)}
</g>
)
}

View File

@@ -1,205 +0,0 @@
'use client'
import { animated, to, useSpring } from '@react-spring/web'
import { useEffect, useState } from 'react'
import { useAbacusSettings } from '@/hooks/useAbacusSettings'
import { useCaptureContext } from '../../contexts/CaptureContext'
import { getRelationColor, getRelationOperator } from '../../constants/captureRelations'
import { getEffectiveValue } from '../../utils/pieceSetup'
import { getSquarePosition } from '../../utils/boardCoordinates'
import { PieceRenderer } from '../PieceRenderer'
interface NumberBondVisualizationProps {
onConfirm: () => void
moverStartPos: { x: number; y: number }
helperStartPos: { x: number; y: number }
}
/**
* Number Bond Visualization - uses actual piece positions for smooth rotation/collapse
* Pieces start at their actual positions (mover on board, helper in ring, target on board)
* Animation: Rotate and collapse to target position, only mover remains
*/
export function NumberBondVisualization({
onConfirm,
moverStartPos,
helperStartPos,
}: NumberBondVisualizationProps) {
const { layout, pieces, selectedRelation, closing } = useCaptureContext()
const { targetPos, cellSize, padding, gap } = layout
const { mover: moverPiece, target: targetPiece, helper: helperPiece } = pieces
const relation = selectedRelation!
// Get abacus settings
const { data: abacusSettings } = useAbacusSettings()
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
const autoAnimate = true
const [animating, setAnimating] = useState(false)
// Auto-trigger animation immediately when component mounts (after helper selection)
useEffect(() => {
if (!autoAnimate) return
const timer = setTimeout(() => {
setAnimating(true)
}, 300) // Short delay to show the triangle briefly
return () => clearTimeout(timer)
}, [autoAnimate])
const color = getRelationColor(relation)
const operator = getRelationOperator(relation)
// Calculate actual board position for target
const targetBoardPos = getSquarePosition(targetPiece.square, { cellSize, gap, padding })
// Animation: Rotate and collapse from actual positions to target
const captureAnimation = useSpring({
from: { rotation: 0, progress: 0, opacity: 1 },
rotation: animating ? Math.PI * 20 : 0, // 10 full rotations
progress: animating ? 1 : 0, // 0 = at start positions, 1 = at target position
opacity: animating ? 0 : 1,
config: animating ? { duration: 2500 } : { tension: 280, friction: 20 },
onRest: () => {
if (animating) {
onConfirm()
}
},
})
// Type guard - this component should only be rendered when helper is selected
// Must be after all hooks to follow Rules of Hooks
if (!helperPiece) {
return null
}
// Get piece values
const getMoverValue = () => getEffectiveValue(moverPiece)
const getHelperValue = () => getEffectiveValue(helperPiece)
const getTargetValue = () => getEffectiveValue(targetPiece)
return (
<g>
{/* Triangle connecting lines between actual piece positions - fade during animation */}
<animated.g opacity={to([captureAnimation.opacity], (op) => (animating ? op * 0.5 : 0.5))}>
<line
x1={moverStartPos.x}
y1={moverStartPos.y}
x2={helperStartPos.x}
y2={helperStartPos.y}
stroke={color}
strokeWidth={4}
/>
<line
x1={moverStartPos.x}
y1={moverStartPos.y}
x2={targetBoardPos.x}
y2={targetBoardPos.y}
stroke={color}
strokeWidth={4}
/>
<line
x1={helperStartPos.x}
y1={helperStartPos.y}
x2={targetBoardPos.x}
y2={targetBoardPos.y}
stroke={color}
strokeWidth={4}
/>
</animated.g>
{/* Operator symbol in center of triangle - fade during animation */}
<animated.text
x={(moverStartPos.x + helperStartPos.x + targetBoardPos.x) / 3}
y={(moverStartPos.y + helperStartPos.y + targetBoardPos.y) / 3}
textAnchor="middle"
dominantBaseline="central"
fill={color}
fontSize={cellSize * 0.8}
fontWeight="900"
fontFamily="Georgia, 'Times New Roman', serif"
opacity={to([captureAnimation.opacity], (op) => (animating ? op * 0.9 : 0.9))}
>
{operator}
</animated.text>
{/* Mover piece - starts at board position, spirals to target, STAYS VISIBLE */}
<animated.g
transform={to([captureAnimation.rotation, captureAnimation.progress], (rot, prog) => {
// Interpolate from start position to target position
const x = moverStartPos.x + (targetBoardPos.x - moverStartPos.x) * prog
const y = moverStartPos.y + (targetBoardPos.y - moverStartPos.y) * prog
// Add spiral rotation around the interpolated center
const spiralRadius = (1 - prog) * cellSize * 0.5
const spiralX = x + Math.cos(rot) * spiralRadius
const spiralY = y + Math.sin(rot) * spiralRadius
return `translate(${spiralX}, ${spiralY})`
})}
opacity={1} // Mover stays fully visible
>
<g transform={`translate(${-cellSize / 2}, ${-cellSize / 2})`}>
<PieceRenderer
type={moverPiece.type}
color={moverPiece.color}
value={getMoverValue() || 0}
size={cellSize}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</g>
</animated.g>
{/* Helper piece - starts in ring, spirals to target, FADES OUT */}
<animated.g
transform={to([captureAnimation.rotation, captureAnimation.progress], (rot, prog) => {
const x = helperStartPos.x + (targetBoardPos.x - helperStartPos.x) * prog
const y = helperStartPos.y + (targetBoardPos.y - helperStartPos.y) * prog
const spiralRadius = (1 - prog) * cellSize * 0.5
const angle = rot + (Math.PI * 2) / 3 // Offset by 120°
const spiralX = x + Math.cos(angle) * spiralRadius
const spiralY = y + Math.sin(angle) * spiralRadius
return `translate(${spiralX}, ${spiralY})`
})}
opacity={to([captureAnimation.opacity], (op) => (animating ? op : 1))}
>
<g transform={`translate(${-cellSize / 2}, ${-cellSize / 2})`}>
<PieceRenderer
type={helperPiece.type}
color={helperPiece.color}
value={getHelperValue() || 0}
size={cellSize}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</g>
</animated.g>
{/* Target piece - stays at board position, spirals in place, FADES OUT */}
<animated.g
transform={to([captureAnimation.rotation, captureAnimation.progress], (rot, prog) => {
const x = targetBoardPos.x
const y = targetBoardPos.y
const spiralRadius = (1 - prog) * cellSize * 0.5
const angle = rot + (Math.PI * 4) / 3 // Offset by 240°
const spiralX = x + Math.cos(angle) * spiralRadius
const spiralY = y + Math.sin(angle) * spiralRadius
return `translate(${spiralX}, ${spiralY})`
})}
opacity={to([captureAnimation.opacity], (op) => (animating ? op : 1))}
>
<g transform={`translate(${-cellSize / 2}, ${-cellSize / 2})`}>
<PieceRenderer
type={targetPiece.type}
useNativeAbacusNumbers={useNativeAbacusNumbers}
color={targetPiece.color}
value={getTargetValue() || 0}
size={cellSize}
/>
</g>
</animated.g>
</g>
)
}

View File

@@ -1,348 +0,0 @@
import { useTranslations } from 'next-intl'
import { css } from '../../../../../styled-system/css'
import { RithmomachiaBoard, type ExamplePiece } from '../RithmomachiaBoard'
/**
* Helper to convert square names to crop area coordinates
* @param topLeft - e.g. 'D3'
* @param bottomRight - e.g. 'H6'
*/
function squaresToCropArea(topLeft: string, bottomRight: string) {
const minCol = topLeft.charCodeAt(0) - 65 // A=0
const maxCol = bottomRight.charCodeAt(0) - 65
const maxRow = Number.parseInt(topLeft.slice(1), 10)
const minRow = Number.parseInt(bottomRight.slice(1), 10)
return { minCol, maxCol, minRow, maxRow }
}
export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
const t = useTranslations('rithmomachia.guide')
// Example board positions for captures
const equalityExample: ExamplePiece[] = [
{ square: 'G4', type: 'C', color: 'W', value: 25 }, // White's 25
{ square: 'H4', type: 'C', color: 'B', value: 25 }, // Black's 25 (can be captured)
]
const multipleExample: ExamplePiece[] = [
{ square: 'E5', type: 'S', color: 'W', value: 64 }, // White's 64
{ square: 'F5', type: 'T', color: 'B', value: 16 }, // Black's 16 (can be captured: 64÷16=4)
]
const sumExample: ExamplePiece[] = [
{ square: 'F4', type: 'C', color: 'W', value: 9 }, // White's 9 (attacker)
{ square: 'E5', type: 'T', color: 'W', value: 16 }, // White's 16 (helper)
{ square: 'G4', type: 'C', color: 'B', value: 25 }, // Black's 25 (target: 9+16=25)
]
const differenceExample: ExamplePiece[] = [
{ square: 'F4', type: 'T', color: 'W', value: 30 }, // White's 30 (attacker)
{ square: 'E5', type: 'C', color: 'W', value: 10 }, // White's 10 (helper)
{ square: 'G4', type: 'T', color: 'B', value: 20 }, // Black's 20 (target: 30-10=20)
]
const productExample: ExamplePiece[] = [
{ square: 'F4', type: 'C', color: 'W', value: 5 }, // White's 5 (attacker)
{ square: 'E5', type: 'C', color: 'W', value: 5 }, // White's 5 (helper)
{ square: 'G4', type: 'C', color: 'B', value: 25 }, // Black's 25 (target: 5×5=25)
]
const ratioExample: ExamplePiece[] = [
{ square: 'F4', type: 'T', color: 'W', value: 20 }, // White's 20 (attacker)
{ square: 'E5', type: 'C', color: 'W', value: 4 }, // White's 4 (helper)
{ square: 'G4', type: 'C', color: 'B', value: 5 }, // Black's 5 (target: 20÷4=5)
]
return (
<div data-section="capture">
<h3
className={css({
fontSize: { base: '20px', md: '24px' },
fontWeight: 'bold',
color: '#7c2d12',
mb: '16px',
})}
>
{t('capture.title')}
</h3>
<p className={css({ fontSize: '15px', lineHeight: '1.6', mb: '24px', color: '#374151' })}>
{t('capture.description')}
</p>
<h4
className={css({
fontSize: '18px',
fontWeight: 'bold',
color: '#111827',
mb: '12px',
mt: '20px',
})}
>
{t('capture.simpleTitle')}
</h4>
{/* Equality */}
<div
className={css({
mb: '20px',
p: '16px',
bg: '#f9fafb',
borderRadius: '8px',
border: '2px solid #e5e7eb',
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('capture.equality')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('capture.equalityExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
pieces={equalityExample}
scale={0.4}
cropArea={squaresToCropArea('F5', 'I3')}
highlightSquares={['G4', 'H4']}
showLabels={true}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</div>
<p
className={css({
fontSize: '12px',
color: '#9ca3af',
mt: '8px',
textAlign: 'center',
fontStyle: 'italic',
})}
>
{t('capture.equalityCaption')}
</p>
</div>
{/* Multiple/Divisor */}
<div
className={css({
mb: '20px',
p: '16px',
bg: '#f9fafb',
borderRadius: '8px',
border: '2px solid #e5e7eb',
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('capture.multiple')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('capture.multipleExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
pieces={multipleExample}
scale={0.4}
cropArea={squaresToCropArea('D6', 'G4')}
highlightSquares={['E5', 'F5']}
showLabels={true}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</div>
<p
className={css({
fontSize: '12px',
color: '#9ca3af',
mt: '8px',
textAlign: 'center',
fontStyle: 'italic',
})}
>
{t('capture.multipleCaption')}
</p>
</div>
<h4
className={css({
fontSize: '18px',
fontWeight: 'bold',
color: '#111827',
mb: '12px',
mt: '24px',
})}
>
{t('capture.advancedTitle')}
</h4>
{/* Sum */}
<div
className={css({
mb: '20px',
p: '16px',
bg: '#f9fafb',
borderRadius: '8px',
border: '2px solid #e5e7eb',
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('capture.sum')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('capture.sumExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
pieces={sumExample}
scale={0.4}
cropArea={squaresToCropArea('D6', 'H3')}
highlightSquares={['F4', 'E5', 'G4']}
showLabels={true}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</div>
<p
className={css({
fontSize: '12px',
color: '#9ca3af',
mt: '8px',
textAlign: 'center',
fontStyle: 'italic',
})}
>
{t('capture.sumCaption')}
</p>
</div>
{/* Difference */}
<div
className={css({
mb: '20px',
p: '16px',
bg: '#f9fafb',
borderRadius: '8px',
border: '2px solid #e5e7eb',
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('capture.difference')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('capture.differenceExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
pieces={differenceExample}
scale={0.4}
cropArea={squaresToCropArea('D6', 'H3')}
highlightSquares={['F4', 'E5', 'G4']}
showLabels={true}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</div>
<p
className={css({
fontSize: '12px',
color: '#9ca3af',
mt: '8px',
textAlign: 'center',
fontStyle: 'italic',
})}
>
{t('capture.differenceCaption')}
</p>
</div>
{/* Product */}
<div
className={css({
mb: '20px',
p: '16px',
bg: '#f9fafb',
borderRadius: '8px',
border: '2px solid #e5e7eb',
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('capture.product')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('capture.productExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
pieces={productExample}
scale={0.4}
cropArea={squaresToCropArea('D6', 'H3')}
highlightSquares={['F4', 'E5', 'G4']}
showLabels={true}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</div>
<p
className={css({
fontSize: '12px',
color: '#9ca3af',
mt: '8px',
textAlign: 'center',
fontStyle: 'italic',
})}
>
{t('capture.productCaption')}
</p>
</div>
{/* Ratio */}
<div
className={css({
mb: '20px',
p: '16px',
bg: '#f9fafb',
borderRadius: '8px',
border: '2px solid #e5e7eb',
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('capture.ratio')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('capture.ratioExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
pieces={ratioExample}
scale={0.4}
cropArea={squaresToCropArea('D6', 'H3')}
highlightSquares={['F4', 'E5', 'G4']}
showLabels={true}
useNativeAbacusNumbers={useNativeAbacusNumbers}
/>
</div>
<p
className={css({
fontSize: '12px',
color: '#9ca3af',
mt: '8px',
textAlign: 'center',
fontStyle: 'italic',
})}
>
{t('capture.ratioCaption')}
</p>
</div>
<div
className={css({
mt: '24px',
p: '16px',
bg: 'rgba(59, 130, 246, 0.1)',
borderLeft: '4px solid #3b82f6',
borderRadius: '4px',
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#1e40af', mb: '8px' })}>
{t('capture.helpersTitle')}
</p>
<p className={css({ fontSize: '14px', color: '#1e3a8a', lineHeight: '1.6' })}>
{t('capture.helpersDescription')}
</p>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More