Compare commits
6 Commits
abacus-rea
...
v4.68.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde7ca39cc | ||
|
|
f637ddfdb8 | ||
|
|
128da7f3d2 | ||
|
|
5bd0dadfdf | ||
|
|
755487c42d | ||
|
|
e5b58c844c |
@@ -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
|
||||
16
.mcp.json
16
.mcp.json
@@ -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
14468
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
102
Dockerfile
102
Dockerfile
@@ -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"]
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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! 🚀
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.*
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Binary file not shown.
@@ -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 |
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ✅
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { rithmomachiaGame } from '@/arcade-games/rithmomachia'
|
||||
|
||||
const { Provider, GameComponent } = rithmomachiaGame
|
||||
|
||||
export default function RithmomachiaPage() {
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -45,13 +45,3 @@ body {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
@@ -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!
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 `1–4`
|
||||
* **Black half:** Rows `5–8`
|
||||
|
||||
---
|
||||
|
||||
## 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 (2–9) for tactical infiltration, sparse placement
|
||||
* **Central battlefield (E–L)**: 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 5–8, Black in rows 1–4) so their **values stand in a classical proportion**.
|
||||
|
||||
### 7.1 Three types of harmony (three-piece structure: A–M–B)
|
||||
|
||||
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 A–M–B
|
||||
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 6–8–12 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
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user