Compare commits
4 Commits
codex/inte
...
v4.67.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fab490ffea | ||
|
|
8b4dacdc98 | ||
|
|
28fc0a14be | ||
|
|
fffaf1df1d |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6552
CHANGELOG.md
6552
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
32
Dockerfile
32
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,32 +44,12 @@ 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
|
||||
FROM node:18-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install build tools temporarily for better-sqlite3 installation
|
||||
RUN apk add --no-cache python3 py3-setuptools make g++
|
||||
|
||||
# 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
|
||||
|
||||
# Production image
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install ONLY runtime dependencies (no build tools needed)
|
||||
RUN apk add --no-cache python3 py3-pip typst qpdf
|
||||
# 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
|
||||
@@ -89,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
|
||||
|
||||
@@ -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.**
|
||||
@@ -324,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,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,49 +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(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;
|
||||
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,7 +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-spring/web": "^10.0.2",
|
||||
"@soroban/abacus-react": "workspace:*",
|
||||
"@soroban/core": "workspace:*",
|
||||
"@soroban/templates": "workspace:*",
|
||||
@@ -60,23 +60,17 @@
|
||||
"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",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"y-protocols": "^1.0.6",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.27",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -92,7 +86,6 @@
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"concurrently": "^8.0.0",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 ✅
|
||||
})
|
||||
})
|
||||
@@ -34,7 +34,7 @@ const MOMENTUM_DECAY_RATES = {
|
||||
|
||||
const MOMENTUM_GAIN_PER_CORRECT = 15 // Momentum added for each correct answer
|
||||
const SPEED_MULTIPLIER = 0.15 // Convert momentum to speed (% per second at momentum=100)
|
||||
const UPDATE_INTERVAL = 50 // Update every 50ms (~20 fps)
|
||||
const UPDATE_INTERVAL = 16 // Update every 16ms (~60 fps for smooth animation)
|
||||
const GAME_DURATION = 60000 // 60 seconds in milliseconds
|
||||
|
||||
export function useSteamJourney() {
|
||||
@@ -45,7 +45,6 @@ export function useSteamJourney() {
|
||||
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
|
||||
const missedPassengersRef = useRef<Set<string>>(new Set()) // Track which passengers have been logged as missed
|
||||
const pendingBoardingRef = useRef<Set<string>>(new Set()) // Track passengers with pending boarding requests across frames
|
||||
const pendingDeliveryRef = useRef<Set<string>>(new Set()) // Track passengers with pending delivery requests across frames
|
||||
const previousTrainPositionRef = useRef<number>(0) // Track previous position to detect threshold crossings
|
||||
|
||||
// Initialize game start time
|
||||
@@ -66,10 +65,9 @@ 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 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 set if they've been claimed or delivered
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
|
||||
pendingBoardingRef.current.delete(passenger.id)
|
||||
@@ -77,10 +75,9 @@ export function useSteamJourney() {
|
||||
})
|
||||
}, [state.passengers])
|
||||
|
||||
// Clear all pending boarding and delivery requests when route changes
|
||||
// Clear all pending boarding requests when route changes
|
||||
useEffect(() => {
|
||||
pendingBoardingRef.current.clear()
|
||||
pendingDeliveryRef.current.clear()
|
||||
missedPassengersRef.current.clear()
|
||||
previousTrainPositionRef.current = 0 // Reset previous position for new route
|
||||
}, [state.currentRoute])
|
||||
@@ -162,9 +159,6 @@ export function useSteamJourney() {
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
|
||||
|
||||
// Skip if already has a pending delivery request
|
||||
if (pendingDeliveryRef.current.has(passenger.id)) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
@@ -178,10 +172,6 @@ export function useSteamJourney() {
|
||||
console.log(
|
||||
`🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
|
||||
)
|
||||
|
||||
// Mark as pending BEFORE dispatch to prevent duplicate delivery attempts across frames
|
||||
pendingDeliveryRef.current.add(passenger.id)
|
||||
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
|
||||
@@ -104,7 +104,9 @@ export function useTrackManagement({
|
||||
setDisplayPassengers(passengers)
|
||||
}
|
||||
// Otherwise, keep displaying old passengers until train resets
|
||||
}, [passengers, displayPassengers, trainPosition, currentRoute])
|
||||
// Note: displayPassengers is intentionally NOT in deps to avoid infinite loop
|
||||
// (it's used for comparison, but we don't need to re-run when it changes)
|
||||
}, [passengers, trainPosition, currentRoute])
|
||||
|
||||
// Generate ties and rails when path is ready
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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,8 +1,6 @@
|
||||
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 = {
|
||||
title: 'Soroban Flashcard Generator',
|
||||
@@ -17,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>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { HeroAbacus } from '@/components/HeroAbacus'
|
||||
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
|
||||
@@ -75,7 +74,6 @@ function MiniAbacus({
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const t = useTranslations('home')
|
||||
const [selectedSkillIndex, setSelectedSkillIndex] = useState(1) // Default to "Friends techniques"
|
||||
const fullTutorial = getTutorialForEditor()
|
||||
|
||||
@@ -85,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),
|
||||
},
|
||||
]
|
||||
@@ -135,10 +133,10 @@ export default function HomePage() {
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('learnByDoing.title')}
|
||||
Learn by Doing
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
{t('learnByDoing.subtitle')}
|
||||
Interactive tutorials teach you step-by-step. Try this example right now:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -199,39 +197,39 @@ export default function HomePage() {
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
{t('whatYouLearn.title')}
|
||||
What You'll Learn
|
||||
</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'),
|
||||
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: t('skills.friends.title'),
|
||||
desc: t('skills.friends.desc'),
|
||||
example: t('skills.friends.example'),
|
||||
badge: t('skills.friends.badge'),
|
||||
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: t('skills.multiply.title'),
|
||||
desc: t('skills.multiply.desc'),
|
||||
example: t('skills.multiply.example'),
|
||||
badge: t('skills.multiply.badge'),
|
||||
title: '✖️ Multiply & divide',
|
||||
desc: 'Fluent multi-digit calculations with advanced techniques',
|
||||
example: '12×34',
|
||||
badge: 'Advanced',
|
||||
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'),
|
||||
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,
|
||||
},
|
||||
@@ -368,17 +366,20 @@ export default function HomePage() {
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('arcade.title')}
|
||||
The Arcade
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md' })}>{t('arcade.subtitle')}</p>
|
||||
<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
|
||||
? t('arcade.soloChallenge')
|
||||
: t('arcade.playersCount', { min: 1, max: game.manifest.maxPlayers })
|
||||
? 'Solo challenge'
|
||||
: `1-${game.manifest.maxPlayers} players`
|
||||
return (
|
||||
<GameCard
|
||||
key={game.manifest.name}
|
||||
@@ -406,9 +407,11 @@ export default function HomePage() {
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('journey.title')}
|
||||
Your Journey
|
||||
</h2>
|
||||
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>{t('journey.subtitle')}</p>
|
||||
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>
|
||||
Progress from beginner to master
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LevelSliderDisplay />
|
||||
@@ -425,10 +428,10 @@ export default function HomePage() {
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('flashcards.title')}
|
||||
Create Custom Flashcards
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
{t('flashcards.subtitle')}
|
||||
Design beautiful flashcards for learning and practice
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -454,19 +457,19 @@ export default function HomePage() {
|
||||
<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: '📄',
|
||||
title: 'Multiple Formats',
|
||||
desc: 'PDF, PNG, SVG, HTML',
|
||||
},
|
||||
{
|
||||
icon: t('flashcards.features.customizable.icon'),
|
||||
title: t('flashcards.features.customizable.title'),
|
||||
desc: t('flashcards.features.customizable.desc'),
|
||||
icon: '🎨',
|
||||
title: 'Customizable',
|
||||
desc: 'Bead shapes, colors, layouts',
|
||||
},
|
||||
{
|
||||
icon: t('flashcards.features.paperSizes.icon'),
|
||||
title: t('flashcards.features.paperSizes.title'),
|
||||
desc: t('flashcards.features.paperSizes.desc'),
|
||||
icon: '📐',
|
||||
title: 'All Paper Sizes',
|
||||
desc: 'A3, A4, A5, US Letter',
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
@@ -516,7 +519,7 @@ export default function HomePage() {
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{t('flashcards.cta')}</span>
|
||||
<span>Create Flashcards</span>
|
||||
<span>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
const MOMENTUM_GAIN_PER_CORRECT = 15
|
||||
const MOMENTUM_LOSS_PER_WRONG = 10
|
||||
const SPEED_MULTIPLIER = 0.15 // momentum * 0.15 = % per second
|
||||
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
|
||||
const UPDATE_INTERVAL = 16 // 16ms = ~60fps for smooth animation
|
||||
|
||||
// Transform multiplayer state to look like single-player state
|
||||
const compatibleState = useMemo((): CompatibleGameState => {
|
||||
@@ -641,7 +641,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
console.log('[POS_BROADCAST] Starting position broadcast interval')
|
||||
|
||||
// Send position update every 100ms for smoother ghost trains (reads from refs to avoid restarting interval)
|
||||
// Send position update every 16ms (~60fps) for smoother ghost trains (reads from refs to avoid restarting interval)
|
||||
const interval = setInterval(() => {
|
||||
const currentPos = clientPositionRef.current
|
||||
broadcastCountRef.current++
|
||||
@@ -664,7 +664,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
userId: viewerId || '',
|
||||
data: { position: currentPos },
|
||||
} as ComplementRaceMove)
|
||||
}, 100)
|
||||
}, 16)
|
||||
|
||||
return () => {
|
||||
console.log(`[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`)
|
||||
|
||||
@@ -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,934 +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 mover value
|
||||
let moverValue: number
|
||||
if (mover.type === 'P') {
|
||||
if (!pyramidFaceUsed) {
|
||||
return { valid: false, error: 'Pyramid must choose a face for capture' }
|
||||
}
|
||||
// Validate face is valid
|
||||
if (!mover.pyramidFaces?.some((f) => f === pyramidFaceUsed)) {
|
||||
return { valid: false, error: 'Invalid pyramid face' }
|
||||
}
|
||||
moverValue = pyramidFaceUsed
|
||||
} else {
|
||||
moverValue = mover.value!
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Check the relation
|
||||
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,324 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}: PieceRendererProps) {
|
||||
const isDark = color === 'B'
|
||||
const { config } = useAbacusDisplay()
|
||||
|
||||
// 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()}
|
||||
|
||||
{/* Pyramids don't show numbers */}
|
||||
{type !== 'P' &&
|
||||
(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>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { useAbacusSettings } from '@/hooks/useAbacusSettings'
|
||||
import { OverviewSection } from './guide-sections/OverviewSection'
|
||||
import { PiecesSection } from './guide-sections/PiecesSection'
|
||||
import { CaptureSection } from './guide-sections/CaptureSection'
|
||||
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
|
||||
}
|
||||
|
||||
type Section = 'overview' | 'pieces' | 'capture' | 'strategy' | 'harmony' | 'victory'
|
||||
|
||||
export function PlayingGuideModal({ isOpen, onClose, standalone = false }: PlayingGuideModalProps) {
|
||||
const t = useTranslations('rithmomachia.guide')
|
||||
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: 800, 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)
|
||||
|
||||
// Center modal on mount (not in standalone mode)
|
||||
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])
|
||||
|
||||
// Handle dragging
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (window.innerWidth < 768 || standalone) return // No dragging on mobile or standalone
|
||||
setIsDragging(true)
|
||||
setDragStart({
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle resize start
|
||||
const handleResizeStart = (e: React.MouseEvent, direction: string) => {
|
||||
if (window.innerWidth < 768 || standalone) return
|
||||
e.stopPropagation()
|
||||
setIsResizing(true)
|
||||
setResizeDirection(direction)
|
||||
setDragStart({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Mouse move effect for dragging and resizing
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
setPosition({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
})
|
||||
} else if (isResizing) {
|
||||
const deltaX = e.clientX - dragStart.x
|
||||
const deltaY = e.clientY - dragStart.y
|
||||
|
||||
setSize((prev) => {
|
||||
let newWidth = prev.width
|
||||
let newHeight = prev.height
|
||||
let newX = position.x
|
||||
let newY = position.y
|
||||
|
||||
// Handle different resize directions
|
||||
if (resizeDirection.includes('e')) {
|
||||
newWidth = Math.max(450, Math.min(window.innerWidth * 0.9, prev.width + deltaX))
|
||||
}
|
||||
if (resizeDirection.includes('w')) {
|
||||
const desiredWidth = prev.width - deltaX
|
||||
newWidth = Math.max(450, Math.min(window.innerWidth * 0.9, desiredWidth))
|
||||
const widthDiff = newWidth - prev.width
|
||||
newX = position.x - widthDiff
|
||||
}
|
||||
if (resizeDirection.includes('s')) {
|
||||
newHeight = Math.max(600, Math.min(window.innerHeight * 0.8, prev.height + deltaY))
|
||||
}
|
||||
if (resizeDirection.includes('n')) {
|
||||
const desiredHeight = prev.height - deltaY
|
||||
newHeight = Math.max(600, Math.min(window.innerHeight * 0.8, desiredHeight))
|
||||
const heightDiff = newHeight - prev.height
|
||||
newY = position.y - heightDiff
|
||||
}
|
||||
|
||||
if (newX !== position.x || newY !== position.y) {
|
||||
setPosition({ x: newX, y: newY })
|
||||
}
|
||||
|
||||
return { width: newWidth, height: newHeight }
|
||||
})
|
||||
|
||||
// Reset dragStart to current mouse position for next delta calculation
|
||||
setDragStart({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
setIsResizing(false)
|
||||
setResizeDirection('')
|
||||
}
|
||||
|
||||
if (isDragging || isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isDragging, isResizing, dragStart, position, resizeDirection])
|
||||
|
||||
if (!isOpen && !standalone) 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: 'harmony', label: t('sections.harmony'), icon: '🎵' },
|
||||
{ id: 'victory', label: t('sections.victory'), icon: '👑' },
|
||||
]
|
||||
|
||||
const renderResizeHandles = () => {
|
||||
if (!isHovered || window.innerWidth < 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 = (
|
||||
<div
|
||||
ref={modalRef}
|
||||
data-component="playing-guide-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
bg: 'white',
|
||||
borderRadius: standalone ? 0 : '12px',
|
||||
boxShadow: standalone ? 'none' : '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
border: standalone ? 'none' : '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
style={{
|
||||
...(standalone
|
||||
? { top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 1 }
|
||||
: {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
zIndex: Z_INDEX.MODAL,
|
||||
}),
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{renderResizeHandles()}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
data-element="modal-header"
|
||||
className={css({
|
||||
bg: '#f9fafb',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
p: '24px',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
})}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
cursor: isDragging
|
||||
? 'grabbing'
|
||||
: !standalone && window.innerWidth >= 768
|
||||
? 'grab'
|
||||
: 'default',
|
||||
}}
|
||||
>
|
||||
{/* Close and utility buttons - top right */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
{/* Bust-out button (only if not already standalone) */}
|
||||
{!standalone && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="bust-out-guide"
|
||||
onClick={handleBustOut}
|
||||
className={css({
|
||||
bg: '#e5e7eb',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
transition: 'background 0.2s',
|
||||
_hover: { bg: '#d1d5db' },
|
||||
})}
|
||||
title={t('bustOut')}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-guide"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
bg: '#e5e7eb',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
transition: 'background 0.2s',
|
||||
_hover: { bg: '#d1d5db' },
|
||||
})}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Centered title and subtitle */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '24px', md: '28px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
mb: '8px',
|
||||
})}
|
||||
>
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: '14px', md: '16px' },
|
||||
color: '#6b7280',
|
||||
mb: '16px',
|
||||
})}
|
||||
>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div
|
||||
data-element="guide-nav"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
borderBottom: '2px solid #e5e7eb',
|
||||
bg: '#f9fafb',
|
||||
overflow: 'auto',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
data-action={`navigate-${section.id}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
minWidth: 'fit-content',
|
||||
p: { base: '12px 16px', md: '14px 20px' },
|
||||
fontSize: { base: '13px', md: '14px' },
|
||||
fontWeight: activeSection === section.id ? 'bold' : '500',
|
||||
color: activeSection === section.id ? '#7c2d12' : '#6b7280',
|
||||
bg: activeSection === section.id ? 'white' : 'transparent',
|
||||
borderBottom: '3px solid',
|
||||
borderBottomColor: activeSection === section.id ? '#7c2d12' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
border: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
_hover: {
|
||||
bg: activeSection === section.id ? 'white' : '#f3f4f6',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{section.icon}</span>
|
||||
<span>{section.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
data-element="guide-content"
|
||||
className={css({
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
p: '24px',
|
||||
})}
|
||||
>
|
||||
{activeSection === 'overview' && (
|
||||
<OverviewSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'pieces' && (
|
||||
<PiecesSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'capture' && (
|
||||
<CaptureSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'harmony' && (
|
||||
<HarmonySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'victory' && (
|
||||
<VictorySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// If standalone, just render the content without Dialog wrapper
|
||||
if (standalone) {
|
||||
return modalContent
|
||||
}
|
||||
|
||||
// Otherwise, just render the modal (no backdrop so game is visible)
|
||||
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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,588 +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 HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
|
||||
const t = useTranslations('rithmomachia.guide')
|
||||
|
||||
// Example board positions for harmonies (White pieces in Black's territory: rows 5-8)
|
||||
const arithmeticExample: ExamplePiece[] = [
|
||||
{ square: 'E6', type: 'C', color: 'W', value: 6 }, // White's 6
|
||||
{ square: 'F6', type: 'C', color: 'W', value: 9 }, // White's 9 (middle)
|
||||
{ square: 'G6', type: 'T', color: 'W', value: 12 }, // White's 12 (arithmetic: 9 = (6+12)/2)
|
||||
]
|
||||
|
||||
const geometricExample: ExamplePiece[] = [
|
||||
{ square: 'E6', type: 'C', color: 'W', value: 4 }, // White's 4
|
||||
{ square: 'F6', type: 'C', color: 'W', value: 8 }, // White's 8 (middle)
|
||||
{ square: 'G6', type: 'T', color: 'W', value: 16 }, // White's 16 (geometric: 8² = 4×16 = 64)
|
||||
]
|
||||
|
||||
const harmonicExample: ExamplePiece[] = [
|
||||
{ square: 'E6', type: 'C', color: 'W', value: 6 }, // White's 6
|
||||
{ square: 'F6', type: 'C', color: 'W', value: 8 }, // White's 8 (middle)
|
||||
{ square: 'G6', type: 'T', color: 'W', value: 12 }, // White's 12 (harmonic: 2×6×12 = 144 = 8×18)
|
||||
]
|
||||
|
||||
return (
|
||||
<div data-section="harmony">
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '16px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.title')}
|
||||
</h3>
|
||||
<p className={css({ fontSize: '15px', lineHeight: '1.6', mb: '8px', color: '#374151' })}>
|
||||
{t('harmony.intro')}
|
||||
</p>
|
||||
<p className={css({ fontSize: '14px', lineHeight: '1.6', mb: '24px', color: '#6b7280' })}>
|
||||
{t('harmony.introDetail')}
|
||||
</p>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '24px' })}>
|
||||
{/* Arithmetic Progression */}
|
||||
<div
|
||||
className={css({
|
||||
p: '16px',
|
||||
bg: '#f0fdf4',
|
||||
border: '2px solid #86efac',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({ fontSize: '18px', fontWeight: 'bold', color: '#15803d', mb: '8px' })}
|
||||
>
|
||||
{t('harmony.arithmetic')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#166534', mb: '12px', lineHeight: '1.6' })}>
|
||||
{t('harmony.arithmeticDesc')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
p: '12px',
|
||||
borderRadius: '6px',
|
||||
mb: '12px',
|
||||
border: '1px solid #86efac',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
color: '#15803d',
|
||||
mb: '8px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.howToCheck')}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
color: '#166534',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
mb: '12px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.arithmeticFormula')}
|
||||
</p>
|
||||
|
||||
<div className={css({ fontSize: '13px', color: '#166534', lineHeight: '1.8' })}>
|
||||
<p className={css({ fontWeight: 'bold', mb: '4px' })}>
|
||||
{t('harmony.example')} 6, 9, 12
|
||||
</p>
|
||||
<p>
|
||||
{t('harmony.differences')} 9−6=3, 12−9=3 {t('harmony.equal')}
|
||||
</p>
|
||||
<p>{t('harmony.check')} 9×2 = 18 = 6+12 ✓</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ display: 'flex', justifyContent: 'center', mb: '12px' })}>
|
||||
<RithmomachiaBoard
|
||||
pieces={arithmeticExample}
|
||||
scale={0.4}
|
||||
cropArea={squaresToCropArea('D7', 'H5')}
|
||||
highlightSquares={['E6', 'F6', 'G6']}
|
||||
showLabels={true}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: '#166534',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
mb: '12px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.arithmeticCaption')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(21, 128, 61, 0.1)',
|
||||
p: '10px',
|
||||
borderRadius: '6px',
|
||||
borderLeft: '3px solid #15803d',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: '13px', color: '#15803d' })}>
|
||||
<strong>{t('harmony.strategyTip')}</strong> {t('harmony.arithmeticTip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geometric Progression */}
|
||||
<div
|
||||
className={css({
|
||||
p: '16px',
|
||||
bg: '#fef3c7',
|
||||
border: '2px solid #fcd34d',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({ fontSize: '18px', fontWeight: 'bold', color: '#92400e', mb: '8px' })}
|
||||
>
|
||||
{t('harmony.geometric')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#78350f', mb: '12px', lineHeight: '1.6' })}>
|
||||
{t('harmony.geometricDesc')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
p: '12px',
|
||||
borderRadius: '6px',
|
||||
mb: '12px',
|
||||
border: '1px solid #fcd34d',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
mb: '8px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.howToCheck')}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
color: '#78350f',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
mb: '12px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.geometricFormula')}
|
||||
</p>
|
||||
|
||||
<div className={css({ fontSize: '13px', color: '#78350f', lineHeight: '1.8' })}>
|
||||
<p className={css({ fontWeight: 'bold', mb: '4px' })}>
|
||||
{t('harmony.example')} 4, 8, 16
|
||||
</p>
|
||||
<p>
|
||||
{t('harmony.ratios')} 8÷4=2, 16÷8=2 {t('harmony.equal')}
|
||||
</p>
|
||||
<p>{t('harmony.check')} 8² = 64 = 4×16 ✓</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ display: 'flex', justifyContent: 'center', mb: '12px' })}>
|
||||
<RithmomachiaBoard
|
||||
pieces={geometricExample}
|
||||
scale={0.4}
|
||||
cropArea={squaresToCropArea('D7', 'H5')}
|
||||
highlightSquares={['E6', 'F6', 'G6']}
|
||||
showLabels={true}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: '#78350f',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
mb: '12px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.geometricCaption')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(146, 64, 14, 0.1)',
|
||||
p: '10px',
|
||||
borderRadius: '6px',
|
||||
borderLeft: '3px solid #92400e',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: '13px', color: '#92400e' })}>
|
||||
<strong>{t('harmony.strategyTip')}</strong> {t('harmony.geometricTip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Harmonic Progression */}
|
||||
<div
|
||||
className={css({
|
||||
p: '16px',
|
||||
bg: '#dbeafe',
|
||||
border: '2px solid #93c5fd',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({ fontSize: '18px', fontWeight: 'bold', color: '#1e40af', mb: '8px' })}
|
||||
>
|
||||
{t('harmony.harmonic')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#1e3a8a', mb: '12px', lineHeight: '1.6' })}>
|
||||
{t('harmony.harmonicDesc')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
p: '12px',
|
||||
borderRadius: '6px',
|
||||
mb: '12px',
|
||||
border: '1px solid #93c5fd',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1e40af',
|
||||
mb: '8px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.howToCheck')}
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
color: '#1e3a8a',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
mb: '12px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.harmonicFormula')}
|
||||
</p>
|
||||
|
||||
<div className={css({ fontSize: '13px', color: '#1e3a8a', lineHeight: '1.8' })}>
|
||||
<p className={css({ fontWeight: 'bold', mb: '4px' })}>
|
||||
{t('harmony.example')} 6, 8, 12
|
||||
</p>
|
||||
<p>{t('harmony.check')} 2×6×12 = 144 = 8×(6+12) = 8×18 ✓</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ display: 'flex', justifyContent: 'center', mb: '12px' })}>
|
||||
<RithmomachiaBoard
|
||||
pieces={harmonicExample}
|
||||
scale={0.4}
|
||||
cropArea={squaresToCropArea('D7', 'H5')}
|
||||
highlightSquares={['E6', 'F6', 'G6']}
|
||||
showLabels={true}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: '#1e3a8a',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
mb: '12px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.harmonicCaption')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(30, 64, 175, 0.1)',
|
||||
p: '10px',
|
||||
borderRadius: '6px',
|
||||
borderLeft: '3px solid #1e40af',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: '13px', color: '#1e40af' })}>
|
||||
<strong>{t('harmony.strategyTip')}</strong> {t('harmony.harmonicTip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules Section */}
|
||||
<div
|
||||
className={css({
|
||||
mt: '24px',
|
||||
p: '16px',
|
||||
bg: 'rgba(239, 68, 68, 0.1)',
|
||||
borderLeft: '4px solid #ef4444',
|
||||
borderRadius: '4px',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: '16px', fontWeight: 'bold', color: '#991b1b', mb: '12px' })}>
|
||||
{t('harmony.rulesTitle')}
|
||||
</p>
|
||||
<ul className={css({ fontSize: '14px', color: '#7f1d1d', lineHeight: '1.8', pl: '20px' })}>
|
||||
<li>
|
||||
<strong>{t('harmony.enemyTerritoryTitle')}</strong> {t('harmony.enemyTerritory')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{t('harmony.straightLineTitle')}</strong> {t('harmony.straightLine')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{t('harmony.adjacentTitle')}</strong> {t('harmony.adjacent')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{t('harmony.survivalTitle')}</strong> {t('harmony.survival')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{t('harmony.victoryTitle')}</strong> {t('harmony.victoryRule')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Strategy Section */}
|
||||
<div className={css({ mt: '24px' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '18px', md: '20px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '16px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.strategyTitle')}
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '16px' })}>
|
||||
{/* Start with 2, Add the Third */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
|
||||
>
|
||||
{t('harmony.startWith2Title')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
|
||||
{t('harmony.startWith2')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Use Common Values */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
|
||||
>
|
||||
{t('harmony.useCommonTitle')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
|
||||
{t('harmony.useCommon')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Protect the Line */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
|
||||
>
|
||||
{t('harmony.protectTitle')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
|
||||
{t('harmony.protect')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Block Opponent's Harmonies */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
|
||||
>
|
||||
{t('harmony.blockTitle')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
|
||||
{t('harmony.block')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Calculate Before You Declare */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
|
||||
>
|
||||
{t('harmony.calculateTitle')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
|
||||
{t('harmony.calculate')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Reference Tables */}
|
||||
<div className={css({ mt: '24px' })}>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '18px', md: '20px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '16px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.quickRefTitle')}
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(3, 1fr)' },
|
||||
gap: '16px',
|
||||
})}
|
||||
>
|
||||
{/* Arithmetic Table */}
|
||||
<div
|
||||
className={css({
|
||||
p: '12px',
|
||||
bg: '#f0fdf4',
|
||||
border: '1px solid #86efac',
|
||||
borderRadius: '6px',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#15803d',
|
||||
mb: '8px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.arithmetic')}
|
||||
</h4>
|
||||
<ul
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
color: '#166534',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '1.6',
|
||||
pl: '0',
|
||||
listStyle: 'none',
|
||||
})}
|
||||
>
|
||||
<li>(4, 6, 8)</li>
|
||||
<li>(5, 7, 9)</li>
|
||||
<li>(6, 9, 12)</li>
|
||||
<li>(8, 12, 16)</li>
|
||||
<li>(10, 15, 20)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Geometric Table */}
|
||||
<div
|
||||
className={css({
|
||||
p: '12px',
|
||||
bg: '#fef3c7',
|
||||
border: '1px solid #fcd34d',
|
||||
borderRadius: '6px',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
mb: '8px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.geometric')}
|
||||
</h4>
|
||||
<ul
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
color: '#78350f',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '1.6',
|
||||
pl: '0',
|
||||
listStyle: 'none',
|
||||
})}
|
||||
>
|
||||
<li>(2, 8, 32)</li>
|
||||
<li>(3, 9, 27)</li>
|
||||
<li>(4, 8, 16)</li>
|
||||
<li>(4, 16, 64)</li>
|
||||
<li>(5, 25, 125)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Harmonic Table */}
|
||||
<div
|
||||
className={css({
|
||||
p: '12px',
|
||||
bg: '#dbeafe',
|
||||
border: '1px solid #93c5fd',
|
||||
borderRadius: '6px',
|
||||
})}
|
||||
>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1e40af',
|
||||
mb: '8px',
|
||||
})}
|
||||
>
|
||||
{t('harmony.harmonic')}
|
||||
</h4>
|
||||
<ul
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
color: '#1e3a8a',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '1.6',
|
||||
pl: '0',
|
||||
listStyle: 'none',
|
||||
})}
|
||||
>
|
||||
<li>(3, 4, 6)</li>
|
||||
<li>(4, 6, 12)</li>
|
||||
<li>(6, 8, 12)</li>
|
||||
<li>(6, 10, 15)</li>
|
||||
<li>(8, 12, 24)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { RithmomachiaBoard, type ExamplePiece } from '../RithmomachiaBoard'
|
||||
|
||||
export function OverviewSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
|
||||
const t = useTranslations('rithmomachia.guide')
|
||||
|
||||
// Initial board setup - full starting position
|
||||
const initialSetup: ExamplePiece[] = [
|
||||
// BLACK - Column A
|
||||
{ square: 'A1', type: 'S', color: 'B', value: 28 },
|
||||
{ square: 'A2', type: 'S', color: 'B', value: 66 },
|
||||
{ square: 'A7', type: 'S', color: 'B', value: 225 },
|
||||
{ square: 'A8', type: 'S', color: 'B', value: 361 },
|
||||
// BLACK - Column B
|
||||
{ square: 'B1', type: 'S', color: 'B', value: 28 },
|
||||
{ square: 'B2', type: 'S', color: 'B', value: 66 },
|
||||
{ square: 'B3', type: 'T', color: 'B', value: 36 },
|
||||
{ square: 'B4', type: 'T', color: 'B', value: 30 },
|
||||
{ square: 'B5', type: 'T', color: 'B', value: 56 },
|
||||
{ square: 'B6', type: 'T', color: 'B', value: 64 },
|
||||
{ square: 'B7', type: 'S', color: 'B', value: 120 },
|
||||
{ square: 'B8', type: 'P', color: 'B', value: 36 },
|
||||
// BLACK - Column C
|
||||
{ square: 'C1', type: 'T', color: 'B', value: 16 },
|
||||
{ square: 'C2', type: 'T', color: 'B', value: 12 },
|
||||
{ square: 'C3', type: 'C', color: 'B', value: 9 },
|
||||
{ square: 'C4', type: 'C', color: 'B', value: 25 },
|
||||
{ square: 'C5', type: 'C', color: 'B', value: 49 },
|
||||
{ square: 'C6', type: 'C', color: 'B', value: 81 },
|
||||
{ square: 'C7', type: 'T', color: 'B', value: 90 },
|
||||
{ square: 'C8', type: 'T', color: 'B', value: 100 },
|
||||
// BLACK - Column D
|
||||
{ square: 'D3', type: 'C', color: 'B', value: 3 },
|
||||
{ square: 'D4', type: 'C', color: 'B', value: 5 },
|
||||
{ square: 'D5', type: 'C', color: 'B', value: 7 },
|
||||
{ square: 'D6', type: 'C', color: 'B', value: 9 },
|
||||
// WHITE - Column M
|
||||
{ square: 'M3', type: 'C', color: 'W', value: 8 },
|
||||
{ square: 'M4', type: 'C', color: 'W', value: 6 },
|
||||
{ square: 'M5', type: 'C', color: 'W', value: 4 },
|
||||
{ square: 'M6', type: 'C', color: 'W', value: 2 },
|
||||
// WHITE - Column N
|
||||
{ square: 'N1', type: 'T', color: 'W', value: 81 },
|
||||
{ square: 'N2', type: 'T', color: 'W', value: 72 },
|
||||
{ square: 'N3', type: 'C', color: 'W', value: 64 },
|
||||
{ square: 'N4', type: 'C', color: 'W', value: 16 },
|
||||
{ square: 'N5', type: 'C', color: 'W', value: 16 },
|
||||
{ square: 'N6', type: 'C', color: 'W', value: 4 },
|
||||
{ square: 'N7', type: 'T', color: 'W', value: 6 },
|
||||
{ square: 'N8', type: 'T', color: 'W', value: 9 },
|
||||
// WHITE - Column O
|
||||
{ square: 'O1', type: 'S', color: 'W', value: 153 },
|
||||
{ square: 'O2', type: 'P', color: 'W', value: 64 },
|
||||
{ square: 'O3', type: 'T', color: 'W', value: 72 },
|
||||
{ square: 'O4', type: 'T', color: 'W', value: 20 },
|
||||
{ square: 'O5', type: 'T', color: 'W', value: 20 },
|
||||
{ square: 'O6', type: 'T', color: 'W', value: 25 },
|
||||
{ square: 'O7', type: 'S', color: 'W', value: 45 },
|
||||
{ square: 'O8', type: 'S', color: 'W', value: 15 },
|
||||
// WHITE - Column P
|
||||
{ square: 'P1', type: 'S', color: 'W', value: 289 },
|
||||
{ square: 'P2', type: 'S', color: 'W', value: 169 },
|
||||
{ square: 'P7', type: 'S', color: 'W', value: 81 },
|
||||
{ square: 'P8', type: 'S', color: 'W', value: 25 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div data-section="overview">
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '16px',
|
||||
})}
|
||||
>
|
||||
{t('overview.goalTitle')}
|
||||
</h3>
|
||||
<p className={css({ fontSize: '16px', lineHeight: '1.6', mb: '20px', color: '#374151' })}>
|
||||
{t('overview.goal')}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '18px', md: '20px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '12px',
|
||||
mt: '24px',
|
||||
})}
|
||||
>
|
||||
{t('overview.boardTitle')}
|
||||
</h3>
|
||||
|
||||
<div className={css({ mb: '20px' })}>
|
||||
<RithmomachiaBoard
|
||||
pieces={initialSetup}
|
||||
scale={0.6}
|
||||
showLabels={true}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={css({ fontSize: '14px', color: '#6b7280', mb: '20px', fontStyle: 'italic' })}>
|
||||
{t('overview.boardCaption')}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
className={css({
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.8',
|
||||
pl: '20px',
|
||||
mb: '20px',
|
||||
color: '#374151',
|
||||
})}
|
||||
>
|
||||
<li>{t('overview.boardSize')}</li>
|
||||
<li>{t('overview.territory')}</li>
|
||||
<li>{t('overview.enemyTerritory')}</li>
|
||||
</ul>
|
||||
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '18px', md: '20px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '12px',
|
||||
mt: '24px',
|
||||
})}
|
||||
>
|
||||
{t('overview.howToPlayTitle')}
|
||||
</h3>
|
||||
<ol
|
||||
className={css({
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.8',
|
||||
pl: '20px',
|
||||
color: '#374151',
|
||||
})}
|
||||
>
|
||||
<li>{t('overview.step1')}</li>
|
||||
<li>{t('overview.step2')}</li>
|
||||
<li>{t('overview.step3')}</li>
|
||||
<li>{t('overview.step4')}</li>
|
||||
<li>{t('overview.step5')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { PieceRenderer } from '../PieceRenderer'
|
||||
import { RithmomachiaBoard } from '../RithmomachiaBoard'
|
||||
import type { PieceType } from '../../types'
|
||||
|
||||
/**
|
||||
* 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 PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
|
||||
const t = useTranslations('rithmomachia.guide')
|
||||
const pieces: {
|
||||
type: PieceType
|
||||
name: string
|
||||
movement: string
|
||||
count: number
|
||||
exampleValues: number[]
|
||||
}[] = [
|
||||
{
|
||||
type: 'C',
|
||||
name: t('pieces.circle'),
|
||||
movement: t('pieces.circleMove'),
|
||||
count: 12,
|
||||
exampleValues: [3, 5, 7, 9],
|
||||
},
|
||||
{
|
||||
type: 'T',
|
||||
name: t('pieces.triangle'),
|
||||
movement: t('pieces.triangleMove'),
|
||||
count: 6,
|
||||
exampleValues: [12, 16, 20, 30],
|
||||
},
|
||||
{
|
||||
type: 'S',
|
||||
name: t('pieces.square'),
|
||||
movement: t('pieces.squareMove'),
|
||||
count: 6,
|
||||
exampleValues: [25, 28, 45, 66],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div data-section="pieces">
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '16px',
|
||||
})}
|
||||
>
|
||||
{t('pieces.title')}
|
||||
</h3>
|
||||
<p className={css({ fontSize: '15px', mb: '24px', color: '#374151' })}>
|
||||
{t('pieces.description')}
|
||||
</p>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '24px' })}>
|
||||
{pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.type}
|
||||
className={css({
|
||||
p: '16px',
|
||||
bg: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #e5e7eb',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({ display: 'flex', alignItems: 'center', gap: '12px', mb: '12px' })}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<PieceRenderer
|
||||
type={piece.type}
|
||||
color="W"
|
||||
value={piece.exampleValues[0]}
|
||||
size={60}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
<div className={css({ flex: 1 })}>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
mb: '4px',
|
||||
})}
|
||||
>
|
||||
{piece.name} ({piece.count} per side)
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#6b7280' })}>{piece.movement}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Example values */}
|
||||
<div className={css({ mt: '12px' })}>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
color: '#9ca3af',
|
||||
mb: '8px',
|
||||
fontStyle: 'italic',
|
||||
})}
|
||||
>
|
||||
{t('pieces.exampleValues')}:
|
||||
</p>
|
||||
<div className={css({ display: 'flex', gap: '12px', flexWrap: 'wrap' })}>
|
||||
{piece.exampleValues.map((value) => (
|
||||
<div
|
||||
key={value}
|
||||
className={css({
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
})}
|
||||
>
|
||||
<PieceRenderer
|
||||
type={piece.type}
|
||||
color="W"
|
||||
value={value}
|
||||
size={48}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pyramid section */}
|
||||
<div
|
||||
className={css({
|
||||
mt: '32px',
|
||||
p: '20px',
|
||||
bg: 'rgba(251, 191, 36, 0.1)',
|
||||
borderLeft: '4px solid #f59e0b',
|
||||
borderRadius: '4px',
|
||||
})}
|
||||
>
|
||||
<h4 className={css({ fontSize: '18px', fontWeight: 'bold', color: '#92400e', mb: '12px' })}>
|
||||
{t('pieces.pyramidTitle')}
|
||||
</h4>
|
||||
<p className={css({ fontSize: '14px', color: '#78350f', lineHeight: '1.6', mb: '16px' })}>
|
||||
{t('pieces.pyramidIntro')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '32px',
|
||||
flexWrap: 'wrap',
|
||||
mt: '16px',
|
||||
mb: '20px',
|
||||
})}
|
||||
>
|
||||
{/* Black Pyramid */}
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
mb: '8px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{t('pieces.blackPyramid')}:
|
||||
</p>
|
||||
<div className={css({ width: '80px', height: '80px', margin: '0 auto' })}>
|
||||
<PieceRenderer
|
||||
type="P"
|
||||
color="B"
|
||||
value="P"
|
||||
size={80}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
color: '#78350f',
|
||||
mt: '8px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{t('pieces.blackPyramidValues')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* White Pyramid */}
|
||||
<div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
mb: '8px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{t('pieces.whitePyramid')}:
|
||||
</p>
|
||||
<div className={css({ width: '80px', height: '80px', margin: '0 auto' })}>
|
||||
<PieceRenderer
|
||||
type="P"
|
||||
color="W"
|
||||
value="P"
|
||||
size={80}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
color: '#78350f',
|
||||
mt: '8px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{t('pieces.whitePyramidValues')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How face selection works */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
p: '16px',
|
||||
borderRadius: '6px',
|
||||
mb: '16px',
|
||||
border: '1px solid #fbbf24',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
mb: '12px',
|
||||
})}
|
||||
>
|
||||
{t('pieces.pyramidHowItWorks')}
|
||||
</p>
|
||||
<ul
|
||||
className={css({ fontSize: '13px', color: '#78350f', lineHeight: '1.8', pl: '20px' })}
|
||||
>
|
||||
<li>{t('pieces.pyramidRule1')}</li>
|
||||
<li>{t('pieces.pyramidRule2')}</li>
|
||||
<li>{t('pieces.pyramidRule3')}</li>
|
||||
<li>{t('pieces.pyramidRule4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Example */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(146, 64, 14, 0.1)',
|
||||
p: '12px',
|
||||
borderRadius: '6px',
|
||||
mb: '20px',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: '13px', color: '#78350f', lineHeight: '1.6' })}>
|
||||
<strong>{t('pieces.example')}</strong> {t('pieces.pyramidExample')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Visual Example */}
|
||||
<div>
|
||||
<h5
|
||||
className={css({
|
||||
fontSize: '15px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
mb: '12px',
|
||||
})}
|
||||
>
|
||||
{t('pieces.pyramidVisualTitle')}
|
||||
</h5>
|
||||
<p className={css({ fontSize: '13px', color: '#78350f', mb: '12px', lineHeight: '1.6' })}>
|
||||
{t('pieces.pyramidVisualDesc')}
|
||||
</p>
|
||||
|
||||
<div className={css({ display: 'flex', justifyContent: 'center', mb: '12px' })}>
|
||||
<RithmomachiaBoard
|
||||
pieces={[
|
||||
// White Pyramid at H5
|
||||
{ square: 'H5', type: 'P', color: 'W', value: 49 },
|
||||
// Black pieces that can be captured
|
||||
{ square: 'I5', type: 'T', color: 'B', value: 16 },
|
||||
{ square: 'H6', type: 'S', color: 'B', value: 49 },
|
||||
{ square: 'G5', type: 'C', color: 'B', value: 25 },
|
||||
]}
|
||||
scale={0.4}
|
||||
cropArea={squaresToCropArea('F7', 'J4')}
|
||||
highlightSquares={['H5', 'I5', 'H6', 'G5']}
|
||||
showLabels={true}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
p: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #fbbf24',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
color: '#92400e',
|
||||
mb: '8px',
|
||||
})}
|
||||
>
|
||||
{t('pieces.pyramidCaptureOptions')}
|
||||
</p>
|
||||
<ul
|
||||
className={css({ fontSize: '13px', color: '#78350f', lineHeight: '1.8', pl: '20px' })}
|
||||
>
|
||||
<li>{t('pieces.pyramidOption1')}</li>
|
||||
<li>{t('pieces.pyramidOption2')}</li>
|
||||
<li>{t('pieces.pyramidOption3')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,171 +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 VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
|
||||
const t = useTranslations('rithmomachia.guide')
|
||||
|
||||
// Example winning position: White has formed a geometric progression in Black's territory
|
||||
const winningExample: ExamplePiece[] = [
|
||||
// White's winning progression in enemy territory (rows 5-8)
|
||||
{ square: 'E6', type: 'C', color: 'W', value: 4 },
|
||||
{ square: 'F6', type: 'C', color: 'W', value: 8 },
|
||||
{ square: 'G6', type: 'T', color: 'W', value: 16 },
|
||||
// Some Black pieces remaining (unable to break the harmony)
|
||||
{ square: 'C7', type: 'S', color: 'B', value: 49 },
|
||||
{ square: 'J6', type: 'T', color: 'B', value: 30 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div data-section="victory">
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '16px',
|
||||
})}
|
||||
>
|
||||
{t('victory.title')}
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '24px' })}>
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
mb: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<span>👑</span>
|
||||
<span>{t('victory.harmony')}</span>
|
||||
</h4>
|
||||
<p className={css({ fontSize: '15px', lineHeight: '1.6', color: '#374151', mb: '12px' })}>
|
||||
{t('victory.harmonyDesc')}
|
||||
</p>
|
||||
|
||||
{/* Visual example of winning harmony */}
|
||||
<div
|
||||
className={css({
|
||||
mb: '16px',
|
||||
p: '16px',
|
||||
bg: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #86efac',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#15803d',
|
||||
mb: '12px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{t('victory.exampleTitle')}
|
||||
</p>
|
||||
<div className={css({ display: 'flex', justifyContent: 'center' })}>
|
||||
<RithmomachiaBoard
|
||||
pieces={winningExample}
|
||||
scale={0.4}
|
||||
cropArea={squaresToCropArea('B8', 'K5')}
|
||||
highlightSquares={['E6', 'F6', 'G6']}
|
||||
showLabels={true}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: '#166534',
|
||||
mt: '12px',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
})}
|
||||
>
|
||||
{t('victory.exampleCaption')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
p: '12px',
|
||||
bg: '#f0fdf4',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #86efac',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: '13px', color: '#15803d' })}>
|
||||
{t('victory.harmonyNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
mb: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
})}
|
||||
>
|
||||
<span>🚫</span>
|
||||
<span>{t('victory.exhaustion')}</span>
|
||||
</h4>
|
||||
<p className={css({ fontSize: '15px', lineHeight: '1.6', color: '#374151' })}>
|
||||
{t('victory.exhaustionDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: '18px', md: '20px' },
|
||||
fontWeight: 'bold',
|
||||
color: '#7c2d12',
|
||||
mb: '12px',
|
||||
mt: '32px',
|
||||
})}
|
||||
>
|
||||
{t('victory.strategyTitle')}
|
||||
</h3>
|
||||
<ul
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.8',
|
||||
pl: '20px',
|
||||
color: '#374151',
|
||||
})}
|
||||
>
|
||||
<li>{t('victory.tip1')}</li>
|
||||
<li>{t('victory.tip2')}</li>
|
||||
<li>{t('victory.tip3')}</li>
|
||||
<li>{t('victory.tip4')}</li>
|
||||
<li>{t('victory.tip5')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
{
|
||||
"guide": {
|
||||
"title": "Rithmomachia Spielanleitung",
|
||||
"subtitle": "Rithmomachia – Das Spiel der Philosophen",
|
||||
"close": "Schließen",
|
||||
"maximize": "Maximieren",
|
||||
"restore": "Wiederherstellen",
|
||||
"bustOut": "In neuem Fenster öffnen",
|
||||
"sections": {
|
||||
"overview": "Schnellstart",
|
||||
"pieces": "Spielfiguren",
|
||||
"capture": "Schlagen",
|
||||
"strategy": "Strategie",
|
||||
"harmony": "Harmonie",
|
||||
"victory": "Sieg"
|
||||
},
|
||||
"languageSelector": {
|
||||
"label": "Sprache",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"ja": "日本語",
|
||||
"hi": "हिन्दी",
|
||||
"es": "Español",
|
||||
"la": "Latina"
|
||||
},
|
||||
"overview": {
|
||||
"goalTitle": "Spielziel",
|
||||
"goal": "Bringen Sie 3 Ihrer Steine ins gegnerische Gebiet, um eine mathematische Progression zu bilden, überstehen Sie eine Runde des Gegners und gewinnen Sie.",
|
||||
"boardTitle": "Das Spielbrett",
|
||||
"boardSize": "8 Reihen × 16 Spalten (Spalten A-P, Reihen 1-8)",
|
||||
"territory": "Ihre Hälfte: Schwarz kontrolliert Reihen 5-8, Weiß kontrolliert Reihen 1-4",
|
||||
"enemyTerritory": "Gegnerisches Gebiet: Wo Sie Ihre Siegprogression aufbauen müssen",
|
||||
"howToPlayTitle": "Spielablauf",
|
||||
"step1": "Bewegen Sie Steine zunächst zur Mitte",
|
||||
"step2": "Suchen Sie nach Schlagmöglichkeiten durch mathematische Beziehungen",
|
||||
"step3": "Dringen Sie ins gegnerische Gebiet vor (Reihen 1-4 für Schwarz, Reihen 5-8 für Weiß)",
|
||||
"step4": "Achten Sie auf Harmoniemöglichkeiten mit Ihren vorderen Steinen",
|
||||
"step5": "Gewinnen Sie durch eine Progression, die eine Runde übersteht!",
|
||||
"goalDesc": "Ordnen Sie <strong>3 Ihrer Steine im gegnerischen Gebiet</strong> an, um eine <strong>mathematische Progression</strong> zu bilden, überstehen Sie eine Gegnerrunde und gewinnen Sie.",
|
||||
"boardItems": [
|
||||
"8 Reihen × 16 Spalten (Spalten A-P, Reihen 1-8)",
|
||||
"<strong>Ihre Hälfte:</strong> Schwarz kontrolliert Reihen 5-8, Weiß kontrolliert Reihen 1-4",
|
||||
"<strong>Gegnerisches Gebiet:</strong> Wo Sie Ihre Siegprogression aufbauen müssen"
|
||||
],
|
||||
"howToPlayItems": [
|
||||
"Bewegen Sie Steine zunächst zur Mitte",
|
||||
"Suchen Sie nach Schlagmöglichkeiten durch mathematische Beziehungen",
|
||||
"Dringen Sie ins gegnerische Gebiet vor (Reihen 1-4 für Schwarz, Reihen 5-8 für Weiß)",
|
||||
"Achten Sie auf Harmoniemöglichkeiten mit Ihren vorderen Steinen",
|
||||
"Gewinnen Sie durch eine Progression, die eine Runde übersteht!"
|
||||
]
|
||||
},
|
||||
"pieces": {
|
||||
"title": "Ihre Spielfiguren (25 insgesamt)",
|
||||
"description": "Jede Seite hat 25 Figuren mit unterschiedlichen Bewegungsmustern. Die Form zeigt, wie sie sich bewegt:",
|
||||
"count": "Anzahl",
|
||||
"circle": "Kreis",
|
||||
"circleMove": "Bewegt sich diagonal",
|
||||
"triangle": "Dreieck",
|
||||
"triangleMove": "Bewegt sich in geraden Linien",
|
||||
"square": "Quadrat",
|
||||
"squareMove": "Bewegt sich in alle Richtungen",
|
||||
"pyramid": "Pyramide",
|
||||
"pyramidMove": "Ein Feld in alle Richtungen (wie ein König)",
|
||||
"exampleValues": "Beispielwerte",
|
||||
"pyramidTitle": "⭐ Pyramiden: Die besondere Figur",
|
||||
"pyramidIntro": "Pyramiden sind besonders. Sie haben 4 verschiedene Werte, die Sie beim Schlagen wählen können. Das macht sie super flexibel!",
|
||||
"blackPyramid": "Schwarze Pyramiden-Seiten",
|
||||
"blackPyramidValues": "36 (6²), 25 (5²), 16 (4²), 4 (2²)",
|
||||
"whitePyramid": "Weiße Pyramiden-Seiten",
|
||||
"whitePyramidValues": "64 (8²), 49 (7²), 36 (6²), 25 (5²)",
|
||||
"pyramidHowItWorks": "So funktioniert es:",
|
||||
"pyramidRule1": "Wählen Sie einen Wert, bevor Sie schlagen",
|
||||
"pyramidRule2": "Dieser Wert wird die Zahl Ihrer Figur für die Mathematik",
|
||||
"pyramidRule3": "Sie können jedes Mal einen anderen Wert wählen - nichts ist festgelegt",
|
||||
"pyramidRule4": "Das macht Pyramiden super für Überraschungsangriffe und zum Helfen",
|
||||
"example": "Beispiel:",
|
||||
"pyramidExample": "Weiße Pyramide kann schwarze 16 schlagen mit Seite 64 (Vielfaches: 64÷16=4), Seite 36 (Vielfaches: 36÷9=4, mit schwarzer 9), oder Seite 25 mit Gleichheit beim Schlagen von schwarzer 25.",
|
||||
"pyramidVisualTitle": "Visuelles Beispiel: Mehrfache Schlagoptionen der Pyramide",
|
||||
"pyramidVisualDesc": "Weiße Pyramide (Seiten: 64, 49, 36, 25) ist positioniert, um schwarze Figuren zu schlagen. Beachten Sie die Flexibilität:",
|
||||
"pyramidCaptureOptions": "Schlagoptionen von H5:",
|
||||
"pyramidOption1": "Zug nach I5: Wähle Seite 64 → schlägt 16 durch Vielfaches (64÷16=4)",
|
||||
"pyramidOption2": "Zug nach H6: Wähle Seite 49 → schlägt 49 durch Gleichheit (49=49)",
|
||||
"pyramidOption3": "Zug nach G5: Wähle Seite 25 → schlägt 25 durch Gleichheit (25=25)",
|
||||
"intro": "Jeder Stein hat einen <strong>Zahlenwert</strong> und bewegt sich unterschiedlich:",
|
||||
"pieceTypes": {
|
||||
"circle": {
|
||||
"name": "Kreis",
|
||||
"movement": "Diagonal (wie ein Läufer)",
|
||||
"count": "Anzahl: 8"
|
||||
},
|
||||
"triangle": {
|
||||
"name": "Dreieck",
|
||||
"movement": "Gerade Linien (wie ein Turm)",
|
||||
"count": "Anzahl: 8"
|
||||
},
|
||||
"square": {
|
||||
"name": "Quadrat",
|
||||
"movement": "Alle Richtungen (wie eine Dame)",
|
||||
"count": "Anzahl: 7"
|
||||
},
|
||||
"pyramid": {
|
||||
"name": "Pyramide",
|
||||
"movement": "Ein Feld in alle Richtungen (wie ein König)",
|
||||
"count": "Anzahl: 1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"capture": {
|
||||
"title": "Wie man schlägt",
|
||||
"description": "Sie können einen gegnerischen Stein nur schlagen, wenn der Wert Ihres Steins in einer mathematischen Beziehung zu ihm steht:",
|
||||
"simpleTitle": "Einfache Beziehungen (kein Helfer nötig)",
|
||||
"equality": "Gleich",
|
||||
"equalityExample": "Ihre 25 schlägt deren 25",
|
||||
"equalityCaption": "Weißer Kreis (25) kann schwarzen Kreis (25) durch Gleichheit schlagen",
|
||||
"multiple": "Vielfaches / Teiler",
|
||||
"multipleExample": "Ihre 64 schlägt deren 16 (64 ÷ 16 = 4)",
|
||||
"multipleCaption": "Weißes Quadrat (64) kann schwarzes Dreieck (16) schlagen, weil 64 ÷ 16 = 4",
|
||||
"advancedTitle": "Erweiterte Beziehungen (ein Helferstein nötig)",
|
||||
"sum": "Summe",
|
||||
"sumExample": "Ihre 9 + Helfer 16 = Gegner 25",
|
||||
"sumCaption": "Weißer Kreis (9) kann schwarzen Kreis (25) mit Helfer-Dreieck (16) schlagen: 9 + 16 = 25",
|
||||
"difference": "Differenz",
|
||||
"differenceExample": "Ihre 30 - Helfer 10 = Gegner 20",
|
||||
"differenceCaption": "Weißes Dreieck (30) kann schwarzes Dreieck (20) mit Helfer-Kreis (10) schlagen: 30 - 10 = 20",
|
||||
"product": "Produkt",
|
||||
"productExample": "Ihre 5 × Helfer 5 = Gegner 25",
|
||||
"productCaption": "Weißer Kreis (5) kann schwarzen Kreis (25) mit Helfer-Kreis (5) schlagen: 5 × 5 = 25",
|
||||
"ratio": "Verhältnis",
|
||||
"ratioExample": "Ihre 20 ÷ Helfer 4 = Gegner 5",
|
||||
"ratioCaption": "Weißes Dreieck (20) kann schwarzen Kreis (5) mit Helfer-Kreis (4) schlagen: 20 ÷ 4 = 5",
|
||||
"helpersTitle": "💡 Was sind Helfer?",
|
||||
"helpersDescription": "Helfer sind Ihre anderen Steine auf dem Brett. Sie bewegen sich nicht - sie fügen nur ihre Zahl zur Mathematik hinzu. Das Spiel zeigt Ihnen welche Schläge funktionieren wenn Sie einen Stein anklicken.",
|
||||
"intro": "Sie können einen gegnerischen Stein <strong>nur schlagen, wenn der Wert Ihres Steins mathematisch verwandt</strong> ist:",
|
||||
"simpleEqual": {
|
||||
"name": "Gleich",
|
||||
"desc": "Ihre 25 schlägt deren 25"
|
||||
},
|
||||
"simpleMultiple": {
|
||||
"name": "Vielfaches / Teiler",
|
||||
"desc": "Ihre 64 schlägt deren 16 (64 ÷ 16 = 4)"
|
||||
},
|
||||
"advancedSum": {
|
||||
"name": "Summe",
|
||||
"desc": "Ihre 9 + Helfer 16 = Gegner 25"
|
||||
},
|
||||
"advancedDifference": {
|
||||
"name": "Differenz",
|
||||
"desc": "Ihre 30 - Helfer 10 = Gegner 20"
|
||||
},
|
||||
"advancedProduct": {
|
||||
"name": "Produkt",
|
||||
"desc": "Ihre 5 × Helfer 5 = Gegner 25"
|
||||
},
|
||||
"helpersDesc": "Helfer sind Ihre anderen Steine auf dem Brett — sie bewegen sich nicht, sie geben nur ihren Wert für die Mathematik. Das Spiel zeigt Ihnen gültige Schläge, wenn Sie einen Stein auswählen.",
|
||||
"example1Title": "Beispiel: Vielfaches/Teiler-Schlag",
|
||||
"example1Desc": "Weiße 64 (Quadrat) kann schwarze 16 (Dreieck) schlagen, weil 64 ein Vielfaches von 16 ist",
|
||||
"example2Title": "Beispiel: Summen-Schlag mit Helfer",
|
||||
"example2Desc": "Weiße 9 + Helfer 16 = schwarze 25 (9 + 16 = 25)",
|
||||
"example3Title": "Beispiel: Differenz-Schlag mit Helfer",
|
||||
"example3Desc": "Weiße 30 - Helfer 10 = schwarze 20 (30 - 10 = 20)",
|
||||
"example4Title": "Beispiel: Produkt-Schlag mit Helfer",
|
||||
"example4Desc": "Weiße 4 × Helfer 5 = schwarze 20 (4 × 5 = 20)",
|
||||
"example5Title": "Beispiel: Verhältnis-Schlag mit Helfer",
|
||||
"example5Desc": "Weiße 20 ÷ Helfer 4 = schwarze 5 (20 ÷ 4 = 5)",
|
||||
"pyramidTitle": "Spezial: Pyramiden-Schläge",
|
||||
"pyramidIntro": "Pyramiden haben 4 Seitenwerte, was sie unglaublich vielseitig macht. Sie wählen welche Seite Sie beim Schlagversuch verwenden, sodass eine Pyramide mehrere gegnerische Steine bedrohen kann.",
|
||||
"pyramidEx1Title": "Beispiel: Pyramiden-Seitenwahl (Gleichheit)",
|
||||
"pyramidEx1Desc": "Weiße Pyramide (Seiten: 64, 49, 36, 25) kann schwarze 49 schlagen, indem Seite 49 für Gleichheit gewählt wird (49 = 49)",
|
||||
"pyramidEx1Note": "<strong>Seitenwahl:</strong> Weiß wählt Seite 49 → 49 = 49 (Gleichheit) → Schlag erfolgreich! Die Pyramide könnte auch Steine mit Werten 64, 36 oder 25 schlagen mit den entsprechenden Seiten.",
|
||||
"pyramidEx2Title": "Beispiel: Pyramide mit Helfer (Summe)",
|
||||
"pyramidEx2Desc": "Weiße Pyramide verwendet Seite 25 + Helfer 20 = schwarze 45 (25 + 20 = 45)",
|
||||
"pyramidEx2Note": "<strong>Seitenwahl:</strong> Weiß wählt Seite 25 und deklariert Helfer bei D4 (Wert 20) → 25 + 20 = 45 (Summe) → Schlag erfolgreich! Durch Wahl verschiedener Seiten könnte dieselbe Pyramide andere Werte mit verschiedenen Helfern schlagen.",
|
||||
"pyramidEx3Title": "Beispiel: Pyramiden-Flexibilität (Vielfaches/Teiler)",
|
||||
"pyramidEx3Desc": "Schwarze Pyramide (Seiten: 36, 25, 16, 4) kann weiße 9 mit Seite 36 schlagen (Vielfaches: 36 ÷ 9 = 4)",
|
||||
"pyramidEx3Note": "<strong>Seitenwahl:</strong> Schwarz wählt Seite 36 → 36 ÷ 9 = 4 (Vielfaches) → Schlag erfolgreich! Hinweis: Schwarz könnte auch Seite 4 mit Helfer 5 für Summe verwenden (4 + 5 = 9), was mehrere gültige Ansätze zeigt."
|
||||
},
|
||||
"harmony": {
|
||||
"title": "Harmonien: Der coolste Weg zu gewinnen",
|
||||
"intro": "Eine Harmonie ist wie man das Spiel gewinnt. Bringen Sie 3 Ihrer Steine ins gegnerische Gebiet in einer geraden Linie. Ihre Werte müssen ein mathematisches Muster bilden.",
|
||||
"introDetail": "Denken Sie daran wie eine Zahlenfolge. Es gibt 3 Arten von Mustern:",
|
||||
"arithmetic": "1. Arithmetisch (Am einfachsten)",
|
||||
"arithmeticDesc": "Die mittlere Zahl liegt genau zwischen den anderen beiden. Gleicher Abstand.",
|
||||
"arithmeticFormula": "Mitte × 2 = Erste + Letzte",
|
||||
"arithmeticTip": "Kleine Kreise (2-9) funktionieren super. Suchen Sie gleiche Abstände! Beispiel: 6, 9, 12",
|
||||
"arithmeticCaption": "Weiße Steine 6, 9, 12 in einer Reihe im gegnerischen Gebiet bilden eine arithmetische Progression",
|
||||
"geometric": "2. Geometrisch (Mit gleicher Zahl multiplizieren)",
|
||||
"geometricDesc": "Jedes Mal mit der gleichen Zahl multiplizieren. Gleiche Verhältnisse.",
|
||||
"geometricFormula": "Mitte² = Erste × Letzte",
|
||||
"geometricTip": "Quadratzahlen funktionieren super! Beispiel: 4, 8, 16 (jedes Mal mal 2)",
|
||||
"geometricCaption": "Weiße Steine 4, 8, 16 in einer Reihe im gegnerischen Gebiet bilden eine geometrische Progression",
|
||||
"harmonic": "3. Harmonisch (Knifflig!)",
|
||||
"harmonicDesc": "Basiert auf musikalischen Harmonien. Am schwersten zu erkennen.",
|
||||
"harmonicFormula": "2 × Erste × Letzte = Mitte × (Erste + Letzte)",
|
||||
"harmonicTip": "Diese sind selten. Merken Sie sich einfach diese Kombos: 3-4-6, 4-6-12, 6-8-12, 6-10-15",
|
||||
"harmonicCaption": "Weiße Steine 6, 8, 12 in einer Reihe im gegnerischen Gebiet bilden eine harmonische Progression",
|
||||
"howToCheck": "Prüfung:",
|
||||
"example": "Beispiel:",
|
||||
"check": "Prüfung:",
|
||||
"differences": "Differenzen:",
|
||||
"equal": "(gleich!)",
|
||||
"ratios": "Verhältnisse:",
|
||||
"strategyTip": "Strategietipp:",
|
||||
"rulesTitle": "⚠️ Wichtige Regeln",
|
||||
"enemyTerritoryTitle": "Muss im gegnerischen Gebiet sein:",
|
||||
"enemyTerritory": "Alle 3 Steine müssen auf der Gegnerseite sein (Weiß: Reihen 5-8, Schwarz: Reihen 1-4)",
|
||||
"straightLineTitle": "Muss eine Linie sein:",
|
||||
"straightLine": "Reihe, Spalte oder Diagonale. Keine verstreuten Steine!",
|
||||
"adjacentTitle": "Müssen sich berühren:",
|
||||
"adjacent": "Die 3 Steine müssen nebeneinander sein, keine Lücken",
|
||||
"survivalTitle": "Muss eine Runde überleben:",
|
||||
"survival": "Nach der Deklaration hat der Gegner EINEN Zug, um sie zu brechen",
|
||||
"victoryTitle": "Dann gewinnen Sie:",
|
||||
"victoryRule": "Wenn sie überlebt, gewinnen Sie in Ihrem nächsten Zug!",
|
||||
"strategyTitle": "Wie man mit Harmonien gewinnt",
|
||||
"startWith2Title": "Beginnen Sie mit 2, dann den 3.",
|
||||
"startWith2": "Bringen Sie zuerst 2 Steine tief. Finden Sie heraus, welcher 3. Stein das Muster vervollständigt. Dann schieben Sie diesen Stein vor. Sie sehen es vielleicht nicht kommen!",
|
||||
"useCommonTitle": "Nutzen Sie häufige Zahlen",
|
||||
"useCommon": "Zahlen wie 6, 8, 9, 12, 16 funktionieren in vielen Mustern. Wenn Sie diese tief haben, prüfen Sie welcher 3. Stein fertig macht.",
|
||||
"protectTitle": "Schützen Sie Ihre Steine",
|
||||
"protect": "Beim Aufbauen, halten Sie andere Steine in der Nähe zur Verteidigung. Ein Schlag ruiniert alles!",
|
||||
"blockTitle": "Blockieren Sie ihre Harmonien",
|
||||
"block": "Wenn der Gegner 2 Steine tief hat, finden Sie heraus welchen 3. Stein sie brauchen. Blockieren Sie das Feld oder schlagen Sie einen ihrer Steine SOFORT.",
|
||||
"calculateTitle": "Prüfen Sie vor der Deklaration",
|
||||
"calculate": "Vor dem Deklarieren, stellen Sie sicher dass der Gegner keinen Ihrer 3 Steine schlagen kann. Wenn doch, schützen Sie zuerst oder warten Sie.",
|
||||
"quickRefTitle": "💡 Schnellreferenz: Häufige Harmonien in Ihrer Armee"
|
||||
},
|
||||
"victory": {
|
||||
"title": "Wie man gewinnt",
|
||||
"harmony": "Sieg #1: Harmonie (Progression)",
|
||||
"harmonyDesc": "Bilden Sie eine mathematische Progression mit 3 Steinen im gegnerischen Gebiet. Wenn sie den nächsten Zug Ihres Gegners übersteht, gewinnen Sie!",
|
||||
"exampleTitle": "Beispiel: Weiß gewinnt!",
|
||||
"exampleCaption": "Weiße Steine 4, 8, 16 bilden eine geometrische Progression im gegnerischen Gebiet. Schwarz kann sie nicht brechen - Weiß gewinnt!",
|
||||
"harmonyNote": "Dies ist die primäre Siegbedingung in Rithmomachia",
|
||||
"exhaustion": "Sieg #2: Erschöpfung",
|
||||
"exhaustionDesc": "Wenn Ihr Gegner zu Beginn seines Zuges keine legalen Züge hat, verliert er.",
|
||||
"strategyTitle": "Schnelle Tipps",
|
||||
"tip1": "Kontrollieren Sie die Mitte - einfacher nach vorne zu kommen",
|
||||
"tip2": "Kleine Steine sind schnell - Kreise können schnell auf die Gegnerseite",
|
||||
"tip3": "Große Steine sind stark - schwerer zu schlagen",
|
||||
"tip4": "Achten Sie auf ihre Harmonien - lassen Sie nicht 3 Steine tief",
|
||||
"tip5": "Pyramiden sind flexibel - wählen Sie den richtigen Wert für jeden Schlag"
|
||||
},
|
||||
"strategy": {
|
||||
"title": "Strategie & Taktik",
|
||||
"intro": "Rithmomachia belohnt sowohl mathematische Einsicht als auch strategische Planung. Erfolg erfordert das Ausbalancieren von Gebietskontrolle, Steineerhaltung und mathematischen Möglichkeiten.",
|
||||
"openingPrinciples": {
|
||||
"title": "Eröffnungsprinzipien",
|
||||
"controlCenter": {
|
||||
"title": "Kontrollieren Sie das Zentrum",
|
||||
"desc": "Bewegen Sie Steine zur leeren 8-Spalten-Mitte (Spalten E–L). Dies bietet Mobilität und schafft Schlagmöglichkeiten aus mehreren Winkeln."
|
||||
},
|
||||
"developCircles": {
|
||||
"title": "Entwickeln Sie zuerst kleine Kreise",
|
||||
"desc": "Ihre kleinen Kreise (2–9) in Spalten D und M sind mobil und vielseitig. Bewegen Sie sie früh zur Mitte, um Präsenz zu etablieren und Helfer-Netzwerke zu schaffen."
|
||||
},
|
||||
"protectPyramid": {
|
||||
"title": "Schützen Sie Ihre Pyramide",
|
||||
"desc": "Die Pyramide ist Ihr flexibelster Stein (4 Seitenwerte), bewegt sich aber nur 1 Feld. Halten Sie sie hinter vorrückenden Linien bis zum Mittelspiel, wenn mathematische Bedrohungen klar sind."
|
||||
},
|
||||
"knowNumbers": {
|
||||
"title": "Kennen Sie Ihre Zahlen",
|
||||
"desc": "Merken Sie sich wichtige mathematische Beziehungen in Ihrer Armee. Identifizieren Sie welche Steine Faktoren, Vielfache und Summen bilden—dies beschleunigt taktische Berechnungen."
|
||||
}
|
||||
},
|
||||
"midGame": {
|
||||
"title": "Mittelspiel-Taktiken",
|
||||
"helperNetworks": {
|
||||
"title": "Bauen Sie Helfer-Netzwerke",
|
||||
"desc": "Positionieren Sie Steine so, dass mehrere Helfer Schläge unterstützen können. Wenn Sie zum Beispiel Steine mit Werten 5 und 10 haben, können Sie 15 (Summe), 5 (Differenz) oder 50 (Produkt) schlagen, wenn Ihr Beweger richtig positioniert ist."
|
||||
},
|
||||
"createThreats": {
|
||||
"title": "Schaffen Sie Schlagdrohungen",
|
||||
"desc": "Zwingen Sie Ihren Gegner mehrere Steine gleichzeitig zu verteidigen. Auch wenn Sie nicht alle Schläge ausführen können, schränkt die Drohung deren Optionen ein und kontrolliert das Tempo."
|
||||
},
|
||||
"thinkDefensively": {
|
||||
"title": "Denken Sie defensiv",
|
||||
"desc": "Überprüfen Sie nach jedem Zug welche Ihrer Steine geschlagen werden können. Hochwertige Steine wie Quadrate (169, 225, 289, 361) sind anfällig für viele Beziehungen—positionieren Sie sie hinter Verteidigern oder auf geschützten Feldern."
|
||||
},
|
||||
"exchangeWhenAhead": {
|
||||
"title": "Tauschen Sie wenn Sie vorne liegen",
|
||||
"desc": "Wenn Sie mehr Steine oder höhere Werte geschlagen haben, vereinfachen Sie die Position durch Steintausch. Dies reduziert die Angriffsoptionen Ihres Gegners und bringt Sie näher an den Erschöpfungssieg."
|
||||
}
|
||||
},
|
||||
"victoryPaths": {
|
||||
"title": "Wege zum Sieg",
|
||||
"harmony": {
|
||||
"title": "Harmoniesieg (Am elegantesten)",
|
||||
"desc": "Platzieren Sie 3 Steine im gegnerischen Gebiet, die eine arithmetische, geometrische oder harmonische Progression bilden. Häufige Triaden:",
|
||||
"arithmetic": "<strong>Arithmetisch:</strong> (6, 9, 12), (5, 7, 9), (8, 12, 16)",
|
||||
"geometric": "<strong>Geometrisch:</strong> (4, 8, 16), (3, 9, 27), (2, 8, 32)",
|
||||
"harmonic": "<strong>Harmonisch:</strong> (6, 8, 12), (10, 12, 15), (6, 10, 15)"
|
||||
},
|
||||
"exhaustion": {
|
||||
"title": "Erschöpfungssieg (Zermürbung)",
|
||||
"desc": "Schlagen Sie systematisch Steine bis Ihr Gegner keine legalen Züge hat. Fokus auf: mobile Steine eliminieren (Quadrate und Dreiecke), Diagonalen und Reihen blockieren, und die Pyramide in eine Ecke zwingen."
|
||||
},
|
||||
"points": {
|
||||
"title": "Punktesieg (Optionale Regel)",
|
||||
"desc": "Falls aktiviert, schlagen Sie Steine im Wert von 30 Punkten (C=1, T=2, S=3, P=5). Jagen Sie hochwertige Ziele und bewahren Sie Ihre eigenen schweren Steine. Tauschen Sie vorteilhaft."
|
||||
}
|
||||
},
|
||||
"commonMistakes": {
|
||||
"title": "⚠️ Häufige Fehler zu vermeiden",
|
||||
"movingWithoutCalc": "<strong>Bewegen ohne Berechnung:</strong> Überprüfen Sie immer ob Ihr Zielfeld von gegnerischen Steinen geschlagen werden kann",
|
||||
"ignoringGeometry": "<strong>Helfer-Geometrie ignorieren:</strong> Helfer können überall sein, aber Sie brauchen noch einen legalen Zugpfad zum Schlagen",
|
||||
"neglectingHarmony": "<strong>Harmoniedrohungen vernachlässigen:</strong> Wenn Ihr Gegner 2 Steine in Ihrem Gebiet hat, die Teil einer Progression bilden, blockieren Sie deren dritten",
|
||||
"exposingPyramid": "<strong>Die Pyramide exponieren:</strong> Sie bewegt sich nur 1 Feld und hat begrenzte Fluchtoptionen—schützen Sie sie früh"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Fortgeschrittene Konzepte",
|
||||
"sacrifices": {
|
||||
"title": "Positionsopfer",
|
||||
"desc": "Manchmal öffnet das Opfern eines Steins Linien für Ihre anderen Steine oder zwingt Ihren Gegner in eine schlechtere Position. Berechnen Sie das resultierende Ungleichgewicht vor dem Opfern."
|
||||
},
|
||||
"pyramidFaces": {
|
||||
"title": "Pyramiden-Seitenwahl",
|
||||
"desc": "Ihre Pyramide hat 4 Seitenwerte. Beim Schlagen wählen Sie die Seite, die zukünftige Flexibilität maximiert. Bei Harmoniedeklarationen wählen Sie die Seite, die den wertvollsten Progressionstyp vervollständigt."
|
||||
},
|
||||
"tempo": {
|
||||
"title": "Tempo und Initiative",
|
||||
"desc": "Jeder Zug der eine defensive Antwort erzwingt gewinnt Tempo. Reihen Sie erzwungene Züge (Schläge, Drohungen) aneinander, um das Tempo zu diktieren und Ihren Gegner daran zu hindern, deren Plan auszuführen."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
{
|
||||
"guide": {
|
||||
"title": "Rithmomachia Playing Guide",
|
||||
"subtitle": "Rithmomachia – The Philosopher's Game",
|
||||
"close": "Close",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
"bustOut": "Open in new window",
|
||||
"sections": {
|
||||
"overview": "Quick Start",
|
||||
"pieces": "Pieces",
|
||||
"capture": "Capture",
|
||||
"strategy": "Strategy",
|
||||
"harmony": "Harmony",
|
||||
"victory": "Victory"
|
||||
},
|
||||
"languageSelector": {
|
||||
"label": "Language",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"ja": "日本語",
|
||||
"hi": "हिन्दी",
|
||||
"es": "Español",
|
||||
"la": "Latina"
|
||||
},
|
||||
"overview": {
|
||||
"goalTitle": "Goal of the Game",
|
||||
"goal": "Arrange 3 of your pieces in enemy territory to form a mathematical progression, survive one opponent turn, and win.",
|
||||
"goalDesc": "Arrange <strong>3 of your pieces in enemy territory</strong> to form a <strong>mathematical progression</strong>, survive one opponent turn, and win.",
|
||||
"boardTitle": "The Board",
|
||||
"boardSize": "8 rows × 16 columns (columns A-P, rows 1-8)",
|
||||
"territory": "Your half: Black controls rows 5-8, White controls rows 1-4",
|
||||
"enemyTerritory": "Enemy territory: Where you need to build your winning progression",
|
||||
"boardItems": [
|
||||
"8 rows × 16 columns (columns A-P, rows 1-8)",
|
||||
"<strong>Your half:</strong> Black controls rows 5-8, White controls rows 1-4",
|
||||
"<strong>Enemy territory:</strong> Where you need to build your winning progression"
|
||||
],
|
||||
"howToPlayTitle": "How to Play",
|
||||
"step1": "Start by moving pieces toward the center",
|
||||
"step2": "Look for capture opportunities using mathematical relations",
|
||||
"step3": "Push into enemy territory (rows 1-4 for Black, rows 5-8 for White)",
|
||||
"step4": "Watch for harmony opportunities with your forward pieces",
|
||||
"step5": "Win by forming a progression that survives one turn!",
|
||||
"howToPlayItems": [
|
||||
"Start by moving pieces toward the center",
|
||||
"Look for capture opportunities using mathematical relations",
|
||||
"Push into enemy territory (rows 1-4 for Black, rows 5-8 for White)",
|
||||
"Watch for harmony opportunities with your forward pieces",
|
||||
"Win by forming a progression that survives one turn!"
|
||||
]
|
||||
},
|
||||
"pieces": {
|
||||
"title": "Your Pieces (25 total)",
|
||||
"description": "Each side has 25 pieces with different movement patterns. The shape tells you how it moves:",
|
||||
"intro": "Each piece has a <strong>number value</strong> and moves differently:",
|
||||
"count": "Count",
|
||||
"circle": "Circle",
|
||||
"circleMove": "Moves diagonally",
|
||||
"triangle": "Triangle",
|
||||
"triangleMove": "Moves in straight lines",
|
||||
"square": "Square",
|
||||
"squareMove": "Moves in any direction",
|
||||
"pyramid": "Pyramid",
|
||||
"pyramidMove": "One step any way (like a king)",
|
||||
"exampleValues": "Example values",
|
||||
"pyramidTitle": "⭐ Pyramids: The Special Piece",
|
||||
"pyramidIntro": "Pyramids are special. They have 4 different values you can choose from when you capture. This makes them super flexible!",
|
||||
"blackPyramid": "Black Pyramid Faces",
|
||||
"blackPyramidValues": "36 (6²), 25 (5²), 16 (4²), 4 (2²)",
|
||||
"whitePyramid": "White Pyramid Faces",
|
||||
"whitePyramidValues": "64 (8²), 49 (7²), 36 (6²), 25 (5²)",
|
||||
"pyramidHowItWorks": "How it works:",
|
||||
"pyramidRule1": "Pick which value to use before you capture",
|
||||
"pyramidRule2": "That value becomes your piece's number for the math",
|
||||
"pyramidRule3": "You can pick different values each time - nothing is locked in",
|
||||
"pyramidRule4": "This makes Pyramids great for surprise attacks and helping other pieces",
|
||||
"example": "Example:",
|
||||
"pyramidExample": "White's Pyramid can capture Black's 16 using face 64 (multiple: 64÷16=4), face 36 (multiple: 36÷9=4, with Black's 9), or face 25 with equality if capturing Black's 25.",
|
||||
"pyramidVisualTitle": "Visual Example: Pyramid's Multiple Capture Options",
|
||||
"pyramidVisualDesc": "White's Pyramid (faces: 64, 49, 36, 25) is positioned to capture Black pieces. Notice the flexibility:",
|
||||
"pyramidCaptureOptions": "Capture options from H5:",
|
||||
"pyramidOption1": "Move to I5: Choose face 64 → captures 16 by multiple (64÷16=4)",
|
||||
"pyramidOption2": "Move to H6: Choose face 49 → captures 49 by equality (49=49)",
|
||||
"pyramidOption3": "Move to G5: Choose face 25 → captures 25 by equality (25=25)",
|
||||
"pieceTypes": {
|
||||
"circle": {
|
||||
"name": "Circle",
|
||||
"movement": "Diagonal (like a bishop)",
|
||||
"count": "Count: 8"
|
||||
},
|
||||
"triangle": {
|
||||
"name": "Triangle",
|
||||
"movement": "Straight lines (like a rook)",
|
||||
"count": "Count: 8"
|
||||
},
|
||||
"square": {
|
||||
"name": "Square",
|
||||
"movement": "Any direction (like a queen)",
|
||||
"count": "Count: 7"
|
||||
},
|
||||
"pyramid": {
|
||||
"name": "Pyramid",
|
||||
"movement": "One step any way (like a king)",
|
||||
"count": "Count: 1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"capture": {
|
||||
"title": "How to Capture",
|
||||
"description": "You can only capture an enemy piece if your piece value has a mathematical relation to theirs:",
|
||||
"intro": "You can capture an enemy piece <strong>only if your piece's value relates mathematically</strong> to theirs:",
|
||||
"simpleTitle": "Simple Relations (no helper needed)",
|
||||
"equality": "Equal",
|
||||
"equalityExample": "Your 25 captures their 25",
|
||||
"equalityCaption": "White Circle (25) can capture Black Circle (25) by equality",
|
||||
"multiple": "Multiple / Divisor",
|
||||
"multipleExample": "Your 64 captures their 16 (64 ÷ 16 = 4)",
|
||||
"multipleCaption": "White Square (64) can capture Black Triangle (16) because 64 ÷ 16 = 4",
|
||||
"simpleEqual": {
|
||||
"name": "Equal",
|
||||
"desc": "Your 25 captures their 25"
|
||||
},
|
||||
"simpleMultiple": {
|
||||
"name": "Multiple / Divisor",
|
||||
"desc": "Your 64 captures their 16 (64 ÷ 16 = 4)"
|
||||
},
|
||||
"advancedTitle": "Advanced Relations (need one helper piece)",
|
||||
"sum": "Sum",
|
||||
"sumExample": "Your 9 + helper 16 = enemy 25",
|
||||
"sumCaption": "White Circle (9) can capture Black Circle (25) using helper Triangle (16): 9 + 16 = 25",
|
||||
"difference": "Difference",
|
||||
"differenceExample": "Your 30 - helper 10 = enemy 20",
|
||||
"differenceCaption": "White Triangle (30) can capture Black Triangle (20) using helper Circle (10): 30 - 10 = 20",
|
||||
"product": "Product",
|
||||
"productExample": "Your 5 × helper 5 = enemy 25",
|
||||
"productCaption": "White Circle (5) can capture Black Circle (25) using helper Circle (5): 5 × 5 = 25",
|
||||
"ratio": "Ratio",
|
||||
"ratioExample": "Your 20 ÷ helper 4 = enemy 5",
|
||||
"ratioCaption": "White Triangle (20) can capture Black Circle (5) using helper Circle (4): 20 ÷ 4 = 5",
|
||||
"advancedSum": {
|
||||
"name": "Sum",
|
||||
"desc": "Your 9 + helper 16 = enemy 25"
|
||||
},
|
||||
"advancedDifference": {
|
||||
"name": "Difference",
|
||||
"desc": "Your 30 - helper 10 = enemy 20"
|
||||
},
|
||||
"advancedProduct": {
|
||||
"name": "Product",
|
||||
"desc": "Your 5 × helper 5 = enemy 25"
|
||||
},
|
||||
"helpersTitle": "💡 What are helpers?",
|
||||
"helpersDescription": "Helpers are your other pieces on the board. They don't move - they just add their number to the math. The game shows you which captures work when you click a piece.",
|
||||
"helpersDesc": "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.",
|
||||
"example1Title": "Example: Multiple/Divisor Capture",
|
||||
"example1Desc": "White's 64 (square) can capture Black's 16 (triangle) because 64 is a multiple of 16",
|
||||
"example2Title": "Example: Sum Capture with Helper",
|
||||
"example2Desc": "White's 9 + helper 16 = Black's 25 (9 + 16 = 25)",
|
||||
"example3Title": "Example: Difference Capture with Helper",
|
||||
"example3Desc": "White's 30 - helper 10 = Black's 20 (30 - 10 = 20)",
|
||||
"example4Title": "Example: Product Capture with Helper",
|
||||
"example4Desc": "White's 4 × helper 5 = Black's 20 (4 × 5 = 20)",
|
||||
"example5Title": "Example: Ratio Capture with Helper",
|
||||
"example5Desc": "White's 20 ÷ helper 4 = Black's 5 (20 ÷ 4 = 5)",
|
||||
"pyramidTitle": "Special: Pyramid Captures",
|
||||
"pyramidIntro": "Pyramids have 4 face values, making them incredibly versatile. You choose which face to use when attempting a capture, allowing one Pyramid to threaten multiple enemy pieces.",
|
||||
"pyramidEx1Title": "Example: Pyramid Face Selection (Equality)",
|
||||
"pyramidEx1Desc": "White's Pyramid (faces: 64, 49, 36, 25) can capture Black's 49 by choosing face 49 for equality (49 = 49)",
|
||||
"pyramidEx1Note": "<strong>Face selection:</strong> White declares face 49 → 49 = 49 (equality) → Capture succeeds! The Pyramid could also capture pieces valued 64, 36, or 25 using the corresponding faces.",
|
||||
"pyramidEx2Title": "Example: Pyramid with Helper (Sum)",
|
||||
"pyramidEx2Desc": "White's Pyramid uses face 25 + helper 20 = Black's 45 (25 + 20 = 45)",
|
||||
"pyramidEx2Note": "<strong>Face selection:</strong> White chooses face 25 and declares helper at D4 (value 20) → 25 + 20 = 45 (sum) → Capture succeeds! By selecting different faces, the same Pyramid could capture other values using various helpers.",
|
||||
"pyramidEx3Title": "Example: Pyramid Flexibility (Multiple/Divisor)",
|
||||
"pyramidEx3Desc": "Black's Pyramid (faces: 36, 25, 16, 4) can capture White's 9 using face 36 (multiple: 36 ÷ 9 = 4)",
|
||||
"pyramidEx3Note": "<strong>Face selection:</strong> Black chooses face 36 → 36 ÷ 9 = 4 (multiple) → Capture succeeds! Note: Black could also use face 4 with a helper 5 for sum (4 + 5 = 9), showing multiple valid approaches."
|
||||
},
|
||||
"harmony": {
|
||||
"title": "Harmonies: The Coolest Way to Win",
|
||||
"intro": "A Harmony is how you win the game. Get 3 of your pieces into enemy territory in a straight line. Their values must form a mathematical pattern.",
|
||||
"introDetail": "Think of it like a number sequence. There are 3 types of patterns you can make:",
|
||||
"arithmetic": "1. Arithmetic (Easiest)",
|
||||
"arithmeticDesc": "The middle number is exactly between the other two. Equal spacing.",
|
||||
"arithmeticFormula": "Middle × 2 = First + Last",
|
||||
"arithmeticTip": "Small circles (2-9) work great here. Look for equal gaps! Example: 6, 9, 12",
|
||||
"arithmeticCaption": "White pieces 6, 9, 12 in a row in enemy territory form an arithmetic progression",
|
||||
"geometric": "2. Geometric (Multiply by Same Number)",
|
||||
"geometricDesc": "Multiply by the same number each time. Equal ratios.",
|
||||
"geometricFormula": "Middle² = First × Last",
|
||||
"geometricTip": "Square numbers work great! Example: 4, 8, 16 (multiply by 2 each time)",
|
||||
"geometricCaption": "White pieces 4, 8, 16 in a row in enemy territory form a geometric progression",
|
||||
"harmonic": "3. Harmonic (Tricky!)",
|
||||
"harmonicDesc": "Based on musical harmonies. Hardest to spot.",
|
||||
"harmonicFormula": "2 × First × Last = Middle × (First + Last)",
|
||||
"harmonicTip": "These are rare. Just memorize these combos: 3-4-6, 4-6-12, 6-8-12, 6-10-15",
|
||||
"harmonicCaption": "White pieces 6, 8, 12 in a row in enemy territory form a harmonic progression",
|
||||
"howToCheck": "How to check:",
|
||||
"example": "Example:",
|
||||
"check": "Check:",
|
||||
"differences": "Differences:",
|
||||
"equal": "(equal!)",
|
||||
"ratios": "Ratios:",
|
||||
"strategyTip": "Strategy tip:",
|
||||
"rulesTitle": "⚠️ Important Rules",
|
||||
"enemyTerritoryTitle": "Must be in enemy territory:",
|
||||
"enemyTerritory": "All 3 pieces must be on opponent's side (White: rows 5-8, Black: rows 1-4)",
|
||||
"straightLineTitle": "Must be in a line:",
|
||||
"straightLine": "Row, column, or diagonal. No scattered pieces!",
|
||||
"adjacentTitle": "Must be touching:",
|
||||
"adjacent": "The 3 pieces must be next to each other with no gaps",
|
||||
"survivalTitle": "Must survive one turn:",
|
||||
"survival": "After you declare harmony, opponent gets ONE turn to break it",
|
||||
"victoryTitle": "Then you win:",
|
||||
"victoryRule": "If it survives, you win on your next turn!",
|
||||
"strategyTitle": "How to Win with Harmonies",
|
||||
"startWith2Title": "Start with 2, then add the 3rd",
|
||||
"startWith2": "Get 2 pieces deep first. Figure out which 3rd piece completes the pattern. Then push that piece forward. They might not see it coming!",
|
||||
"useCommonTitle": "Use common numbers",
|
||||
"useCommon": "Numbers like 6, 8, 9, 12, 16 work in lots of patterns. If you get these deep, check what 3rd piece finishes the job.",
|
||||
"protectTitle": "Protect your pieces",
|
||||
"protect": "While building, keep other pieces nearby to defend. One capture ruins everything!",
|
||||
"blockTitle": "Block their harmonies",
|
||||
"block": "If opponent has 2 pieces deep, figure out what 3rd piece they need. Block that spot or capture one of their pieces ASAP.",
|
||||
"calculateTitle": "Check before you declare",
|
||||
"calculate": "Before declaring, make sure opponent can't capture any of your 3 pieces. If they can, protect first or wait.",
|
||||
"quickRefTitle": "💡 Quick Reference: Common Harmonies in Your Army"
|
||||
},
|
||||
"victory": {
|
||||
"title": "How to Win",
|
||||
"harmony": "Victory #1: Harmony (Progression)",
|
||||
"harmonyDesc": "Form a mathematical progression with 3 pieces in enemy territory. If it survives your opponent's next turn, you win!",
|
||||
"exampleTitle": "Example: White Wins!",
|
||||
"exampleCaption": "White pieces 4, 8, 16 form a geometric progression in enemy territory. Black cannot break it - White wins!",
|
||||
"harmonyNote": "This is the primary victory condition in Rithmomachia",
|
||||
"exhaustion": "Victory #2: Exhaustion",
|
||||
"exhaustionDesc": "If your opponent has no legal moves at the start of their turn, they lose.",
|
||||
"strategyTitle": "Quick Tips",
|
||||
"tip1": "Control the center - easier to push forward",
|
||||
"tip2": "Small pieces are fast - circles can slip into enemy side quickly",
|
||||
"tip3": "Big pieces are powerful - harder to capture",
|
||||
"tip4": "Watch for their harmonies - don't let 3 pieces get deep",
|
||||
"tip5": "Pyramids are flexible - pick the right value for each capture"
|
||||
},
|
||||
"strategy": {
|
||||
"title": "Strategy & Tactics",
|
||||
"intro": "Rithmomachia rewards both mathematical insight and strategic planning. Success requires balancing territorial control, piece preservation, and mathematical opportunities.",
|
||||
"openingPrinciples": {
|
||||
"title": "Opening Principles",
|
||||
"controlCenter": {
|
||||
"title": "Control the Center",
|
||||
"desc": "Advance pieces toward the 8-column empty center (columns E–L). This provides mobility and creates capturing opportunities from multiple angles."
|
||||
},
|
||||
"developCircles": {
|
||||
"title": "Develop Small Circles First",
|
||||
"desc": "Your small circles (2–9) in columns D and M are mobile and versatile. Move them toward the center early to establish presence and create helper networks."
|
||||
},
|
||||
"protectPyramid": {
|
||||
"title": "Protect Your Pyramid",
|
||||
"desc": "The Pyramid is your most flexible piece (4 face values) but moves only 1 square. Keep it behind advancing lines until mid-game when mathematical threats are clear."
|
||||
},
|
||||
"knowNumbers": {
|
||||
"title": "Know Your Numbers",
|
||||
"desc": "Memorize key mathematical relationships in your army. Identify which pieces form factors, multiples, and sums—this speeds up tactical calculation."
|
||||
}
|
||||
},
|
||||
"midGame": {
|
||||
"title": "Mid-Game Tactics",
|
||||
"helperNetworks": {
|
||||
"title": "Build Helper Networks",
|
||||
"desc": "Position pieces so multiple helpers can support captures. For example, if you have pieces valued 5 and 10, you can capture 15 (sum), 5 (difference), or 50 (product) when your mover is positioned correctly."
|
||||
},
|
||||
"createThreats": {
|
||||
"title": "Create Capture Threats",
|
||||
"desc": "Force your opponent to defend multiple pieces simultaneously. Even if you can't execute all captures, the threat constrains their options and controls the tempo."
|
||||
},
|
||||
"thinkDefensively": {
|
||||
"title": "Think Defensively",
|
||||
"desc": "After each move, check which of your pieces can be captured. High-value pieces like squares (169, 225, 289, 361) are vulnerable to many relations—position them behind defenders or in protected squares."
|
||||
},
|
||||
"exchangeWhenAhead": {
|
||||
"title": "Exchange When Ahead",
|
||||
"desc": "If you've captured more pieces or higher values, simplify the position by trading pieces. This reduces your opponent's attacking options and brings you closer to exhaustion victory."
|
||||
}
|
||||
},
|
||||
"victoryPaths": {
|
||||
"title": "Paths to Victory",
|
||||
"harmony": {
|
||||
"title": "Harmony Victory (Most Elegant)",
|
||||
"desc": "Place 3 pieces in enemy territory forming an arithmetic, geometric, or harmonic progression. Common triads:",
|
||||
"arithmetic": "<strong>Arithmetic:</strong> (6, 9, 12), (5, 7, 9), (8, 12, 16)",
|
||||
"geometric": "<strong>Geometric:</strong> (4, 8, 16), (3, 9, 27), (2, 8, 32)",
|
||||
"harmonic": "<strong>Harmonic:</strong> (6, 8, 12), (10, 12, 15), (6, 10, 15)"
|
||||
},
|
||||
"exhaustion": {
|
||||
"title": "Exhaustion Victory (Attrition)",
|
||||
"desc": "Capture pieces systematically until your opponent has no legal moves. Focus on: eliminating mobile pieces (Squares and Triangles), blocking diagonals and ranks, and forcing the Pyramid into a corner."
|
||||
},
|
||||
"points": {
|
||||
"title": "Point Victory (Optional Rule)",
|
||||
"desc": "If enabled, capture 30 points worth of pieces (C=1, T=2, S=3, P=5). Hunt high-value targets and preserve your own heavy pieces. Trade advantageously."
|
||||
}
|
||||
},
|
||||
"commonMistakes": {
|
||||
"title": "⚠️ Common Mistakes to Avoid",
|
||||
"movingWithoutCalc": "<strong>Moving without calculation:</strong> Always check if your destination square is capturable by enemy pieces",
|
||||
"ignoringGeometry": "<strong>Ignoring helper geometry:</strong> Helpers can be anywhere, but you still need a legal move path to capture",
|
||||
"neglectingHarmony": "<strong>Neglecting harmony threats:</strong> If your opponent has 2 pieces in your territory forming part of a progression, block their third",
|
||||
"exposingPyramid": "<strong>Leaving the Pyramid exposed:</strong> It moves only 1 square and has limited escape options—protect it early"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced Concepts",
|
||||
"sacrifices": {
|
||||
"title": "Positional Sacrifices",
|
||||
"desc": "Sometimes sacrificing a piece opens lines for your other pieces or forces your opponent into a worse position. Calculate the resulting imbalance before sacrificing."
|
||||
},
|
||||
"pyramidFaces": {
|
||||
"title": "Pyramid Face Selection",
|
||||
"desc": "Your Pyramid has 4 face values. In captures, choose the face that maximizes future flexibility. In harmony declarations, choose the face that completes the most valuable progression type."
|
||||
},
|
||||
"tempo": {
|
||||
"title": "Tempo and Initiative",
|
||||
"desc": "Each move that forces a defensive response gains tempo. String together forcing moves (captures, threats) to dictate the pace and prevent your opponent from executing their plan."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
{
|
||||
"guide": {
|
||||
"title": "Guía de Juego",
|
||||
"subtitle": "Rithmomachia – El Juego de los Filósofos",
|
||||
"bustOut": "Abrir en ventana nueva",
|
||||
"close": "Cerrar",
|
||||
"maximize": "Maximizar",
|
||||
"restore": "Restaurar",
|
||||
"sections": {
|
||||
"overview": "Inicio Rápido",
|
||||
"pieces": "Piezas",
|
||||
"capture": "Captura",
|
||||
"strategy": "Estrategia",
|
||||
"harmony": "Armonía",
|
||||
"victory": "Victoria"
|
||||
},
|
||||
"languageSelector": {
|
||||
"label": "Idioma",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"ja": "日本語",
|
||||
"hi": "हिन्दी",
|
||||
"es": "Español",
|
||||
"la": "Latina"
|
||||
},
|
||||
"overview": {
|
||||
"goalTitle": "Objetivo del Juego",
|
||||
"goalDesc": "Organiza <strong>3 de tus piezas en territorio enemigo</strong> para formar una <strong>progresión matemática</strong>, sobrevive un turno del oponente y gana.",
|
||||
"boardTitle": "El Tablero",
|
||||
"boardItems": [
|
||||
"8 filas × 16 columnas (columnas A-P, filas 1-8)",
|
||||
"<strong>Tu mitad:</strong> Negro controla filas 5-8, Blanco controla filas 1-4",
|
||||
"<strong>Territorio enemigo:</strong> Donde necesitas construir tu progresión ganadora"
|
||||
],
|
||||
"howToPlayTitle": "Cómo Jugar",
|
||||
"howToPlayItems": [
|
||||
"Comienza moviendo piezas hacia el centro",
|
||||
"Busca oportunidades de captura usando relaciones matemáticas",
|
||||
"Avanza hacia territorio enemigo (filas 1-4 para Negro, filas 5-8 para Blanco)",
|
||||
"Busca oportunidades de armonía con tus piezas avanzadas",
|
||||
"¡Gana formando una progresión que sobreviva un turno!"
|
||||
],
|
||||
"goal": "Organiza 3 de tus piezas en territorio enemigo para formar una progresión matemática, sobrevive un turno del oponente y gana.",
|
||||
"boardSize": "8 filas × 16 columnas (columnas A-P, filas 1-8)",
|
||||
"territory": "Tu mitad: Negro controla filas 5-8, Blanco controla filas 1-4",
|
||||
"enemyTerritory": "Territorio enemigo: Donde necesitas construir tu progresión ganadora",
|
||||
"step1": "Comienza moviendo piezas hacia el centro",
|
||||
"step2": "Busca oportunidades de captura usando relaciones matemáticas",
|
||||
"step3": "Avanza hacia territorio enemigo (filas 1-4 para Negro, filas 5-8 para Blanco)",
|
||||
"step4": "Busca oportunidades de armonía con tus piezas avanzadas",
|
||||
"step5": "¡Gana formando una progresión que sobreviva un turno!"
|
||||
},
|
||||
"pieces": {
|
||||
"title": "Tus Piezas (24 en total)",
|
||||
"intro": "Cada pieza tiene un <strong>valor numérico</strong> y se mueve de manera diferente:",
|
||||
"pieceTypes": {
|
||||
"circle": {
|
||||
"name": "Círculo",
|
||||
"movement": "Diagonal (como un alfil)",
|
||||
"count": "Cantidad: 8"
|
||||
},
|
||||
"triangle": {
|
||||
"name": "Triángulo",
|
||||
"movement": "Líneas rectas (como una torre)",
|
||||
"count": "Cantidad: 8"
|
||||
},
|
||||
"square": {
|
||||
"name": "Cuadrado",
|
||||
"movement": "Cualquier dirección (como una reina)",
|
||||
"count": "Cantidad: 7"
|
||||
},
|
||||
"pyramid": {
|
||||
"name": "Pirámide",
|
||||
"movement": "Un paso en cualquier dirección (como un rey)",
|
||||
"count": "Cantidad: 1"
|
||||
}
|
||||
},
|
||||
"pyramidSpecial": {
|
||||
"title": "⭐ Pirámides: Las Piezas Multifacéticas",
|
||||
"intro": "A diferencia de otras piezas con un solo valor, las pirámides contienen <strong>4 valores de cara</strong> que representan cuadrados perfectos. Al capturar una pieza enemiga, eliges qué cara usar para la relación matemática.",
|
||||
"blackFaces": "Caras de Pirámide Negra:",
|
||||
"blackValues": "36 (6²), 25 (5²), 16 (4²), 4 (2²)",
|
||||
"whiteFaces": "Caras de Pirámide Blanca:",
|
||||
"whiteValues": "64 (8²), 49 (7²), 36 (6²), 25 (5²)",
|
||||
"howItWorks": "Cómo funciona la selección de caras:",
|
||||
"rules": [
|
||||
"Cuando tu Pirámide intenta una captura, debes declarar qué valor de cara estás usando antes de que se verifique la relación",
|
||||
"El valor de cara elegido se convierte en \"el valor de tu pieza\" para todas las relaciones matemáticas (igualdad, múltiplo/divisor, suma, diferencia, producto, ratio)",
|
||||
"Puedes elegir diferentes caras para diferentes capturas: la Pirámide no se \"bloquea\" en un valor",
|
||||
"Esta flexibilidad hace que las Pirámides sean excelentes para crear oportunidades de captura inesperadas y ayudantes versátiles"
|
||||
],
|
||||
"example": "<strong>Ejemplo:</strong> La Pirámide Blanca puede capturar el 16 Negro usando cara 64 (múltiplo: 64÷16=4), cara 36 (múltiplo: 36÷9=4, con el 9 Negro), o cara 25 con igualdad si captura el 25 Negro.",
|
||||
"visualTitle": "Ejemplo Visual: Múltiples Opciones de Captura de la Pirámide",
|
||||
"visualDesc": "La Pirámide Blanca (caras: 64, 49, 36, 25) está posicionada para capturar piezas Negras. Nota la flexibilidad:",
|
||||
"captureOptions": "<strong>Opciones de captura desde H5:</strong>",
|
||||
"option1": "Mover a I5: Elegir cara <strong>64</strong> → captura 16 por múltiplo (64÷16=4)",
|
||||
"option2": "Mover a H6: Elegir cara <strong>49</strong> → captura 49 por igualdad (49=49)",
|
||||
"option3": "Mover a G5: Elegir cara <strong>25</strong> → captura 25 por igualdad (25=25)"
|
||||
},
|
||||
"description": "Cada lado tiene 25 piezas con diferentes patrones de movimiento. La forma te dice cómo se mueve:",
|
||||
"count": "Cantidad",
|
||||
"circle": "Círculo",
|
||||
"circleMove": "Se mueve en diagonal",
|
||||
"triangle": "Triángulo",
|
||||
"triangleMove": "Se mueve en líneas rectas",
|
||||
"square": "Cuadrado",
|
||||
"squareMove": "Se mueve en cualquier dirección",
|
||||
"pyramid": "Pirámide",
|
||||
"pyramidMove": "Un paso en cualquier dirección (como un rey)",
|
||||
"exampleValues": "Valores de ejemplo",
|
||||
"example": "Ejemplo:"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Cómo Capturar",
|
||||
"intro": "Puedes capturar una pieza enemiga <strong>solo si el valor de tu pieza se relaciona matemáticamente</strong> con la suya:",
|
||||
"simpleTitle": "Relaciones Simples (no se necesita ayudante)",
|
||||
"simpleEqual": {
|
||||
"name": "Igual",
|
||||
"desc": "Tu 25 captura su 25"
|
||||
},
|
||||
"simpleMultiple": {
|
||||
"name": "Múltiplo / Divisor",
|
||||
"desc": "Tu 64 captura su 16 (64 ÷ 16 = 4)"
|
||||
},
|
||||
"advancedTitle": "Relaciones Avanzadas (se necesita una pieza ayudante)",
|
||||
"advancedSum": {
|
||||
"name": "Suma",
|
||||
"desc": "Tu 9 + ayudante 16 = enemigo 25"
|
||||
},
|
||||
"advancedDifference": {
|
||||
"name": "Diferencia",
|
||||
"desc": "Tu 30 - ayudante 10 = enemigo 20"
|
||||
},
|
||||
"advancedProduct": {
|
||||
"name": "Producto",
|
||||
"desc": "Tu 5 × ayudante 5 = enemigo 25"
|
||||
},
|
||||
"helpersTitle": "💡 ¿Qué son los ayudantes?",
|
||||
"helpersDesc": "Los ayudantes son tus otras piezas que aún están en el tablero: no se mueven, solo proporcionan su valor para las matemáticas. El juego te mostrará capturas válidas cuando selecciones una pieza.",
|
||||
"example1Title": "Ejemplo: Captura de Múltiplo/Divisor",
|
||||
"example1Desc": "El 64 de Blanco (cuadrado) puede capturar el 16 de Negro (triángulo) porque 64 es múltiplo de 16",
|
||||
"example2Title": "Ejemplo: Captura de Suma con Ayudante",
|
||||
"example2Desc": "El 9 de Blanco + ayudante 16 = el 25 de Negro (9 + 16 = 25)",
|
||||
"example3Title": "Ejemplo: Captura de Diferencia con Ayudante",
|
||||
"example3Desc": "El 30 de Blanco - ayudante 10 = el 20 de Negro (30 - 10 = 20)",
|
||||
"example4Title": "Ejemplo: Captura de Producto con Ayudante",
|
||||
"example4Desc": "El 4 de Blanco × ayudante 5 = el 20 de Negro (4 × 5 = 20)",
|
||||
"example5Title": "Ejemplo: Captura de Ratio con Ayudante",
|
||||
"example5Desc": "El 20 de Blanco ÷ ayudante 4 = el 5 de Negro (20 ÷ 4 = 5)",
|
||||
"pyramidTitle": "Especial: Capturas de Pirámide",
|
||||
"pyramidIntro": "Las pirámides tienen 4 valores de cara, haciéndolas increíblemente versátiles. Eliges qué cara usar al intentar una captura, permitiendo que una Pirámide amenace múltiples piezas enemigas.",
|
||||
"pyramidEx1Title": "Ejemplo: Selección de Cara de Pirámide (Igualdad)",
|
||||
"pyramidEx1Desc": "La Pirámide de Blanco (caras: 64, 49, 36, 25) puede capturar el 49 de Negro eligiendo cara 49 para igualdad (49 = 49)",
|
||||
"pyramidEx1Note": "<strong>Selección de cara:</strong> Blanco declara cara 49 → 49 = 49 (igualdad) → ¡Captura exitosa! La Pirámide también podría capturar piezas con valores 64, 36 o 25 usando las caras correspondientes.",
|
||||
"pyramidEx2Title": "Ejemplo: Pirámide con Ayudante (Suma)",
|
||||
"pyramidEx2Desc": "La Pirámide de Blanco usa cara 25 + ayudante 20 = el 45 de Negro (25 + 20 = 45)",
|
||||
"pyramidEx2Note": "<strong>Selección de cara:</strong> Blanco elige cara 25 y declara ayudante en D4 (valor 20) → 25 + 20 = 45 (suma) → ¡Captura exitosa! Al seleccionar diferentes caras, la misma Pirámide podría capturar otros valores usando varios ayudantes.",
|
||||
"pyramidEx3Title": "Ejemplo: Flexibilidad de Pirámide (Múltiplo/Divisor)",
|
||||
"pyramidEx3Desc": "La Pirámide de Negro (caras: 36, 25, 16, 4) puede capturar el 9 de Blanco usando cara 36 (múltiplo: 36 ÷ 9 = 4)",
|
||||
"pyramidEx3Note": "<strong>Selección de cara:</strong> Negro elige cara 36 → 36 ÷ 9 = 4 (múltiplo) → ¡Captura exitosa! Nota: Negro también podría usar cara 4 con un ayudante 5 para suma (4 + 5 = 9), mostrando múltiples enfoques válidos.",
|
||||
"description": "Solo puedes capturar una pieza enemiga si el valor de tu pieza tiene una relación matemática con la suya:",
|
||||
"equality": "Igual",
|
||||
"equalityExample": "Tu 25 captura su 25",
|
||||
"equalityCaption": "El Círculo Blanco (25) puede capturar el Círculo Negro (25) por igualdad",
|
||||
"multiple": "Múltiplo / Divisor",
|
||||
"multipleExample": "Tu 64 captura su 16 (64 ÷ 16 = 4)",
|
||||
"multipleCaption": "El Cuadrado Blanco (64) puede capturar el Triángulo Negro (16) porque 64 ÷ 16 = 4",
|
||||
"sum": "Suma",
|
||||
"sumExample": "Tu 9 + ayudante 16 = enemigo 25",
|
||||
"sumCaption": "El Círculo Blanco (9) puede capturar el Círculo Negro (25) usando el Triángulo ayudante (16): 9 + 16 = 25",
|
||||
"difference": "Diferencia",
|
||||
"differenceExample": "Tu 30 - ayudante 10 = enemigo 20",
|
||||
"differenceCaption": "El Triángulo Blanco (30) puede capturar el Triángulo Negro (20) usando el Círculo ayudante (10): 30 - 10 = 20",
|
||||
"product": "Producto",
|
||||
"productExample": "Tu 5 × ayudante 5 = enemigo 25",
|
||||
"productCaption": "El Círculo Blanco (5) puede capturar el Círculo Negro (25) usando el Círculo ayudante (5): 5 × 5 = 25",
|
||||
"ratio": "Ratio",
|
||||
"ratioExample": "Tu 20 ÷ ayudante 4 = enemigo 5",
|
||||
"ratioCaption": "El Triángulo Blanco (20) puede capturar el Círculo Negro (5) usando el Círculo ayudante (4): 20 ÷ 4 = 5",
|
||||
"helpersDescription": "Los ayudantes son tus otras piezas en el tablero. No se mueven - solo agregan su número a las matemáticas. El juego te muestra qué capturas funcionan cuando haces clic en una pieza."
|
||||
},
|
||||
"harmony": {
|
||||
"title": "Armonías: La Victoria Elegante",
|
||||
"intro": "Una Armonía (también llamada \"Victoria Propia\") es la forma más sofisticada de ganar. Lleva 3 de tus piezas al territorio enemigo dispuestas en línea recta donde sus valores forman un patrón matemático.",
|
||||
"introDetail": "Piensa en ello como obtener tres números en una secuencia, pero las secuencias siguen reglas matemáticas especiales de la filosofía antigua y la teoría musical.",
|
||||
"arithmetic": "1. Progresión Aritmética (La Más Fácil de Entender)",
|
||||
"arithmeticDesc": "El número del medio está exactamente a medio camino entre los otros dos. En otras palabras, las diferencias son iguales.",
|
||||
"arithmeticFormula": "Medio × 2 = Primero + Último",
|
||||
"arithmeticTip": "Tus círculos pequeños (2-9) y muchos triángulos forman naturalmente progresiones aritméticas. ¡Busca tres piezas donde los espacios sean iguales!",
|
||||
"arithmeticCaption": "Las piezas blancas 6, 9, 12 en fila en territorio enemigo forman una progresión aritmética",
|
||||
"geometric": "2. Progresión Geométrica (Potencias y Múltiplos)",
|
||||
"geometricDesc": "Cada número se multiplica por la misma cantidad para obtener el siguiente. Los ratios son iguales.",
|
||||
"geometricFormula": "Medio² = Primero × Último",
|
||||
"geometricTip": "¡Los valores cuadrados (4, 9, 16, 25, 36, 49, 64, 81) funcionan genial aquí! Por ejemplo, 4-16-64 (cuadrados de 2, 4, 8).",
|
||||
"geometricCaption": "Las piezas blancas 4, 8, 16 en fila en territorio enemigo forman una progresión geométrica",
|
||||
"harmonic": "3. Progresión Armónica (Basada en Música, La Más Complicada)",
|
||||
"harmonicDesc": "Nombrada por las armonías musicales. El patrón es: el ratio de los números exteriores iguala el ratio de sus diferencias desde el medio.",
|
||||
"harmonicFormula": "2 × Primero × Último = Medio × (Primero + Último)",
|
||||
"harmonicTip": "Las progresiones armónicas son más raras. Memoriza tríadas comunes: (3,4,6), (4,6,12), (6,8,12), (6,10,15), (8,12,24).",
|
||||
"harmonicCaption": "Las piezas blancas 6, 8, 12 en fila en territorio enemigo forman una progresión armónica",
|
||||
"howToCheck": "Cómo verificar:",
|
||||
"example": "Ejemplo:",
|
||||
"check": "Verificar:",
|
||||
"differences": "Diferencias:",
|
||||
"equal": "(¡iguales!)",
|
||||
"ratios": "Ratios:",
|
||||
"strategyTip": "Consejo de estrategia:",
|
||||
"rulesTitle": "⚠️ Reglas de Armonía que Debes Seguir",
|
||||
"enemyTerritoryTitle": "Solo Territorio Enemigo:",
|
||||
"enemyTerritory": "Las 3 piezas deben estar en la mitad de tu oponente (Blanco necesita filas 5-8, Negro necesita filas 1-4)",
|
||||
"straightLineTitle": "Línea Recta:",
|
||||
"straightLine": "Las 3 piezas deben formar una fila, columna o diagonal, sin formaciones dispersas",
|
||||
"adjacentTitle": "Colocación Adyacente:",
|
||||
"adjacent": "En esta implementación, las 3 piezas deben estar una al lado de la otra (sin espacios)",
|
||||
"survivalTitle": "Regla de Supervivencia:",
|
||||
"survival": "Cuando declaras una armonía, tu oponente tiene UN turno para romperla capturando o moviendo una pieza",
|
||||
"victoryTitle": "Victoria:",
|
||||
"victoryRule": "Si tu armonía sobrevive hasta que comience tu siguiente turno, ¡ganas!",
|
||||
"strategyTitle": "Estrategia: Cómo Construir Armonías",
|
||||
"startWith2Title": "Comienza con 2, Agrega la Tercera",
|
||||
"startWith2": "Lleva primero dos piezas al territorio enemigo. Calcula qué tercera pieza completaría la progresión, luego avanza esa pieza. ¡Puede que tu oponente no vea la amenaza hasta que sea demasiado tarde!",
|
||||
"useCommonTitle": "Usa Valores Comunes",
|
||||
"useCommon": "Piezas como 6, 8, 9, 12, 16 aparecen en múltiples progresiones. Si tienes estas en territorio enemigo, calcula todas las posibles terceras piezas que completarían un patrón.",
|
||||
"protectTitle": "Protege la Línea",
|
||||
"protect": "Mientras construyes tu armonía, posiciona otras piezas para defender tus piezas avanzadas. ¡Una captura rompe la progresión!",
|
||||
"blockTitle": "Bloquea las Armonías del Oponente",
|
||||
"block": "Si tu oponente tiene 2 piezas en tu territorio formando parte de una progresión, identifica qué tercera pieza la completaría. Bloquea ese cuadrado o captura una de las dos piezas inmediatamente.",
|
||||
"calculateTitle": "Calcula Antes de Declarar",
|
||||
"calculate": "Antes de declarar armonía, examina si tu oponente puede capturar cualquiera de las 3 piezas en su turno. Si puede, protege esas piezas primero o espera un momento más seguro.",
|
||||
"quickRefTitle": "💡 Referencia Rápida: Armonías Comunes en Tu Ejército"
|
||||
},
|
||||
"victory": {
|
||||
"title": "Cómo Ganar",
|
||||
"harmony": "Victoria #1: Armonía (Progresión)",
|
||||
"exhaustion": "Victoria #2: Agotamiento",
|
||||
"exampleTitle": "Ejemplo: ¡Blanco Gana!",
|
||||
"exampleDesc": "Las piezas de Blanco en E6, F6, G6 forman 6, 9, 12 - una progresión aritmética en territorio de Negro. Si Negro no puede romper esto en su siguiente turno, ¡Blanco gana!",
|
||||
"exampleNote": "✓ Las 3 piezas resaltadas están en territorio enemigo (filas 5-8 para Blanco)\n✓ Forman una línea recta (fila horizontal 6)\n✓ Satisfacen la progresión aritmética: 9 = (6+12)/2",
|
||||
"tipsTitle": "Consejos Rápidos de Estrategia",
|
||||
"tips": [
|
||||
"<strong>Controla el centro</strong> — más fácil para invadir territorio enemigo",
|
||||
"<strong>Las piezas pequeñas son rápidas</strong> — los círculos (3, 5, 7, 9) pueden deslizarse rápidamente a la mitad enemiga",
|
||||
"<strong>Las piezas grandes son poderosas</strong> — más difíciles de capturar debido a su tamaño",
|
||||
"<strong>Vigila las amenazas de armonía</strong> — no dejes que el oponente coloque 3 piezas en lo profundo de tu territorio",
|
||||
"<strong>Las pirámides son flexibles</strong> — elige el valor de cara correcto para cada situación"
|
||||
],
|
||||
"harmonyDesc": "Forma una progresión matemática con 3 piezas en territorio enemigo. Si sobrevive al siguiente turno de tu oponente, ¡ganas!",
|
||||
"exampleCaption": "Las piezas de Blanco 4, 8, 16 forman una progresión geométrica en territorio enemigo. Negro no puede romperla - ¡Blanco gana!",
|
||||
"harmonyNote": "Esta es la condición de victoria principal en Rithmomachia",
|
||||
"exhaustionDesc": "Si tu oponente no tiene movimientos legales al comienzo de su turno, pierde.",
|
||||
"strategyTitle": "Consejos Rápidos",
|
||||
"tip1": "Controla el centro - más fácil avanzar",
|
||||
"tip2": "Las piezas pequeñas son rápidas - los círculos pueden deslizarse rápidamente al lado enemigo",
|
||||
"tip3": "Las piezas grandes son poderosas - más difíciles de capturar",
|
||||
"tip4": "Vigila sus armonías - no dejes que 3 piezas se adentren profundamente",
|
||||
"tip5": "Las pirámides son flexibles - elige el valor correcto para cada captura"
|
||||
},
|
||||
"strategy": {
|
||||
"title": "Estrategia y Tácticas",
|
||||
"intro": "Rithmomachia recompensa tanto la perspicacia matemática como la planificación estratégica. El éxito requiere equilibrar el control territorial, la preservación de piezas y las oportunidades matemáticas.",
|
||||
"openingPrinciples": {
|
||||
"title": "Principios de Apertura",
|
||||
"controlCenter": {
|
||||
"title": "Controla el Centro",
|
||||
"desc": "Avanza piezas hacia el centro vacío de 8 columnas (columnas E–L). Esto proporciona movilidad y crea oportunidades de captura desde múltiples ángulos."
|
||||
},
|
||||
"developCircles": {
|
||||
"title": "Desarrolla Primero los Círculos Pequeños",
|
||||
"desc": "Tus círculos pequeños (2-9) en las columnas D y M son móviles y versátiles. Muévelos hacia el centro temprano para establecer presencia y crear redes de ayudantes."
|
||||
},
|
||||
"protectPyramid": {
|
||||
"title": "Protege Tu Pirámide",
|
||||
"desc": "La Pirámide es tu pieza más flexible (4 valores de cara) pero solo se mueve 1 casilla. Mantenla detrás de las líneas que avanzan hasta el medio juego cuando las amenazas matemáticas sean claras."
|
||||
},
|
||||
"knowNumbers": {
|
||||
"title": "Conoce Tus Números",
|
||||
"desc": "Memoriza las relaciones matemáticas clave en tu ejército. Identifica qué piezas forman factores, múltiplos y sumas: esto acelera el cálculo táctico."
|
||||
}
|
||||
},
|
||||
"midGame": {
|
||||
"title": "Tácticas de Medio Juego",
|
||||
"helperNetworks": {
|
||||
"title": "Construye Redes de Ayudantes",
|
||||
"desc": "Posiciona piezas para que múltiples ayudantes puedan apoyar capturas. Por ejemplo, si tienes piezas valoradas en 5 y 10, puedes capturar 15 (suma), 5 (diferencia) o 50 (producto) cuando tu pieza móvil esté posicionada correctamente."
|
||||
},
|
||||
"createThreats": {
|
||||
"title": "Crea Amenazas de Captura",
|
||||
"desc": "Obliga a tu oponente a defender múltiples piezas simultáneamente. Incluso si no puedes ejecutar todas las capturas, la amenaza limita sus opciones y controla el tempo."
|
||||
},
|
||||
"thinkDefensively": {
|
||||
"title": "Piensa Defensivamente",
|
||||
"desc": "Después de cada movimiento, verifica cuáles de tus piezas pueden ser capturadas. Las piezas de alto valor como los cuadrados (169, 225, 289, 361) son vulnerables a muchas relaciones: colócalas detrás de defensores o en casillas protegidas."
|
||||
},
|
||||
"exchangeWhenAhead": {
|
||||
"title": "Intercambia Cuando Estés Adelante",
|
||||
"desc": "Si has capturado más piezas o valores más altos, simplifica la posición intercambiando piezas. Esto reduce las opciones de ataque de tu oponente y te acerca a la victoria por agotamiento."
|
||||
}
|
||||
},
|
||||
"victoryPaths": {
|
||||
"title": "Caminos a la Victoria",
|
||||
"harmony": {
|
||||
"title": "Victoria por Armonía (Más Elegante)",
|
||||
"desc": "Coloca 3 piezas en territorio enemigo formando una progresión aritmética, geométrica o armónica. Tríadas comunes:",
|
||||
"arithmetic": "<strong>Aritmética:</strong> (6, 9, 12), (5, 7, 9), (8, 12, 16)",
|
||||
"geometric": "<strong>Geométrica:</strong> (4, 8, 16), (3, 9, 27), (2, 8, 32)",
|
||||
"harmonic": "<strong>Armónica:</strong> (6, 8, 12), (10, 12, 15), (6, 10, 15)"
|
||||
},
|
||||
"exhaustion": {
|
||||
"title": "Victoria por Agotamiento (Desgaste)",
|
||||
"desc": "Captura piezas sistemáticamente hasta que tu oponente no tenga movimientos legales. Enfócate en: eliminar piezas móviles (Cuadrados y Triángulos), bloquear diagonales y filas, y forzar la Pirámide a una esquina."
|
||||
},
|
||||
"points": {
|
||||
"title": "Victoria por Puntos (Regla Opcional)",
|
||||
"desc": "Si está habilitado, captura 30 puntos en piezas (C=1, T=2, S=3, P=5). Caza objetivos de alto valor y preserva tus propias piezas pesadas. Intercambia ventajosamente."
|
||||
}
|
||||
},
|
||||
"commonMistakes": {
|
||||
"title": "⚠️ Errores Comunes a Evitar",
|
||||
"movingWithoutCalc": "<strong>Moverse sin calcular:</strong> Siempre verifica si tu casilla de destino puede ser capturada por piezas enemigas",
|
||||
"ignoringGeometry": "<strong>Ignorar la geometría del ayudante:</strong> Los ayudantes pueden estar en cualquier lugar, pero aún necesitas un camino de movimiento legal para capturar",
|
||||
"neglectingHarmony": "<strong>Descuidar las amenazas de armonía:</strong> Si tu oponente tiene 2 piezas en tu territorio formando parte de una progresión, bloquea su tercera",
|
||||
"exposingPyramid": "<strong>Dejar la Pirámide expuesta:</strong> Solo se mueve 1 casilla y tiene opciones de escape limitadas: protégela temprano"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Conceptos Avanzados",
|
||||
"sacrifices": {
|
||||
"title": "Sacrificios Posicionales",
|
||||
"desc": "A veces sacrificar una pieza abre líneas para tus otras piezas o fuerza a tu oponente a una posición peor. Calcula el desequilibrio resultante antes de sacrificar."
|
||||
},
|
||||
"pyramidFaces": {
|
||||
"title": "Selección de Caras de Pirámide",
|
||||
"desc": "Tu Pirámide tiene 4 valores de cara. En capturas, elige la cara que maximice la flexibilidad futura. En declaraciones de armonía, elige la cara que complete el tipo de progresión más valioso."
|
||||
},
|
||||
"tempo": {
|
||||
"title": "Tempo e Iniciativa",
|
||||
"desc": "Cada movimiento que fuerza una respuesta defensiva gana tempo. Encadena movimientos forzados (capturas, amenazas) para dictar el ritmo e impedir que tu oponente ejecute su plan."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
{
|
||||
"guide": {
|
||||
"title": "खेल गाइड",
|
||||
"subtitle": "रिथ्मोमैकिया - दार्शनिकों का खेल",
|
||||
"close": "बंद करें",
|
||||
"maximize": "अधिकतम करें",
|
||||
"restore": "पुनर्स्थापित करें",
|
||||
"sections": {
|
||||
"overview": "त्वरित प्रारंभ",
|
||||
"pieces": "मोहरे",
|
||||
"capture": "पकड़ना",
|
||||
"strategy": "रणनीति",
|
||||
"harmony": "सद्भाव",
|
||||
"victory": "विजय"
|
||||
},
|
||||
"languageSelector": {
|
||||
"label": "भाषा",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"ja": "日本語",
|
||||
"hi": "हिन्दी",
|
||||
"es": "Español",
|
||||
"la": "Latina"
|
||||
},
|
||||
"bustOut": "नई विंडो में खोलें",
|
||||
"overview": {
|
||||
"goalTitle": "खेल का लक्ष्य",
|
||||
"goalDesc": "दुश्मन के क्षेत्र में <strong>अपने 3 मोहरों को व्यवस्थित करें</strong> ताकि <strong>गणितीय प्रगति</strong> बन सके, विरोधी की एक चाल तक जीवित रहें और जीतें।",
|
||||
"boardTitle": "बोर्ड",
|
||||
"boardItems": [
|
||||
"8 पंक्तियाँ × 16 स्तंभ (स्तंभ A-P, पंक्तियाँ 1-8)",
|
||||
"<strong>आपका आधा:</strong> काला 5-8 पंक्तियों को नियंत्रित करता है, सफेद 1-4 पंक्तियों को",
|
||||
"<strong>दुश्मन का क्षेत्र:</strong> जहाँ आपको अपनी विजयी प्रगति बनानी होगी"
|
||||
],
|
||||
"howToPlayTitle": "कैसे खेलें",
|
||||
"howToPlayItems": [
|
||||
"मोहरों को केंद्र की ओर ले जाकर शुरू करें",
|
||||
"गणितीय संबंधों का उपयोग करके पकड़ने के अवसर खोजें",
|
||||
"दुश्मन के क्षेत्र में धकेलें (काले के लिए पंक्तियाँ 1-4, सफेद के लिए 5-8)",
|
||||
"अपने आगे के मोहरों के साथ सद्भाव के अवसरों की तलाश करें",
|
||||
"एक चाल तक जीवित रहने वाली प्रगति बनाकर जीतें!"
|
||||
],
|
||||
"goal": "दुश्मन के क्षेत्र में अपने 3 मोहरों को व्यवस्थित करें ताकि गणितीय प्रगति बने, प्रतिद्वंद्वी की एक चाल तक जीवित रहें और जीतें।",
|
||||
"boardSize": "8 पंक्तियाँ × 16 स्तंभ (स्तंभ A-P, पंक्तियाँ 1-8)",
|
||||
"territory": "आपका आधा: काला 5-8 पंक्तियों को नियंत्रित करता है, सफेद 1-4 पंक्तियों को",
|
||||
"enemyTerritory": "दुश्मन का क्षेत्र: जहाँ आपको अपनी विजयी प्रगति बनानी होगी",
|
||||
"step1": "मोहरों को केंद्र की ओर ले जाकर शुरू करें",
|
||||
"step2": "गणितीय संबंधों का उपयोग करके पकड़ने के अवसर खोजें",
|
||||
"step3": "दुश्मन के क्षेत्र में धकेलें (काले के लिए पंक्तियाँ 1-4, सफेद के लिए 5-8)",
|
||||
"step4": "अपने आगे के मोहरों के साथ सद्भाव के अवसरों की तलाश करें",
|
||||
"step5": "एक चाल तक जीवित रहने वाली प्रगति बनाकर जीतें!"
|
||||
},
|
||||
"pieces": {
|
||||
"title": "आपके मोहरे (कुल 24)",
|
||||
"intro": "प्रत्येक मोहरे का एक <strong>संख्यात्मक मान</strong> होता है और वे अलग-अलग तरीके से चलते हैं:",
|
||||
"pieceTypes": {
|
||||
"circle": {
|
||||
"name": "वृत्त",
|
||||
"movement": "विकर्ण (बिशप की तरह)",
|
||||
"count": "संख्या: 8"
|
||||
},
|
||||
"triangle": {
|
||||
"name": "त्रिकोण",
|
||||
"movement": "सीधी रेखाएं (रूक की तरह)",
|
||||
"count": "संख्या: 8"
|
||||
},
|
||||
"square": {
|
||||
"name": "वर्ग",
|
||||
"movement": "किसी भी दिशा में (रानी की तरह)",
|
||||
"count": "संख्या: 7"
|
||||
},
|
||||
"pyramid": {
|
||||
"name": "पिरामिड",
|
||||
"movement": "एक कदम किसी भी दिशा में (राजा की तरह)",
|
||||
"count": "संख्या: 1"
|
||||
}
|
||||
},
|
||||
"pyramidSpecial": {
|
||||
"title": "⭐ पिरामिड: बहु-मुखी मोहरे",
|
||||
"intro": "एकल मान वाले अन्य मोहरों के विपरीत, पिरामिड में <strong>4 फलक मान</strong> होते हैं जो पूर्ण वर्गों को दर्शाते हैं। जब दुश्मन के मोहरे को पकड़ते हैं, तो आप चुनते हैं कि गणितीय संबंध के लिए किस फलक का उपयोग करना है।",
|
||||
"blackFaces": "काले पिरामिड के फलक:",
|
||||
"blackValues": "36 (6²), 25 (5²), 16 (4²), 4 (2²)",
|
||||
"whiteFaces": "सफेद पिरामिड के फलक:",
|
||||
"whiteValues": "64 (8²), 49 (7²), 36 (6²), 25 (5²)",
|
||||
"howItWorks": "फलक चयन कैसे काम करता है:",
|
||||
"rules": [
|
||||
"जब आपका पिरामिड पकड़ने का प्रयास करता है, तो संबंध की जांच से पहले आपको यह घोषित करना होगा कि आप किस फलक मान का उपयोग कर रहे हैं",
|
||||
"चुना गया फलक मान सभी गणितीय संबंधों (समानता, गुणज/भाजक, योग, अंतर, गुणनफल, अनुपात) के लिए \"आपके मोहरे का मान\" बन जाता है",
|
||||
"आप विभिन्न पकड़ों के लिए विभिन्न फलकों को चुन सकते हैं—पिरामिड एक मान पर \"लॉक इन\" नहीं होता है",
|
||||
"यह लचीलापन पिरामिड को अप्रत्याशित पकड़ने के अवसर बनाने और बहुमुखी सहायकों के लिए उत्कृष्ट बनाता है"
|
||||
],
|
||||
"example": "<strong>उदाहरण:</strong> सफेद का पिरामिड फलक 64 का उपयोग करके काले के 16 को पकड़ सकता है (गुणज: 64÷16=4), फलक 36 का उपयोग करके काले के 9 के साथ (गुणज: 36÷9=4), या काले के 25 को पकड़ते समय समानता के साथ फलक 25 का उपयोग कर सकता है।",
|
||||
"visualTitle": "दृश्य उदाहरण: पिरामिड के कई पकड़ने के विकल्प",
|
||||
"visualDesc": "सफेद का पिरामिड (फलक: 64, 49, 36, 25) काले मोहरों को पकड़ने की स्थिति में है। लचीलेपन पर ध्यान दें:",
|
||||
"captureOptions": "<strong>H5 से पकड़ने के विकल्प:</strong>",
|
||||
"option1": "I5 पर जाएं: फलक <strong>64</strong> चुनें → गुणज द्वारा 16 को पकड़ें (64÷16=4)",
|
||||
"option2": "H6 पर जाएं: फलक <strong>49</strong> चुनें → समानता द्वारा 49 को पकड़ें (49=49)",
|
||||
"option3": "G5 पर जाएं: फलक <strong>25</strong> चुनें → समानता द्वारा 25 को पकड़ें (25=25)"
|
||||
},
|
||||
"description": "प्रत्येक पक्ष के पास अलग-अलग आंदोलन पैटर्न वाले 25 मोहरे हैं। आकार बताता है कि यह कैसे चलता है:",
|
||||
"count": "संख्या",
|
||||
"circle": "वृत्त",
|
||||
"circleMove": "विकर्ण रूप से चलता है",
|
||||
"triangle": "त्रिकोण",
|
||||
"triangleMove": "सीधी रेखाओं में चलता है",
|
||||
"square": "वर्ग",
|
||||
"squareMove": "किसी भी दिशा में चलता है",
|
||||
"pyramid": "पिरामिड",
|
||||
"pyramidMove": "किसी भी तरीके से एक कदम (राजा की तरह)",
|
||||
"exampleValues": "उदाहरण मान",
|
||||
"example": "उदाहरण:"
|
||||
},
|
||||
"capture": {
|
||||
"title": "कैसे पकड़ें",
|
||||
"intro": "आप दुश्मन के मोहरे को <strong>केवल तभी पकड़ सकते हैं जब आपके मोहरे का मान गणितीय रूप से संबंधित हो</strong> उनसे:",
|
||||
"simpleTitle": "साधारण संबंध (सहायक की आवश्यकता नहीं)",
|
||||
"simpleEqual": {
|
||||
"name": "समान",
|
||||
"desc": "आपका 25 उनके 25 को पकड़ता है"
|
||||
},
|
||||
"simpleMultiple": {
|
||||
"name": "गुणज / भाजक",
|
||||
"desc": "आपका 64 उनके 16 को पकड़ता है (64 ÷ 16 = 4)"
|
||||
},
|
||||
"advancedTitle": "उन्नत संबंध (एक सहायक मोहरे की आवश्यकता)",
|
||||
"advancedSum": {
|
||||
"name": "योग",
|
||||
"desc": "आपका 9 + सहायक 16 = दुश्मन 25"
|
||||
},
|
||||
"advancedDifference": {
|
||||
"name": "अंतर",
|
||||
"desc": "आपका 30 - सहायक 10 = दुश्मन 20"
|
||||
},
|
||||
"advancedProduct": {
|
||||
"name": "गुणनफल",
|
||||
"desc": "आपका 5 × सहायक 5 = दुश्मन 25"
|
||||
},
|
||||
"helpersTitle": "💡 सहायक क्या हैं?",
|
||||
"helpersDesc": "सहायक आपके अन्य मोहरे हैं जो अभी भी बोर्ड पर हैं — वे चलते नहीं हैं, बस गणित के लिए अपना मान प्रदान करते हैं। जब आप एक मोहरा चुनते हैं तो खेल आपको वैध पकड़ दिखाएगा।",
|
||||
"example1Title": "उदाहरण: गुणज/भाजक पकड़",
|
||||
"example1Desc": "सफेद का 64 (वर्ग) काले के 16 (त्रिकोण) को पकड़ सकता है क्योंकि 64, 16 का गुणज है",
|
||||
"example2Title": "उदाहरण: सहायक के साथ योग पकड़",
|
||||
"example2Desc": "सफेद का 9 + सहायक 16 = काले का 25 (9 + 16 = 25)",
|
||||
"example3Title": "उदाहरण: सहायक के साथ अंतर पकड़",
|
||||
"example3Desc": "सफेद का 30 - सहायक 10 = काले का 20 (30 - 10 = 20)",
|
||||
"example4Title": "उदाहरण: सहायक के साथ गुणनफल पकड़",
|
||||
"example4Desc": "सफेद का 4 × सहायक 5 = काले का 20 (4 × 5 = 20)",
|
||||
"example5Title": "उदाहरण: सहायक के साथ अनुपात पकड़",
|
||||
"example5Desc": "सफेद का 20 ÷ सहायक 4 = काले का 5 (20 ÷ 4 = 5)",
|
||||
"pyramidTitle": "विशेष: पिरामिड पकड़",
|
||||
"pyramidIntro": "पिरामिड में 4 फलक मान होते हैं, जो उन्हें अविश्वसनीय रूप से बहुमुखी बनाते हैं। पकड़ने का प्रयास करते समय आप चुनते हैं कि किस फलक का उपयोग करना है, जिससे एक पिरामिड कई दुश्मन मोहरों को धमकी दे सकता है।",
|
||||
"pyramidEx1Title": "उदाहरण: पिरामिड फलक चयन (समानता)",
|
||||
"pyramidEx1Desc": "सफेद का पिरामिड (फलक: 64, 49, 36, 25) समानता के लिए फलक 49 चुनकर काले के 49 को पकड़ सकता है (49 = 49)",
|
||||
"pyramidEx1Note": "<strong>फलक चयन:</strong> सफेद फलक 49 घोषित करता है → 49 = 49 (समानता) → पकड़ सफल! पिरामिड संबंधित फलकों का उपयोग करके 64, 36, या 25 मूल्य वाले मोहरों को भी पकड़ सकता है।",
|
||||
"pyramidEx2Title": "उदाहरण: सहायक के साथ पिरामिड (योग)",
|
||||
"pyramidEx2Desc": "सफेद का पिरामिड फलक 25 + सहायक 20 = काले का 45 का उपयोग करता है (25 + 20 = 45)",
|
||||
"pyramidEx2Note": "<strong>फलक चयन:</strong> सफेद फलक 25 चुनता है और D4 पर सहायक घोषित करता है (मान 20) → 25 + 20 = 45 (योग) → पकड़ सफल! विभिन्न फलकों का चयन करके, वही पिरामिड विभिन्न सहायकों का उपयोग करके अन्य मानों को पकड़ सकता है।",
|
||||
"pyramidEx3Title": "उदाहरण: पिरामिड लचीलापन (गुणज/भाजक)",
|
||||
"pyramidEx3Desc": "काले का पिरामिड (फलक: 36, 25, 16, 4) फलक 36 का उपयोग करके सफेद के 9 को पकड़ सकता है (गुणज: 36 ÷ 9 = 4)",
|
||||
"pyramidEx3Note": "<strong>फलक चयन:</strong> काला फलक 36 चुनता है → 36 ÷ 9 = 4 (गुणज) → पकड़ सफल! नोट: काला योग के लिए फलक 4 के साथ सहायक 5 का भी उपयोग कर सकता है (4 + 5 = 9), जो कई वैध दृष्टिकोण दिखाता है।",
|
||||
"description": "आप केवल एक दुश्मन मोहरे को पकड़ सकते हैं यदि आपके मोहरे का मान उनसे गणितीय संबंध रखता है:",
|
||||
"equality": "समान",
|
||||
"equalityExample": "आपका 25 उनके 25 को पकड़ता है",
|
||||
"equalityCaption": "सफेद वृत्त (25) समानता द्वारा काले वृत्त (25) को पकड़ सकता है",
|
||||
"multiple": "गुणज / भाजक",
|
||||
"multipleExample": "आपका 64 उनके 16 को पकड़ता है (64 ÷ 16 = 4)",
|
||||
"multipleCaption": "सफेद वर्ग (64) काले त्रिकोण (16) को पकड़ सकता है क्योंकि 64 ÷ 16 = 4",
|
||||
"sum": "योग",
|
||||
"sumExample": "आपका 9 + सहायक 16 = दुश्मन 25",
|
||||
"sumCaption": "सफेद वृत्त (9) सहायक त्रिकोण (16) का उपयोग करके काले वृत्त (25) को पकड़ सकता है: 9 + 16 = 25",
|
||||
"difference": "अंतर",
|
||||
"differenceExample": "आपका 30 - सहायक 10 = दुश्मन 20",
|
||||
"differenceCaption": "सफेद त्रिकोण (30) सहायक वृत्त (10) का उपयोग करके काले त्रिकोण (20) को पकड़ सकता है: 30 - 10 = 20",
|
||||
"product": "गुणनफल",
|
||||
"productExample": "आपका 5 × सहायक 5 = दुश्मन 25",
|
||||
"productCaption": "सफेद वृत्त (5) सहायक वृत्त (5) का उपयोग करके काले वृत्त (25) को पकड़ सकता है: 5 × 5 = 25",
|
||||
"ratio": "अनुपात",
|
||||
"ratioExample": "आपका 20 ÷ सहायक 4 = दुश्मन 5",
|
||||
"ratioCaption": "सफेद त्रिकोण (20) सहायक वृत्त (4) का उपयोग करके काले वृत्त (5) को पकड़ सकता है: 20 ÷ 4 = 5",
|
||||
"helpersDescription": "सहायक आपके अन्य मोहरे बोर्ड पर हैं। वे चलते नहीं हैं - वे केवल गणित में अपनी संख्या जोड़ते हैं। जब आप एक मोहरा क्लिक करते हैं तो खेल आपको दिखाता है कि कौन से कैप्चर काम करते हैं।"
|
||||
},
|
||||
"harmony": {
|
||||
"title": "सद्भाव: सुरुचिपूर्ण विजय",
|
||||
"intro": "सद्भाव (जिसे \"उचित विजय\" भी कहा जाता है) जीतने का सबसे परिष्कृत तरीका है। अपने 3 मोहरों को दुश्मन के क्षेत्र में एक सीधी रेखा में व्यवस्थित करें जहाँ उनके मान एक गणितीय पैटर्न बनाते हैं।",
|
||||
"introDetail": "इसे एक अनुक्रम में तीन संख्याएं प्राप्त करने की तरह समझें—लेकिन अनुक्रम प्राचीन दर्शन और संगीत सिद्धांत के विशेष गणितीय नियमों का पालन करते हैं।",
|
||||
"arithmetic": "1. अंकगणितीय प्रगति (समझने में सबसे आसान)",
|
||||
"arithmeticDesc": "बीच की संख्या अन्य दो के बीच में बिल्कुल आधी होती है। दूसरे शब्दों में, अंतर समान होते हैं।",
|
||||
"arithmeticFormula": "बीच × 2 = पहला + अंतिम",
|
||||
"arithmeticTip": "आपके छोटे वृत्त (2-9) और कई त्रिकोण स्वाभाविक रूप से अंकगणितीय प्रगति बनाते हैं। तीन मोहरों की तलाश करें जहाँ अंतराल समान हों!",
|
||||
"arithmeticCaption": "सफेद मोहरे 6, 9, 12 दुश्मन के क्षेत्र में एक पंक्ति में अंकगणितीय प्रगति बनाते हैं",
|
||||
"geometric": "2. ज्यामितीय प्रगति (घात और गुणज)",
|
||||
"geometricDesc": "प्रत्येक संख्या को अगली प्राप्त करने के लिए समान राशि से गुणा किया जाता है। अनुपात समान होते हैं।",
|
||||
"geometricFormula": "बीच² = पहला × अंतिम",
|
||||
"geometricTip": "वर्ग मान (4, 9, 16, 25, 36, 49, 64, 81) यहाँ बहुत अच्छे हैं! उदाहरण के लिए, 4-16-64 (2, 4, 8 के वर्ग)।",
|
||||
"geometricCaption": "सफेद मोहरे 4, 8, 16 दुश्मन के क्षेत्र में एक पंक्ति में ज्यामितीय प्रगति बनाते हैं",
|
||||
"harmonic": "3. हार्मोनिक प्रगति (संगीत-आधारित, सबसे मुश्किल)",
|
||||
"harmonicDesc": "संगीतीय सामंजस्य के नाम पर। पैटर्न है: बाहरी संख्याओं का अनुपात बीच से उनके अंतर के अनुपात के बराबर होता है।",
|
||||
"harmonicFormula": "2 × पहला × अंतिम = बीच × (पहला + अंतिम)",
|
||||
"harmonicTip": "हार्मोनिक प्रगति दुर्लभ हैं। सामान्य त्रिक याद रखें: (3,4,6), (4,6,12), (6,8,12), (6,10,15), (8,12,24)।",
|
||||
"harmonicCaption": "सफेद मोहरे 6, 8, 12 दुश्मन के क्षेत्र में एक पंक्ति में हार्मोनिक प्रगति बनाते हैं",
|
||||
"howToCheck": "कैसे जाँचें:",
|
||||
"example": "उदाहरण:",
|
||||
"check": "जाँच:",
|
||||
"differences": "अंतर:",
|
||||
"equal": "(समान!)",
|
||||
"ratios": "अनुपात:",
|
||||
"strategyTip": "रणनीति युक्ति:",
|
||||
"rulesTitle": "⚠️ सद्भाव नियम जिनका आपको पालन करना चाहिए",
|
||||
"enemyTerritoryTitle": "केवल दुश्मन का क्षेत्र:",
|
||||
"enemyTerritory": "सभी 3 मोहरों को प्रतिद्वंद्वी के आधे में होना चाहिए (सफेद को पंक्तियाँ 5-8, काले को पंक्तियाँ 1-4 चाहिए)",
|
||||
"straightLineTitle": "सीधी रेखा:",
|
||||
"straightLine": "3 मोहरों को एक पंक्ति, स्तंभ, या विकर्ण बनाना चाहिए—कोई बिखरी हुई संरचना नहीं",
|
||||
"adjacentTitle": "आसन्न प्लेसमेंट:",
|
||||
"adjacent": "इस कार्यान्वयन में, 3 मोहरों को एक दूसरे के बगल में होना चाहिए (कोई अंतराल नहीं)",
|
||||
"survivalTitle": "जीवित रहने का नियम:",
|
||||
"survival": "जब आप सद्भाव घोषित करते हैं, तो आपके प्रतिद्वंद्वी को एक मोहरा पकड़कर या स्थानांतरित करके इसे तोड़ने के लिए एक चाल मिलती है",
|
||||
"victoryTitle": "विजय:",
|
||||
"victoryRule": "यदि आपका सद्भाव आपकी अगली बारी शुरू होने तक जीवित रहता है—आप जीत जाते हैं!",
|
||||
"strategyTitle": "रणनीति: सद्भाव कैसे बनाएं",
|
||||
"startWith2Title": "2 से शुरू करें, तीसरा जोड़ें",
|
||||
"startWith2": "पहले दो मोहरे दुश्मन के क्षेत्र में ले जाएं। गणना करें कि कौन सा तीसरा मोहरा प्रगति को पूरा करेगा, फिर उस मोहरे को आगे बढ़ाएं। आपके प्रतिद्वंद्वी को खतरा बहुत देर हो जाने तक दिखाई नहीं दे सकता है!",
|
||||
"useCommonTitle": "सामान्य मानों का उपयोग करें",
|
||||
"useCommon": "6, 8, 9, 12, 16 जैसे मोहरे कई प्रगतियों में दिखाई देते हैं। यदि आपके पास दुश्मन के क्षेत्र में ये हैं, तो सभी संभावित तीसरे मोहरों की गणना करें जो एक पैटर्न को पूरा करेंगे।",
|
||||
"protectTitle": "लाइन की रक्षा करें",
|
||||
"protect": "अपने सद्भाव का निर्माण करते समय, अपने आगे बढ़ने वाले मोहरों की रक्षा के लिए अन्य मोहरों को स्थापित करें। एक पकड़ प्रगति को तोड़ती है!",
|
||||
"blockTitle": "प्रतिद्वंद्वी के सद्भाव को ब्लॉक करें",
|
||||
"block": "यदि आपके प्रतिद्वंद्वी के पास आपके क्षेत्र में 2 मोहरे हैं जो एक प्रगति का हिस्सा बनाते हैं, तो पहचानें कि कौन सा तीसरा मोहरा इसे पूरा करेगा। उस वर्ग को ब्लॉक करें या दो मोहरों में से एक को तुरंत पकड़ें।",
|
||||
"calculateTitle": "घोषित करने से पहले गणना करें",
|
||||
"calculate": "सद्भाव घोषित करने से पहले, जाँचें कि क्या आपका प्रतिद्वंद्वी अपनी बारी पर 3 में से किसी मोहरे को पकड़ सकता है। यदि वे कर सकते हैं, तो या तो पहले उन मोहरों की रक्षा करें या एक सुरक्षित क्षण की प्रतीक्षा करें।",
|
||||
"quickRefTitle": "💡 त्वरित संदर्भ: आपकी सेना में सामान्य सद्भाव"
|
||||
},
|
||||
"victory": {
|
||||
"title": "कैसे जीतें",
|
||||
"harmony": "विजय #1: सद्भाव (प्रगति)",
|
||||
"exhaustion": "विजय #2: थकावट",
|
||||
"exampleTitle": "उदाहरण: सफेद की जीत!",
|
||||
"exampleDesc": "E6, F6, G6 पर सफेद के मोहरे 6, 9, 12 बनाते हैं - काले के क्षेत्र में एक अंकगणितीय प्रगति। यदि काला अपनी अगली चाल पर इसे नहीं तोड़ सकता, तो सफेद की जीत है!",
|
||||
"exampleNote": "✓ सभी 3 हाइलाइट किए गए मोहरे दुश्मन के क्षेत्र में हैं (सफेद के लिए पंक्तियाँ 5-8)\n✓ वे एक सीधी रेखा बनाते हैं (क्षैतिज पंक्ति 6)\n✓ वे अंकगणितीय प्रगति को संतुष्ट करते हैं: 9 = (6+12)/2",
|
||||
"tipsTitle": "त्वरित रणनीति टिप्स",
|
||||
"tips": [
|
||||
"<strong>केंद्र को नियंत्रित करें</strong> — दुश्मन के क्षेत्र पर आक्रमण करना आसान",
|
||||
"<strong>छोटे मोहरे तेज हैं</strong> — वृत्त (3, 5, 7, 9) जल्दी से दुश्मन के आधे हिस्से में फिसल सकते हैं",
|
||||
"<strong>बड़े मोहरे शक्तिशाली हैं</strong> — उनके आकार के कारण पकड़ना कठिन",
|
||||
"<strong>सद्भाव के खतरों पर नज़र रखें</strong> — प्रतिद्वंद्वी को अपने क्षेत्र में गहराई से 3 मोहरे न रखने दें",
|
||||
"<strong>पिरामिड लचीले हैं</strong> — प्रत्येक स्थिति के लिए सही फलक मान चुनें"
|
||||
],
|
||||
"harmonyDesc": "दुश्मन के क्षेत्र में 3 मोहरों के साथ एक गणितीय प्रगति बनाएं। यदि यह आपके प्रतिद्वंद्वी की अगली चाल से बच जाता है, तो आप जीत जाते हैं!",
|
||||
"exampleCaption": "सफेद के मोहरे 4, 8, 16 दुश्मन के क्षेत्र में ज्यामितीय प्रगति बनाते हैं। काला इसे तोड़ नहीं सकता - सफेद की जीत!",
|
||||
"harmonyNote": "यह रिथ्मोमैकिया में प्राथमिक विजय की शर्त है",
|
||||
"exhaustionDesc": "यदि आपके प्रतिद्वंद्वी के पास अपनी बारी की शुरुआत में कोई कानूनी चाल नहीं है, तो वे हार जाते हैं।",
|
||||
"strategyTitle": "त्वरित टिप्स",
|
||||
"tip1": "केंद्र को नियंत्रित करें - आगे बढ़ना आसान",
|
||||
"tip2": "छोटे मोहरे तेज हैं - वृत्त जल्दी से दुश्मन की ओर फिसल सकते हैं",
|
||||
"tip3": "बड़े मोहरे शक्तिशाली हैं - पकड़ना कठिन",
|
||||
"tip4": "उनके सद्भावों पर ध्यान दें - 3 मोहरों को गहराई से न जाने दें",
|
||||
"tip5": "पिरामिड लचीले हैं - प्रत्येक कैप्चर के लिए सही मूल्य चुनें"
|
||||
},
|
||||
"strategy": {
|
||||
"title": "रणनीति और रणनीतियां",
|
||||
"intro": "रिथ्मोमैकिया गणितीय अंतर्दृष्टि और रणनीतिक योजना दोनों को पुरस्कृत करता है। सफलता के लिए क्षेत्रीय नियंत्रण, मोहरे के संरक्षण और गणितीय अवसरों को संतुलित करने की आवश्यकता होती है।",
|
||||
"openingPrinciples": {
|
||||
"title": "उद्घाटन सिद्धांत",
|
||||
"controlCenter": {
|
||||
"title": "केंद्र पर नियंत्रण रखें",
|
||||
"desc": "मोहरों को 8-स्तंभ खाली केंद्र (स्तंभ E–L) की ओर बढ़ाएं। यह गतिशीलता प्रदान करता है और कई कोणों से पकड़ने के अवसर बनाता है।"
|
||||
},
|
||||
"developCircles": {
|
||||
"title": "पहले छोटे वृत्तों को विकसित करें",
|
||||
"desc": "स्तंभ D और M में आपके छोटे वृत्त (2–9) गतिशील और बहुमुखी हैं। उन्हें जल्दी केंद्र की ओर ले जाएं ताकि उपस्थिति स्थापित हो और सहायक नेटवर्क बनें।"
|
||||
},
|
||||
"protectPyramid": {
|
||||
"title": "अपने पिरामिड की रक्षा करें",
|
||||
"desc": "पिरामिड आपका सबसे लचीला मोहरा है (4 फलक मान) लेकिन केवल 1 वर्ग चलता है। इसे मध्य खेल तक आगे बढ़ने वाली लाइनों के पीछे रखें जब गणितीय खतरे स्पष्ट हों।"
|
||||
},
|
||||
"knowNumbers": {
|
||||
"title": "अपनी संख्याओं को जानें",
|
||||
"desc": "अपनी सेना में प्रमुख गणितीय संबंधों को याद रखें। पहचानें कि कौन से मोहरे गुणनखंड, गुणज और योग बनाते हैं—यह रणनीतिक गणना को तेज करता है।"
|
||||
}
|
||||
},
|
||||
"midGame": {
|
||||
"title": "मध्य-खेल रणनीतियां",
|
||||
"helperNetworks": {
|
||||
"title": "सहायक नेटवर्क बनाएं",
|
||||
"desc": "मोहरों को इस तरह स्थापित करें कि कई सहायक पकड़ने का समर्थन कर सकें। उदाहरण के लिए, यदि आपके पास 5 और 10 मूल्य वाले मोहरे हैं, तो जब आपका चलने वाला मोहरा सही ढंग से स्थित हो तो आप 15 (योग), 5 (अंतर), या 50 (गुणनफल) पकड़ सकते हैं।"
|
||||
},
|
||||
"createThreats": {
|
||||
"title": "पकड़ने के खतरे बनाएं",
|
||||
"desc": "अपने प्रतिद्वंद्वी को एक साथ कई मोहरों की रक्षा करने के लिए मजबूर करें। भले ही आप सभी पकड़ नहीं सकते, खतरा उनके विकल्पों को सीमित करता है और टेम्पो को नियंत्रित करता है।"
|
||||
},
|
||||
"thinkDefensively": {
|
||||
"title": "रक्षात्मक रूप से सोचें",
|
||||
"desc": "प्रत्येक चाल के बाद, जांचें कि आपके कौन से मोहरे पकड़े जा सकते हैं। वर्ग (169, 225, 289, 361) जैसे उच्च-मूल्य वाले मोहरे कई संबंधों के प्रति संवेदनशील हैं—उन्हें रक्षकों के पीछे या संरक्षित वर्गों में रखें।"
|
||||
},
|
||||
"exchangeWhenAhead": {
|
||||
"title": "आगे होने पर विनिमय करें",
|
||||
"desc": "यदि आपने अधिक मोहरे या उच्च मूल्य पकड़े हैं, तो मोहरों का व्यापार करके स्थिति को सरल बनाएं। यह आपके प्रतिद्वंद्वी के हमले के विकल्पों को कम करता है और आपको थकावट विजय के करीब लाता है।"
|
||||
}
|
||||
},
|
||||
"victoryPaths": {
|
||||
"title": "विजय के मार्ग",
|
||||
"harmony": {
|
||||
"title": "सद्भाव विजय (सबसे सुरुचिपूर्ण)",
|
||||
"desc": "दुश्मन के क्षेत्र में 3 मोहरे रखें जो एक अंकगणितीय, ज्यामितीय, या हार्मोनिक प्रगति बनाते हैं। सामान्य त्रिक:",
|
||||
"arithmetic": "<strong>अंकगणितीय:</strong> (6, 9, 12), (5, 7, 9), (8, 12, 16)",
|
||||
"geometric": "<strong>ज्यामितीय:</strong> (4, 8, 16), (3, 9, 27), (2, 8, 32)",
|
||||
"harmonic": "<strong>हार्मोनिक:</strong> (6, 8, 12), (10, 12, 15), (6, 10, 15)"
|
||||
},
|
||||
"exhaustion": {
|
||||
"title": "थकावट विजय (घर्षण)",
|
||||
"desc": "व्यवस्थित रूप से मोहरों को पकड़ें जब तक कि आपके प्रतिद्वंद्वी के पास कोई कानूनी चाल न हो। ध्यान केंद्रित करें: गतिशील मोहरों (वर्ग और त्रिकोण) को समाप्त करना, विकर्णों और रैंकों को अवरुद्ध करना, और पिरामिड को एक कोने में मजबूर करना।"
|
||||
},
|
||||
"points": {
|
||||
"title": "अंक विजय (वैकल्पिक नियम)",
|
||||
"desc": "यदि सक्षम है, तो 30 अंक मूल्य के मोहरे पकड़ें (C=1, T=2, S=3, P=5)। उच्च-मूल्य लक्ष्यों का शिकार करें और अपने स्वयं के भारी मोहरों को संरक्षित करें। लाभप्रद रूप से व्यापार करें।"
|
||||
}
|
||||
},
|
||||
"commonMistakes": {
|
||||
"title": "⚠️ बचने योग्य सामान्य गलतियाँ",
|
||||
"movingWithoutCalc": "<strong>गणना के बिना चलना:</strong> हमेशा जांचें कि क्या आपका गंतव्य वर्ग दुश्मन के मोहरों द्वारा पकड़ा जा सकता है",
|
||||
"ignoringGeometry": "<strong>सहायक ज्यामिति को नजरअंदाज करना:</strong> सहायक कहीं भी हो सकते हैं, लेकिन पकड़ने के लिए आपको अभी भी एक कानूनी चाल पथ की आवश्यकता है",
|
||||
"neglectingHarmony": "<strong>सद्भाव खतरों की उपेक्षा:</strong> यदि आपके प्रतिद्वंद्वी के पास आपके क्षेत्र में 2 मोहरे हैं जो एक प्रगति का हिस्सा बनाते हैं, तो उनके तीसरे को अवरुद्ध करें",
|
||||
"exposingPyramid": "<strong>पिरामिड को उजागर करना:</strong> यह केवल 1 वर्ग चलता है और इसके पास सीमित पलायन विकल्प हैं—इसे जल्दी सुरक्षित रखें"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "उन्नत अवधारणाएं",
|
||||
"sacrifices": {
|
||||
"title": "स्थितिगत बलिदान",
|
||||
"desc": "कभी-कभी एक मोहरे का बलिदान आपके अन्य मोहरों के लिए लाइनें खोलता है या आपके प्रतिद्वंद्वी को एक खराब स्थिति में मजबूर करता है। बलिदान करने से पहले परिणामी असंतुलन की गणना करें।"
|
||||
},
|
||||
"pyramidFaces": {
|
||||
"title": "पिरामिड फलक चयन",
|
||||
"desc": "आपके पिरामिड में 4 फलक मान हैं। पकड़ने में, उस फलक को चुनें जो भविष्य की लचीलापन को अधिकतम करता है। सद्भाव घोषणाओं में, उस फलक को चुनें जो सबसे मूल्यवान प्रगति प्रकार को पूरा करता है।"
|
||||
},
|
||||
"tempo": {
|
||||
"title": "टेम्पो और पहल",
|
||||
"desc": "प्रत्येक चाल जो एक रक्षात्मक प्रतिक्रिया को मजबूर करती है, टेम्पो प्राप्त करती है। मजबूर चालों (पकड़, खतरे) को एक साथ जोड़ें ताकि गति को नियंत्रित किया जा सके और अपने प्रतिद्वंद्वी को उनकी योजना को क्रियान्वित करने से रोका जा सके।"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
{
|
||||
"guide": {
|
||||
"title": "プレイングガイド",
|
||||
"subtitle": "リトモマキア - 哲学者のゲーム",
|
||||
"close": "閉じる",
|
||||
"maximize": "最大化",
|
||||
"restore": "元に戻す",
|
||||
"sections": {
|
||||
"overview": "クイックスタート",
|
||||
"pieces": "駒",
|
||||
"capture": "捕獲",
|
||||
"strategy": "戦略",
|
||||
"harmony": "ハーモニー",
|
||||
"victory": "勝利"
|
||||
},
|
||||
"languageSelector": {
|
||||
"label": "言語",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"ja": "日本語",
|
||||
"hi": "हिन्दी",
|
||||
"es": "Español",
|
||||
"la": "Latina"
|
||||
},
|
||||
"overview": {
|
||||
"goalTitle": "ゲームの目標",
|
||||
"goalDesc": "<strong>敵陣に3つの駒を配置</strong>して<strong>数学的級数</strong>を形成し、相手のターンを1回生き延びれば勝利です。",
|
||||
"boardTitle": "ボード",
|
||||
"boardItems": [
|
||||
"8行×16列(A-P列、1-8行)",
|
||||
"<strong>自陣:</strong>黒は5-8行、白は1-4行を支配",
|
||||
"<strong>敵陣:</strong>勝利の級数を構築する必要がある場所"
|
||||
],
|
||||
"howToPlayTitle": "プレイ方法",
|
||||
"howToPlayItems": [
|
||||
"駒を中央に向けて移動することから始める",
|
||||
"数学的関係を使用して捕獲の機会を探す",
|
||||
"敵陣に進む(黒は1-4行、白は5-8行)",
|
||||
"前進した駒でハーモニーの機会を探す",
|
||||
"1ターン生き残る級数を形成して勝利!"
|
||||
],
|
||||
"goal": "敵陣に3つの駒を配置して数学的級数を形成し、相手のターンを1回生き延びれば勝利です。",
|
||||
"boardSize": "8行×16列(A-P列、1-8行)",
|
||||
"territory": "自陣:黒は5-8行、白は1-4行を支配",
|
||||
"enemyTerritory": "敵陣:勝利の級数を構築する必要がある場所",
|
||||
"step1": "中央に向かって駒を動かすことから始める",
|
||||
"step2": "数学的関係を使って捕獲の機会を探す",
|
||||
"step3": "敵陣に進入する(黒は1-4行、白は5-8行)",
|
||||
"step4": "前進した駒でハーモニーの機会を狙う",
|
||||
"step5": "1ターン生き残る級数を形成して勝利!"
|
||||
},
|
||||
"pieces": {
|
||||
"title": "あなたの駒(全24個)",
|
||||
"intro": "各駒には<strong>数値</strong>があり、異なる動き方をします:",
|
||||
"pieceTypes": {
|
||||
"circle": {
|
||||
"name": "円",
|
||||
"movement": "斜め(ビショップのように)",
|
||||
"count": "数:8"
|
||||
},
|
||||
"triangle": {
|
||||
"name": "三角",
|
||||
"movement": "直線(ルークのように)",
|
||||
"count": "数:8"
|
||||
},
|
||||
"square": {
|
||||
"name": "正方形",
|
||||
"movement": "全方向(クイーンのように)",
|
||||
"count": "数:7"
|
||||
},
|
||||
"pyramid": {
|
||||
"name": "ピラミッド",
|
||||
"movement": "全方向に1マス(キングのように)",
|
||||
"count": "数:1"
|
||||
}
|
||||
},
|
||||
"pyramidSpecial": {
|
||||
"title": "⭐ ピラミッド:多面的な駒",
|
||||
"intro": "他の駒が単一の値を持つのとは異なり、ピラミッドは完全平方数を表す<strong>4つの面の値</strong>を持っています。敵の駒を捕獲する際、どの面を数学的関係に使用するかを選択します。",
|
||||
"blackFaces": "黒のピラミッドの面:",
|
||||
"blackValues": "36(6²)、25(5²)、16(4²)、4(2²)",
|
||||
"whiteFaces": "白のピラミッドの面:",
|
||||
"whiteValues": "64(8²)、49(7²)、36(6²)、25(5²)",
|
||||
"howItWorks": "面の選択の仕組み:",
|
||||
"rules": [
|
||||
"ピラミッドが捕獲を試みる際、関係がチェックされる前にどの面の値を使用するかを宣言する必要があります",
|
||||
"選択した面の値が、すべての数学的関係(等式、倍数/約数、和、差、積、比)における「駒の値」となります",
|
||||
"捕獲ごとに異なる面を選択できます。ピラミッドは1つの値に「固定」されません",
|
||||
"この柔軟性により、ピラミッドは予期しない捕獲機会の創出や多用途なヘルパーとして優れています"
|
||||
],
|
||||
"example": "<strong>例:</strong>白のピラミッドは、面64を使用して黒の16を捕獲できます(倍数:64÷16=4)、面36を使用して黒の9と共に捕獲できます(倍数:36÷9=4)、または黒の25を捕獲する場合は面25で等式を使用します。",
|
||||
"visualTitle": "視覚的な例:ピラミッドの複数の捕獲オプション",
|
||||
"visualDesc": "白のピラミッド(面:64、49、36、25)が黒の駒を捕獲できる位置にあります。柔軟性に注目してください:",
|
||||
"captureOptions": "<strong>H5からの捕獲オプション:</strong>",
|
||||
"option1": "I5に移動:面<strong>64</strong>を選択 → 倍数で16を捕獲(64÷16=4)",
|
||||
"option2": "H6に移動:面<strong>49</strong>を選択 → 等式で49を捕獲(49=49)",
|
||||
"option3": "G5に移動:面<strong>25</strong>を選択 → 等式で25を捕獲(25=25)"
|
||||
},
|
||||
"description": "各サイドには異なる移動パターンを持つ25個の駒があります。形状がどのように動くかを示します:",
|
||||
"count": "数",
|
||||
"circle": "円",
|
||||
"circleMove": "斜めに移動",
|
||||
"triangle": "三角",
|
||||
"triangleMove": "直線で移動",
|
||||
"square": "正方形",
|
||||
"squareMove": "全方向に移動",
|
||||
"pyramid": "ピラミッド",
|
||||
"pyramidMove": "全方向に1マス(キングのように)",
|
||||
"exampleValues": "例の値",
|
||||
"pyramidTitle": "⭐ ピラミッド:特別な駒",
|
||||
"pyramidIntro": "ピラミッドは特別です。捕獲時に選べる4つの異なる値があります。これにより非常に柔軟に使えます!",
|
||||
"blackPyramid": "黒のピラミッドの面",
|
||||
"blackPyramidValues": "36(6²)、25(5²)、16(4²)、4(2²)",
|
||||
"whitePyramid": "白のピラミッドの面",
|
||||
"whitePyramidValues": "64(8²)、49(7²)、36(6²)、25(5²)",
|
||||
"pyramidHowItWorks": "仕組み:",
|
||||
"pyramidRule1": "捕獲前にどの値を使用するか選ぶ",
|
||||
"pyramidRule2": "その値が計算のための駒の数字になる",
|
||||
"pyramidRule3": "毎回異なる値を選べる - 何も固定されない",
|
||||
"pyramidRule4": "これによりピラミッドは奇襲攻撃や他の駒の支援に最適",
|
||||
"example": "例:",
|
||||
"pyramidExample": "白のピラミッドは面64を使用して黒の16を捕獲できます(倍数:64÷16=4)、面36を使用して黒の9と共に捕獲できます(倍数:36÷9=4)、または黒の25を捕獲する場合は面25で等式を使用します。",
|
||||
"pyramidVisualTitle": "視覚的な例:ピラミッドの複数の捕獲オプション",
|
||||
"pyramidVisualDesc": "白のピラミッド(面:64、49、36、25)が黒の駒を捕獲できる位置にあります。柔軟性に注目してください:",
|
||||
"pyramidCaptureOptions": "H5からの捕獲オプション:",
|
||||
"pyramidOption1": "I5に移動:面64を選択 → 倍数で16を捕獲(64÷16=4)",
|
||||
"pyramidOption2": "H6に移動:面49を選択 → 等式で49を捕獲(49=49)",
|
||||
"pyramidOption3": "G5に移動:面25を選択 → 等式で25を捕獲(25=25)"
|
||||
},
|
||||
"capture": {
|
||||
"title": "捕獲方法",
|
||||
"intro": "駒の値が<strong>数学的に関連している場合のみ</strong>敵の駒を捕獲できます:",
|
||||
"simpleTitle": "単純な関係(ヘルパー不要)",
|
||||
"simpleEqual": {
|
||||
"name": "等しい",
|
||||
"desc": "あなたの25が相手の25を捕獲"
|
||||
},
|
||||
"simpleMultiple": {
|
||||
"name": "倍数/約数",
|
||||
"desc": "あなたの64が相手の16を捕獲(64÷16=4)"
|
||||
},
|
||||
"advancedTitle": "高度な関係(ヘルパー駒が1つ必要)",
|
||||
"advancedSum": {
|
||||
"name": "合計",
|
||||
"desc": "あなたの9+ヘルパー16=敵の25"
|
||||
},
|
||||
"advancedDifference": {
|
||||
"name": "差",
|
||||
"desc": "あなたの30-ヘルパー10=敵の20"
|
||||
},
|
||||
"advancedProduct": {
|
||||
"name": "積",
|
||||
"desc": "あなたの5×ヘルパー5=敵の25"
|
||||
},
|
||||
"helpersTitle": "💡 ヘルパーとは?",
|
||||
"helpersDesc": "ヘルパーはボード上の他の駒です — 移動せず、計算のために値を提供するだけです。駒を選択すると、有効な捕獲がゲームに表示されます。",
|
||||
"example1Title": "例:倍数/約数捕獲",
|
||||
"example1Desc": "白の64(正方形)は64が16の倍数であるため黒の16(三角)を捕獲可能",
|
||||
"example2Title": "例:ヘルパー付き合計捕獲",
|
||||
"example2Desc": "白の9+ヘルパー16=黒の25(9+16=25)",
|
||||
"example3Title": "例:ヘルパー付き差捕獲",
|
||||
"example3Desc": "白の30-ヘルパー10=黒の20(30-10=20)",
|
||||
"example4Title": "例:ヘルパー付き積捕獲",
|
||||
"example4Desc": "白の4×ヘルパー5=黒の20(4×5=20)",
|
||||
"example5Title": "例:ヘルパー付き比捕獲",
|
||||
"example5Desc": "白の20÷ヘルパー4=黒の5(20÷4=5)",
|
||||
"pyramidTitle": "特別:ピラミッド捕獲",
|
||||
"pyramidIntro": "ピラミッドは4つの面の値を持ち、非常に多様です。捕獲を試みるときにどの面を使用するかを選択でき、1つのピラミッドで複数の敵の駒を脅かすことができます。",
|
||||
"pyramidEx1Title": "例:ピラミッド面選択(等しさ)",
|
||||
"pyramidEx1Desc": "白のピラミッド(面:64、49、36、25)は等しさのために面49を選択して黒の49を捕獲可能(49=49)",
|
||||
"pyramidEx1Note": "<strong>面選択:</strong>白は面49を宣言→49=49(等しさ)→捕獲成功!ピラミッドは対応する面を使用して値64、36、または25の駒も捕獲可能。",
|
||||
"pyramidEx2Title": "例:ヘルパー付きピラミッド(合計)",
|
||||
"pyramidEx2Desc": "白のピラミッドは面25+ヘルパー20=黒の45を使用(25+20=45)",
|
||||
"pyramidEx2Note": "<strong>面選択:</strong>白は面25を選択しD4でヘルパーを宣言(値20)→25+20=45(合計)→捕獲成功!異なる面を選択することで、同じピラミッドが様々なヘルパーを使用して他の値を捕獲可能。",
|
||||
"pyramidEx3Title": "例:ピラミッドの柔軟性(倍数/約数)",
|
||||
"pyramidEx3Desc": "黒のピラミッド(面:36、25、16、4)は面36を使用して白の9を捕獲可能(倍数:36÷9=4)",
|
||||
"pyramidEx3Note": "<strong>面選択:</strong>黒は面36を選択→36÷9=4(倍数)→捕獲成功!注:黒は合計のために面4とヘルパー5も使用可能(4+5=9)、複数の有効なアプローチを示す。",
|
||||
"description": "駒の値が数学的関係を持つ場合のみ、敵の駒を捕獲できます:",
|
||||
"equality": "等しい",
|
||||
"equalityExample": "あなたの25が相手の25を捕獲",
|
||||
"equalityCaption": "白の円(25)は等しさにより黒の円(25)を捕獲可能",
|
||||
"multiple": "倍数/約数",
|
||||
"multipleExample": "あなたの64が相手の16を捕獲(64÷16=4)",
|
||||
"multipleCaption": "白の正方形(64)は64÷16=4であるため黒の三角(16)を捕獲可能",
|
||||
"sum": "合計",
|
||||
"sumExample": "あなたの9+ヘルパー16=敵の25",
|
||||
"sumCaption": "白の円(9)はヘルパー三角(16)を使用して黒の円(25)を捕獲可能:9+16=25",
|
||||
"difference": "差",
|
||||
"differenceExample": "あなたの30-ヘルパー10=敵の20",
|
||||
"differenceCaption": "白の三角(30)はヘルパー円(10)を使用して黒の三角(20)を捕獲可能:30-10=20",
|
||||
"product": "積",
|
||||
"productExample": "あなたの5×ヘルパー5=敵の25",
|
||||
"productCaption": "白の円(5)はヘルパー円(5)を使用して黒の円(25)を捕獲可能:5×5=25",
|
||||
"ratio": "比",
|
||||
"ratioExample": "あなたの20÷ヘルパー4=敵の5",
|
||||
"ratioCaption": "白の三角(20)はヘルパー円(4)を使用して黒の円(5)を捕獲可能:20÷4=5",
|
||||
"helpersDescription": "ヘルパーはボード上の他の駒です。移動しません - 計算に数字を追加するだけです。駒をクリックすると、どの捕獲が機能するかがゲームに表示されます。"
|
||||
},
|
||||
"harmony": {
|
||||
"title": "ハーモニー:エレガントな勝利",
|
||||
"intro": "ハーモニー(「正式な勝利」とも呼ばれる)は、勝利する最も洗練された方法です。3つの駒を敵陣に配置し、直線上に並べ、その値が数学的パターンを形成します。",
|
||||
"introDetail": "3つの数字を連続で並べるようなものですが、連続は古代哲学と音楽理論の特別な数学的規則に従います。",
|
||||
"arithmetic": "1. 算術級数(最も理解しやすい)",
|
||||
"arithmeticDesc": "中央の数字は他の2つの正確に中間にあります。言い換えれば、差が等しいのです。",
|
||||
"arithmeticFormula": "中央 × 2 = 最初 + 最後",
|
||||
"arithmeticTip": "小さな円(2-9)と多くの三角は自然に算術級数を形成します。ギャップが等しい3つの駒を探してください!",
|
||||
"arithmeticCaption": "白の駒6、9、12が敵陣で一列に並び算術級数を形成しています",
|
||||
"geometric": "2. 幾何級数(累乗と倍数)",
|
||||
"geometricDesc": "各数字は次を得るために同じ量で乗算されます。比が等しいのです。",
|
||||
"geometricFormula": "中央² = 最初 × 最後",
|
||||
"geometricTip": "平方値(4、9、16、25、36、49、64、81)がここで最適です!例えば、4-16-64(2、4、8の平方)。",
|
||||
"geometricCaption": "白の駒4、8、16が敵陣で一列に並び幾何級数を形成しています",
|
||||
"harmonic": "3. 調和級数(音楽ベース、最もトリッキー)",
|
||||
"harmonicDesc": "音楽的調和にちなんで名付けられました。パターンは:外側の数字の比が中央からの差の比に等しいことです。",
|
||||
"harmonicFormula": "2 × 最初 × 最後 = 中央 × (最初 + 最後)",
|
||||
"harmonicTip": "調和級数はより稀です。一般的な三つ組を記憶してください:(3,4,6)、(4,6,12)、(6,8,12)、(6,10,15)、(8,12,24)。",
|
||||
"harmonicCaption": "白の駒6、8、12が敵陣で一列に並び調和級数を形成しています",
|
||||
"howToCheck": "確認方法:",
|
||||
"example": "例:",
|
||||
"check": "確認:",
|
||||
"differences": "差:",
|
||||
"equal": "(等しい!)",
|
||||
"ratios": "比:",
|
||||
"strategyTip": "戦略のヒント:",
|
||||
"rulesTitle": "⚠️ 従うべきハーモニールール",
|
||||
"enemyTerritoryTitle": "敵陣のみ:",
|
||||
"enemyTerritory": "3つの駒すべてが相手の半分にある必要があります(白は5-8行、黒は1-4行が必要)",
|
||||
"straightLineTitle": "直線:",
|
||||
"straightLine": "3つの駒は行、列、または斜めを形成する必要があります。散在する形成は不可",
|
||||
"adjacentTitle": "隣接配置:",
|
||||
"adjacent": "この実装では、3つの駒は互いに隣接している必要があります(ギャップなし)",
|
||||
"survivalTitle": "生存ルール:",
|
||||
"survival": "ハーモニーを宣言すると、相手は駒を捕獲または移動して破るために1ターン得ます",
|
||||
"victoryTitle": "勝利:",
|
||||
"victoryRule": "次のターンが始まるまでハーモニーが生き残れば、勝利です!",
|
||||
"strategyTitle": "戦略:ハーモニーの構築方法",
|
||||
"startWith2Title": "2つから始めて、3つ目を追加",
|
||||
"startWith2": "まず2つの駒を敵陣に入れます。どの3つ目の駒が級数を完成させるかを計算し、その駒を進めます。相手は手遅れになるまで脅威に気づかないかもしれません!",
|
||||
"useCommonTitle": "一般的な値を使用",
|
||||
"useCommon": "6、8、9、12、16のような駒は複数の級数に現れます。これらを敵陣に持っている場合、パターンを完成させるすべての可能な3つ目の駒を計算します。",
|
||||
"protectTitle": "ラインを保護",
|
||||
"protect": "ハーモニーを構築している間、他の駒を配置して前進する駒を守ります。1回の捕獲で級数が壊れます!",
|
||||
"blockTitle": "相手のハーモニーをブロック",
|
||||
"block": "相手が級数の一部を形成する2つの駒を自陣に持っている場合、それを完成させる3つ目の駒を特定します。そのマスをブロックするか、2つの駒のいずれかをすぐに捕獲します。",
|
||||
"calculateTitle": "宣言前に計算",
|
||||
"calculate": "ハーモニーを宣言する前に、相手がそのターンで3つの駒のいずれかを捕獲できるかどうかを確認します。できる場合は、まずそれらの駒を保護するか、より安全な瞬間を待ちます。",
|
||||
"quickRefTitle": "💡 クイックリファレンス:軍の一般的なハーモニー"
|
||||
},
|
||||
"victory": {
|
||||
"title": "勝利方法",
|
||||
"harmony": "勝利#1:ハーモニー(級数)",
|
||||
"exhaustion": "勝利#2:消耗",
|
||||
"exampleTitle": "例:白の勝利!",
|
||||
"exampleDesc": "E6、F6、G6の白の駒は6、9、12を形成 - 黒の陣地での算術級数です。黒が次のターンでこれを破れなければ、白の勝利です!",
|
||||
"exampleNote": "✓ ハイライトされた3つの駒すべてが敵陣にあります(白は5-8行)\n✓ 直線を形成しています(水平6行)\n✓ 算術級数を満たしています:9 = (6+12)/2",
|
||||
"tipsTitle": "クイック戦略のヒント",
|
||||
"tips": [
|
||||
"<strong>中央を支配</strong> — 敵陣への侵入が容易",
|
||||
"<strong>小さな駒は速い</strong> — 円(3、5、7、9)は敵の半分に素早く滑り込める",
|
||||
"<strong>大きな駒は強力</strong> — サイズのため捕獲が困難",
|
||||
"<strong>ハーモニーの脅威を監視</strong> — 相手が自陣の深くに3つの駒を配置しないように",
|
||||
"<strong>ピラミッドは柔軟</strong> — 各状況に適した面の値を選択"
|
||||
],
|
||||
"harmonyDesc": "敵陣に3つの駒で数学的級数を形成します。相手の次のターンを生き残れば勝利!",
|
||||
"exampleCaption": "白の駒4、8、16が敵陣で幾何級数を形成。黒はそれを壊せない - 白の勝利!",
|
||||
"harmonyNote": "これはリトモマキアの主要な勝利条件です",
|
||||
"exhaustionDesc": "相手がターン開始時に合法的な移動がない場合、負けます。",
|
||||
"strategyTitle": "クイックヒント",
|
||||
"tip1": "中央を制御 - 前進しやすい",
|
||||
"tip2": "小さな駒は速い - 円は敵陣に素早く侵入可能",
|
||||
"tip3": "大きな駒は強力 - 捕獲が困難",
|
||||
"tip4": "相手のハーモニーに注意 - 3つの駒を深く進ませない",
|
||||
"tip5": "ピラミッドは柔軟 - 各捕獲に適した値を選択"
|
||||
},
|
||||
"strategy": {
|
||||
"title": "戦略とタクティクス",
|
||||
"intro": "リトモマキアは数学的洞察力と戦略的計画の両方を報います。成功には領土支配、駒の保護、数学的機会のバランスが必要です。",
|
||||
"openingPrinciples": {
|
||||
"title": "オープニングの原則",
|
||||
"controlCenter": {
|
||||
"title": "中央を制御",
|
||||
"desc": "8列の空の中央(E-L列)に向かって駒を進めます。これにより機動性が提供され、複数の角度から捕獲機会が生まれます。"
|
||||
},
|
||||
"developCircles": {
|
||||
"title": "小さな円を最初に開発",
|
||||
"desc": "DとM列の小さな円(2-9)は機動性があり多様です。早期に中央に移動させて存在感を確立し、ヘルパーネットワークを作成します。"
|
||||
},
|
||||
"protectPyramid": {
|
||||
"title": "ピラミッドを保護",
|
||||
"desc": "ピラミッドは最も柔軟な駒(4つの面の値)ですが、1マスしか移動しません。数学的脅威が明確になる中盤まで、前進ラインの後ろに保持します。"
|
||||
},
|
||||
"knowNumbers": {
|
||||
"title": "数字を知る",
|
||||
"desc": "軍隊の主要な数学的関係を記憶します。どの駒が因数、倍数、合計を形成するかを識別します—これにより戦術的計算が加速されます。"
|
||||
}
|
||||
},
|
||||
"midGame": {
|
||||
"title": "中盤のタクティクス",
|
||||
"helperNetworks": {
|
||||
"title": "ヘルパーネットワークを構築",
|
||||
"desc": "複数のヘルパーが捕獲をサポートできるように駒を配置します。例えば、値5と10の駒がある場合、移動駒が正しく配置されていれば15(合計)、5(差)、または50(積)を捕獲できます。"
|
||||
},
|
||||
"createThreats": {
|
||||
"title": "捕獲の脅威を作成",
|
||||
"desc": "相手に複数の駒を同時に守らせます。すべての捕獲を実行できなくても、脅威によって相手の選択肢が制限され、テンポが制御されます。"
|
||||
},
|
||||
"thinkDefensively": {
|
||||
"title": "防御的に考える",
|
||||
"desc": "各移動後、どの駒が捕獲可能かを確認します。正方形(169、225、289、361)などの高価値の駒は多くの関係に脆弱です—防御駒の後ろまたは保護されたマスに配置します。"
|
||||
},
|
||||
"exchangeWhenAhead": {
|
||||
"title": "リードしているときに交換",
|
||||
"desc": "より多くの駒またはより高い値を捕獲した場合、駒を交換して局面を単純化します。これにより相手の攻撃オプションが減少し、消耗勝利に近づきます。"
|
||||
}
|
||||
},
|
||||
"victoryPaths": {
|
||||
"title": "勝利への道",
|
||||
"harmony": {
|
||||
"title": "ハーモニー勝利(最もエレガント)",
|
||||
"desc": "算術、幾何、または調和級数を形成する3つの駒を敵陣に配置します。一般的な三つ組:",
|
||||
"arithmetic": "<strong>算術:</strong>(6、9、12)、(5、7、9)、(8、12、16)",
|
||||
"geometric": "<strong>幾何:</strong>(4、8、16)、(3、9、27)、(2、8、32)",
|
||||
"harmonic": "<strong>調和:</strong>(6、8、12)、(10、12、15)、(6、10、15)"
|
||||
},
|
||||
"exhaustion": {
|
||||
"title": "消耗勝利(消耗戦)",
|
||||
"desc": "相手が合法的な移動を持たなくなるまで系統的に駒を捕獲します。焦点:機動駒(正方形と三角)の排除、対角線と列のブロック、ピラミッドを隅に追い込む。"
|
||||
},
|
||||
"points": {
|
||||
"title": "ポイント勝利(オプションルール)",
|
||||
"desc": "有効な場合、30ポイント相当の駒を捕獲します(C=1、T=2、S=3、P=5)。高価値ターゲットを狙い、自分の重い駒を保護します。有利に交換します。"
|
||||
}
|
||||
},
|
||||
"commonMistakes": {
|
||||
"title": "⚠️ 避けるべき一般的なミス",
|
||||
"movingWithoutCalc": "<strong>計算せずに移動:</strong>目的地のマスが敵の駒に捕獲可能かを常に確認",
|
||||
"ignoringGeometry": "<strong>ヘルパーの幾何学を無視:</strong>ヘルパーはどこにでもいられますが、捕獲には合法的な移動パスが必要",
|
||||
"neglectingHarmony": "<strong>ハーモニー脅威の無視:</strong>相手が自陣に級数の一部を形成する2つの駒を持っている場合、3つ目をブロック",
|
||||
"exposingPyramid": "<strong>ピラミッドの露出:</strong>1マスしか移動せず、脱出オプションが限られています—早期に保護"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高度な概念",
|
||||
"sacrifices": {
|
||||
"title": "位置的犠牲",
|
||||
"desc": "時には駒を犠牲にすることで他の駒のラインが開かれたり、相手をより悪い位置に追い込んだりします。犠牲にする前に結果の不均衡を計算します。"
|
||||
},
|
||||
"pyramidFaces": {
|
||||
"title": "ピラミッドの面選択",
|
||||
"desc": "ピラミッドには4つの面の値があります。捕獲では、将来の柔軟性を最大化する面を選択します。ハーモニー宣言では、最も価値のある級数タイプを完成させる面を選択します。"
|
||||
},
|
||||
"tempo": {
|
||||
"title": "テンポとイニシアチブ",
|
||||
"desc": "防御的応答を強制する各移動はテンポを獲得します。強制移動(捕獲、脅威)を連続させてペースを支配し、相手が計画を実行するのを防ぎます。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bustOut": "新しいウィンドウで開く"
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
{
|
||||
"guide": {
|
||||
"title": "Libellus Ludi Rithmomachia",
|
||||
"subtitle": "Rithmomachia – Ludus Philosophorum",
|
||||
"close": "Claudere",
|
||||
"maximize": "Maximizare",
|
||||
"restore": "Restituere",
|
||||
"bustOut": "Aperire in fenestra nova",
|
||||
"sections": {
|
||||
"overview": "Initium Celeriter",
|
||||
"pieces": "Tabulae",
|
||||
"capture": "Captura",
|
||||
"strategy": "Ratio",
|
||||
"harmony": "Harmonia",
|
||||
"victory": "Victoria"
|
||||
},
|
||||
"languageSelector": {
|
||||
"label": "Lingua",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"ja": "日本語",
|
||||
"hi": "हिन्दी",
|
||||
"es": "Español",
|
||||
"la": "Latina"
|
||||
},
|
||||
"overview": {
|
||||
"goalTitle": "Finis Ludi",
|
||||
"goal": "Dispone 3 tabulas tuas in territorio hostium ut progressionem mathematicam faciant, unum tempus adversarii superesse, et vincere.",
|
||||
"goalDesc": "Dispone <strong>3 tabulas tuas in territorio hostium</strong> ut <strong>progressionem mathematicam</strong> faciant, unum tempus adversarii superesse, et vincere.",
|
||||
"boardTitle": "Tabula",
|
||||
"boardSize": "8 ordines × 16 columnae (columnae A-P, ordines 1-8)",
|
||||
"territory": "Tua pars: Niger regit ordines 5-8, Albus regit ordines 1-4",
|
||||
"enemyTerritory": "Territorium hostium: Ubi progressionem victoriae tuae aedificare debes",
|
||||
"boardItems": [
|
||||
"8 ordines × 16 columnae (columnae A-P, ordines 1-8)",
|
||||
"<strong>Tua pars:</strong> Niger regit ordines 5-8, Albus regit ordines 1-4",
|
||||
"<strong>Territorium hostium:</strong> Ubi progressionem victoriae tuae aedificare debes"
|
||||
],
|
||||
"howToPlayTitle": "Quomodo Ludere",
|
||||
"step1": "Incipe tabulas ad centrum movendo",
|
||||
"step2": "Occasiones capturae quaere relationibus mathematicis utendo",
|
||||
"step3": "In territorium hostium impelle (ordines 1-4 pro Nigro, ordines 5-8 pro Albo)",
|
||||
"step4": "Occasiones harmoniae cum tabulis tuis anterioribus specta",
|
||||
"step5": "Vince progressionem faciendo quae unum tempus supersit!",
|
||||
"howToPlayItems": [
|
||||
"Incipe tabulas ad centrum movendo",
|
||||
"Occasiones capturae quaere relationibus mathematicis utendo",
|
||||
"In territorium hostium impelle (ordines 1-4 pro Nigro, ordines 5-8 pro Albo)",
|
||||
"Occasiones harmoniae cum tabulis tuis anterioribus specta",
|
||||
"Vince progressionem faciendo quae unum tempus supersit!"
|
||||
]
|
||||
},
|
||||
"pieces": {
|
||||
"title": "Tabulae Tuae (25 in summa)",
|
||||
"description": "Utraque pars 25 tabulas habet cum diversis modis movendi. Forma tibi dicit quomodo movetur:",
|
||||
"intro": "Quaeque tabula <strong>valorem numericum</strong> habet et diversimode movetur:",
|
||||
"circle": "Circulus",
|
||||
"circleMove": "Diagonaliter movetur",
|
||||
"triangle": "Triangulum",
|
||||
"triangleMove": "Lineae rectae movetur",
|
||||
"square": "Quadratum",
|
||||
"squareMove": "Quaque directione movetur",
|
||||
"pyramid": "Pyramis",
|
||||
"pyramidMove": "Unus gradus quaque via",
|
||||
"count": "Numerus",
|
||||
"exampleValues": "Valores exempli",
|
||||
"example": "Exemplum:",
|
||||
"pieceTypes": {
|
||||
"circle": {
|
||||
"name": "Circulus",
|
||||
"movement": "Diagonaliter (sicut episcopus)",
|
||||
"count": "Numerus: 8"
|
||||
},
|
||||
"triangle": {
|
||||
"name": "Triangulum",
|
||||
"movement": "Lineae rectae (sicut turris)",
|
||||
"count": "Numerus: 8"
|
||||
},
|
||||
"square": {
|
||||
"name": "Quadratum",
|
||||
"movement": "Quaque directione (sicut regina)",
|
||||
"count": "Numerus: 7"
|
||||
},
|
||||
"pyramid": {
|
||||
"name": "Pyramis",
|
||||
"movement": "Unus gradus quaque via (sicut rex)",
|
||||
"count": "Numerus: 1"
|
||||
}
|
||||
},
|
||||
"pyramidTitle": "⭐ Pyramides: Tabula Specialis",
|
||||
"pyramidIntro": "Pyramides speciales sunt. 4 valores diversos habeant quos eligere potes cum capias. Hoc eas valde flexibiles facit!",
|
||||
"pyramidExample": "Pyramis Albi 16 Nigri capere potest faciem 64 utendo (multiplex: 64÷16=4), faciem 36 (multiplex: 36÷9=4, cum 9 Nigri), vel faciem 25 cum aequalitate si 25 Nigri capit.",
|
||||
"blackPyramid": "Facies Pyramidis Nigrae",
|
||||
"blackPyramidValues": "36 (6²), 25 (5²), 16 (4²), 4 (2²)",
|
||||
"whitePyramid": "Facies Pyramidis Albae",
|
||||
"whitePyramidValues": "64 (8²), 49 (7²), 36 (6²), 25 (5²)",
|
||||
"pyramidHowItWorks": "Quomodo operatur:",
|
||||
"pyramidRule1": "Elige quem valorem uti antequam capias",
|
||||
"pyramidRule2": "Ille valor fit numerus tabulae tuae pro mathematica",
|
||||
"pyramidRule3": "Valores diversos eligere potes singulis vicibus - nihil fixum est",
|
||||
"pyramidRule4": "Hoc Pyramides optimas facit ad impetus subitos aliasque tabulas adiuvandas",
|
||||
"pyramidVisualTitle": "Exemplum Visuale: Multae Optiones Capturae Pyramidis",
|
||||
"pyramidVisualDesc": "Pyramis Alba (facies: 64, 49, 36, 25) posita est ad tabulas Nigras capiendas. Nota flexibilitatem:",
|
||||
"pyramidCaptureOptions": "Optiones capturae ex H5:",
|
||||
"pyramidOption1": "Move ad I5: Elige faciem 64 → capit 16 per multiplum (64÷16=4)",
|
||||
"pyramidOption2": "Move ad H6: Elige faciem 49 → capit 49 per aequalitatem (49=49)",
|
||||
"pyramidOption3": "Move ad G5: Elige faciem 25 → capit 25 per aequalitatem (25=25)"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Quomodo Capere",
|
||||
"description": "Tabulam hostilem capere potes solum si valor tabulae tuae relationem mathematicam ad eam habet:",
|
||||
"intro": "Tabulam hostilem capere potes <strong>solum si valor tabulae tuae mathematice se refert</strong> ad eam:",
|
||||
"equality": "Aequale",
|
||||
"equalityExample": "Tuum 25 capit eorum 25",
|
||||
"equalityCaption": "Circulus Albus (25) capere potest Circulum Nigrum (25) per aequalitatem",
|
||||
"multiple": "Multiplex / Divisor",
|
||||
"multipleExample": "Tuum 64 capit eorum 16 (64 ÷ 16 = 4)",
|
||||
"multipleCaption": "Quadratum Album (64) capere potest Triangulum Nigrum (16) quia 64 ÷ 16 = 4",
|
||||
"sum": "Summa",
|
||||
"sumExample": "Tuum 9 + adiutor 16 = hostis 25",
|
||||
"sumCaption": "Circulus Albus (9) capere potest Circulum Nigrum (25) adiutore Triangulo (16) utendo: 9 + 16 = 25",
|
||||
"difference": "Differentia",
|
||||
"differenceExample": "Tuum 30 - adiutor 10 = hostis 20",
|
||||
"differenceCaption": "Triangulum Album (30) capere potest Triangulum Nigrum (20) adiutore Circulo (10) utendo: 30 - 10 = 20",
|
||||
"product": "Productum",
|
||||
"productExample": "Tuum 5 × adiutor 5 = hostis 25",
|
||||
"productCaption": "Circulus Albus (5) capere potest Circulum Nigrum (25) adiutore Circulo (5) utendo: 5 × 5 = 25",
|
||||
"ratio": "Ratio",
|
||||
"ratioExample": "Tuum 20 ÷ adiutor 4 = hostis 5",
|
||||
"ratioCaption": "Triangulum Album (20) capere potest Circulum Nigrum (5) adiutore Circulo (4) utendo: 20 ÷ 4 = 5",
|
||||
"simpleTitle": "Relationes Simplices (adiutor non necessarius)",
|
||||
"simpleEqual": {
|
||||
"name": "Aequale",
|
||||
"desc": "Tuum 25 capit eorum 25"
|
||||
},
|
||||
"simpleMultiple": {
|
||||
"name": "Multiplex / Divisor",
|
||||
"desc": "Tuum 64 capit eorum 16 (64 ÷ 16 = 4)"
|
||||
},
|
||||
"advancedTitle": "Relationes Provectae (una tabula adiutrix necessaria)",
|
||||
"advancedSum": {
|
||||
"name": "Summa",
|
||||
"desc": "Tuum 9 + adiutor 16 = hostis 25"
|
||||
},
|
||||
"advancedDifference": {
|
||||
"name": "Differentia",
|
||||
"desc": "Tuum 30 - adiutor 10 = hostis 20"
|
||||
},
|
||||
"advancedProduct": {
|
||||
"name": "Productum",
|
||||
"desc": "Tuum 5 × adiutor 5 = hostis 25"
|
||||
},
|
||||
"helpersTitle": "💡 Quid sunt adiutores?",
|
||||
"helpersDescription": "Adiutores sunt aliae tabulae tuae in tabula. Non movent - tantum numerum suum mathematicae addunt. Ludus tibi ostendet quae capturae operentur cum tabulam eligas.",
|
||||
"helpersDesc": "Adiutores sunt aliae tabulae tuae adhuc in tabula — non movent, tantum valorem suum mathematicae praebent. Ludus capturas validas tibi ostendet cum tabulam eligas.",
|
||||
"example1Title": "Exemplum: Captura Multiplicis/Divisoris",
|
||||
"example1Desc": "64 Albi (quadratum) capere potest 16 Nigri (triangulum) quia 64 multiplex 16 est",
|
||||
"example2Title": "Exemplum: Captura Summae cum Adiutore",
|
||||
"example2Desc": "9 Albi + adiutor 16 = 25 Nigri (9 + 16 = 25)",
|
||||
"example3Title": "Exemplum: Captura Differentiae cum Adiutore",
|
||||
"example3Desc": "30 Albi - adiutor 10 = 20 Nigri (30 - 10 = 20)",
|
||||
"example4Title": "Exemplum: Captura Producti cum Adiutore",
|
||||
"example4Desc": "4 Albi × adiutor 5 = 20 Nigri (4 × 5 = 20)",
|
||||
"example5Title": "Exemplum: Captura Rationis cum Adiutore",
|
||||
"example5Desc": "20 Albi ÷ adiutor 4 = 5 Nigri (20 ÷ 4 = 5)",
|
||||
"pyramidTitle": "Speciale: Capturae Pyramidis",
|
||||
"pyramidIntro": "Pyramides 4 valores faciei habent, eas incredibiliter versatiles faciendo. Eligis quam faciem uti cum capturam tentes, permittendo unam Pyramidem multas tabulas hostiles minari.",
|
||||
"pyramidEx1Title": "Exemplum: Electio Faciei Pyramidis (Aequalitas)",
|
||||
"pyramidEx1Desc": "Pyramis Albi (facies: 64, 49, 36, 25) capere potest 49 Nigri eligendo faciem 49 pro aequalitate (49 = 49)",
|
||||
"pyramidEx1Note": "<strong>Electio faciei:</strong> Albus faciem 49 declarat → 49 = 49 (aequalitas) → Captura succedit! Pyramis quoque tabulas valoris 64, 36, vel 25 capere posset faciebus correspondentibus utendo.",
|
||||
"pyramidEx2Title": "Exemplum: Pyramis cum Adiutore (Summa)",
|
||||
"pyramidEx2Desc": "Pyramis Albi faciem 25 + adiutorem 20 = 45 Nigri utitur (25 + 20 = 45)",
|
||||
"pyramidEx2Note": "<strong>Electio faciei:</strong> Albus faciem 25 eligit et adiutorem in D4 (valor 20) declarat → 25 + 20 = 45 (summa) → Captura succedit! Facies diversas eligendo, eadem Pyramis valores alios variis adiutoribus utendo capere posset.",
|
||||
"pyramidEx3Title": "Exemplum: Flexibilitas Pyramidis (Multiplex/Divisor)",
|
||||
"pyramidEx3Desc": "Pyramis Nigri (facies: 36, 25, 16, 4) capere potest 9 Albi faciem 36 utendo (multiplex: 36 ÷ 9 = 4)",
|
||||
"pyramidEx3Note": "<strong>Electio faciei:</strong> Niger faciem 36 eligit → 36 ÷ 9 = 4 (multiplex) → Captura succedit! Nota: Niger quoque faciem 4 cum adiutore 5 pro summa uti posset (4 + 5 = 9), multas vias validas ostendens."
|
||||
},
|
||||
"harmony": {
|
||||
"title": "Harmoniae: Via Optima Vincendi",
|
||||
"intro": "Harmonia est quomodo ludum vincas. 3 tabulas tuas in territorium hostium in lineam rectam pone. Earum valores formam mathematicam facere debent.",
|
||||
"introDetail": "Cogita de eo sicut de serie numerorum. Sunt 3 genera formarum quas facere potes:",
|
||||
"arithmetic": "1. Arithmetica (Facillima)",
|
||||
"arithmeticDesc": "Numerus medius exacte inter alios duos est. Spatia aequalia.",
|
||||
"arithmeticFormula": "Medius × 2 = Primus + Ultimus",
|
||||
"arithmeticTip": "Circuli parvi (2-9) hic optime operantur. Hiatus aequales quaere! Exemplum: 6, 9, 12",
|
||||
"arithmeticCaption": "Tabulae Albae 6, 9, 12 in ordine in territorio hostium progressionem arithmeticam faciunt",
|
||||
"geometric": "2. Geometrica (Eodem Numero Multiplica)",
|
||||
"geometricDesc": "Eodem numero semper multiplica. Rationes aequales.",
|
||||
"geometricFormula": "Medius² = Primus × Ultimus",
|
||||
"geometricTip": "Numeri quadrati optime operantur! Exemplum: 4, 8, 16 (per 2 semper multiplica)",
|
||||
"geometricCaption": "Tabulae Albae 4, 8, 16 in ordine in territorio hostium progressionem geometricam faciunt",
|
||||
"harmonic": "3. Harmonica (Difficilis!)",
|
||||
"harmonicDesc": "Ex harmoniis musicalibus. Difficillima invenienda.",
|
||||
"harmonicFormula": "2 × Primus × Ultimus = Medius × (Primus + Ultimus)",
|
||||
"harmonicTip": "Hae rarae sunt. Tantum haec memoriza: 3-4-6, 4-6-12, 6-8-12, 6-10-15",
|
||||
"harmonicCaption": "Tabulae Albae 6, 8, 12 in ordine in territorio hostium progressionem harmonicam faciunt",
|
||||
"howToCheck": "Quomodo verificare:",
|
||||
"example": "Exemplum:",
|
||||
"check": "Verifica:",
|
||||
"differences": "Differentiae:",
|
||||
"equal": "(aequales!)",
|
||||
"ratios": "Rationes:",
|
||||
"strategyTip": "Consilium:",
|
||||
"rulesTitle": "⚠️ Regulae Importantes",
|
||||
"enemyTerritoryTitle": "In territorio hostium esse debent:",
|
||||
"enemyTerritory": "Omnes 3 tabulae in parte adversarii esse debent (Albus: ordines 5-8, Niger: ordines 1-4)",
|
||||
"straightLineTitle": "In linea esse debent:",
|
||||
"straightLine": "Ordo, columna, vel diagonalis. Nullae tabulae dispersae!",
|
||||
"adjacentTitle": "Contingentes esse debent:",
|
||||
"adjacent": "3 tabulae proximae inter se esse debent sine hiatu",
|
||||
"survivalTitle": "Unum tempus superesse debent:",
|
||||
"survival": "Postquam harmoniam declaras, adversarius UNUM tempus accipit ad eam frangendum",
|
||||
"victoryTitle": "Tunc vincis:",
|
||||
"victoryRule": "Si supersit, in tuo proximo tempore vincis!",
|
||||
"strategyTitle": "Quomodo Harmoniis Vincere",
|
||||
"startWith2Title": "Incipe cum 2, tunc adde tertiam",
|
||||
"startWith2": "Primum 2 tabulas profunde pone. Cogita quae tertia tabula formam complet. Tunc eam tabulam impelle. Forsitan non venientem videant!",
|
||||
"useCommonTitle": "Numeros communes utere",
|
||||
"useCommon": "Numeri sicut 6, 8, 9, 12, 16 in multis formis operantur. Si hos profunde habes, vide quae tertia tabula laborem perficit.",
|
||||
"protectTitle": "Tabulas tuas protege",
|
||||
"protect": "Dum aedificas, alias tabulas prope tene ad defendendum. Una captura omnia perdit!",
|
||||
"blockTitle": "Harmonias eorum obstrue",
|
||||
"block": "Si adversarius 2 tabulas profunde habet, cogita quam tertiam tabulam necessitant. Eum locum obstrue vel unam ex tabulis eorum STATIM cape.",
|
||||
"calculateTitle": "Verifica antequam declares",
|
||||
"calculate": "Antequam declarare, certa quod adversarius nullam ex 3 tabulis tuis capere possit. Si potest, primum protege vel exspecta.",
|
||||
"quickRefTitle": "💡 Referentia Celera: Harmoniae Communes in Exercitu Tuo"
|
||||
},
|
||||
"victory": {
|
||||
"title": "Quomodo Vincere",
|
||||
"harmony": "Victoria #1: Harmonia (Progressio)",
|
||||
"harmonyDesc": "Fac progressionem mathematicam cum 3 tabulis in territorio hostium. Si proximum tempus adversarii tui supersit, vincis!",
|
||||
"exampleTitle": "Exemplum: Albus Vincit!",
|
||||
"exampleCaption": "Tabulae Albae 4, 8, 16 progressionem geometricam in territorio hostium faciunt. Niger eam frangere non potest - Albus vincit!",
|
||||
"harmonyNote": "Haec est condicio victoriae primaria in Rithmomachia",
|
||||
"exhaustion": "Victoria #2: Exhaustio",
|
||||
"exhaustionDesc": "Si adversarius tuus nullos motus legales in initio sui temporis habet, perdit.",
|
||||
"strategyTitle": "Consilia Celera",
|
||||
"tip1": "Centrum rege - facilius impellere",
|
||||
"tip2": "Tabulae parvae celeres sunt - circuli in partem hostilem celeriter labi possunt",
|
||||
"tip3": "Tabulae magnae potentes sunt - difficilius capere",
|
||||
"tip4": "Harmonias eorum specta - noli 3 tabulas profunde ire sinere",
|
||||
"tip5": "Pyramides flexibiles sunt - valorem rectum pro singula captura elige"
|
||||
},
|
||||
"strategy": {
|
||||
"title": "Ratio et Tactica",
|
||||
"intro": "Rithmomachia et perspicaciam mathematicam et consilium strategicum remuneratur. Successus requirit temperantiam inter imperium territoriale, conservationem tabularum, et occasiones mathematicas.",
|
||||
"openingPrinciples": {
|
||||
"title": "Principia Aperturae",
|
||||
"controlCenter": {
|
||||
"title": "Centrum Rege",
|
||||
"desc": "Tabulas ad vacuum centrum 8-columnarum (columnae E–L) impelle. Hoc mobilitatem praebet et occasiones capiendi ex multis angulis creat."
|
||||
},
|
||||
"developCircles": {
|
||||
"title": "Circulos Parvos Primum Evolve",
|
||||
"desc": "Circuli tui parvi (2–9) in columnis D et M mobiles et versatiles sunt. Eos ad centrum mature move ut praesentiam stabilias et retia adiutorum crees."
|
||||
},
|
||||
"protectPyramid": {
|
||||
"title": "Pyramidem Tuam Protege",
|
||||
"desc": "Pyramis est tabula tua flexibilissima (4 valores faciei) sed tantum 1 quadratum movetur. Eam post lineas impellentes tene usque ad medium ludum cum minae mathematicae clarae sint."
|
||||
},
|
||||
"knowNumbers": {
|
||||
"title": "Numeros Tuos Cognosce",
|
||||
"desc": "Relationes mathematicas principales in exercitu tuo memoriza. Cognosce quae tabulae factores, multiplices, et summas faciunt—hoc computationem tacticam accelerat."
|
||||
}
|
||||
},
|
||||
"midGame": {
|
||||
"title": "Tactica Medii Ludi",
|
||||
"helperNetworks": {
|
||||
"title": "Retia Adiutorum Aedifica",
|
||||
"desc": "Tabulas dispone ut multi adiutores capturas sustinere possint. Exempli gratia, si tabulas valoris 5 et 10 habes, 15 (summa), 5 (differentia), vel 50 (productum) capere potes cum tabula tua mobilis recte posita sit."
|
||||
},
|
||||
"createThreats": {
|
||||
"title": "Minas Capturae Crea",
|
||||
"desc": "Adversarium tuum coge ut multas tabulas simul defendat. Etsi omnes capturas exsequi non potes, mina optiones eorum constringit et tempus regit."
|
||||
},
|
||||
"thinkDefensively": {
|
||||
"title": "Defensive Cogita",
|
||||
"desc": "Post singulos motus, verifica quae ex tabulis tuis capi possint. Tabulae magni valoris sicut quadrata (169, 225, 289, 361) multis relationibus vulnerabiles sunt—eas post defensores vel in quadratis protectis pone."
|
||||
},
|
||||
"exchangeWhenAhead": {
|
||||
"title": "Permuta Cum Praecedis",
|
||||
"desc": "Si plures tabulas vel valores altiores cepisti, positionem simplifica tabulas permutando. Hoc optiones oppugnationis adversarii tui reducit et te ad victoriam exhaustionis propius ducit."
|
||||
}
|
||||
},
|
||||
"victoryPaths": {
|
||||
"title": "Viae ad Victoriam",
|
||||
"harmony": {
|
||||
"title": "Victoria Harmoniae (Elegantissima)",
|
||||
"desc": "Pone 3 tabulas in territorio hostium progressionem arithmeticam, geometricam, vel harmonicam facientes. Triades communes:",
|
||||
"arithmetic": "<strong>Arithmetica:</strong> (6, 9, 12), (5, 7, 9), (8, 12, 16)",
|
||||
"geometric": "<strong>Geometrica:</strong> (4, 8, 16), (3, 9, 27), (2, 8, 32)",
|
||||
"harmonic": "<strong>Harmonica:</strong> (6, 8, 12), (10, 12, 15), (6, 10, 15)"
|
||||
},
|
||||
"exhaustion": {
|
||||
"title": "Victoria Exhaustionis (Attritio)",
|
||||
"desc": "Tabulas systematice cape donec adversarius tuus nullos motus legales habeat. Attende ad: tabulas mobiles (Quadrata et Triangula) eliminandas, diagonales et ordines obstruendos, et Pyramidem in angulum cogendam."
|
||||
},
|
||||
"points": {
|
||||
"title": "Victoria Punctorum (Regula Optionalis)",
|
||||
"desc": "Si habilitata, 30 puncta tabularum cape (C=1, T=2, S=3, P=5). Scopos magni valoris vena et tabulas tuas graves conserva. Utiliter permuta."
|
||||
}
|
||||
},
|
||||
"commonMistakes": {
|
||||
"title": "⚠️ Errores Communes Vitandi",
|
||||
"movingWithoutCalc": "<strong>Movere sine computatione:</strong> Semper verifica si quadratum tuum destinationis a tabulis hostilibus capi potest",
|
||||
"ignoringGeometry": "<strong>Geometriam adiutoris ignorare:</strong> Adiutores ubicumque esse possunt, sed adhuc viam motus legalem ad capiendum necessitas",
|
||||
"neglectingHarmony": "<strong>Minas harmoniae neglegere:</strong> Si adversarius tuus 2 tabulas in tuo territorio habet partem progressionis facientes, tertiam eorum obstrue",
|
||||
"exposingPyramid": "<strong>Pyramidem expositam relinquere:</strong> Tantum 1 quadratum movetur et optiones fugae limitatas habet—eam mature protege"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Conceptus Provecti",
|
||||
"sacrifices": {
|
||||
"title": "Sacrificia Positionalia",
|
||||
"desc": "Interdum tabulam sacrificare lineas pro aliis tabulis tuis aperit vel adversarium tuum in positionem peiorem cogit. Inaequalitatem resultantem computa antequam sacrifices."
|
||||
},
|
||||
"pyramidFaces": {
|
||||
"title": "Electio Facierum Pyramidis",
|
||||
"desc": "Pyramis tua 4 valores faciei habet. In capturis, faciem elige quae flexibilitatem futuram maximizat. In declarationibus harmoniae, faciem elige quae genus progressionis pretiosissimum complet."
|
||||
},
|
||||
"tempo": {
|
||||
"title": "Tempus et Initium",
|
||||
"desc": "Quilibet motus qui responsum defensivum cogit tempus acquirit. Motus cogentes (capturas, minas) constringe ut cursum dictes et adversarium tuum consilium suum exsequi prohibeas."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { defineGame, type GameManifest, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import { RithmomachiaGame } from './components/RithmomachiaGame'
|
||||
import { RithmomachiaProvider } from './Provider'
|
||||
import type { RithmomachiaConfig, RithmomachiaMove, RithmomachiaState } from './types'
|
||||
import { rithmomachiaValidator } from './Validator'
|
||||
|
||||
/**
|
||||
* Game manifest for Rithmomachia.
|
||||
*/
|
||||
const manifest: GameManifest = {
|
||||
name: 'rithmomachia',
|
||||
displayName: 'Rithmomachia',
|
||||
icon: '🎲',
|
||||
description: 'Medieval mathematical battle game',
|
||||
longDescription:
|
||||
'Rithmomachia (Battle of Numbers) is a medieval strategy game where pieces with numerical values capture each other through mathematical relations. Win by achieving harmony (a mathematical progression) in enemy territory, or by capturing enough pieces to exhaust your opponent.',
|
||||
maxPlayers: 2,
|
||||
difficulty: 'Advanced',
|
||||
chips: ['⚔️ Strategy', '🔢 Mathematical', '🏛️ Historical', '🎯 Two-Player'],
|
||||
...getGameTheme('purple'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration for Rithmomachia.
|
||||
*/
|
||||
const defaultConfig: RithmomachiaConfig = {
|
||||
pointWinEnabled: false,
|
||||
pointWinThreshold: 30,
|
||||
repetitionRule: true,
|
||||
fiftyMoveRule: true,
|
||||
allowAnySetOnRecheck: true,
|
||||
timeControlMs: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Config validation (type guard).
|
||||
* Validates all config fields and their constraints.
|
||||
*/
|
||||
function validateConfig(config: unknown): config is RithmomachiaConfig {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const c = config as Record<string, unknown>
|
||||
|
||||
// Validate pointWinEnabled
|
||||
if (!('pointWinEnabled' in c) || typeof c.pointWinEnabled !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate pointWinThreshold
|
||||
if (
|
||||
!('pointWinThreshold' in c) ||
|
||||
typeof c.pointWinThreshold !== 'number' ||
|
||||
c.pointWinThreshold < 1
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate repetitionRule
|
||||
if (!('repetitionRule' in c) || typeof c.repetitionRule !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate fiftyMoveRule
|
||||
if (!('fiftyMoveRule' in c) || typeof c.fiftyMoveRule !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate allowAnySetOnRecheck
|
||||
if (!('allowAnySetOnRecheck' in c) || typeof c.allowAnySetOnRecheck !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate timeControlMs
|
||||
if ('timeControlMs' in c) {
|
||||
if (c.timeControlMs !== null && (typeof c.timeControlMs !== 'number' || c.timeControlMs < 0)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Rithmomachia game definition.
|
||||
*/
|
||||
export const rithmomachiaGame = defineGame<RithmomachiaConfig, RithmomachiaState, RithmomachiaMove>(
|
||||
{
|
||||
manifest,
|
||||
Provider: RithmomachiaProvider,
|
||||
GameComponent: RithmomachiaGame,
|
||||
validator: rithmomachiaValidator,
|
||||
defaultConfig,
|
||||
validateConfig,
|
||||
}
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Rithmomachia translations aggregated by locale
|
||||
* Co-located with the game code
|
||||
*/
|
||||
|
||||
// Import existing locale files
|
||||
import enGuide from './i18n/locales/en.json'
|
||||
import deGuide from './i18n/locales/de.json'
|
||||
import jaGuide from './i18n/locales/ja.json'
|
||||
import hiGuide from './i18n/locales/hi.json'
|
||||
import esGuide from './i18n/locales/es.json'
|
||||
import laGuide from './i18n/locales/la.json'
|
||||
|
||||
export const rithmomachiaMessages = {
|
||||
en: { rithmomachia: enGuide },
|
||||
de: { rithmomachia: deGuide },
|
||||
ja: { rithmomachia: jaGuide },
|
||||
hi: { rithmomachia: hiGuide },
|
||||
es: { rithmomachia: esGuide },
|
||||
la: { rithmomachia: laGuide },
|
||||
} as const
|
||||
@@ -1,317 +0,0 @@
|
||||
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk'
|
||||
|
||||
// === PIECE TYPES ===
|
||||
|
||||
export type PieceType = 'C' | 'T' | 'S' | 'P' // Circle, Triangle, Square, Pyramid
|
||||
export type Color = 'W' | 'B' // White, Black
|
||||
|
||||
export interface Piece {
|
||||
id: string // stable UUID (e.g., "W_C_01")
|
||||
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
|
||||
}
|
||||
|
||||
// === RELATIONS ===
|
||||
|
||||
export type RelationKind =
|
||||
| 'EQUAL' // a == b
|
||||
| 'MULTIPLE' // a % b == 0
|
||||
| 'DIVISOR' // b % a == 0
|
||||
| 'SUM' // a + h == b or b + h == a
|
||||
| 'DIFF' // |a - h| == b or |b - h| == a
|
||||
| 'PRODUCT' // a * h == b or b * h == a
|
||||
| 'RATIO' // a * r == b or b * r == a (r = helper value)
|
||||
|
||||
export interface CaptureContext {
|
||||
relation: RelationKind
|
||||
moverPieceId: string
|
||||
targetPieceId: string
|
||||
helperPieceId?: string // required for SUM/DIFF/PRODUCT/RATIO
|
||||
moverFaceUsed?: number | null // if mover was a Pyramid
|
||||
}
|
||||
|
||||
export interface AmbushContext {
|
||||
relation: RelationKind
|
||||
enemyPieceId: string
|
||||
helper1Id: string
|
||||
helper2Id: string // two helpers for ambush
|
||||
}
|
||||
|
||||
// === HARMONY ===
|
||||
|
||||
export type HarmonyType = 'ARITH' | 'GEOM' | 'HARM'
|
||||
|
||||
export interface HarmonyDeclaration {
|
||||
by: Color
|
||||
pieceIds: string[] // exactly 3 for classical three-piece proportions
|
||||
type: HarmonyType
|
||||
params: {
|
||||
a?: string // first value in proportion (A-M-B structure)
|
||||
m?: string // middle value in proportion
|
||||
b?: string // last value in proportion
|
||||
}
|
||||
declaredAtPly: number
|
||||
}
|
||||
|
||||
// === MOVE RECORDS ===
|
||||
|
||||
export 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'
|
||||
}
|
||||
|
||||
// === GAME STATE ===
|
||||
|
||||
export interface RithmomachiaState extends GameState {
|
||||
// Configuration (stored in state per arcade pattern)
|
||||
pointWinEnabled: boolean
|
||||
pointWinThreshold: number
|
||||
repetitionRule: boolean
|
||||
fiftyMoveRule: boolean
|
||||
allowAnySetOnRecheck: boolean
|
||||
timeControlMs: number | null
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
|
||||
// Game phase
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
|
||||
// Board dimensions
|
||||
boardCols: number // 16
|
||||
boardRows: number // 8
|
||||
|
||||
// Current turn
|
||||
turn: Color // 'W' or 'B'
|
||||
|
||||
// Pieces (key = piece.id)
|
||||
pieces: Record<string, Piece>
|
||||
|
||||
// Captured pieces
|
||||
capturedPieces: {
|
||||
W: Piece[]
|
||||
B: Piece[]
|
||||
}
|
||||
|
||||
// Move history
|
||||
history: MoveRecord[]
|
||||
|
||||
// Pending harmony (declared last turn, awaiting validation)
|
||||
pendingHarmony: HarmonyDeclaration | null
|
||||
|
||||
// Draw/repetition tracking
|
||||
noProgressCount: number // for 50-move rule
|
||||
stateHashes: string[] // Zobrist hashes for repetition detection
|
||||
|
||||
// Victory state
|
||||
winner: Color | null
|
||||
winCondition:
|
||||
| 'HARMONY'
|
||||
| 'EXHAUSTION'
|
||||
| 'RESIGNATION'
|
||||
| 'POINTS'
|
||||
| 'AGREEMENT'
|
||||
| 'REPETITION'
|
||||
| 'FIFTY'
|
||||
| null
|
||||
|
||||
// Points (if enabled by config)
|
||||
pointsCaptured?: {
|
||||
W: number
|
||||
B: number
|
||||
}
|
||||
}
|
||||
|
||||
// === GAME CONFIG ===
|
||||
|
||||
export interface RithmomachiaConfig extends GameConfig {
|
||||
// Rule toggles
|
||||
pointWinEnabled: boolean // default: false
|
||||
pointWinThreshold: number // default: 30
|
||||
repetitionRule: boolean // default: true
|
||||
fiftyMoveRule: boolean // default: true
|
||||
allowAnySetOnRecheck: boolean // default: true (harmony revalidation)
|
||||
|
||||
// Optional time controls (not implemented in v1)
|
||||
timeControlMs?: number | null
|
||||
|
||||
// Player assignments (null = auto-assign)
|
||||
whitePlayerId?: string | null // default: null (auto-assign first active player)
|
||||
blackPlayerId?: string | null // default: null (auto-assign second active player)
|
||||
}
|
||||
|
||||
// === GAME MOVES ===
|
||||
|
||||
export type RithmomachiaMove =
|
||||
| {
|
||||
type: 'START_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
playerColor: Color
|
||||
activePlayers: string[]
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'MOVE'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
from: string
|
||||
to: string
|
||||
pieceId: string
|
||||
pyramidFaceUsed?: number | null
|
||||
capture?: Omit<CaptureContext, 'moverPieceId' | 'targetPieceId'> & {
|
||||
targetPieceId: string
|
||||
}
|
||||
ambush?: AmbushContext
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'DECLARE_HARMONY'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
pieceIds: string[]
|
||||
harmonyType: HarmonyType
|
||||
params: HarmonyDeclaration['params']
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'RESIGN'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'OFFER_DRAW'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'ACCEPT_DRAW'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'CLAIM_REPETITION'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'CLAIM_FIFTY_MOVE'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'SET_CONFIG'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
field: string
|
||||
value: any
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'RESET_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'GO_TO_SETUP'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
// === HELPER TYPES ===
|
||||
|
||||
// Square notation helpers
|
||||
export type File =
|
||||
| 'A'
|
||||
| 'B'
|
||||
| 'C'
|
||||
| 'D'
|
||||
| 'E'
|
||||
| 'F'
|
||||
| 'G'
|
||||
| 'H'
|
||||
| 'I'
|
||||
| 'J'
|
||||
| 'K'
|
||||
| 'L'
|
||||
| 'M'
|
||||
| 'N'
|
||||
| 'O'
|
||||
| 'P'
|
||||
export type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
|
||||
export type Square = `${File}${Rank}`
|
||||
|
||||
// Board boundaries
|
||||
export const WHITE_HALF_ROWS = [1, 2, 3, 4] as const
|
||||
export const BLACK_HALF_ROWS = [5, 6, 7, 8] as const
|
||||
|
||||
// Point values for pieces
|
||||
export const PIECE_POINTS: Record<PieceType, number> = {
|
||||
C: 1, // Circle
|
||||
T: 2, // Triangle
|
||||
S: 3, // Square
|
||||
P: 5, // Pyramid
|
||||
}
|
||||
|
||||
// Utility: check if square is in enemy half
|
||||
export function isInEnemyHalf(square: string, color: Color): boolean {
|
||||
const rank = Number.parseInt(square[1], 10)
|
||||
if (color === 'W') {
|
||||
return (BLACK_HALF_ROWS as readonly number[]).includes(rank)
|
||||
}
|
||||
return (WHITE_HALF_ROWS as readonly number[]).includes(rank)
|
||||
}
|
||||
|
||||
// Utility: parse square notation
|
||||
export function parseSquare(square: string): { file: number; rank: number } {
|
||||
const file = square.charCodeAt(0) - 65 // A=0, B=1, ..., P=15
|
||||
const rank = Number.parseInt(square[1], 10) // 1-8
|
||||
return { file, rank }
|
||||
}
|
||||
|
||||
// Utility: create square notation
|
||||
export function makeSquare(file: number, rank: number): string {
|
||||
return `${String.fromCharCode(65 + file)}${rank}`
|
||||
}
|
||||
|
||||
// Utility: get opponent color
|
||||
export function opponentColor(color: Color): Color {
|
||||
return color === 'W' ? 'B' : 'W'
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import type { Color, HarmonyDeclaration, HarmonyType, Piece } from '../types'
|
||||
import { isInEnemyHalf, parseSquare } from '../types'
|
||||
import { getEffectiveValue } from './pieceSetup'
|
||||
|
||||
/**
|
||||
* Harmony (progression) validator for Rithmomachia.
|
||||
* Detects arithmetic, geometric, and harmonic proportions using three pieces.
|
||||
*
|
||||
* Updated to match classical Rithmomachia rules:
|
||||
* - Three pieces (A-M-B) where M is spatially in the middle
|
||||
* - Must be in a straight line (row, column, or diagonal)
|
||||
* - Uses three-piece proportion formulas (no division needed)
|
||||
*/
|
||||
|
||||
export interface HarmonyValidationResult {
|
||||
valid: boolean
|
||||
type?: HarmonyType
|
||||
params?: HarmonyDeclaration['params']
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type HarmonyLayoutMode = 'adjacent' | 'equalSpacing' | 'collinear'
|
||||
|
||||
/**
|
||||
* Check if three squares are collinear (on same row, column, or diagonal)
|
||||
*/
|
||||
function areCollinear(sq1: string, sq2: string, sq3: string): boolean {
|
||||
const p1 = parseSquare(sq1)
|
||||
const p2 = parseSquare(sq2)
|
||||
const p3 = parseSquare(sq3)
|
||||
|
||||
if (!p1 || !p2 || !p3) return false
|
||||
|
||||
// Same rank (horizontal row)
|
||||
if (p1.rank === p2.rank && p2.rank === p3.rank) return true
|
||||
|
||||
// Same file (vertical column)
|
||||
if (p1.file === p2.file && p2.file === p3.file) return true
|
||||
|
||||
// Diagonal: check if slope is consistent
|
||||
const dx12 = p2.file - p1.file
|
||||
const dy12 = p2.rank - p1.rank
|
||||
const dx23 = p3.file - p2.file
|
||||
const dy23 = p3.rank - p2.rank
|
||||
|
||||
// Cross product should be zero for collinear points
|
||||
return dx12 * dy23 === dy12 * dx23
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the distance between two squares (Manhattan or diagonal)
|
||||
*/
|
||||
function getDistance(sq1: string, sq2: string): number {
|
||||
const p1 = parseSquare(sq1)
|
||||
const p2 = parseSquare(sq2)
|
||||
|
||||
if (!p1 || !p2) return Infinity
|
||||
|
||||
return Math.max(Math.abs(p2.file - p1.file), Math.abs(p2.rank - p1.rank))
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which piece is spatially in the middle on a line
|
||||
* Returns the middle piece, or null if they're not properly ordered
|
||||
*/
|
||||
function findMiddlePiece(pieces: Piece[]): Piece | null {
|
||||
if (pieces.length !== 3) return null
|
||||
|
||||
const [p1, p2, p3] = pieces
|
||||
|
||||
// Check all permutations to find which one is in the middle
|
||||
const positions = [parseSquare(p1.square), parseSquare(p2.square), parseSquare(p3.square)]
|
||||
|
||||
if (!positions[0] || !positions[1] || !positions[2]) return null
|
||||
|
||||
// For each piece, check if it's between the other two
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const candidate = positions[i]
|
||||
const others = [positions[(i + 1) % 3], positions[(i + 2) % 3]]
|
||||
|
||||
// Check if candidate is between the other two on all axes
|
||||
const betweenX =
|
||||
(candidate.file >= others[0].file && candidate.file <= others[1].file) ||
|
||||
(candidate.file >= others[1].file && candidate.file <= others[0].file)
|
||||
|
||||
const betweenY =
|
||||
(candidate.rank >= others[0].rank && candidate.rank <= others[1].rank) ||
|
||||
(candidate.rank >= others[1].rank && candidate.rank <= others[0].rank)
|
||||
|
||||
if (betweenX && betweenY) {
|
||||
return pieces[i]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if three pieces satisfy layout constraint
|
||||
*/
|
||||
function checkLayout(pieces: Piece[], mode: HarmonyLayoutMode): boolean {
|
||||
if (pieces.length !== 3) return false
|
||||
|
||||
const [p1, p2, p3] = pieces
|
||||
const squares = [p1.square, p2.square, p3.square]
|
||||
|
||||
// All modes require collinearity
|
||||
if (!areCollinear(squares[0], squares[1], squares[2])) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Find which piece is in the middle
|
||||
const middle = findMiddlePiece(pieces)
|
||||
if (!middle) return false
|
||||
|
||||
const others = pieces.filter((p) => p !== middle)
|
||||
|
||||
if (mode === 'adjacent') {
|
||||
// All distances must be 1
|
||||
const d1 = getDistance(middle.square, others[0].square)
|
||||
const d2 = getDistance(middle.square, others[1].square)
|
||||
return d1 === 1 && d2 === 1
|
||||
}
|
||||
|
||||
if (mode === 'equalSpacing') {
|
||||
// Distances must be equal (and can be 1 or 2)
|
||||
const d1 = getDistance(middle.square, others[0].square)
|
||||
const d2 = getDistance(middle.square, others[1].square)
|
||||
return d1 === d2 && (d1 === 1 || d1 === 2)
|
||||
}
|
||||
|
||||
// mode === 'collinear': any spacing is OK (already checked collinearity)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if three values form an arithmetic proportion (A-M-B).
|
||||
* AP: 2M = A + B (middle is arithmetic mean)
|
||||
*/
|
||||
function isArithmeticProportion(a: number, m: number, b: number): HarmonyValidationResult {
|
||||
if (2 * m === a + b) {
|
||||
return {
|
||||
valid: true,
|
||||
type: 'ARITH',
|
||||
params: {
|
||||
a: a.toString(),
|
||||
m: m.toString(),
|
||||
b: b.toString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Not arithmetic: 2·${m} ≠ ${a} + ${b} (${2 * m} ≠ ${a + b})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if three values form a geometric proportion (A-M-B).
|
||||
* GP: M² = A · B (middle is geometric mean)
|
||||
*/
|
||||
function isGeometricProportion(a: number, m: number, b: number): HarmonyValidationResult {
|
||||
if (m * m === a * b) {
|
||||
return {
|
||||
valid: true,
|
||||
type: 'GEOM',
|
||||
params: {
|
||||
a: a.toString(),
|
||||
m: m.toString(),
|
||||
b: b.toString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Not geometric: ${m}² ≠ ${a} · ${b} (${m * m} ≠ ${a * b})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if three values form a harmonic proportion (A-M-B).
|
||||
* HP: 2AB = M(A + B) (middle is harmonic mean)
|
||||
* Equivalently: 1/A, 1/M, 1/B forms an arithmetic progression
|
||||
*/
|
||||
function isHarmonicProportion(a: number, m: number, b: number): HarmonyValidationResult {
|
||||
if (a === 0 || b === 0 || m === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'Harmonic proportion cannot contain 0',
|
||||
}
|
||||
}
|
||||
|
||||
if (2 * a * b === m * (a + b)) {
|
||||
return {
|
||||
valid: true,
|
||||
type: 'HARM',
|
||||
params: {
|
||||
a: a.toString(),
|
||||
m: m.toString(),
|
||||
b: b.toString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Not harmonic: 2·${a}·${b} ≠ ${m}·(${a}+${b}) (${2 * a * b} ≠ ${m * (a + b)})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if three pieces form a valid harmony.
|
||||
* Returns the first valid proportion type found, or invalid result.
|
||||
*/
|
||||
export function validateHarmony(
|
||||
pieces: Piece[],
|
||||
color: Color,
|
||||
layoutMode: HarmonyLayoutMode = 'adjacent'
|
||||
): HarmonyValidationResult {
|
||||
// Check: exactly 3 pieces
|
||||
if (pieces.length !== 3) {
|
||||
return { valid: false, reason: 'Harmony requires exactly 3 pieces' }
|
||||
}
|
||||
|
||||
// Check: all pieces must be in enemy half
|
||||
const notInEnemyHalf = pieces.filter((p) => !isInEnemyHalf(p.square, color))
|
||||
if (notInEnemyHalf.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Pieces not in enemy half: ${notInEnemyHalf.map((p) => p.square).join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Check: must satisfy layout constraint
|
||||
if (!checkLayout(pieces, layoutMode)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `Pieces not in valid ${layoutMode} layout (must be collinear with correct spacing)`,
|
||||
}
|
||||
}
|
||||
|
||||
// Find middle piece
|
||||
const middle = findMiddlePiece(pieces)
|
||||
if (!middle) {
|
||||
return { valid: false, reason: 'Could not determine middle piece' }
|
||||
}
|
||||
|
||||
const others = pieces.filter((p) => p !== middle)
|
||||
|
||||
// Extract values (handling Pyramids)
|
||||
const getVal = (p: Piece) => {
|
||||
const val = getEffectiveValue(p)
|
||||
if (val === null) {
|
||||
throw new Error(`Piece ${p.id} has no effective value (Pyramid face not set?)`)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
try {
|
||||
const m = getVal(middle)
|
||||
const a = getVal(others[0])
|
||||
const b = getVal(others[1])
|
||||
|
||||
// Check for duplicates
|
||||
if (a === m || m === b || a === b) {
|
||||
return { valid: false, reason: 'Harmony cannot contain duplicate values' }
|
||||
}
|
||||
|
||||
// Try all three proportion types
|
||||
const apCheck = isArithmeticProportion(a, m, b)
|
||||
if (apCheck.valid) return apCheck
|
||||
|
||||
const gpCheck = isGeometricProportion(a, m, b)
|
||||
if (gpCheck.valid) return gpCheck
|
||||
|
||||
const hpCheck = isHarmonicProportion(a, m, b)
|
||||
if (hpCheck.valid) return hpCheck
|
||||
|
||||
return { valid: false, reason: 'Values do not form any valid proportion' }
|
||||
} catch (err) {
|
||||
return { valid: false, reason: (err as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all possible harmonies for a color from a set of pieces.
|
||||
* Returns an array of all valid 3-piece combinations that form harmonies.
|
||||
*/
|
||||
export function findPossibleHarmonies(
|
||||
pieces: Record<string, Piece>,
|
||||
color: Color,
|
||||
layoutMode: HarmonyLayoutMode = 'adjacent'
|
||||
): Array<{ pieceIds: string[]; validation: HarmonyValidationResult }> {
|
||||
const results: Array<{ pieceIds: string[]; validation: HarmonyValidationResult }> = []
|
||||
|
||||
// Get all live pieces for this color in enemy half
|
||||
const candidatePieces = Object.values(pieces).filter(
|
||||
(p) => p.color === color && !p.captured && isInEnemyHalf(p.square, color)
|
||||
)
|
||||
|
||||
if (candidatePieces.length < 3) {
|
||||
return results
|
||||
}
|
||||
|
||||
// Generate all combinations of exactly 3 pieces
|
||||
for (let i = 0; i < candidatePieces.length; i++) {
|
||||
for (let j = i + 1; j < candidatePieces.length; j++) {
|
||||
for (let k = j + 1; k < candidatePieces.length; k++) {
|
||||
const combo = [candidatePieces[i], candidatePieces[j], candidatePieces[k]]
|
||||
const validation = validateHarmony(combo, color, layoutMode)
|
||||
if (validation.valid) {
|
||||
results.push({
|
||||
pieceIds: combo.map((p) => p.id),
|
||||
validation,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific harmony declaration is currently valid.
|
||||
* Used for harmony persistence checking.
|
||||
*/
|
||||
export function isHarmonyStillValid(
|
||||
pieces: Record<string, Piece>,
|
||||
harmony: HarmonyDeclaration,
|
||||
layoutMode: HarmonyLayoutMode = 'adjacent'
|
||||
): boolean {
|
||||
const relevantPieces = harmony.pieceIds.map((id) => pieces[id]).filter((p) => p && !p.captured)
|
||||
|
||||
if (relevantPieces.length !== 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
const validation = validateHarmony(relevantPieces, harmony.by, layoutMode)
|
||||
return validation.valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ANY valid harmony exists for a color (for harmony persistence recheck).
|
||||
*/
|
||||
export function hasAnyValidHarmony(
|
||||
pieces: Record<string, Piece>,
|
||||
color: Color,
|
||||
layoutMode: HarmonyLayoutMode = 'adjacent'
|
||||
): boolean {
|
||||
const harmonies = findPossibleHarmonies(pieces, color, layoutMode)
|
||||
return harmonies.length > 0
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Parse the Rithmomachia board setup CSV and generate piece setup.
|
||||
*
|
||||
* CSV Format:
|
||||
* - Portrait orientation: black at top, white at bottom
|
||||
* - 8 columns (CSV horizontal) = 8 rows in game (1-8)
|
||||
* - 16 ranks (CSV vertical, triplets) = 16 columns in game (A-P)
|
||||
* - Each rank is 3 CSV rows: [color, shape, number]
|
||||
*
|
||||
* Game Rotation:
|
||||
* - Board is rotated 90° counterclockwise from CSV
|
||||
* - CSV column 0 → game row 1 (bottom)
|
||||
* - CSV column 7 → game row 8 (top)
|
||||
* - CSV rank 0 → game column A (leftmost, black side)
|
||||
* - CSV rank 15 → game column P (rightmost, white side)
|
||||
*/
|
||||
|
||||
import type { Color, Piece, PieceType } from '../types'
|
||||
|
||||
interface CSVPiece {
|
||||
color: Color
|
||||
type: PieceType
|
||||
value?: number
|
||||
pyramidFaces?: number[]
|
||||
square: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSV content into structured board layout.
|
||||
*/
|
||||
export function parseCSV(csvContent: string): CSVPiece[] {
|
||||
const lines = csvContent.trim().split('\n')
|
||||
const pieces: CSVPiece[] = []
|
||||
|
||||
// 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
|
||||
if (!color || !shape || !numberStr) 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 = color.toLowerCase() === 'black' ? 'B' : 'W'
|
||||
|
||||
// Parse type
|
||||
let pieceType: 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 - for now use default faces, we'll need to determine these
|
||||
pieces.push({
|
||||
color: pieceColor,
|
||||
type: pieceType,
|
||||
pyramidFaces: pieceColor === 'B' ? [36, 25, 16, 4] : [64, 49, 36, 25],
|
||||
square,
|
||||
})
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert CSV pieces to full Piece objects with IDs.
|
||||
*/
|
||||
export function createBoardFromCSV(csvPieces: CSVPiece[]): Record<string, Piece> {
|
||||
const pieces: Record<string, Piece> = {}
|
||||
|
||||
// Count pieces by color and type for ID generation
|
||||
const counts = {
|
||||
B: { C: 0, T: 0, S: 0, P: 0 },
|
||||
W: { C: 0, T: 0, S: 0, P: 0 },
|
||||
}
|
||||
|
||||
for (const csvPiece of csvPieces) {
|
||||
const color = csvPiece.color
|
||||
const type = csvPiece.type
|
||||
|
||||
// Generate piece ID
|
||||
const count = ++counts[color][type]
|
||||
const id = `${color}_${type}_${String(count).padStart(2, '0')}`
|
||||
|
||||
// Create full piece
|
||||
const piece: Piece = {
|
||||
id,
|
||||
color,
|
||||
type,
|
||||
square: csvPiece.square,
|
||||
captured: false,
|
||||
}
|
||||
|
||||
if (type === 'P') {
|
||||
piece.pyramidFaces = csvPiece.pyramidFaces
|
||||
piece.activePyramidFace = null
|
||||
} else {
|
||||
piece.value = csvPiece.value
|
||||
}
|
||||
|
||||
pieces[id] = piece
|
||||
}
|
||||
|
||||
return pieces
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: read CSV and generate board.
|
||||
*/
|
||||
export async function loadBoardFromCSV(csvPath: string): Promise<Record<string, Piece>> {
|
||||
const fs = await import('fs')
|
||||
const csvContent = fs.readFileSync(csvPath, 'utf-8')
|
||||
const csvPieces = parseCSV(csvContent)
|
||||
return createBoardFromCSV(csvPieces)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate board layout summary for verification.
|
||||
*/
|
||||
export function generateBoardSummary(pieces: Record<string, Piece>): string {
|
||||
const lines: string[] = []
|
||||
|
||||
// Generate grid view (A-P columns, 1-8 rows)
|
||||
lines.push('\n=== Board Layout (Game Orientation) ===\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 = Object.values(pieces).find((p) => p.square === square)
|
||||
|
||||
if (piece) {
|
||||
const colorChar = piece.color
|
||||
const typeChar = piece.type
|
||||
const value = piece.type === 'P' ? 'P' : piece.value?.toString().padStart(3, ' ')
|
||||
line += ` ${colorChar}${typeChar}${value}`
|
||||
} else {
|
||||
line += ' ---'
|
||||
}
|
||||
}
|
||||
lines.push(line)
|
||||
}
|
||||
|
||||
// Piece counts
|
||||
lines.push('\n=== Piece Counts ===')
|
||||
const blackPieces = Object.values(pieces).filter((p) => p.color === 'B')
|
||||
const whitePieces = Object.values(pieces).filter((p) => p.color === 'W')
|
||||
|
||||
const countByType = (pieces: Piece[]) => {
|
||||
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)
|
||||
|
||||
lines.push(
|
||||
`Black: ${blackPieces.length} total (C:${blackCounts.C}, T:${blackCounts.T}, S:${blackCounts.S}, P:${blackCounts.P})`
|
||||
)
|
||||
lines.push(
|
||||
`White: ${whitePieces.length} total (C:${whiteCounts.C}, T:${whiteCounts.T}, S:${whiteCounts.S}, P:${whiteCounts.P})`
|
||||
)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
[
|
||||
{
|
||||
"color": "B",
|
||||
"type": "S",
|
||||
"value": 28,
|
||||
"square": "A1"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "S",
|
||||
"value": 66,
|
||||
"square": "A2"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "S",
|
||||
"value": 225,
|
||||
"square": "A7"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "S",
|
||||
"value": 361,
|
||||
"square": "A8"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "S",
|
||||
"value": 28,
|
||||
"square": "B1"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "S",
|
||||
"value": 66,
|
||||
"square": "B2"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "T",
|
||||
"value": 36,
|
||||
"square": "B3"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "T",
|
||||
"value": 30,
|
||||
"square": "B4"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "T",
|
||||
"value": 56,
|
||||
"square": "B5"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "T",
|
||||
"value": 64,
|
||||
"square": "B6"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "S",
|
||||
"value": 120,
|
||||
"square": "B7"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "P",
|
||||
"pyramidFaces": [36, 25, 16, 4],
|
||||
"square": "B8"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "T",
|
||||
"value": 16,
|
||||
"square": "C1"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "T",
|
||||
"value": 12,
|
||||
"square": "C2"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "C",
|
||||
"value": 9,
|
||||
"square": "C3"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "C",
|
||||
"value": 25,
|
||||
"square": "C4"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "C",
|
||||
"value": 49,
|
||||
"square": "C5"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "C",
|
||||
"value": 81,
|
||||
"square": "C6"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "T",
|
||||
"value": 90,
|
||||
"square": "C7"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "T",
|
||||
"value": 100,
|
||||
"square": "C8"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "C",
|
||||
"value": 3,
|
||||
"square": "D3"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "C",
|
||||
"value": 5,
|
||||
"square": "D4"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "C",
|
||||
"value": 7,
|
||||
"square": "D5"
|
||||
},
|
||||
{
|
||||
"color": "B",
|
||||
"type": "C",
|
||||
"value": 9,
|
||||
"square": "D6"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "C",
|
||||
"value": 8,
|
||||
"square": "M3"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "C",
|
||||
"value": 6,
|
||||
"square": "M4"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "C",
|
||||
"value": 4,
|
||||
"square": "M5"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "C",
|
||||
"value": 2,
|
||||
"square": "M6"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "T",
|
||||
"value": 81,
|
||||
"square": "N1"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "T",
|
||||
"value": 72,
|
||||
"square": "N2"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "C",
|
||||
"value": 64,
|
||||
"square": "N3"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "C",
|
||||
"value": 16,
|
||||
"square": "N4"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "C",
|
||||
"value": 16,
|
||||
"square": "N5"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "C",
|
||||
"value": 4,
|
||||
"square": "N6"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "T",
|
||||
"value": 6,
|
||||
"square": "N7"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "T",
|
||||
"value": 9,
|
||||
"square": "N8"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "S",
|
||||
"value": 153,
|
||||
"square": "O1"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "P",
|
||||
"pyramidFaces": [64, 49, 36, 25],
|
||||
"square": "O2"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "T",
|
||||
"value": 72,
|
||||
"square": "O3"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "T",
|
||||
"value": 20,
|
||||
"square": "O4"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "T",
|
||||
"value": 20,
|
||||
"square": "O5"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "T",
|
||||
"value": 25,
|
||||
"square": "O6"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "S",
|
||||
"value": 45,
|
||||
"square": "O7"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "S",
|
||||
"value": 15,
|
||||
"square": "O8"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "S",
|
||||
"value": 289,
|
||||
"square": "P1"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "S",
|
||||
"value": 169,
|
||||
"square": "P2"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "S",
|
||||
"value": 81,
|
||||
"square": "P7"
|
||||
},
|
||||
{
|
||||
"color": "W",
|
||||
"type": "S",
|
||||
"value": 25,
|
||||
"square": "P8"
|
||||
}
|
||||
]
|
||||
@@ -1,200 +0,0 @@
|
||||
import type { Piece, PieceType } from '../types'
|
||||
import { makeSquare, parseSquare } from '../types'
|
||||
|
||||
/**
|
||||
* Path validation for Rithmomachia piece movement.
|
||||
* Checks if a move is geometrically legal and the path is clear.
|
||||
*/
|
||||
|
||||
export interface PathValidationResult {
|
||||
valid: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a move is geometrically legal for a given piece type.
|
||||
* Does NOT check if the path is clear (that's done separately).
|
||||
*/
|
||||
export function isGeometryLegal(
|
||||
pieceType: PieceType,
|
||||
from: string,
|
||||
to: string
|
||||
): PathValidationResult {
|
||||
if (from === to) {
|
||||
return { valid: false, reason: 'Cannot move to the same square' }
|
||||
}
|
||||
|
||||
const fromCoords = parseSquare(from)
|
||||
const toCoords = parseSquare(to)
|
||||
|
||||
const deltaFile = toCoords.file - fromCoords.file
|
||||
const deltaRank = toCoords.rank - fromCoords.rank
|
||||
|
||||
const absDeltaFile = Math.abs(deltaFile)
|
||||
const absDeltaRank = Math.abs(deltaRank)
|
||||
|
||||
switch (pieceType) {
|
||||
case 'C': {
|
||||
// Circle: diagonal only (like bishop)
|
||||
if (absDeltaFile === absDeltaRank && absDeltaFile > 0) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, reason: 'Circles move diagonally' }
|
||||
}
|
||||
|
||||
case 'T': {
|
||||
// Triangle: orthogonal only (like rook)
|
||||
if ((deltaFile === 0 && deltaRank !== 0) || (deltaRank === 0 && deltaFile !== 0)) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, reason: 'Triangles move orthogonally' }
|
||||
}
|
||||
|
||||
case 'S': {
|
||||
// Square: queen-like (orthogonal or diagonal)
|
||||
const isDiagonal = absDeltaFile === absDeltaRank && absDeltaFile > 0
|
||||
const isOrthogonal =
|
||||
(deltaFile === 0 && deltaRank !== 0) || (deltaRank === 0 && deltaFile !== 0)
|
||||
if (isDiagonal || isOrthogonal) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, reason: 'Squares move orthogonally or diagonally' }
|
||||
}
|
||||
|
||||
case 'P': {
|
||||
// Pyramid: king-like (1 step in any direction)
|
||||
if (absDeltaFile <= 1 && absDeltaRank <= 1 && (absDeltaFile > 0 || absDeltaRank > 0)) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, reason: 'Pyramids move 1 step in any direction' }
|
||||
}
|
||||
|
||||
default:
|
||||
return { valid: false, reason: 'Unknown piece type' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the path from 'from' to 'to' is clear (no pieces in between).
|
||||
* Assumes the geometry is already validated.
|
||||
*/
|
||||
export function isPathClear(
|
||||
pieces: Record<string, Piece>,
|
||||
from: string,
|
||||
to: string
|
||||
): PathValidationResult {
|
||||
const fromCoords = parseSquare(from)
|
||||
const toCoords = parseSquare(to)
|
||||
|
||||
const deltaFile = toCoords.file - fromCoords.file
|
||||
const deltaRank = toCoords.rank - fromCoords.rank
|
||||
|
||||
// Calculate step direction
|
||||
const stepFile = deltaFile === 0 ? 0 : deltaFile > 0 ? 1 : -1
|
||||
const stepRank = deltaRank === 0 ? 0 : deltaRank > 0 ? 1 : -1
|
||||
|
||||
// Calculate number of steps (excluding start and end)
|
||||
const steps = Math.max(Math.abs(deltaFile), Math.abs(deltaRank)) - 1
|
||||
|
||||
// Check each intermediate square
|
||||
let currentFile = fromCoords.file + stepFile
|
||||
let currentRank = fromCoords.rank + stepRank
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const square = makeSquare(currentFile, currentRank)
|
||||
const pieceAtSquare = Object.values(pieces).find((p) => p.square === square && !p.captured)
|
||||
|
||||
if (pieceAtSquare) {
|
||||
return { valid: false, reason: `Path blocked by ${pieceAtSquare.id} at ${square}` }
|
||||
}
|
||||
|
||||
currentFile += stepFile
|
||||
currentRank += stepRank
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a complete move (geometry + path clearance).
|
||||
*/
|
||||
export function validateMove(
|
||||
piece: Piece,
|
||||
from: string,
|
||||
to: string,
|
||||
pieces: Record<string, Piece>
|
||||
): PathValidationResult {
|
||||
// Check geometry
|
||||
const geometryCheck = isGeometryLegal(piece.type, from, to)
|
||||
if (!geometryCheck.valid) {
|
||||
return geometryCheck
|
||||
}
|
||||
|
||||
// Check path clearance
|
||||
const pathCheck = isPathClear(pieces, from, to)
|
||||
if (!pathCheck.valid) {
|
||||
return pathCheck
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all legal move destinations for a piece (ignoring captures/relations).
|
||||
* Returns an array of square notations.
|
||||
*/
|
||||
export function getLegalMoves(piece: Piece, pieces: Record<string, Piece>): string[] {
|
||||
const legalMoves: string[] = []
|
||||
|
||||
// Generate all possible squares on the board
|
||||
for (let file = 0; file < 16; file++) {
|
||||
for (let rank = 1; rank <= 8; rank++) {
|
||||
const targetSquare = makeSquare(file, rank)
|
||||
|
||||
// Skip if same square
|
||||
if (targetSquare === piece.square) continue
|
||||
|
||||
// Check if move is legal
|
||||
const validation = validateMove(piece, piece.square, targetSquare, pieces)
|
||||
if (validation.valid) {
|
||||
legalMoves.push(targetSquare)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return legalMoves
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a square is within board bounds.
|
||||
*/
|
||||
export function isSquareValid(square: string): boolean {
|
||||
if (square.length !== 2) return false
|
||||
|
||||
const file = square.charCodeAt(0) - 65 // A=0, B=1, ..., P=15
|
||||
const rank = Number.parseInt(square[1], 10)
|
||||
|
||||
return file >= 0 && file <= 15 && rank >= 1 && rank <= 8
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the direction of movement (for UI purposes).
|
||||
*/
|
||||
export function getDirection(
|
||||
from: string,
|
||||
to: string
|
||||
): {
|
||||
horizontal: 'left' | 'right' | 'none'
|
||||
vertical: 'up' | 'down' | 'none'
|
||||
} {
|
||||
const fromCoords = parseSquare(from)
|
||||
const toCoords = parseSquare(to)
|
||||
|
||||
const deltaFile = toCoords.file - fromCoords.file
|
||||
const deltaRank = toCoords.rank - fromCoords.rank
|
||||
|
||||
return {
|
||||
horizontal: deltaFile < 0 ? 'left' : deltaFile > 0 ? 'right' : 'none',
|
||||
vertical: deltaRank < 0 ? 'down' : deltaRank > 0 ? 'up' : 'none',
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import type { Color, Piece } from '../types'
|
||||
|
||||
/**
|
||||
* Generate the initial board setup for traditional Rithmomachia.
|
||||
* Returns a Record of piece.id → Piece.
|
||||
*
|
||||
* Layout generated from authoritative CSV (rotated 90° CCW):
|
||||
* - BLACK on left (columns A-D)
|
||||
* - WHITE on right (columns M-P)
|
||||
* - 24 pieces per side (48 total)
|
||||
*/
|
||||
export function createInitialBoard(): Record<string, Piece> {
|
||||
const pieces: Record<string, Piece> = {}
|
||||
|
||||
// === BLACK PIECES (Left side) ===
|
||||
// Layout from CSV: portrait → rotated 90° CCW for game orientation
|
||||
|
||||
// Column A: Outer edge (sparse)
|
||||
const blackColumnA = [
|
||||
{ type: 'S', value: 28, square: 'A1' },
|
||||
{ type: 'S', value: 66, square: 'A2' },
|
||||
{ type: 'S', value: 225, square: 'A7' },
|
||||
{ type: 'S', value: 361, square: 'A8' },
|
||||
] as const
|
||||
|
||||
// Column B: Mixed with Pyramid at B8
|
||||
const blackColumnB = [
|
||||
{ type: 'S', value: 28, square: 'B1' },
|
||||
{ type: 'S', value: 66, square: 'B2' },
|
||||
{ type: 'T', value: 36, square: 'B3' },
|
||||
{ type: 'T', value: 30, square: 'B4' },
|
||||
{ type: 'T', value: 56, square: 'B5' },
|
||||
{ type: 'T', value: 64, square: 'B6' },
|
||||
{ type: 'S', value: 120, square: 'B7' },
|
||||
// B8: Pyramid (see below)
|
||||
] as const
|
||||
|
||||
// Column C: Triangles and circles
|
||||
const blackColumnC = [
|
||||
{ type: 'T', value: 16, square: 'C1' },
|
||||
{ type: 'T', value: 12, square: 'C2' },
|
||||
{ type: 'C', value: 9, square: 'C3' },
|
||||
{ type: 'C', value: 25, square: 'C4' },
|
||||
{ type: 'C', value: 49, square: 'C5' },
|
||||
{ type: 'C', value: 81, square: 'C6' },
|
||||
{ type: 'T', value: 90, square: 'C7' },
|
||||
{ type: 'T', value: 100, square: 'C8' },
|
||||
] as const
|
||||
|
||||
// Column D: Small circles (sparse)
|
||||
const blackColumnD = [
|
||||
{ type: 'C', value: 3, square: 'D3' },
|
||||
{ type: 'C', value: 5, square: 'D4' },
|
||||
{ type: 'C', value: 7, square: 'D5' },
|
||||
{ type: 'C', value: 9, square: 'D6' },
|
||||
] as const
|
||||
|
||||
let blackSquareCount = 0
|
||||
let blackTriangleCount = 0
|
||||
let blackCircleCount = 0
|
||||
|
||||
for (const piece of [...blackColumnA, ...blackColumnB, ...blackColumnC, ...blackColumnD]) {
|
||||
let id: string
|
||||
let count: number
|
||||
if (piece.type === 'S') {
|
||||
count = ++blackSquareCount
|
||||
id = `B_S_${String(count).padStart(2, '0')}`
|
||||
} else if (piece.type === 'T') {
|
||||
count = ++blackTriangleCount
|
||||
id = `B_T_${String(count).padStart(2, '0')}`
|
||||
} else {
|
||||
count = ++blackCircleCount
|
||||
id = `B_C_${String(count).padStart(2, '0')}`
|
||||
}
|
||||
pieces[id] = {
|
||||
id,
|
||||
color: 'B',
|
||||
type: piece.type,
|
||||
value: piece.value,
|
||||
square: piece.square,
|
||||
captured: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Black Pyramid at B8
|
||||
pieces.B_P_01 = {
|
||||
id: 'B_P_01',
|
||||
color: 'B',
|
||||
type: 'P',
|
||||
pyramidFaces: [36, 25, 16, 4],
|
||||
activePyramidFace: null,
|
||||
square: 'B8',
|
||||
captured: false,
|
||||
}
|
||||
|
||||
// === WHITE PIECES (Right side) ===
|
||||
// Layout from CSV: portrait → rotated 90° CCW for game orientation
|
||||
|
||||
// Column M: Small circles (sparse)
|
||||
const whiteColumnM = [
|
||||
{ type: 'C', value: 8, square: 'M3' },
|
||||
{ type: 'C', value: 6, square: 'M4' },
|
||||
{ type: 'C', value: 4, square: 'M5' },
|
||||
{ type: 'C', value: 2, square: 'M6' },
|
||||
] as const
|
||||
|
||||
// Column N: Triangles and circles
|
||||
const whiteColumnN = [
|
||||
{ type: 'T', value: 81, square: 'N1' },
|
||||
{ type: 'T', value: 72, square: 'N2' },
|
||||
{ type: 'C', value: 64, square: 'N3' },
|
||||
{ type: 'C', value: 16, square: 'N4' },
|
||||
{ type: 'C', value: 16, square: 'N5' },
|
||||
{ type: 'C', value: 4, square: 'N6' },
|
||||
{ type: 'T', value: 6, square: 'N7' },
|
||||
{ type: 'T', value: 9, square: 'N8' },
|
||||
] as const
|
||||
|
||||
// Column O: Mixed with Pyramid at O2
|
||||
const whiteColumnO = [
|
||||
{ type: 'S', value: 153, square: 'O1' },
|
||||
// O2: Pyramid (see below)
|
||||
{ type: 'T', value: 72, square: 'O3' },
|
||||
{ type: 'T', value: 20, square: 'O4' },
|
||||
{ type: 'T', value: 20, square: 'O5' },
|
||||
{ type: 'T', value: 25, square: 'O6' },
|
||||
{ type: 'S', value: 45, square: 'O7' },
|
||||
{ type: 'S', value: 15, square: 'O8' },
|
||||
] as const
|
||||
|
||||
// Column P: Outer edge (sparse)
|
||||
const whiteColumnP = [
|
||||
{ type: 'S', value: 289, square: 'P1' },
|
||||
{ type: 'S', value: 169, square: 'P2' },
|
||||
{ type: 'S', value: 81, square: 'P7' },
|
||||
{ type: 'S', value: 25, square: 'P8' },
|
||||
] as const
|
||||
|
||||
let whiteSquareCount = 0
|
||||
let whiteTriangleCount = 0
|
||||
let whiteCircleCount = 0
|
||||
|
||||
for (const piece of [...whiteColumnM, ...whiteColumnN, ...whiteColumnO, ...whiteColumnP]) {
|
||||
let id: string
|
||||
let count: number
|
||||
if (piece.type === 'S') {
|
||||
count = ++whiteSquareCount
|
||||
id = `W_S_${String(count).padStart(2, '0')}`
|
||||
} else if (piece.type === 'T') {
|
||||
count = ++whiteTriangleCount
|
||||
id = `W_T_${String(count).padStart(2, '0')}`
|
||||
} else {
|
||||
count = ++whiteCircleCount
|
||||
id = `W_C_${String(count).padStart(2, '0')}`
|
||||
}
|
||||
pieces[id] = {
|
||||
id,
|
||||
color: 'W',
|
||||
type: piece.type,
|
||||
value: piece.value,
|
||||
square: piece.square,
|
||||
captured: false,
|
||||
}
|
||||
}
|
||||
|
||||
// White Pyramid at O2
|
||||
pieces.W_P_01 = {
|
||||
id: 'W_P_01',
|
||||
color: 'W',
|
||||
type: 'P',
|
||||
pyramidFaces: [64, 49, 36, 25],
|
||||
activePyramidFace: null,
|
||||
square: 'O2',
|
||||
captured: false,
|
||||
}
|
||||
|
||||
return pieces
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective value of a piece for relation checks.
|
||||
* For Circles/Triangles/Squares, returns their value.
|
||||
* For Pyramids, returns the activePyramidFace (or null if not set).
|
||||
*/
|
||||
export function getEffectiveValue(piece: Piece): number | null {
|
||||
if (piece.type === 'P') {
|
||||
return piece.activePyramidFace ?? null
|
||||
}
|
||||
return piece.value ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all live (non-captured) pieces for a given color.
|
||||
*/
|
||||
export function getLivePiecesForColor(pieces: Record<string, Piece>, color: Color): Piece[] {
|
||||
return Object.values(pieces).filter((p) => p.color === color && !p.captured)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the piece at a specific square (if any).
|
||||
*/
|
||||
export function getPieceAt(pieces: Record<string, Piece>, square: string): Piece | null {
|
||||
return Object.values(pieces).find((p) => p.square === square && !p.captured) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a square is occupied by a live piece.
|
||||
*/
|
||||
export function isSquareOccupied(pieces: Record<string, Piece>, square: string): boolean {
|
||||
return getPieceAt(pieces, square) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a piece by ID (throws if not found).
|
||||
*/
|
||||
export function getPieceById(pieces: Record<string, Piece>, id: string): Piece {
|
||||
const piece = pieces[id]
|
||||
if (!piece) {
|
||||
throw new Error(`Piece not found: ${id}`)
|
||||
}
|
||||
return piece
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a pieces record (shallow clone for immutability).
|
||||
*/
|
||||
export function clonePieces(pieces: Record<string, Piece>): Record<string, Piece> {
|
||||
const result: Record<string, Piece> = {}
|
||||
for (const [id, piece] of Object.entries(pieces)) {
|
||||
result[id] = { ...piece }
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import type { RelationKind } from '../types'
|
||||
|
||||
/**
|
||||
* Relation checking engine for Rithmomachia captures.
|
||||
* All arithmetic uses BigInt for precision with large values.
|
||||
*/
|
||||
|
||||
export interface RelationCheckResult {
|
||||
valid: boolean
|
||||
relation?: RelationKind
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two values satisfy the EQUAL relation.
|
||||
* a == b
|
||||
*/
|
||||
export function checkEqual(a: number, b: number): RelationCheckResult {
|
||||
if (a === b) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'EQUAL',
|
||||
explanation: `${a} == ${b}`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
explanation: `${a} ≠ ${b} (values are not equal)`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two values satisfy the MULTIPLE relation.
|
||||
* a % b == 0 (a is a multiple of b)
|
||||
*/
|
||||
export function checkMultiple(a: number, b: number): RelationCheckResult {
|
||||
if (b === 0) return { valid: false, explanation: 'Cannot check multiple with zero' }
|
||||
if (a % b === 0) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'MULTIPLE',
|
||||
explanation: `${a} is a multiple of ${b} (${a}÷${b}=${a / b})`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
explanation: `${a} is not a multiple of ${b} (${a}÷${b}=${(a / b).toFixed(2)}...)`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two values satisfy the DIVISOR relation.
|
||||
* b % a == 0 (a is a divisor of b)
|
||||
*/
|
||||
export function checkDivisor(a: number, b: number): RelationCheckResult {
|
||||
if (a === 0) return { valid: false, explanation: 'Cannot divide by zero' }
|
||||
if (b % a === 0) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'DIVISOR',
|
||||
explanation: `${a} divides ${b} (${b}÷${a}=${b / a})`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
explanation: `${a} does not divide ${b} evenly (${b}÷${a}=${(b / a).toFixed(2)}...)`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if three values satisfy the SUM relation.
|
||||
* a + h == b OR b + h == a
|
||||
*/
|
||||
export function checkSum(a: number, b: number, h: number): RelationCheckResult {
|
||||
if (a + h === b) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'SUM',
|
||||
explanation: `${a} + ${h} = ${b}`,
|
||||
}
|
||||
}
|
||||
if (b + h === a) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'SUM',
|
||||
explanation: `${b} + ${h} = ${a}`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
explanation: `Helper ${h} doesn't satisfy sum (need ${Math.abs(b - a)} but got ${h})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if three values satisfy the DIFF relation.
|
||||
* |a - h| == b OR |b - h| == a
|
||||
*/
|
||||
export function checkDiff(a: number, b: number, h: number): RelationCheckResult {
|
||||
const abs = (x: number) => (x < 0 ? -x : x)
|
||||
|
||||
const diff1 = abs(a - h)
|
||||
if (diff1 === b) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'DIFF',
|
||||
explanation: `|${a} - ${h}| = ${b}`,
|
||||
}
|
||||
}
|
||||
|
||||
const diff2 = abs(b - h)
|
||||
if (diff2 === a) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'DIFF',
|
||||
explanation: `|${b} - ${h}| = ${a}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
explanation: `Helper ${h} doesn't satisfy difference (|${a}-${h}|=${diff1}, |${b}-${h}|=${diff2})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if three values satisfy the PRODUCT relation.
|
||||
* a * h == b OR b * h == a
|
||||
*/
|
||||
export function checkProduct(a: number, b: number, h: number): RelationCheckResult {
|
||||
if (a * h === b) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'PRODUCT',
|
||||
explanation: `${a} × ${h} = ${b}`,
|
||||
}
|
||||
}
|
||||
if (b * h === a) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'PRODUCT',
|
||||
explanation: `${b} × ${h} = ${a}`,
|
||||
}
|
||||
}
|
||||
const needed1 = a === 0 ? 'undefined' : (b / a).toFixed(2)
|
||||
const needed2 = b === 0 ? 'undefined' : (a / b).toFixed(2)
|
||||
return {
|
||||
valid: false,
|
||||
explanation: `Helper ${h} doesn't satisfy product (need ${needed1} or ${needed2})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if three values satisfy the RATIO relation.
|
||||
* a * r == b OR b * r == a (where r is the helper value)
|
||||
* This is similar to PRODUCT but with explicit ratio semantics.
|
||||
*/
|
||||
export function checkRatio(a: number, b: number, r: number): RelationCheckResult {
|
||||
if (r === 0) return { valid: false, explanation: 'Cannot use zero as ratio helper' }
|
||||
|
||||
if (a * r === b) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'RATIO',
|
||||
explanation: `${a} × ${r} = ${b}`,
|
||||
}
|
||||
}
|
||||
if (b * r === a) {
|
||||
return {
|
||||
valid: true,
|
||||
relation: 'RATIO',
|
||||
explanation: `${b} × ${r} = ${a}`,
|
||||
}
|
||||
}
|
||||
const needed1 = a === 0 ? 'undefined' : (b / a).toFixed(2)
|
||||
const needed2 = b === 0 ? 'undefined' : (a / b).toFixed(2)
|
||||
return {
|
||||
valid: false,
|
||||
explanation: `Helper ${r} doesn't satisfy ratio (need ${needed1} or ${needed2})`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a relation holds between mover and target values.
|
||||
* Returns the first valid relation found, or null if none.
|
||||
*/
|
||||
export function checkRelation(
|
||||
relation: RelationKind,
|
||||
moverValue: number,
|
||||
targetValue: number,
|
||||
helperValue?: number
|
||||
): RelationCheckResult {
|
||||
switch (relation) {
|
||||
case 'EQUAL':
|
||||
return checkEqual(moverValue, targetValue)
|
||||
|
||||
case 'MULTIPLE':
|
||||
return checkMultiple(moverValue, targetValue)
|
||||
|
||||
case 'DIVISOR':
|
||||
return checkDivisor(moverValue, targetValue)
|
||||
|
||||
case 'SUM':
|
||||
if (helperValue === undefined) {
|
||||
return { valid: false, explanation: 'SUM requires a helper' }
|
||||
}
|
||||
return checkSum(moverValue, targetValue, helperValue)
|
||||
|
||||
case 'DIFF':
|
||||
if (helperValue === undefined) {
|
||||
return { valid: false, explanation: 'DIFF requires a helper' }
|
||||
}
|
||||
return checkDiff(moverValue, targetValue, helperValue)
|
||||
|
||||
case 'PRODUCT':
|
||||
if (helperValue === undefined) {
|
||||
return { valid: false, explanation: 'PRODUCT requires a helper' }
|
||||
}
|
||||
return checkProduct(moverValue, targetValue, helperValue)
|
||||
|
||||
case 'RATIO':
|
||||
if (helperValue === undefined) {
|
||||
return { valid: false, explanation: 'RATIO requires a helper' }
|
||||
}
|
||||
return checkRatio(moverValue, targetValue, helperValue)
|
||||
|
||||
default:
|
||||
return { valid: false, explanation: 'Unknown relation type' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all valid relations between two values (without helper).
|
||||
* Returns an array of valid relations.
|
||||
*/
|
||||
export function findValidRelationsNoHelper(a: number, b: number): RelationKind[] {
|
||||
const valid: RelationKind[] = []
|
||||
|
||||
if (checkEqual(a, b).valid) valid.push('EQUAL')
|
||||
if (checkMultiple(a, b).valid) valid.push('MULTIPLE')
|
||||
if (checkDivisor(a, b).valid) valid.push('DIVISOR')
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all valid relations between two values WITH a helper.
|
||||
* Returns an array of valid relations.
|
||||
*/
|
||||
export function findValidRelationsWithHelper(a: number, b: number, h: number): RelationKind[] {
|
||||
const valid: RelationKind[] = []
|
||||
|
||||
// First check no-helper relations
|
||||
valid.push(...findValidRelationsNoHelper(a, b))
|
||||
|
||||
// Then check helper-based relations
|
||||
if (checkSum(a, b, h).valid) valid.push('SUM')
|
||||
if (checkDiff(a, b, h).valid) valid.push('DIFF')
|
||||
if (checkProduct(a, b, h).valid) valid.push('PRODUCT')
|
||||
if (checkRatio(a, b, h).valid) valid.push('RATIO')
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ANY relation holds between mover and target (no helper).
|
||||
* Returns the first valid relation or null.
|
||||
*/
|
||||
export function findAnyValidRelation(a: number, b: number): RelationCheckResult | null {
|
||||
const relations = findValidRelationsNoHelper(a, b)
|
||||
if (relations.length > 0) {
|
||||
return checkRelation(relations[0], a, b)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ANY relation holds between mover and target WITH a helper.
|
||||
* Returns the first valid relation or null.
|
||||
*/
|
||||
export function findAnyValidRelationWithHelper(
|
||||
a: number,
|
||||
b: number,
|
||||
h: number
|
||||
): RelationCheckResult | null {
|
||||
const relations = findValidRelationsWithHelper(a, b, h)
|
||||
if (relations.length > 0) {
|
||||
return checkRelation(relations[0], a, b, h)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a BigInt value for display (commas for readability).
|
||||
*/
|
||||
export function formatValue(value: number): string {
|
||||
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import type { Color, Piece, PieceType } from '../types'
|
||||
|
||||
/**
|
||||
* Zobrist hashing for efficient board state comparison and repetition detection.
|
||||
* Each combination of (piece type, color, square) gets a unique random number.
|
||||
* The hash of a position is the XOR of all piece hashes.
|
||||
*/
|
||||
|
||||
// Zobrist hash table: [pieceType][color][square] => hash
|
||||
type ZobristTable = Record<PieceType, Record<Color, Record<string, bigint>>>
|
||||
|
||||
// Single zobrist table instance (initialized lazily)
|
||||
let zobristTable: ZobristTable | null = null
|
||||
|
||||
// Turn hash (XOR this when it's Black's turn)
|
||||
let turnHash: bigint | null = null
|
||||
|
||||
/**
|
||||
* Simple seedable PRNG using xorshift128+
|
||||
*/
|
||||
class SeededRandom {
|
||||
private state0: bigint
|
||||
private state1: bigint
|
||||
|
||||
constructor(seed: number) {
|
||||
// Initialize state from seed
|
||||
this.state0 = BigInt(seed)
|
||||
this.state1 = BigInt(seed * 2 + 1)
|
||||
}
|
||||
|
||||
next(): bigint {
|
||||
let s1 = this.state0
|
||||
const s0 = this.state1
|
||||
this.state0 = s0
|
||||
s1 ^= s1 << 23n
|
||||
s1 ^= s1 >> 17n
|
||||
s1 ^= s0
|
||||
s1 ^= s0 >> 26n
|
||||
this.state1 = s1
|
||||
return (s0 + s1) & 0xffffffffffffffffn // 64-bit mask
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Zobrist hash table with deterministic random values.
|
||||
*/
|
||||
function initZobristTable(): ZobristTable {
|
||||
const rng = new SeededRandom(0x52495448) // "RITH" as seed
|
||||
|
||||
const table: ZobristTable = {
|
||||
C: { W: {}, B: {} },
|
||||
T: { W: {}, B: {} },
|
||||
S: { W: {}, B: {} },
|
||||
P: { W: {}, B: {} },
|
||||
}
|
||||
|
||||
const pieceTypes: PieceType[] = ['C', 'T', 'S', 'P']
|
||||
const colors: Color[] = ['W', 'B']
|
||||
|
||||
// Generate hash for each (pieceType, color, square) combination
|
||||
for (const type of pieceTypes) {
|
||||
for (const color of colors) {
|
||||
for (let file = 0; file < 16; file++) {
|
||||
for (let rank = 1; rank <= 8; rank++) {
|
||||
const square = `${String.fromCharCode(65 + file)}${rank}`
|
||||
table[type][color][square] = rng.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return table
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Zobrist hash table (lazy initialization).
|
||||
*/
|
||||
function getZobristTable(): ZobristTable {
|
||||
if (!zobristTable) {
|
||||
zobristTable = initZobristTable()
|
||||
// Also initialize turn hash
|
||||
const rng = new SeededRandom(0x5455524e) // "TURN" as seed
|
||||
turnHash = rng.next()
|
||||
}
|
||||
return zobristTable
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the turn hash value.
|
||||
*/
|
||||
function getTurnHash(): bigint {
|
||||
if (turnHash === null) {
|
||||
getZobristTable() // This will also initialize turnHash
|
||||
}
|
||||
return turnHash!
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Zobrist hash for a board position.
|
||||
*/
|
||||
export function computeZobristHash(pieces: Record<string, Piece>, turn: Color): string {
|
||||
const table = getZobristTable()
|
||||
let hash = 0n
|
||||
|
||||
// XOR all piece hashes
|
||||
for (const piece of Object.values(pieces)) {
|
||||
if (piece.captured) continue
|
||||
|
||||
const pieceHash = table[piece.type][piece.color][piece.square]
|
||||
if (pieceHash) {
|
||||
hash ^= pieceHash
|
||||
}
|
||||
}
|
||||
|
||||
// XOR turn hash if it's Black's turn
|
||||
if (turn === 'B') {
|
||||
hash ^= getTurnHash()
|
||||
}
|
||||
|
||||
// Return as hex string
|
||||
return hash.toString(16).padStart(16, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hash appears N times in the history (for repetition detection).
|
||||
*/
|
||||
export function countHashOccurrences(hashes: string[], targetHash: string): number {
|
||||
return hashes.filter((h) => h === targetHash).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for threefold repetition (hash appears 3 times).
|
||||
*/
|
||||
export function isThreefoldRepetition(hashes: string[]): boolean {
|
||||
if (hashes.length < 3) return false
|
||||
|
||||
const currentHash = hashes[hashes.length - 1]
|
||||
return countHashOccurrences(hashes, currentHash) >= 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally update a Zobrist hash after a move.
|
||||
* This is more efficient than recomputing from scratch.
|
||||
*/
|
||||
export function updateZobristHash(
|
||||
currentHash: string,
|
||||
movedPiece: Piece,
|
||||
fromSquare: string,
|
||||
toSquare: string,
|
||||
capturedPiece: Piece | null,
|
||||
newTurn: Color
|
||||
): string {
|
||||
const table = getZobristTable()
|
||||
let hash = BigInt(`0x${currentHash}`)
|
||||
|
||||
// Remove moved piece from old square
|
||||
const oldPieceHash = table[movedPiece.type][movedPiece.color][fromSquare]
|
||||
if (oldPieceHash) {
|
||||
hash ^= oldPieceHash
|
||||
}
|
||||
|
||||
// Add moved piece to new square
|
||||
const newPieceHash = table[movedPiece.type][movedPiece.color][toSquare]
|
||||
if (newPieceHash) {
|
||||
hash ^= newPieceHash
|
||||
}
|
||||
|
||||
// Remove captured piece (if any)
|
||||
if (capturedPiece) {
|
||||
const capturedHash = table[capturedPiece.type][capturedPiece.color][capturedPiece.square]
|
||||
if (capturedHash) {
|
||||
hash ^= capturedHash
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle turn (XOR turn hash twice = no change, once = change)
|
||||
hash ^= getTurnHash()
|
||||
|
||||
return hash.toString(16).padStart(16, '0')
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type * as Y from 'yjs'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useArcadeSocket } from '@/hooks/useArcadeSocket'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GridCell, YjsDemoState } from './types'
|
||||
|
||||
interface YjsDemoContextValue {
|
||||
state: YjsDemoState
|
||||
yjsState: {
|
||||
cells: Y.Array<GridCell> | null
|
||||
awareness: any
|
||||
}
|
||||
addCell: (x: number, y: number) => void
|
||||
startGame: () => void
|
||||
endGame: () => void
|
||||
goToSetup: () => void
|
||||
exitSession: () => void
|
||||
lastError: string | null
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
const YjsDemoContext = createContext<YjsDemoContextValue | null>(null)
|
||||
|
||||
export function useYjsDemo() {
|
||||
const context = useContext(YjsDemoContext)
|
||||
if (!context) {
|
||||
throw new Error('useYjsDemo must be used within YjsDemoProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function YjsDemoProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayers: activePlayerIds } = useGameMode()
|
||||
const [forceUpdate, setForceUpdate] = useState(0)
|
||||
|
||||
// Initial state for arcade session
|
||||
const initialState: YjsDemoState = {
|
||||
gamePhase: 'setup',
|
||||
gridSize: 8,
|
||||
duration: 60,
|
||||
activePlayers: [],
|
||||
playerScores: {},
|
||||
}
|
||||
|
||||
// Use arcade session for phase transitions
|
||||
const { state, sendMove, exitSession, lastError, clearError } = useArcadeSession<YjsDemoState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id,
|
||||
initialState,
|
||||
applyMove: (currentState) => currentState, // Server handles state
|
||||
})
|
||||
|
||||
// Yjs setup - Socket.IO based sync
|
||||
const docRef = useRef<Y.Doc | null>(null)
|
||||
const awarenessRef = useRef<any>(null)
|
||||
const cellsRef = useRef<Y.Array<GridCell> | null>(null)
|
||||
|
||||
// Get socket from arcade socket hook
|
||||
const { socket } = useArcadeSocket()
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomData?.id || !socket) return
|
||||
|
||||
let doc: Y.Doc
|
||||
let awareness: any
|
||||
let cells: Y.Array<GridCell>
|
||||
|
||||
// Dynamic import to avoid loading Yjs in server bundle
|
||||
const initYjs = async () => {
|
||||
const Y = await import('yjs')
|
||||
const awarenessProtocol = await import('y-protocols/awareness')
|
||||
const syncProtocol = await import('y-protocols/sync')
|
||||
const encoding = await import('lib0/encoding')
|
||||
const decoding = await import('lib0/decoding')
|
||||
|
||||
doc = new Y.Doc()
|
||||
docRef.current = doc
|
||||
|
||||
// Create awareness
|
||||
awareness = new awarenessProtocol.Awareness(doc)
|
||||
awarenessRef.current = awareness
|
||||
|
||||
cells = doc.getArray<GridCell>('cells')
|
||||
cellsRef.current = cells
|
||||
|
||||
// Listen for changes in cells array to trigger re-renders
|
||||
const observer = () => {
|
||||
setForceUpdate((n) => n + 1)
|
||||
}
|
||||
cells.observe(observer)
|
||||
|
||||
// Set up Socket.IO handlers for Yjs sync
|
||||
|
||||
// Handle incoming sync/update messages from server
|
||||
const handleYjsMessage = (data: number[]) => {
|
||||
const message = new Uint8Array(data)
|
||||
const decoder = decoding.createDecoder(message)
|
||||
const messageType = decoding.readVarUint(decoder)
|
||||
|
||||
if (messageType === 0) {
|
||||
// Sync protocol message (sync step or update)
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, 0)
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, socket.id)
|
||||
|
||||
// Send response if there's content
|
||||
if (encoding.length(encoder) > 1) {
|
||||
socket.emit('yjs-update', Array.from(encoding.toUint8Array(encoder)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming awareness updates
|
||||
const handleYjsAwareness = (data: number[]) => {
|
||||
const message = new Uint8Array(data)
|
||||
const decoder = decoding.createDecoder(message)
|
||||
const messageType = decoding.readVarUint(decoder)
|
||||
|
||||
if (messageType === 0) {
|
||||
// Read the awareness update from the message
|
||||
const awarenessUpdate = decoding.readVarUint8Array(decoder)
|
||||
awarenessProtocol.applyAwarenessUpdate(awareness, awarenessUpdate, socket.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Register Socket.IO event handlers
|
||||
// Both sync and update events use the same handler since readSyncMessage handles both
|
||||
socket.on('yjs-sync', handleYjsMessage)
|
||||
socket.on('yjs-update', handleYjsMessage)
|
||||
socket.on('yjs-awareness', handleYjsAwareness)
|
||||
|
||||
// Send updates to server when document changes
|
||||
const updateHandler = (update: Uint8Array, origin: any) => {
|
||||
// Don't send updates that came from the server
|
||||
if (origin === socket.id) return
|
||||
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, 0) // Message type: sync
|
||||
syncProtocol.writeUpdate(encoder, update)
|
||||
const message = encoding.toUint8Array(encoder)
|
||||
|
||||
socket.emit('yjs-update', Array.from(message))
|
||||
}
|
||||
doc.on('update', updateHandler)
|
||||
|
||||
// Send awareness updates to server
|
||||
const awarenessUpdateHandler = ({ added, updated, removed }: any) => {
|
||||
const changedClients = added.concat(updated).concat(removed)
|
||||
const update = awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
|
||||
socket.emit('yjs-awareness', Array.from(update))
|
||||
}
|
||||
awareness.on('update', awarenessUpdateHandler)
|
||||
|
||||
// Set local awareness state
|
||||
if (viewerId) {
|
||||
awareness.setLocalStateField('user', {
|
||||
id: viewerId,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Join the Yjs room
|
||||
console.log('[YjsDemo] Joining Yjs room:', roomData.id)
|
||||
socket.emit('yjs-join', roomData.id)
|
||||
|
||||
// Cleanup function stored for later
|
||||
return () => {
|
||||
socket.off('yjs-sync', handleYjsMessage)
|
||||
socket.off('yjs-update', handleYjsMessage)
|
||||
socket.off('yjs-awareness', handleYjsAwareness)
|
||||
doc.off('update', updateHandler)
|
||||
awareness.off('update', awarenessUpdateHandler)
|
||||
}
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
void initYjs().then((cleanupFn) => {
|
||||
cleanup = cleanupFn
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
}
|
||||
if (awarenessRef.current) {
|
||||
awarenessRef.current.setLocalState(null)
|
||||
awarenessRef.current.destroy()
|
||||
}
|
||||
if (docRef.current) {
|
||||
docRef.current.destroy()
|
||||
}
|
||||
docRef.current = null
|
||||
awarenessRef.current = null
|
||||
cellsRef.current = null
|
||||
}
|
||||
}, [roomData?.id, viewerId, socket])
|
||||
|
||||
// Player colors
|
||||
const playerColors = useMemo(() => {
|
||||
const colors = [
|
||||
'#FF6B6B',
|
||||
'#4ECDC4',
|
||||
'#45B7D1',
|
||||
'#FFA07A',
|
||||
'#98D8C8',
|
||||
'#F7DC6F',
|
||||
'#BB8FCE',
|
||||
'#85C1E2',
|
||||
]
|
||||
const playerList = Array.from(activePlayerIds)
|
||||
const colorMap: Record<string, string> = {}
|
||||
for (let i = 0; i < playerList.length; i++) {
|
||||
colorMap[playerList[i]] = colors[i % colors.length]
|
||||
}
|
||||
return colorMap
|
||||
}, [activePlayerIds])
|
||||
|
||||
// Actions
|
||||
const addCell = useCallback(
|
||||
(x: number, y: number) => {
|
||||
if (!cellsRef.current || !viewerId || !docRef.current) return
|
||||
if (state.gamePhase !== 'playing') return
|
||||
|
||||
const cell: GridCell = {
|
||||
id: `${viewerId}-${Date.now()}`,
|
||||
x,
|
||||
y,
|
||||
playerId: viewerId,
|
||||
timestamp: Date.now(),
|
||||
color: playerColors[viewerId] || '#999999',
|
||||
}
|
||||
|
||||
docRef.current.transact(() => {
|
||||
cellsRef.current?.push([cell])
|
||||
})
|
||||
|
||||
// Update score in local state (this would be synced via Yjs in a real impl)
|
||||
// For now, we're just showing the concept
|
||||
},
|
||||
[viewerId, state.gamePhase, playerColors]
|
||||
)
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
const players = Array.from(activePlayerIds)
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: players[0] || viewerId || '',
|
||||
userId: viewerId || '',
|
||||
data: { activePlayers: players },
|
||||
})
|
||||
}, [activePlayerIds, viewerId, sendMove])
|
||||
|
||||
const endGame = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'END_GAME',
|
||||
playerId: viewerId || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId: viewerId || '',
|
||||
userId: viewerId || '',
|
||||
data: {},
|
||||
})
|
||||
}, [viewerId, sendMove])
|
||||
|
||||
const yjsState = {
|
||||
cells: cellsRef.current,
|
||||
awareness: awarenessRef.current || null,
|
||||
}
|
||||
|
||||
return (
|
||||
<YjsDemoContext.Provider
|
||||
value={{
|
||||
state,
|
||||
yjsState,
|
||||
addCell,
|
||||
startGame,
|
||||
endGame,
|
||||
goToSetup,
|
||||
exitSession,
|
||||
lastError,
|
||||
clearError,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</YjsDemoContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/validation/types'
|
||||
import type { YjsDemoConfig, YjsDemoMove, YjsDemoState } from './types'
|
||||
|
||||
export class YjsDemoValidator implements GameValidator<YjsDemoState, YjsDemoMove> {
|
||||
validateMove(state: YjsDemoState, move: YjsDemoMove): ValidationResult {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
return this.validateStartGame(state, move.data.activePlayers)
|
||||
case 'END_GAME':
|
||||
return this.validateEndGame(state)
|
||||
case 'GO_TO_SETUP':
|
||||
return this.validateGoToSetup(state)
|
||||
default:
|
||||
return { valid: false, error: 'Unknown move type' }
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGame(state: YjsDemoState, activePlayers: string[]): ValidationResult {
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return { valid: false, error: 'Game already started' }
|
||||
}
|
||||
|
||||
if (activePlayers.length === 0) {
|
||||
return { valid: false, error: 'No players selected' }
|
||||
}
|
||||
|
||||
const playerScores: Record<string, number> = {}
|
||||
for (const playerId of activePlayers) {
|
||||
playerScores[playerId] = 0
|
||||
}
|
||||
|
||||
const newState: YjsDemoState = {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
activePlayers,
|
||||
playerScores,
|
||||
startTime: Date.now(),
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateEndGame(state: YjsDemoState): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game is not in progress' }
|
||||
}
|
||||
|
||||
const newState: YjsDemoState = {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
endTime: Date.now(),
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateGoToSetup(state: YjsDemoState): ValidationResult {
|
||||
const newState: YjsDemoState = {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
activePlayers: [],
|
||||
playerScores: {},
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
isGameComplete(state: YjsDemoState): boolean {
|
||||
return state.gamePhase === 'results'
|
||||
}
|
||||
|
||||
getInitialState(config: YjsDemoConfig): YjsDemoState {
|
||||
return {
|
||||
gamePhase: 'setup',
|
||||
gridSize: config.gridSize,
|
||||
duration: config.duration,
|
||||
activePlayers: [],
|
||||
playerScores: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const yjsDemoValidator = new YjsDemoValidator()
|
||||
@@ -1,215 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useYjsDemo } from '../Provider'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
|
||||
export function PlayingPhase() {
|
||||
const { state, yjsState, addCell, endGame } = useYjsDemo()
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { gridSize } = state
|
||||
|
||||
// Convert Yjs array to regular array for rendering
|
||||
const cells = useMemo(() => {
|
||||
if (!yjsState.cells) return []
|
||||
return yjsState.cells.toArray()
|
||||
}, [yjsState.cells])
|
||||
|
||||
// Create a map of occupied cells
|
||||
const occupiedCells = useMemo(() => {
|
||||
const map = new Map<string, { color: string; playerId: string }>()
|
||||
for (const cell of cells) {
|
||||
const key = `${cell.x}-${cell.y}`
|
||||
map.set(key, { color: cell.color, playerId: cell.playerId })
|
||||
}
|
||||
return map
|
||||
}, [cells])
|
||||
|
||||
// Calculate scores
|
||||
const scores = useMemo(() => {
|
||||
const scoreMap: Record<string, number> = {}
|
||||
for (const cell of cells) {
|
||||
scoreMap[cell.playerId] = (scoreMap[cell.playerId] || 0) + 1
|
||||
}
|
||||
return scoreMap
|
||||
}, [cells])
|
||||
|
||||
const handleCellClick = (x: number, y: number) => {
|
||||
const key = `${x}-${y}`
|
||||
if (occupiedCells.has(key)) return // Already occupied
|
||||
addCell(x, y)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerStyle}>
|
||||
<div className={headerStyle}>
|
||||
<div className={titleStyle}>Click cells to claim them!</div>
|
||||
<div className={statsStyle}>
|
||||
Total cells claimed: {cells.length} / {gridSize * gridSize}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={gridStyle}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${gridSize}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: gridSize * gridSize }).map((_, index) => {
|
||||
const x = Math.floor(index / gridSize)
|
||||
const y = index % gridSize
|
||||
const key = `${x}-${y}`
|
||||
const cellData = occupiedCells.get(key)
|
||||
const isOwn = cellData?.playerId === viewerId
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => handleCellClick(x, y)}
|
||||
disabled={!!cellData}
|
||||
className={cellStyle}
|
||||
style={{
|
||||
backgroundColor: cellData ? cellData.color : '#f0f0f0',
|
||||
cursor: cellData ? 'default' : 'pointer',
|
||||
border: isOwn ? '3px solid #333' : '1px solid #ccc',
|
||||
}}
|
||||
title={cellData ? `Claimed by ${cellData.playerId}` : 'Click to claim'}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={scoresContainerStyle}>
|
||||
<div className={scoresTitleStyle}>Current Scores:</div>
|
||||
<div className={scoresListStyle}>
|
||||
{Object.entries(scores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([playerId, score]) => (
|
||||
<div key={playerId} className={scoreItemStyle}>
|
||||
<span className={scorePlayerStyle}>
|
||||
{playerId === viewerId ? 'You' : playerId.slice(0, 8)}
|
||||
</span>
|
||||
<span className={scoreValueStyle}>{score} cells</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={endGame} className={endButtonStyle}>
|
||||
End Game
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const containerStyle = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: { base: '12px', md: '24px' },
|
||||
gap: '20px',
|
||||
minHeight: '70vh',
|
||||
})
|
||||
|
||||
const headerStyle = css({
|
||||
textAlign: 'center',
|
||||
})
|
||||
|
||||
const titleStyle = css({
|
||||
fontSize: { base: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
marginBottom: '8px',
|
||||
})
|
||||
|
||||
const statsStyle = css({
|
||||
fontSize: { base: '14px', md: '16px' },
|
||||
color: 'gray.600',
|
||||
})
|
||||
|
||||
const gridStyle = css({
|
||||
display: 'grid',
|
||||
gap: '4px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'gray.100',
|
||||
borderRadius: '8px',
|
||||
maxWidth: { base: '320px', sm: '400px', md: '500px' },
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
})
|
||||
|
||||
const cellStyle = css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderRadius: '4px',
|
||||
transition: 'all 0.2s',
|
||||
'&:not(:disabled):hover': {
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
},
|
||||
'&:disabled': {
|
||||
cursor: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
const scoresContainerStyle = css({
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
minWidth: '250px',
|
||||
})
|
||||
|
||||
const scoresTitleStyle = css({
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
marginBottom: '12px',
|
||||
})
|
||||
|
||||
const scoresListStyle = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
})
|
||||
|
||||
const scoreItemStyle = css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px',
|
||||
backgroundColor: 'gray.50',
|
||||
borderRadius: '4px',
|
||||
})
|
||||
|
||||
const scorePlayerStyle = css({
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: 'gray.700',
|
||||
})
|
||||
|
||||
const scoreValueStyle = css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
})
|
||||
|
||||
const endButtonStyle = css({
|
||||
padding: '12px 24px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'red.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'red.600',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useYjsDemo } from '../Provider'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const { state, yjsState, goToSetup } = useYjsDemo()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
// Convert Yjs array to regular array for rendering
|
||||
const cells = useMemo(() => {
|
||||
if (!yjsState.cells) return []
|
||||
return yjsState.cells.toArray()
|
||||
}, [yjsState.cells])
|
||||
|
||||
// Calculate final scores
|
||||
const scores = useMemo(() => {
|
||||
const scoreMap: Record<string, number> = {}
|
||||
for (const cell of cells) {
|
||||
scoreMap[cell.playerId] = (scoreMap[cell.playerId] || 0) + 1
|
||||
}
|
||||
return scoreMap
|
||||
}, [cells])
|
||||
|
||||
const sortedScores = useMemo(() => {
|
||||
return Object.entries(scores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([playerId, score]) => ({ playerId, score }))
|
||||
}, [scores])
|
||||
|
||||
const winner = sortedScores[0]
|
||||
|
||||
return (
|
||||
<div className={containerStyle}>
|
||||
<div className={titleStyle}>🎉 Game Complete! 🎉</div>
|
||||
|
||||
{winner && (
|
||||
<div className={winnerBoxStyle}>
|
||||
<div className={winnerTitleStyle}>Winner!</div>
|
||||
<div className={winnerNameStyle}>
|
||||
{winner.playerId === viewerId ? 'You' : winner.playerId}
|
||||
</div>
|
||||
<div className={winnerScoreStyle}>{winner.score} cells claimed</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={scoresContainerStyle}>
|
||||
<div className={scoresTitleStyle}>Final Scores:</div>
|
||||
<div className={scoresListStyle}>
|
||||
{sortedScores.map(({ playerId, score }, index) => (
|
||||
<div key={playerId} className={scoreItemStyle}>
|
||||
<span className={scoreRankStyle}>#{index + 1}</span>
|
||||
<span className={scorePlayerStyle}>
|
||||
{playerId === viewerId ? 'You' : playerId.slice(0, 8)}
|
||||
</span>
|
||||
<span className={scoreValueStyle}>{score} cells</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={statsBoxStyle}>
|
||||
<div className={statItemStyle}>
|
||||
<span className={statLabelStyle}>Total cells claimed:</span>
|
||||
<span className={statValueStyle}>{cells.length}</span>
|
||||
</div>
|
||||
<div className={statItemStyle}>
|
||||
<span className={statLabelStyle}>Grid size:</span>
|
||||
<span className={statValueStyle}>
|
||||
{state.gridSize} × {state.gridSize}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={goToSetup} className={playAgainButtonStyle}>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const containerStyle = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: { base: '20px', md: '40px' },
|
||||
gap: '24px',
|
||||
minHeight: '70vh',
|
||||
})
|
||||
|
||||
const titleStyle = css({
|
||||
fontSize: { base: '28px', md: '36px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
textAlign: 'center',
|
||||
})
|
||||
|
||||
const winnerBoxStyle = css({
|
||||
backgroundColor: 'yellow.100',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
border: '3px solid',
|
||||
borderColor: 'yellow.400',
|
||||
textAlign: 'center',
|
||||
minWidth: '250px',
|
||||
})
|
||||
|
||||
const winnerTitleStyle = css({
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: 'yellow.700',
|
||||
marginBottom: '8px',
|
||||
})
|
||||
|
||||
const winnerNameStyle = css({
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
color: 'yellow.800',
|
||||
marginBottom: '4px',
|
||||
})
|
||||
|
||||
const winnerScoreStyle = css({
|
||||
fontSize: '18px',
|
||||
color: 'yellow.700',
|
||||
})
|
||||
|
||||
const scoresContainerStyle = css({
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
minWidth: '300px',
|
||||
})
|
||||
|
||||
const scoresTitleStyle = css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
marginBottom: '16px',
|
||||
})
|
||||
|
||||
const scoresListStyle = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
})
|
||||
|
||||
const scoreItemStyle = css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'gray.50',
|
||||
borderRadius: '8px',
|
||||
})
|
||||
|
||||
const scoreRankStyle = css({
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.500',
|
||||
minWidth: '32px',
|
||||
})
|
||||
|
||||
const scorePlayerStyle = css({
|
||||
flex: 1,
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
color: 'gray.700',
|
||||
})
|
||||
|
||||
const scoreValueStyle = css({
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
})
|
||||
|
||||
const statsBoxStyle = css({
|
||||
backgroundColor: 'blue.50',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
minWidth: '250px',
|
||||
})
|
||||
|
||||
const statItemStyle = css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'blue.100',
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
const statLabelStyle = css({
|
||||
fontSize: '14px',
|
||||
color: 'gray.600',
|
||||
})
|
||||
|
||||
const statValueStyle = css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.700',
|
||||
})
|
||||
|
||||
const playAgainButtonStyle = css({
|
||||
padding: '16px 32px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
backgroundColor: 'blue.600',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useYjsDemo } from '../Provider'
|
||||
|
||||
export function SetupPhase() {
|
||||
const { activePlayers } = useGameMode()
|
||||
const { startGame } = useYjsDemo()
|
||||
|
||||
const canStart = activePlayers.size >= 1
|
||||
|
||||
return (
|
||||
<div className={containerStyle}>
|
||||
<div className={titleStyle}>Collaborative Grid Demo</div>
|
||||
<div className={descriptionStyle}>
|
||||
Click on the grid to add colored cells. See other players' clicks in real-time using
|
||||
Yjs!
|
||||
</div>
|
||||
|
||||
<div className={infoBoxStyle}>
|
||||
<div className={infoTitleStyle}>How it works:</div>
|
||||
<ul className={listStyle}>
|
||||
<li>Each player gets a unique color</li>
|
||||
<li>Click cells to claim them</li>
|
||||
<li>State is synchronized with Yjs CRDTs</li>
|
||||
<li>No traditional server validation - Yjs handles conflicts</li>
|
||||
<li>All players see updates in real-time</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
disabled={!canStart}
|
||||
className={css({
|
||||
padding: '16px 32px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: canStart ? 'blue.500' : 'gray.300',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: canStart ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s',
|
||||
_hover: canStart ? { backgroundColor: 'blue.600', transform: 'scale(1.05)' } : {},
|
||||
})}
|
||||
>
|
||||
{canStart ? 'Start Demo' : 'Select at least 1 player'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const containerStyle = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: { base: '20px', md: '40px' },
|
||||
gap: '24px',
|
||||
minHeight: '60vh',
|
||||
})
|
||||
|
||||
const titleStyle = css({
|
||||
fontSize: { base: '28px', md: '36px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
textAlign: 'center',
|
||||
})
|
||||
|
||||
const descriptionStyle = css({
|
||||
fontSize: { base: '16px', md: '18px' },
|
||||
color: 'gray.700',
|
||||
textAlign: 'center',
|
||||
maxWidth: '600px',
|
||||
})
|
||||
|
||||
const infoBoxStyle = css({
|
||||
backgroundColor: 'blue.50',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
maxWidth: '500px',
|
||||
border: '2px solid',
|
||||
borderColor: 'blue.200',
|
||||
})
|
||||
|
||||
const infoTitleStyle = css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.700',
|
||||
marginBottom: '12px',
|
||||
})
|
||||
|
||||
const listStyle = css({
|
||||
fontSize: '14px',
|
||||
color: 'gray.700',
|
||||
paddingLeft: '20px',
|
||||
'& li': {
|
||||
marginBottom: '8px',
|
||||
},
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useYjsDemo } from '../Provider'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
import { PlayingPhase } from './PlayingPhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
|
||||
export function YjsDemoGame() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, goToSetup } = useYjsDemo()
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Yjs Demo"
|
||||
navEmoji="🔄"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
playerScores={state.playerScores}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
}}
|
||||
onSetup={state.gamePhase !== 'setup' ? () => goToSetup() : undefined}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <PlayingPhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { YjsDemoGame } from './YjsDemoGame'
|
||||
export { SetupPhase } from './SetupPhase'
|
||||
export { PlayingPhase } from './PlayingPhase'
|
||||
export { ResultsPhase } from './ResultsPhase'
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Yjs Demo Game Definition
|
||||
*
|
||||
* A demonstration of real-time multiplayer synchronization using Yjs CRDTs.
|
||||
* Players collaborate on a shared grid, with state synchronized via Yjs WebSockets.
|
||||
*/
|
||||
|
||||
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { YjsDemoGame } from './components/YjsDemoGame'
|
||||
import { YjsDemoProvider } from './Provider'
|
||||
import type { YjsDemoConfig, YjsDemoMove, YjsDemoState } from './types'
|
||||
import { yjsDemoValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'yjs-demo',
|
||||
displayName: 'Yjs Sync Demo',
|
||||
icon: '🔄',
|
||||
description: 'Real-time collaboration demo with Yjs',
|
||||
longDescription:
|
||||
'Experience the power of Yjs CRDTs in action! This demo shows how multiple players can interact with a shared grid in real-time. ' +
|
||||
'Click on cells to claim them, and watch as other players do the same. Yjs handles all the conflict resolution automatically, ' +
|
||||
'ensuring everyone sees a consistent view of the game state without traditional server validation.',
|
||||
maxPlayers: 8,
|
||||
difficulty: 'Beginner',
|
||||
chips: ['🤝 Collaborative', '⚡ Real-time', '🔬 Demo'],
|
||||
...getGameTheme('teal'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: YjsDemoConfig = {
|
||||
gridSize: 8,
|
||||
duration: 60,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateYjsDemoConfig(config: unknown): config is YjsDemoConfig {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const c = config as any
|
||||
|
||||
// Validate gridSize
|
||||
if (!('gridSize' in c) || ![8, 12, 16].includes(c.gridSize)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate duration
|
||||
if (!('duration' in c) || ![60, 120, 180].includes(c.duration)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const yjsDemoGame = defineGame<YjsDemoConfig, YjsDemoState, YjsDemoMove>({
|
||||
manifest,
|
||||
Provider: YjsDemoProvider,
|
||||
GameComponent: YjsDemoGame,
|
||||
validator: yjsDemoValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateYjsDemoConfig,
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
|
||||
|
||||
export interface YjsDemoConfig extends GameConfig {
|
||||
gridSize: 8 | 12 | 16
|
||||
duration: 60 | 120 | 180
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface YjsDemoState extends GameState {
|
||||
gamePhase: 'setup' | 'playing' | 'results'
|
||||
gridSize: number
|
||||
duration: number
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
activePlayers: string[]
|
||||
playerScores: Record<string, number>
|
||||
// Cells array for persistence (synced from Y.Doc)
|
||||
cells?: GridCell[]
|
||||
}
|
||||
|
||||
// For Yjs synchronization
|
||||
export interface GridCell {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
playerId: string
|
||||
timestamp: number
|
||||
color: string
|
||||
}
|
||||
|
||||
// Moves are not used in Yjs demo (everything goes through Y.Doc)
|
||||
// but we need this for arcade compatibility
|
||||
export type YjsDemoMove =
|
||||
| {
|
||||
type: 'START_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: { activePlayers: string[] }
|
||||
}
|
||||
| {
|
||||
type: 'END_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'GO_TO_SETUP'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { useState } from 'react'
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { hstack, stack } from '../../styled-system/patterns'
|
||||
import { useAbacusSettings, useUpdateAbacusSettings } from '../hooks/useAbacusSettings'
|
||||
|
||||
interface AbacusDisplayDropdownProps {
|
||||
isFullscreen?: boolean
|
||||
@@ -22,8 +21,6 @@ export function AbacusDisplayDropdown({
|
||||
}: AbacusDisplayDropdownProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { config, updateConfig, resetToDefaults } = useAbacusDisplay()
|
||||
const { data: abacusSettings } = useAbacusSettings()
|
||||
const { mutate: updateAbacusSettings } = useUpdateAbacusSettings()
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
@@ -249,16 +246,6 @@ export function AbacusDisplayDropdown({
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label="Native Abacus Numbers" isFullscreen={isFullscreen}>
|
||||
<SwitchField
|
||||
checked={abacusSettings?.nativeAbacusNumbers ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAbacusSettings({ nativeAbacusNumbers: checked })
|
||||
}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
|
||||
@@ -45,24 +45,7 @@ export function AbacusSettingsSync() {
|
||||
// Only sync if config has changed since last sync
|
||||
if (configJson !== lastSyncedConfigRef.current) {
|
||||
console.log('🔄 Syncing abacus settings to API')
|
||||
|
||||
// Only sync abacus-react config fields, not app-specific fields like nativeAbacusNumbers
|
||||
const abacusReactFields = {
|
||||
colorScheme: config.colorScheme,
|
||||
beadShape: config.beadShape,
|
||||
colorPalette: config.colorPalette,
|
||||
hideInactiveBeads: config.hideInactiveBeads,
|
||||
coloredNumerals: config.coloredNumerals,
|
||||
scaleFactor: config.scaleFactor,
|
||||
showNumbers: config.showNumbers,
|
||||
animated: config.animated,
|
||||
interactive: config.interactive,
|
||||
gestures: config.gestures,
|
||||
soundEnabled: config.soundEnabled,
|
||||
soundVolume: config.soundVolume,
|
||||
}
|
||||
|
||||
updateApiSettings(abacusReactFields)
|
||||
updateApiSettings(config)
|
||||
lastSyncedConfigRef.current = configJson
|
||||
}
|
||||
}, [config, updateApiSettings])
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Z_INDEX } from '../constants/zIndex'
|
||||
import { useFullscreen } from '../contexts/FullscreenContext'
|
||||
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
|
||||
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
|
||||
import { LanguageSelector } from './LanguageSelector'
|
||||
|
||||
// Import HomeHeroContext for optional usage
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
@@ -412,34 +411,6 @@ function HamburgerMenu({
|
||||
onOpenChange={handleNestedDropdownChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Separator
|
||||
style={{
|
||||
height: '1px',
|
||||
background: 'rgba(75, 85, 99, 0.5)',
|
||||
margin: '6px 0',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Language Section */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(196, 181, 253, 0.7)',
|
||||
marginBottom: '6px',
|
||||
marginLeft: '12px',
|
||||
marginTop: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Language
|
||||
</div>
|
||||
|
||||
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<LanguageSelector variant="dropdown-item" isFullscreen={isFullscreen} />
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
@@ -532,8 +503,6 @@ function MinimalNav({
|
||||
transition: 'opacity 0.3s ease',
|
||||
pointerEvents: 'auto',
|
||||
maxWidth: 'calc(100% - 128px)', // Leave space for hamburger + margin
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
@@ -708,9 +677,6 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
|
||||
{/* Abacus Style Dropdown */}
|
||||
<AbacusDisplayDropdown isFullscreen={false} />
|
||||
|
||||
{/* Language Selector */}
|
||||
<LanguageSelector variant="inline" isFullscreen={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,29 +2,25 @@
|
||||
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { ToastProvider } from '@/components/common/ToastContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
import { LocaleProvider, useLocaleContext } from '@/contexts/LocaleContext'
|
||||
import { createQueryClient } from '@/lib/queryClient'
|
||||
import { type Locale } from '@/i18n/messages'
|
||||
import { AbacusSettingsSync } from './AbacusSettingsSync'
|
||||
import { DeploymentInfo } from './DeploymentInfo'
|
||||
|
||||
interface ClientProvidersProps {
|
||||
children: ReactNode
|
||||
initialLocale: Locale
|
||||
initialMessages: Record<string, any>
|
||||
}
|
||||
|
||||
function InnerProviders({ children }: { children: ReactNode }) {
|
||||
const { locale, messages } = useLocaleContext()
|
||||
export function ClientProviders({ children }: ClientProvidersProps) {
|
||||
// Create a stable QueryClient instance that persists across renders
|
||||
const [queryClient] = useState(() => createQueryClient())
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<AbacusDisplayProvider>
|
||||
<AbacusSettingsSync />
|
||||
@@ -38,23 +34,6 @@ function InnerProviders({ children }: { children: ReactNode }) {
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
</ToastProvider>
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClientProviders({
|
||||
children,
|
||||
initialLocale,
|
||||
initialMessages,
|
||||
}: ClientProvidersProps) {
|
||||
// Create a stable QueryClient instance that persists across renders
|
||||
const [queryClient] = useState(() => createQueryClient())
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LocaleProvider initialLocale={initialLocale} initialMessages={initialMessages}>
|
||||
<InnerProviders>{children}</InnerProviders>
|
||||
</LocaleProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,102 +22,8 @@ export function DeploymentInfoModal({ children }: DeploymentInfoModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const gesture = {
|
||||
tracking: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startTime: 0,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
gesture.tracking = false
|
||||
return
|
||||
}
|
||||
|
||||
const touch = event.touches[0]
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const cornerThresholdX = viewportWidth * 0.15
|
||||
const cornerThresholdY = viewportHeight * 0.15
|
||||
|
||||
if (touch.clientX <= cornerThresholdX && touch.clientY <= cornerThresholdY) {
|
||||
gesture.tracking = true
|
||||
gesture.startX = touch.clientX
|
||||
gesture.startY = touch.clientY
|
||||
gesture.startTime = performance.now()
|
||||
gesture.lastX = touch.clientX
|
||||
gesture.lastY = touch.clientY
|
||||
} else {
|
||||
gesture.tracking = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (!gesture.tracking) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.touches.length !== 1) {
|
||||
gesture.tracking = false
|
||||
return
|
||||
}
|
||||
|
||||
const touch = event.touches[0]
|
||||
gesture.lastX = touch.clientX
|
||||
gesture.lastY = touch.clientY
|
||||
}
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
gesture.tracking = false
|
||||
}
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
if (!gesture.tracking) {
|
||||
return
|
||||
}
|
||||
|
||||
const { startX, startY, startTime } = gesture
|
||||
const touch = event.changedTouches[0]
|
||||
const endX = touch?.clientX ?? gesture.lastX
|
||||
const endY = touch?.clientY ?? gesture.lastY
|
||||
const elapsed = performance.now() - startTime
|
||||
|
||||
gesture.tracking = false
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const bottomThresholdX = viewportWidth * 0.85
|
||||
const bottomThresholdY = viewportHeight * 0.85
|
||||
const minimumDistance = Math.hypot(endX - startX, endY - startY)
|
||||
const diagonal = Math.hypot(viewportWidth, viewportHeight)
|
||||
const minimumRequiredDistance = diagonal * 0.25
|
||||
|
||||
if (
|
||||
endX >= bottomThresholdX &&
|
||||
endY >= bottomThresholdY &&
|
||||
minimumDistance >= minimumRequiredDistance &&
|
||||
elapsed <= 1500
|
||||
) {
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||
window.addEventListener('touchend', handleTouchEnd)
|
||||
window.addEventListener('touchcancel', handleTouchCancel)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('touchstart', handleTouchStart)
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', handleTouchEnd)
|
||||
window.removeEventListener('touchcancel', handleTouchCancel)
|
||||
}
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useLocaleContext } from '@/contexts/LocaleContext'
|
||||
import { locales } from '@/i18n/routing'
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
variant?: 'dropdown-item' | 'inline'
|
||||
isFullscreen?: boolean
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
ja: '日本語',
|
||||
hi: 'हिन्दी',
|
||||
es: 'Español',
|
||||
la: 'Latina',
|
||||
}
|
||||
|
||||
const LANGUAGE_FLAGS: Record<string, string> = {
|
||||
en: '🇬🇧',
|
||||
de: '🇩🇪',
|
||||
ja: '🇯🇵',
|
||||
hi: '🇮🇳',
|
||||
es: '🇪🇸',
|
||||
la: '🏛️',
|
||||
}
|
||||
|
||||
export function LanguageSelector({
|
||||
variant = 'inline',
|
||||
isFullscreen = false,
|
||||
}: LanguageSelectorProps) {
|
||||
const locale = useLocale()
|
||||
const { changeLocale } = useLocaleContext()
|
||||
|
||||
if (variant === 'dropdown-item') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>🌐</span>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => changeLocale(e.target.value as (typeof locales)[number])}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'rgba(31, 41, 55, 0.6)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
padding: '6px 10px',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(31, 41, 55, 0.8)'
|
||||
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.5)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(31, 41, 55, 0.6)'
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}}
|
||||
>
|
||||
{locales.map((langCode) => (
|
||||
<option key={langCode} value={langCode}>
|
||||
{LANGUAGE_FLAGS[langCode]} {LANGUAGE_LABELS[langCode]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline variant for full navbar
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => changeLocale(e.target.value as (typeof locales)[number])}
|
||||
style={{
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'rgba(17, 24, 39, 0.5)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||
borderRadius: '8px',
|
||||
color: 'rgba(209, 213, 219, 0.9)',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.25)'
|
||||
e.currentTarget.style.color = 'rgba(196, 181, 253, 1)'
|
||||
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.5)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = isFullscreen
|
||||
? 'rgba(0, 0, 0, 0.85)'
|
||||
: 'rgba(17, 24, 39, 0.5)'
|
||||
e.currentTarget.style.color = 'rgba(209, 213, 219, 0.9)'
|
||||
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.3)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)'
|
||||
}}
|
||||
>
|
||||
{locales.map((langCode) => (
|
||||
<option key={langCode} value={langCode}>
|
||||
{LANGUAGE_FLAGS[langCode]} {LANGUAGE_LABELS[langCode]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useViewerId } from '../hooks/useViewerId'
|
||||
import { AppNavBar } from './AppNavBar'
|
||||
import { GameContextNav, type RosterWarning } from './nav/GameContextNav'
|
||||
import type { PlayerBadge } from './nav/types'
|
||||
import { GameContextNav } from './nav/GameContextNav'
|
||||
import { PlayerConfigDialog } from './nav/PlayerConfigDialog'
|
||||
import { ModerationNotifications } from './nav/ModerationNotifications'
|
||||
|
||||
@@ -15,7 +14,6 @@ interface PageWithNavProps {
|
||||
navEmoji?: string
|
||||
gameName?: 'matching' | 'memory-quiz' | 'complement-race' // Internal game name for API
|
||||
emphasizePlayerSelection?: boolean
|
||||
disableFullscreenSelection?: boolean // Disable "Select Your Champions" overlay
|
||||
onExitSession?: () => void
|
||||
onSetup?: () => void
|
||||
onNewGame?: () => void
|
||||
@@ -24,16 +22,6 @@ interface PageWithNavProps {
|
||||
currentPlayerId?: string
|
||||
playerScores?: Record<string, number>
|
||||
playerStreaks?: Record<string, number>
|
||||
playerBadges?: Record<string, PlayerBadge>
|
||||
// Game-specific roster warnings
|
||||
rosterWarning?: RosterWarning
|
||||
// Side assignments (for 2-player games like Rithmomachia)
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
onAssignWhitePlayer?: (playerId: string | null) => void
|
||||
onAssignBlackPlayer?: (playerId: string | null) => void
|
||||
// Game phase (for showing spectating vs assign)
|
||||
gamePhase?: 'setup' | 'playing' | 'results'
|
||||
}
|
||||
|
||||
export function PageWithNav({
|
||||
@@ -41,7 +29,6 @@ export function PageWithNav({
|
||||
navEmoji,
|
||||
gameName,
|
||||
emphasizePlayerSelection = false,
|
||||
disableFullscreenSelection = false,
|
||||
onExitSession,
|
||||
onSetup,
|
||||
onNewGame,
|
||||
@@ -49,13 +36,6 @@ export function PageWithNav({
|
||||
currentPlayerId,
|
||||
playerScores,
|
||||
playerStreaks,
|
||||
playerBadges,
|
||||
rosterWarning,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
onAssignWhitePlayer,
|
||||
onAssignBlackPlayer,
|
||||
gamePhase,
|
||||
}: PageWithNavProps) {
|
||||
const { players, activePlayers, setActive, activePlayerCount } = useGameMode()
|
||||
const { roomData, isInRoom, moderationEvent, clearModerationEvent } = useRoomData()
|
||||
@@ -124,8 +104,7 @@ export function PageWithNav({
|
||||
: 'none'
|
||||
|
||||
const shouldEmphasize = emphasizePlayerSelection && mounted
|
||||
const showFullscreenSelection =
|
||||
!disableFullscreenSelection && shouldEmphasize && activePlayerCount === 0
|
||||
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
|
||||
|
||||
// Compute arcade session info for display
|
||||
// Memoized to prevent unnecessary re-renders
|
||||
@@ -189,17 +168,10 @@ export function PageWithNav({
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={playerScores}
|
||||
playerStreaks={playerStreaks}
|
||||
playerBadges={playerBadges}
|
||||
showPopover={showPopover}
|
||||
setShowPopover={setShowPopover}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
rosterWarning={rosterWarning}
|
||||
whitePlayerId={whitePlayerId}
|
||||
blackPlayerId={blackPlayerId}
|
||||
onAssignWhitePlayer={onAssignWhitePlayer}
|
||||
onAssignBlackPlayer={onAssignBlackPlayer}
|
||||
gamePhase={gamePhase}
|
||||
/>
|
||||
) : null
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
interface StandardGameLayoutProps {
|
||||
@@ -14,46 +14,19 @@ interface StandardGameLayoutProps {
|
||||
* 2. Navigation never covers game elements (safe area padding)
|
||||
* 3. Perfect viewport fit on all devices
|
||||
* 4. Consistent experience across all games
|
||||
* 5. Dynamically calculates nav height for proper spacing
|
||||
*/
|
||||
export function StandardGameLayout({ children, className }: StandardGameLayoutProps) {
|
||||
const [navHeight, setNavHeight] = useState(80) // Default fallback
|
||||
|
||||
useEffect(() => {
|
||||
// Measure the actual nav height from the fixed header
|
||||
const measureNavHeight = () => {
|
||||
const header = document.querySelector('header')
|
||||
if (header) {
|
||||
const rect = header.getBoundingClientRect()
|
||||
// Add extra spacing for safety (nav top position + nav height + margin)
|
||||
const calculatedHeight = rect.top + rect.height + 20
|
||||
setNavHeight(calculatedHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure on mount and when window resizes
|
||||
measureNavHeight()
|
||||
window.addEventListener('resize', measureNavHeight)
|
||||
|
||||
// Also measure after a short delay to catch any late-rendering nav elements
|
||||
const timer = setTimeout(measureNavHeight, 100)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', measureNavHeight)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-layout="standard-game-layout"
|
||||
data-nav-height={navHeight}
|
||||
className={`${css({
|
||||
// Exact viewport sizing - no scrolling ever
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
|
||||
// Safe area for navigation (fixed at top: 4px, right: 4px)
|
||||
// Navigation is ~60px tall, so we need padding-top of ~80px to be safe
|
||||
paddingTop: '80px',
|
||||
paddingRight: '4px', // Ensure nav doesn't overlap content on right side
|
||||
paddingBottom: '4px',
|
||||
paddingLeft: '4px',
|
||||
@@ -68,10 +41,6 @@ export function StandardGameLayout({ children, className }: StandardGameLayoutPr
|
||||
// Transparent background - themes will be applied at nav level
|
||||
background: 'transparent',
|
||||
})} ${className || ''}`}
|
||||
style={{
|
||||
// Dynamic padding based on measured nav height
|
||||
paddingTop: `${navHeight}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useClipboard } from '@/hooks/useClipboard'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
|
||||
export interface QRCodeButtonProps {
|
||||
/**
|
||||
* The URL to encode in the QR code and display
|
||||
*/
|
||||
url: string
|
||||
|
||||
/**
|
||||
* Optional custom styles for the trigger button
|
||||
*/
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Button that opens a popover with a QR code for the share link
|
||||
* Includes the URL text with a copy button
|
||||
*/
|
||||
export function QRCodeButton({ url, style }: QRCodeButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { copied, copy } = useClipboard()
|
||||
|
||||
const buttonStyles: CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px solid rgba(251, 146, 60, 0.4)',
|
||||
background: 'linear-gradient(135deg, rgba(251, 146, 60, 0.2), rgba(251, 146, 60, 0.3))',
|
||||
borderRadius: '8px',
|
||||
padding: '4px',
|
||||
fontSize: '16px',
|
||||
color: 'rgba(253, 186, 116, 1)',
|
||||
height: '100%',
|
||||
aspectRatio: '1',
|
||||
flexShrink: 0,
|
||||
...style,
|
||||
}
|
||||
|
||||
const hoverStyles: CSSProperties = {
|
||||
background: 'linear-gradient(135deg, rgba(251, 146, 60, 0.3), rgba(251, 146, 60, 0.4))',
|
||||
borderColor: 'rgba(251, 146, 60, 0.6)',
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
style={buttonStyles}
|
||||
onMouseEnter={(e) => {
|
||||
Object.assign(e.currentTarget.style, hoverStyles)
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
Object.assign(e.currentTarget.style, buttonStyles)
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG value={url} size={84} level="L" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="center"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
|
||||
border: '2px solid rgba(251, 146, 60, 0.4)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
|
||||
zIndex: Z_INDEX.GAME_NAV.HAMBURGER_NESTED_DROPDOWN,
|
||||
maxWidth: '320px',
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'rgba(253, 186, 116, 1)',
|
||||
marginBottom: '12px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Scan to Join
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG value={url} size={200} level="H" />
|
||||
</div>
|
||||
|
||||
{/* URL with copy button */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(209, 213, 219, 0.7)',
|
||||
marginBottom: '6px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Or copy link:
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(url)}
|
||||
style={{
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
background: copied
|
||||
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.3))'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.3))',
|
||||
border: copied
|
||||
? '2px solid rgba(34, 197, 94, 0.5)'
|
||||
: '2px solid rgba(59, 130, 246, 0.4)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: copied ? 'rgba(134, 239, 172, 1)' : 'rgba(147, 197, 253, 1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!copied) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.3), rgba(59, 130, 246, 0.4))'
|
||||
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.6)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!copied) {
|
||||
e.currentTarget.style.background =
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.3))'
|
||||
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.4)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<span style={{ fontSize: '12px' }}>✓</span>
|
||||
<span>Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontSize: '12px' }}>🔗</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Popover.Arrow
|
||||
style={{
|
||||
fill: 'rgba(251, 146, 60, 0.4)',
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
import type { PlayerBadge } from './types'
|
||||
|
||||
interface Player {
|
||||
id: string
|
||||
@@ -20,17 +19,6 @@ interface ActivePlayersListProps {
|
||||
currentPlayerId?: string
|
||||
playerScores?: Record<string, number>
|
||||
playerStreaks?: Record<string, number>
|
||||
playerBadges?: Record<string, PlayerBadge>
|
||||
// Side assignments (for 2-player games)
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
onAssignWhitePlayer?: (playerId: string | null) => void
|
||||
onAssignBlackPlayer?: (playerId: string | null) => void
|
||||
// Room/host context for assignment permissions
|
||||
isInRoom?: boolean
|
||||
isCurrentUserHost?: boolean
|
||||
// Game phase (for showing spectating vs assign)
|
||||
gamePhase?: 'setup' | 'playing' | 'results'
|
||||
}
|
||||
|
||||
export function ActivePlayersList({
|
||||
@@ -41,65 +29,8 @@ export function ActivePlayersList({
|
||||
currentPlayerId,
|
||||
playerScores = {},
|
||||
playerStreaks = {},
|
||||
playerBadges = {},
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
onAssignWhitePlayer,
|
||||
onAssignBlackPlayer,
|
||||
isInRoom = false,
|
||||
isCurrentUserHost = false,
|
||||
gamePhase,
|
||||
}: ActivePlayersListProps) {
|
||||
const [hoveredPlayerId, setHoveredPlayerId] = React.useState<string | null>(null)
|
||||
const [hoveredBadge, setHoveredBadge] = React.useState<string | null>(null)
|
||||
const [clickCooldown, setClickCooldown] = React.useState<string | null>(null)
|
||||
|
||||
// Determine if user can assign players
|
||||
// Can assign if: not in room (local play) OR in room and is host
|
||||
const canAssignPlayers = !isInRoom || isCurrentUserHost
|
||||
|
||||
// Handler to assign to white
|
||||
const handleAssignWhite = React.useCallback(
|
||||
(playerId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignWhitePlayer) return
|
||||
onAssignWhitePlayer(playerId)
|
||||
setClickCooldown(playerId)
|
||||
},
|
||||
[onAssignWhitePlayer]
|
||||
)
|
||||
|
||||
// Handler to assign to black
|
||||
const handleAssignBlack = React.useCallback(
|
||||
(playerId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignBlackPlayer) return
|
||||
onAssignBlackPlayer(playerId)
|
||||
setClickCooldown(playerId)
|
||||
},
|
||||
[onAssignBlackPlayer]
|
||||
)
|
||||
|
||||
// Handler to swap sides
|
||||
const handleSwap = React.useCallback(
|
||||
(playerId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignWhitePlayer || !onAssignBlackPlayer) return
|
||||
|
||||
if (whitePlayerId === playerId) {
|
||||
// Currently white, swap with black player
|
||||
const currentBlack = blackPlayerId ?? null
|
||||
onAssignWhitePlayer(currentBlack)
|
||||
onAssignBlackPlayer(playerId)
|
||||
} else if (blackPlayerId === playerId) {
|
||||
// Currently black, swap with white player
|
||||
const currentWhite = whitePlayerId ?? null
|
||||
onAssignBlackPlayer(currentWhite)
|
||||
onAssignWhitePlayer(playerId)
|
||||
}
|
||||
},
|
||||
[whitePlayerId, blackPlayerId, onAssignWhitePlayer, onAssignBlackPlayer]
|
||||
)
|
||||
|
||||
// Helper to get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
@@ -117,7 +48,6 @@ export function ActivePlayersList({
|
||||
const score = playerScores[player.id] || 0
|
||||
const streak = playerStreaks[player.id] || 0
|
||||
const celebrationLevel = getCelebrationLevel(streak)
|
||||
const badge = playerBadges[player.id]
|
||||
|
||||
return (
|
||||
<PlayerTooltip
|
||||
@@ -223,47 +153,6 @@ export function ActivePlayersList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show playerBadge only if assignment UI is not present */}
|
||||
{badge && !onAssignWhitePlayer && !onAssignBlackPlayer && (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '999px',
|
||||
background: badge.background ?? 'rgba(148, 163, 184, 0.25)',
|
||||
color: badge.color ?? '#0f172a',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
boxShadow: badge.shadowColor
|
||||
? `0 4px 12px ${badge.shadowColor}`
|
||||
: '0 4px 12px rgba(15, 23, 42, 0.25)',
|
||||
border: badge.borderColor
|
||||
? `2px solid ${badge.borderColor}`
|
||||
: '2px solid rgba(255,255,255,0.4)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
marginTop: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{badge.icon && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(15,23,42,0.35))',
|
||||
}}
|
||||
>
|
||||
{badge.icon}
|
||||
</span>
|
||||
)}
|
||||
<span>{badge.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldEmphasize && hoveredPlayerId === player.id && (
|
||||
<>
|
||||
{/* Configure button - bottom left */}
|
||||
@@ -382,283 +271,6 @@ export function ActivePlayersList({
|
||||
Your turn
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side assignment badge (white/black for 2-player games) */}
|
||||
{onAssignWhitePlayer && onAssignBlackPlayer && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
width: '88px', // Fixed width to prevent layout shift
|
||||
transition: 'none', // Prevent any inherited transitions
|
||||
}}
|
||||
onMouseEnter={() => canAssignPlayers && setHoveredBadge(player.id)}
|
||||
onMouseLeave={() => {
|
||||
setHoveredBadge(null)
|
||||
setClickCooldown(null)
|
||||
}}
|
||||
>
|
||||
{/* Unassigned player - show split button on hover */}
|
||||
{whitePlayerId !== player.id && blackPlayerId !== player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host/local play: show interactive assignment buttons
|
||||
<>
|
||||
{hoveredBadge === player.id && clickCooldown !== player.id ? (
|
||||
// Hover state: split button
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<div
|
||||
onClick={(e) => handleAssignWhite(player.id, e)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px 0 0 12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
borderRight: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
W
|
||||
</div>
|
||||
<div
|
||||
onClick={(e) => handleAssignBlack(player.id, e)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
borderRadius: '0 12px 12px 0',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
borderLeft: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: ASSIGN or SPECTATING button
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #e5e7eb, #d1d5db)',
|
||||
color: '#6b7280',
|
||||
border: '2px solid #9ca3af',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
transition: 'none',
|
||||
}}
|
||||
>
|
||||
{gamePhase === 'playing' ? 'SPECTATING' : 'ASSIGN'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : // Guest in room: show SPECTATING during gameplay, nothing during setup
|
||||
gamePhase === 'playing' ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
background: 'transparent',
|
||||
color: '#9ca3af',
|
||||
border: '2px solid transparent',
|
||||
textAlign: 'center',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
SPECTATING
|
||||
</div>
|
||||
) : (
|
||||
// During setup/results: show nothing
|
||||
<div style={{ width: '100%', height: '28px' }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* White player - show SWAP to black on hover */}
|
||||
{whitePlayerId === player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host/local play: show interactive swap
|
||||
<>
|
||||
{hoveredBadge === player.id && clickCooldown !== player.id ? (
|
||||
// Hover state: SWAP with black styling
|
||||
<div
|
||||
onClick={(e) => handleSwap(player.id, e)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
SWAP
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: WHITE
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
WHITE
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Guest in room: show static WHITE label
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'default',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
WHITE
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Black player - show SWAP to white on hover */}
|
||||
{blackPlayerId === player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host/local play: show interactive swap
|
||||
<>
|
||||
{hoveredBadge === player.id && clickCooldown !== player.id ? (
|
||||
// Hover state: SWAP with white styling
|
||||
<div
|
||||
onClick={(e) => handleSwap(player.id, e)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
SWAP
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: BLACK
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
BLACK
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Guest in room: show static BLACK label
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'default',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
BLACK
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PlayerTooltip>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import { GameTitleMenu } from './GameTitleMenu'
|
||||
import { NetworkPlayerIndicator } from './NetworkPlayerIndicator'
|
||||
import { PendingInvitations } from './PendingInvitations'
|
||||
import { RoomInfo } from './RoomInfo'
|
||||
import type { PlayerBadge } from './types'
|
||||
|
||||
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
|
||||
|
||||
@@ -34,18 +33,6 @@ interface ArcadeRoomInfo {
|
||||
joinCode?: string
|
||||
}
|
||||
|
||||
export interface RosterWarningAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'primary' | 'danger'
|
||||
}
|
||||
|
||||
export interface RosterWarning {
|
||||
heading: string
|
||||
description: string
|
||||
actions?: RosterWarningAction[]
|
||||
}
|
||||
|
||||
interface GameContextNavProps {
|
||||
navTitle: string
|
||||
navEmoji?: string
|
||||
@@ -68,21 +55,11 @@ interface GameContextNavProps {
|
||||
currentPlayerId?: string
|
||||
playerScores?: Record<string, number>
|
||||
playerStreaks?: Record<string, number>
|
||||
playerBadges?: Record<string, PlayerBadge>
|
||||
// Lifted popover state from PageWithNav
|
||||
showPopover?: boolean
|
||||
setShowPopover?: (show: boolean) => void
|
||||
activeTab?: 'add' | 'invite'
|
||||
setActiveTab?: (tab: 'add' | 'invite') => void
|
||||
// Game-specific roster warnings
|
||||
rosterWarning?: RosterWarning
|
||||
// Side assignments (for 2-player games)
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
onAssignWhitePlayer?: (playerId: string | null) => void
|
||||
onAssignBlackPlayer?: (playerId: string | null) => void
|
||||
// Game phase (for showing spectating vs assign)
|
||||
gamePhase?: 'setup' | 'playing' | 'results'
|
||||
}
|
||||
|
||||
export function GameContextNav({
|
||||
@@ -105,17 +82,10 @@ export function GameContextNav({
|
||||
currentPlayerId,
|
||||
playerScores,
|
||||
playerStreaks,
|
||||
playerBadges,
|
||||
showPopover,
|
||||
setShowPopover,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
rosterWarning,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
onAssignWhitePlayer,
|
||||
onAssignBlackPlayer,
|
||||
gamePhase,
|
||||
}: GameContextNavProps) {
|
||||
// Get current user info for moderation
|
||||
const { data: currentUserId } = useViewerId()
|
||||
@@ -207,95 +177,12 @@ export function GameContextNav({
|
||||
onInvitationChange={() => refetchRoomData()}
|
||||
/>
|
||||
|
||||
{/* Roster Warning Banner - Game-specific warnings (e.g., too many players) */}
|
||||
{rosterWarning && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(245, 158, 11, 0.1))',
|
||||
borderLeft: '4px solid #f59e0b',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h4
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#92400e',
|
||||
}}
|
||||
>
|
||||
{rosterWarning.heading}
|
||||
</h4>
|
||||
<p
|
||||
style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '13px',
|
||||
color: '#78350f',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
{rosterWarning.description}
|
||||
</p>
|
||||
</div>
|
||||
{rosterWarning.actions && rosterWarning.actions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{rosterWarning.actions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: action.variant === 'danger' ? '#dc2626' : '#f59e0b',
|
||||
color: 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#b91c1c' : '#d97706'
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#dc2626' : '#f59e0b'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#b91c1c' : '#d97706'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
action.variant === 'danger' ? '#dc2626' : '#f59e0b'
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
alignItems: 'center',
|
||||
width: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{/* Game Title Section - Always mounted, hidden when in room */}
|
||||
@@ -400,16 +287,9 @@ export function GameContextNav({
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={playerScores}
|
||||
playerStreaks={playerStreaks}
|
||||
playerBadges={playerBadges}
|
||||
roomId={roomInfo?.roomId}
|
||||
currentUserId={currentUserId ?? undefined}
|
||||
isCurrentUserHost={isCurrentUserHost}
|
||||
whitePlayerId={whitePlayerId}
|
||||
blackPlayerId={blackPlayerId}
|
||||
onAssignWhitePlayer={onAssignWhitePlayer}
|
||||
onAssignBlackPlayer={onAssignBlackPlayer}
|
||||
isInRoom={!!roomInfo}
|
||||
gamePhase={gamePhase}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -445,14 +325,6 @@ export function GameContextNav({
|
||||
currentPlayerId={currentPlayerId}
|
||||
playerScores={playerScores}
|
||||
playerStreaks={playerStreaks}
|
||||
playerBadges={playerBadges}
|
||||
whitePlayerId={whitePlayerId}
|
||||
blackPlayerId={blackPlayerId}
|
||||
onAssignWhitePlayer={onAssignWhitePlayer}
|
||||
onAssignBlackPlayer={onAssignBlackPlayer}
|
||||
isInRoom={!!roomInfo}
|
||||
isCurrentUserHost={isCurrentUserHost}
|
||||
gamePhase={gamePhase}
|
||||
/>
|
||||
|
||||
<AddPlayerButton
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
|
||||
interface HistoricalMember {
|
||||
userId: string
|
||||
displayName: string
|
||||
firstJoinedAt: string
|
||||
lastSeenAt: string
|
||||
status: 'active' | 'banned' | 'kicked' | 'left'
|
||||
isCurrentlyInRoom: boolean
|
||||
isBanned: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to show historical players who are not currently in the room
|
||||
* with invite buttons to bring them back
|
||||
*/
|
||||
export function HistoricalPlayersInvite() {
|
||||
const { roomData } = useRoomData()
|
||||
const [historicalMembers, setHistoricalMembers] = useState<HistoricalMember[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [invitingUserId, setInvitingUserId] = useState<string | null>(null)
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
// Fetch historical members
|
||||
useEffect(() => {
|
||||
if (!roomData?.id) return
|
||||
|
||||
const loadHistoricalMembers = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/arcade/rooms/${roomData.id}/history`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
// Filter to only show members who are NOT currently in the room and NOT banned
|
||||
const notInRoom = (data.historicalMembers || []).filter(
|
||||
(m: HistoricalMember) => !m.isCurrentlyInRoom && !m.isBanned
|
||||
)
|
||||
setHistoricalMembers(notInRoom)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load historical members:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadHistoricalMembers()
|
||||
}, [roomData?.id])
|
||||
|
||||
const handleInvite = async (userId: string, displayName: string) => {
|
||||
if (!roomData?.id) return
|
||||
|
||||
setInvitingUserId(userId)
|
||||
try {
|
||||
const res = await fetch(`/api/arcade/rooms/${roomData.id}/invite`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId, userName: displayName }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json()
|
||||
throw new Error(errorData.error || 'Failed to send invitation')
|
||||
}
|
||||
|
||||
showSuccess(`Invitation sent to ${displayName}`)
|
||||
|
||||
// Remove from list after inviting
|
||||
setHistoricalMembers((prev) => prev.filter((m) => m.userId !== userId))
|
||||
} catch (err) {
|
||||
showError(err instanceof Error ? err.message : 'Failed to send invitation')
|
||||
} finally {
|
||||
setInvitingUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!roomData?.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
Loading past players...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (historicalMembers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}
|
||||
>
|
||||
Past Players
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{historicalMembers.map((member) => (
|
||||
<div
|
||||
key={member.userId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(255, 255, 255, 0.5)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#1e293b',
|
||||
}}
|
||||
>
|
||||
{member.displayName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
Last seen: {new Date(member.lastSeenAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInvite(member.userId, member.displayName)}
|
||||
disabled={invitingUserId === member.userId}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background:
|
||||
invitingUserId === member.userId
|
||||
? '#d1d5db'
|
||||
: 'linear-gradient(135deg, #8b5cf6, #7c3aed)',
|
||||
color: 'white',
|
||||
cursor: invitingUserId === member.userId ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: invitingUserId === member.userId ? 0.6 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (invitingUserId !== member.userId) {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed, #6d28d9)'
|
||||
e.currentTarget.style.transform = 'scale(1.05)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (invitingUserId !== member.userId) {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6, #7c3aed)'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{invitingUserId === member.userId ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCreateRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { RoomShareButtons } from './RoomShareButtons'
|
||||
import { HistoricalPlayersInvite } from './HistoricalPlayersInvite'
|
||||
|
||||
/**
|
||||
* Tab content for inviting players to a room
|
||||
@@ -177,11 +176,6 @@ export function InvitePlayersTab() {
|
||||
Share to invite players
|
||||
</div>
|
||||
<RoomShareButtons joinCode={roomData.code} shareUrl={shareUrl} />
|
||||
|
||||
{/* Historical players who can be invited back */}
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<HistoricalPlayersInvite />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { PlayerTooltip } from './PlayerTooltip'
|
||||
import { ReportPlayerModal } from './ReportPlayerModal'
|
||||
import type { PlayerBadge } from './types'
|
||||
import { useDeactivatePlayer } from '@/hooks/useRoomData'
|
||||
|
||||
interface NetworkPlayer {
|
||||
id: string
|
||||
@@ -21,20 +19,10 @@ interface NetworkPlayerIndicatorProps {
|
||||
currentPlayerId?: string
|
||||
playerScores?: Record<string, number>
|
||||
playerStreaks?: Record<string, number>
|
||||
playerBadges?: Record<string, PlayerBadge>
|
||||
// Moderation props
|
||||
roomId?: string
|
||||
currentUserId?: string
|
||||
isCurrentUserHost?: boolean
|
||||
// Side assignments (for 2-player games)
|
||||
whitePlayerId?: string | null
|
||||
blackPlayerId?: string | null
|
||||
onAssignWhitePlayer?: (playerId: string | null) => void
|
||||
onAssignBlackPlayer?: (playerId: string | null) => void
|
||||
// Room context for assignment permissions
|
||||
isInRoom?: boolean
|
||||
// Game phase (for showing spectating vs assign)
|
||||
gamePhase?: 'setup' | 'playing' | 'results'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,26 +35,11 @@ export function NetworkPlayerIndicator({
|
||||
currentPlayerId,
|
||||
playerScores = {},
|
||||
playerStreaks = {},
|
||||
playerBadges = {},
|
||||
roomId,
|
||||
currentUserId,
|
||||
isCurrentUserHost,
|
||||
whitePlayerId,
|
||||
blackPlayerId,
|
||||
onAssignWhitePlayer,
|
||||
onAssignBlackPlayer,
|
||||
isInRoom = true, // Network players are always in a room
|
||||
gamePhase,
|
||||
}: NetworkPlayerIndicatorProps) {
|
||||
const [showReportModal, setShowReportModal] = useState(false)
|
||||
const [hoveredPlayerId, setHoveredPlayerId] = useState<string | null>(null)
|
||||
const [hoveredBadge, setHoveredBadge] = useState(false)
|
||||
const [clickCooldown, setClickCooldown] = useState(false)
|
||||
const { mutate: deactivatePlayer } = useDeactivatePlayer()
|
||||
|
||||
// Determine if user can assign players
|
||||
// For network players: Can assign only if user is host (always in a room)
|
||||
const canAssignPlayers = isCurrentUserHost
|
||||
|
||||
const playerName = player.name || `Network Player ${player.id.slice(0, 8)}`
|
||||
const extraInfo = player.memberName ? `Controlled by ${player.memberName}` : undefined
|
||||
@@ -99,47 +72,6 @@ export function NetworkPlayerIndicator({
|
||||
return 'normal'
|
||||
}
|
||||
const celebrationLevel = getCelebrationLevel(streak)
|
||||
const badge = playerBadges[player.id]
|
||||
|
||||
// Handler for deactivating player (host only)
|
||||
const handleDeactivate = () => {
|
||||
if (!roomId || !isCurrentUserHost) return
|
||||
deactivatePlayer({ roomId, playerId: player.id })
|
||||
}
|
||||
|
||||
// Handler to assign to white
|
||||
const handleAssignWhite = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignWhitePlayer) return
|
||||
onAssignWhitePlayer(player.id)
|
||||
setClickCooldown(true)
|
||||
}
|
||||
|
||||
// Handler to assign to black
|
||||
const handleAssignBlack = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignBlackPlayer) return
|
||||
onAssignBlackPlayer(player.id)
|
||||
setClickCooldown(true)
|
||||
}
|
||||
|
||||
// Handler to swap sides
|
||||
const handleSwap = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!onAssignWhitePlayer || !onAssignBlackPlayer) return
|
||||
|
||||
if (whitePlayerId === player.id) {
|
||||
// Currently white, swap with black player
|
||||
const currentBlack = blackPlayerId ?? null
|
||||
onAssignWhitePlayer(currentBlack)
|
||||
onAssignBlackPlayer(player.id)
|
||||
} else if (blackPlayerId === player.id) {
|
||||
// Currently black, swap with white player
|
||||
const currentWhite = whitePlayerId ?? null
|
||||
onAssignBlackPlayer(currentWhite)
|
||||
onAssignWhitePlayer(player.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -172,8 +104,6 @@ export function NetworkPlayerIndicator({
|
||||
justifyContent: 'center',
|
||||
opacity: hasGameState ? (isCurrentPlayer ? 1 : 0.65) : 1,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredPlayerId(player.id)}
|
||||
onMouseLeave={() => setHoveredPlayerId(null)}
|
||||
>
|
||||
{/* Turn indicator border ring - show when current player */}
|
||||
{isCurrentPlayer && hasGameState && (
|
||||
@@ -266,61 +196,6 @@ export function NetworkPlayerIndicator({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button - top left (host only, on hover) */}
|
||||
{shouldEmphasize && isCurrentUserHost && hoveredPlayerId === player.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeactivate()
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
left: '-6px',
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid white',
|
||||
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
transition: 'all 0.2s ease',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #dc2626, #b91c1c)'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(239, 68, 68, 0.5)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #dc2626, #b91c1c)'
|
||||
e.currentTarget.style.transform = 'scale(1.15)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(239, 68, 68, 0.5)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)'
|
||||
}}
|
||||
aria-label={`Deactivate ${playerName}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@@ -379,44 +254,6 @@ export function NetworkPlayerIndicator({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show playerBadge only if assignment UI is not present */}
|
||||
{badge && !onAssignWhitePlayer && !onAssignBlackPlayer && (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '999px',
|
||||
background: badge.background ?? 'rgba(148, 163, 184, 0.25)',
|
||||
color: badge.color ?? '#0f172a',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
boxShadow: badge.shadowColor
|
||||
? `0 4px 12px ${badge.shadowColor}`
|
||||
: '0 4px 12px rgba(15, 23, 42, 0.25)',
|
||||
border: badge.borderColor
|
||||
? `2px solid ${badge.borderColor}`
|
||||
: '2px solid rgba(255,255,255,0.4)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
marginTop: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{badge.icon && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ fontSize: '14px', filter: 'drop-shadow(0 2px 4px rgba(15,23,42,0.35))' }}
|
||||
>
|
||||
{badge.icon}
|
||||
</span>
|
||||
)}
|
||||
<span>{badge.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Turn label */}
|
||||
{isCurrentPlayer && hasGameState && (
|
||||
<div
|
||||
@@ -441,283 +278,6 @@ export function NetworkPlayerIndicator({
|
||||
Their turn
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side assignment badge (white/black for 2-player games) */}
|
||||
{onAssignWhitePlayer && onAssignBlackPlayer && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
width: '88px', // Fixed width to prevent layout shift
|
||||
transition: 'none', // Prevent any inherited transitions
|
||||
}}
|
||||
onMouseEnter={() => canAssignPlayers && setHoveredBadge(true)}
|
||||
onMouseLeave={() => {
|
||||
setHoveredBadge(false)
|
||||
setClickCooldown(false)
|
||||
}}
|
||||
>
|
||||
{/* Unassigned player - show split button on hover */}
|
||||
{whitePlayerId !== player.id && blackPlayerId !== player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host: show interactive assignment buttons
|
||||
<>
|
||||
{hoveredBadge && !clickCooldown ? (
|
||||
// Hover state: split button
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<div
|
||||
onClick={handleAssignWhite}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px 0 0 12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
borderRight: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
W
|
||||
</div>
|
||||
<div
|
||||
onClick={handleAssignBlack}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
borderRadius: '0 12px 12px 0',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
borderLeft: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: ASSIGN or SPECTATING button
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #e5e7eb, #d1d5db)',
|
||||
color: '#6b7280',
|
||||
border: '2px solid #9ca3af',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
transition: 'none',
|
||||
}}
|
||||
>
|
||||
{gamePhase === 'playing' ? 'SPECTATING' : 'ASSIGN'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : // Guest: show SPECTATING during gameplay, nothing during setup
|
||||
gamePhase === 'playing' ? (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
background: 'transparent',
|
||||
color: '#9ca3af',
|
||||
border: '2px solid transparent',
|
||||
textAlign: 'center',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
SPECTATING
|
||||
</div>
|
||||
) : (
|
||||
// During setup/results: show nothing
|
||||
<div style={{ width: '100%', height: '28px' }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* White player - show SWAP to black on hover */}
|
||||
{whitePlayerId === player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host: show interactive swap
|
||||
<>
|
||||
{hoveredBadge && !clickCooldown ? (
|
||||
// Hover state: SWAP with black styling
|
||||
<div
|
||||
onClick={handleSwap}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
SWAP
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: WHITE
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
WHITE
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Guest: show static WHITE label
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'default',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
WHITE
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Black player - show SWAP to white on hover */}
|
||||
{blackPlayerId === player.id && (
|
||||
<>
|
||||
{canAssignPlayers ? (
|
||||
// Host: show interactive swap
|
||||
<>
|
||||
{hoveredBadge && !clickCooldown ? (
|
||||
// Hover state: SWAP with white styling
|
||||
<div
|
||||
onClick={handleSwap}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #f0f0f0, #ffffff)',
|
||||
color: '#1a202c',
|
||||
border: '2px solid #cbd5e0',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
SWAP
|
||||
</div>
|
||||
) : (
|
||||
// Normal state: BLACK
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
BLACK
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Guest: show static BLACK label
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 0',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '800',
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'default',
|
||||
background: 'linear-gradient(135deg, #2d3748, #1a202c)',
|
||||
color: '#ffffff',
|
||||
border: '2px solid #4a5568',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
BLACK
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PlayerTooltip>
|
||||
|
||||
|
||||
@@ -673,8 +673,8 @@ export function RoomInfo({
|
||||
<span>Join Another</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{/* Leave Room - only show when in a room with other members */}
|
||||
{roomId && roomData && roomData.members.length > 1 && (
|
||||
{/* Leave Room - only show when in a room */}
|
||||
{roomId && (
|
||||
<>
|
||||
<DropdownMenu.Separator
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CopyButton } from '@/components/common/CopyButton'
|
||||
import { QRCodeButton } from '@/components/common/QRCodeButton'
|
||||
|
||||
export interface RoomShareButtonsProps {
|
||||
/**
|
||||
@@ -19,37 +18,29 @@ export interface RoomShareButtonsProps {
|
||||
*/
|
||||
export function RoomShareButtons({ joinCode, shareUrl }: RoomShareButtonsProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '6px' }}>
|
||||
{/* Left side: stacked buttons */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', flex: 1 }}>
|
||||
<CopyButton
|
||||
text={joinCode}
|
||||
variant="code"
|
||||
label={
|
||||
<>
|
||||
<span>📋</span>
|
||||
<span>{joinCode}</span>
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<>
|
||||
<CopyButton
|
||||
text={joinCode}
|
||||
variant="code"
|
||||
label={
|
||||
<>
|
||||
<span>📋</span>
|
||||
<span>{joinCode}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<CopyButton
|
||||
text={shareUrl}
|
||||
variant="link"
|
||||
label={
|
||||
<>
|
||||
<span>🔗</span>
|
||||
<span>Share Link</span>
|
||||
</>
|
||||
}
|
||||
copiedLabel="Link Copied!"
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side: QR code button */}
|
||||
<QRCodeButton url={shareUrl} />
|
||||
</div>
|
||||
<CopyButton
|
||||
text={shareUrl}
|
||||
variant="link"
|
||||
label={
|
||||
<>
|
||||
<span>🔗</span>
|
||||
<span>Share Link</span>
|
||||
</>
|
||||
}
|
||||
copiedLabel="Link Copied!"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import React from 'react'
|
||||
import { InvitePlayersTab } from './InvitePlayersTab'
|
||||
|
||||
interface Player {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
interface SetupPlayerRequirementProps {
|
||||
minPlayers: number
|
||||
currentPlayers: Player[]
|
||||
inactivePlayers: Player[]
|
||||
onAddPlayer: (playerId: string) => void
|
||||
onConfigurePlayer: (playerId: string) => void
|
||||
gameTitle: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Prominent player requirement component shown during setup when not enough players are active.
|
||||
* Forces the user to add more players before proceeding with setup.
|
||||
*/
|
||||
export function SetupPlayerRequirement({
|
||||
minPlayers,
|
||||
currentPlayers,
|
||||
inactivePlayers,
|
||||
onAddPlayer,
|
||||
onConfigurePlayer,
|
||||
gameTitle,
|
||||
}: SetupPlayerRequirementProps) {
|
||||
const [activeTab, setActiveTab] = React.useState<'add' | 'invite'>('add')
|
||||
const needsPlayers = currentPlayers.length < minPlayers
|
||||
const playersNeeded = minPlayers - currentPlayers.length
|
||||
|
||||
if (!needsPlayers) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto 32px auto',
|
||||
padding: '24px',
|
||||
background: 'linear-gradient(135deg, rgba(96, 165, 250, 0.15), rgba(167, 139, 250, 0.15))',
|
||||
borderRadius: '16px',
|
||||
border: '3px solid rgba(96, 165, 250, 0.4)',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.15)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h2
|
||||
style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
}}
|
||||
>
|
||||
{gameTitle} needs {playersNeeded} more {playersNeeded === 1 ? 'player' : 'players'}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: '#64748b',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Add local players or invite friends to join
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab selector */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('add')}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
borderRadius: '8px',
|
||||
border: activeTab === 'add' ? '2px solid #60a5fa' : '2px solid transparent',
|
||||
background: activeTab === 'add' ? '#60a5fa' : 'rgba(255, 255, 255, 0.5)',
|
||||
color: activeTab === 'add' ? 'white' : '#64748b',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Add Local Player
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('invite')}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
borderRadius: '8px',
|
||||
border: activeTab === 'invite' ? '2px solid #a78bfa' : '2px solid transparent',
|
||||
background: activeTab === 'invite' ? '#a78bfa' : 'rgba(255, 255, 255, 0.5)',
|
||||
color: activeTab === 'invite' ? 'white' : '#64748b',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Invite Players
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'add' ? (
|
||||
// Add local player tab
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
>
|
||||
{inactivePlayers.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<p style={{ margin: '0 0 12px 0', color: '#64748b', fontSize: '14px' }}>
|
||||
No inactive players available
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfigurePlayer('new')}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #60a5fa',
|
||||
background: '#60a5fa',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Create New Player
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: '16px',
|
||||
justifyItems: 'center',
|
||||
}}
|
||||
>
|
||||
{inactivePlayers.map((player) => (
|
||||
<div
|
||||
key={player.id}
|
||||
onClick={() => onAddPlayer(player.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
background: 'white',
|
||||
border: '2px solid rgba(96, 165, 250, 0.3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)'
|
||||
e.currentTarget.style.borderColor = '#60a5fa'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(96, 165, 250, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.borderColor = 'rgba(96, 165, 250, 0.3)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', lineHeight: 1 }}>{player.emoji}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#1e293b',
|
||||
textAlign: 'center',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{player.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Invite players tab
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
>
|
||||
<InvitePlayersTab />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface PlayerBadge {
|
||||
label: string
|
||||
icon?: string
|
||||
background?: string
|
||||
color?: string
|
||||
borderColor?: string
|
||||
shadowColor?: string
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { PedagogicalSegment } from '../DecompositionWithReasons'
|
||||
import { useTutorialUI } from '../TutorialUIContext'
|
||||
|
||||
export function CoachBar() {
|
||||
const ui = useTutorialUI()
|
||||
const t = useTranslations('tutorial.coachBar')
|
||||
const seg: PedagogicalSegment | null = ui.activeSegment
|
||||
|
||||
if (!ui.showCoachBar || !seg || !seg.readable?.summary) return null
|
||||
@@ -16,13 +14,13 @@ export function CoachBar() {
|
||||
return (
|
||||
<aside className="coachbar" role="status" aria-live="polite" data-test-id="coachbar">
|
||||
<div className="coachbar__row">
|
||||
<div className="coachbar__title">{r.title ?? t('titleFallback')}</div>
|
||||
<div className="coachbar__title">{r.title ?? 'Step'}</div>
|
||||
{ui.canHideCoachBar && (
|
||||
<button
|
||||
type="button"
|
||||
className="coachbar__hide"
|
||||
onClick={() => ui.setShowCoachBar(false)}
|
||||
aria-label={t('hideAria')}
|
||||
aria-label="Hide guidance"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import * as HoverCard from '@radix-ui/react-hover-card'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { TermProvenance, UnifiedStepData } from '../../utils/unifiedStepGenerator'
|
||||
@@ -62,106 +61,114 @@ export function ReasonTooltip({
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const ui = useTutorialUIGate()
|
||||
const t = useTranslations('tutorial.reasonTooltip')
|
||||
const rule = reason?.rule ?? segment?.plan[0]?.rule
|
||||
|
||||
// Use readable format from segment, enhanced with provenance
|
||||
const readable = segment?.readable
|
||||
|
||||
const enhancedContent = useMemo(() => {
|
||||
// Generate enhanced tooltip content using provenance
|
||||
const getEnhancedTooltipContent = () => {
|
||||
if (!provenance) return null
|
||||
|
||||
// For Direct operations, use the enhanced provenance title
|
||||
if (rule === 'Direct') {
|
||||
const rodChip = readable?.chips.find((c) => /^(this )?rod shows$/i.test(c.label))
|
||||
const title = `Add the ${provenance.rhsPlaceName} digit — ${provenance.rhsDigit} ${provenance.rhsPlaceName} (${provenance.rhsValue})`
|
||||
const subtitle = `From addend ${provenance.rhs}`
|
||||
|
||||
return {
|
||||
title: t('directTitle', {
|
||||
place: provenance.rhsPlaceName,
|
||||
digit: provenance.rhsDigit,
|
||||
value: provenance.rhsValue,
|
||||
}),
|
||||
subtitle: t('directSubtitle', { addend: provenance.rhs }),
|
||||
chips: [
|
||||
{
|
||||
label: t('digitChip'),
|
||||
value: `${provenance.rhsDigit} (${provenance.rhsPlaceName})`,
|
||||
},
|
||||
...(rodChip ? [{ label: t('rodChip'), value: rodChip.value }] : []),
|
||||
{
|
||||
label: t('addHereChip'),
|
||||
value: `+${provenance.rhsDigit} ${provenance.rhsPlaceName} → ${provenance.rhsValue}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
const enhancedChips = [
|
||||
{
|
||||
label: "Digit we're using",
|
||||
value: `${provenance.rhsDigit} (${provenance.rhsPlaceName})`,
|
||||
},
|
||||
...(() => {
|
||||
const rodChip = readable?.chips.find((c) => /^(this )?rod shows$/i.test(c.label))
|
||||
return rodChip ? [{ label: 'Rod shows', value: rodChip.value }] : []
|
||||
})(),
|
||||
{
|
||||
label: 'So we add here',
|
||||
value: `+${provenance.rhsDigit} ${provenance.rhsPlaceName} → ${provenance.rhsValue}`,
|
||||
},
|
||||
]
|
||||
|
||||
return { title, subtitle, chips: enhancedChips }
|
||||
}
|
||||
|
||||
// For complement operations, enhance the existing readable content with provenance context
|
||||
if (readable) {
|
||||
const subtitleParts = [
|
||||
readable.subtitle,
|
||||
t('subtitleContext', {
|
||||
addend: provenance.rhs,
|
||||
place: provenance.rhsPlaceName,
|
||||
digit: provenance.rhsDigit,
|
||||
}),
|
||||
].filter(Boolean)
|
||||
// Keep the readable title but add provenance context to subtitle
|
||||
const title = readable.title
|
||||
const subtitle =
|
||||
`${readable.subtitle || ''} • From ${provenance.rhsPlaceName} digit ${provenance.rhsDigit} of ${provenance.rhs}`.trim()
|
||||
|
||||
return {
|
||||
title: readable.title,
|
||||
subtitle: subtitleParts.join(' • '),
|
||||
chips: [
|
||||
{
|
||||
label: t('sourceDigit'),
|
||||
value: `${provenance.rhsDigit} from ${provenance.rhs} (${provenance.rhsPlaceName} place)`,
|
||||
},
|
||||
...readable.chips,
|
||||
],
|
||||
}
|
||||
// Enhance the chips by adding provenance context at the beginning
|
||||
const enhancedChips = [
|
||||
{
|
||||
label: 'Source digit',
|
||||
value: `${provenance.rhsDigit} from ${provenance.rhs} (${provenance.rhsPlaceName} place)`,
|
||||
},
|
||||
...readable.chips,
|
||||
]
|
||||
|
||||
return { title, subtitle, chips: enhancedChips }
|
||||
}
|
||||
|
||||
return null
|
||||
}, [provenance, readable, rule, t])
|
||||
}
|
||||
|
||||
const ruleInfo = useMemo(() => {
|
||||
switch (rule) {
|
||||
const enhancedContent = useMemo(getEnhancedTooltipContent, [])
|
||||
|
||||
const getRuleInfo = (ruleName: PedagogicalRule) => {
|
||||
switch (ruleName) {
|
||||
case 'Direct':
|
||||
return {
|
||||
emoji: '✨',
|
||||
name: t('ruleInfo.Direct.name'),
|
||||
description: t('ruleInfo.Direct.description'),
|
||||
name: 'Direct Move',
|
||||
description: 'Simple bead movement',
|
||||
color: 'green',
|
||||
}
|
||||
case 'FiveComplement':
|
||||
return {
|
||||
emoji: '🤝',
|
||||
name: t('ruleInfo.FiveComplement.name'),
|
||||
description: t('ruleInfo.FiveComplement.description'),
|
||||
name: 'Five Friend',
|
||||
description: 'Using pairs that make 5',
|
||||
color: 'blue',
|
||||
}
|
||||
case 'TenComplement':
|
||||
return {
|
||||
emoji: '🔟',
|
||||
name: t('ruleInfo.TenComplement.name'),
|
||||
description: t('ruleInfo.TenComplement.description'),
|
||||
name: 'Ten Friend',
|
||||
description: 'Using pairs that make 10',
|
||||
color: 'purple',
|
||||
}
|
||||
case 'Cascade':
|
||||
return {
|
||||
emoji: '🌊',
|
||||
name: t('ruleInfo.Cascade.name'),
|
||||
description: t('ruleInfo.Cascade.description'),
|
||||
name: 'Chain Reaction',
|
||||
description: 'One move triggers another',
|
||||
color: 'orange',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
emoji: '💭',
|
||||
name: t('ruleInfo.Fallback.name'),
|
||||
description: t('ruleInfo.Fallback.description'),
|
||||
name: 'Strategy',
|
||||
description: 'Abacus technique',
|
||||
color: 'gray',
|
||||
}
|
||||
}
|
||||
}, [rule, t])
|
||||
}
|
||||
|
||||
const fromPrefix = t('fromPrefix')
|
||||
const ruleInfo = useMemo(
|
||||
() =>
|
||||
rule
|
||||
? getRuleInfo(rule)
|
||||
: {
|
||||
emoji: '💭',
|
||||
name: 'Strategy',
|
||||
description: 'Abacus technique',
|
||||
color: 'gray',
|
||||
},
|
||||
[rule, getRuleInfo]
|
||||
)
|
||||
|
||||
if (!rule) {
|
||||
return <>{children}</>
|
||||
@@ -219,14 +226,12 @@ export function ReasonTooltip({
|
||||
|
||||
{/* Optional provenance nudge (avoid duplicating subtitle) */}
|
||||
{provenance &&
|
||||
!(enhancedContent?.subtitle || readable?.subtitle || '').includes(`${fromPrefix} `) && (
|
||||
!(enhancedContent?.subtitle || readable?.subtitle || '').includes('From ') && (
|
||||
<div className="reason-tooltip__reasoning">
|
||||
<p className="reason-tooltip__explanation-text">
|
||||
{t('reasoning', {
|
||||
addend: provenance.rhs,
|
||||
place: provenance.rhsPlaceName,
|
||||
digit: provenance.rhsDigit,
|
||||
})}
|
||||
From <strong>{provenance.rhs}</strong>: use the{' '}
|
||||
<strong>{provenance.rhsPlaceName}</strong> digit (
|
||||
<strong>{provenance.rhsDigit}</strong>).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -245,7 +250,7 @@ export function ReasonTooltip({
|
||||
type="button"
|
||||
>
|
||||
<span className="reason-tooltip__details-label">
|
||||
{t('details.toggle')}
|
||||
More details
|
||||
<span
|
||||
className="reason-tooltip__chevron"
|
||||
style={{
|
||||
@@ -277,7 +282,7 @@ export function ReasonTooltip({
|
||||
{segment?.plan?.some((p) => p.rule === 'Cascade') && readable?.carryPath && (
|
||||
<div className="reason-tooltip__carry-path">
|
||||
<p className="reason-tooltip__carry-description">
|
||||
<strong>{t('details.carryPath')}</strong> {readable.carryPath}
|
||||
<strong>Carry path:</strong> {readable.carryPath}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -292,7 +297,7 @@ export function ReasonTooltip({
|
||||
type="button"
|
||||
>
|
||||
<span className="reason-tooltip__math-label">
|
||||
{t('details.showMath')}
|
||||
Show the math
|
||||
<span
|
||||
className="reason-tooltip__chevron"
|
||||
style={{
|
||||
@@ -333,7 +338,7 @@ export function ReasonTooltip({
|
||||
type="button"
|
||||
>
|
||||
<span className="reason-tooltip__section-title">
|
||||
{t('details.steps')}
|
||||
Step-by-step breakdown
|
||||
<span
|
||||
className="reason-tooltip__chevron"
|
||||
style={{
|
||||
@@ -368,9 +373,7 @@ export function ReasonTooltip({
|
||||
segment?.readable?.validation &&
|
||||
!segment.readable.validation.ok && (
|
||||
<div className="reason-tooltip__dev-warn">
|
||||
{t('devWarning', {
|
||||
issues: segment.readable.validation.issues.join('; '),
|
||||
})}
|
||||
⚠ Summary/guard mismatch: {segment.readable.validation.issues.join('; ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -383,7 +386,7 @@ export function ReasonTooltip({
|
||||
<code className="reason-tooltip__expanded">{segment.expression}</code>
|
||||
</div>
|
||||
<div className="reason-tooltip__label">
|
||||
{t('formula', { original: originalValue, expanded: segment.expression })}
|
||||
{originalValue} becomes {segment.expression}
|
||||
</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