diff --git a/Dockerfile b/Dockerfile index f1a48db7..2a9ca7eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -150,8 +150,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/ # Set up environment WORKDIR /app/apps/web -# Create data directory for SQLite database -RUN mkdir -p data && chown nextjs:nodejs data +# Create data directory for SQLite database and uploads +RUN mkdir -p data/uploads && chown -R nextjs:nodejs data USER nextjs EXPOSE 3000 diff --git a/apps/web/.claude/WORKSHEET_GRADING_POSTMORTEM.md b/apps/web/.claude/WORKSHEET_GRADING_POSTMORTEM.md new file mode 100644 index 00000000..fc79581e --- /dev/null +++ b/apps/web/.claude/WORKSHEET_GRADING_POSTMORTEM.md @@ -0,0 +1,244 @@ +# Worksheet Grading System - Post-Mortem + +**Date:** 2025-11-10 +**Status:** Failed implementation - needs redesign + +## What Went Wrong + +### 1. **Built Too Much At Once** +- Attempted to implement 7+ features simultaneously: + - OpenAI GPT-5 Responses API integration + - Server-Sent Events (SSE) streaming + - Socket.IO real-time progress + - Database schema for attempts/mastery + - Image upload handling + - Result extraction and validation + - Progress UI with phases + +**Problem:** No way to test each piece individually. When something broke, impossible to isolate the issue. + +### 2. **No Incremental Testing** +- User had no way to test components as they were built +- No debug UI or test harness +- First time user saw anything was when the entire system was "done" +- By then, too many layers of abstraction to debug + +### 3. **Insufficient Visibility** +- No way to see raw API responses +- No way to test socket connections independently +- No way to verify event parsing +- Logs were buried in server console + +### 4. **Wrong Development Order** +Built from bottom-up instead of outside-in: +1. Started with database schema +2. Added API integration +3. Added streaming/sockets +4. Finally built UI + +**Should have been:** +1. Build minimal UI with mock data +2. Add real API calls (non-streaming) +3. Add streaming/progress +4. Add database persistence + +### 5. **API Response Structure Misunderstanding** +- GPT-5 Responses API returns `output[0]` = reasoning, `output[1]` = message +- Didn't discover this until after everything was "working" +- Result was JSON string that needed parsing +- These are fundamental issues that should have been caught early with a test harness + +### 6. **Scope Creep** +Started with "grade a worksheet" and ended up with: +- Mastery tracking system +- Progression path logic +- Retry mechanism with validation errors +- Multiple upload modes (file/camera/QR) +- Real-time streaming progress +- Socket.IO infrastructure + +**Should have started with:** "Can we call OpenAI and get back problem grades?" + +## Root Cause + +**I built a production system without first proving the concept worked.** + +The user couldn't give feedback on each component because there was no way to interact with them individually. By the time integration was done, the feedback was "this is total garbage" because debugging was impossible. + +## What Should Have Happened + +### **Phase 1: Proof of Concept (Day 1)** +**Goal:** Prove we can call OpenAI and get worksheet grades + +**Deliverable:** `/worksheets/debug/api-test` page with: +- Upload image button +- "Call OpenAI" button +- Raw request display (with image truncated) +- Raw response display +- Parsed result display +- Clear error messages + +**User can verify:** +- ✅ Image uploads work +- ✅ OpenAI API responds +- ✅ Response structure is correct +- ✅ We can extract problem grades + +**Exit criteria:** User uploads a real worksheet and sees correct grades displayed. + +--- + +### **Phase 2: Result Validation (Day 2)** +**Goal:** Ensure OpenAI returns valid, usable data + +**Add to debug page:** +- Schema validation results +- Field-by-field validation +- Test multiple worksheets +- Edge case handling (no problems visible, blurry, etc.) + +**User can verify:** +- ✅ Validation logic works +- ✅ Retry mechanism works +- ✅ Error messages are helpful + +**Exit criteria:** 10 test worksheets all grade correctly with valid output. + +--- + +### **Phase 3: Storage (Day 3)** +**Goal:** Save grading results to database + +**Add:** +- Database tables for attempts +- API route to save results +- Display saved results + +**Debug page shows:** +- "Save to DB" button +- Database insert confirmation +- Link to view saved result + +**User can verify:** +- ✅ Results persist correctly +- ✅ Can retrieve and display saved grades + +**Exit criteria:** User can reload page and see their saved grading results. + +--- + +### **Phase 4: Progress UI (Day 4)** +**Goal:** Add streaming progress updates + +**First:** Add Socket.IO test page +- Connect/disconnect buttons +- Emit test events +- Display all received events +- Connection status + +**User can verify:** +- ✅ Socket connections work +- ✅ Events are received +- ✅ Disconnections are handled + +**Then:** Add streaming to debug page +- Toggle streaming on/off +- Display events as they arrive +- Compare streaming vs non-streaming + +**Exit criteria:** User sees token counts updating in real-time. + +--- + +### **Phase 5: Production UI (Day 5+)** +Only after all pieces work individually: +- Build upload page +- Add camera capture +- Add results page +- Add mastery tracking + +Each piece can reference working debug pages if something breaks. + +## Key Principles for Next Attempt + +### 1. **Build Testable Components** +Every major component should have a dedicated test/debug page: +- `/worksheets/debug/api-test` - OpenAI API calls +- `/worksheets/debug/socket-test` - Socket.IO connections +- `/worksheets/debug/upload-test` - Image upload handling +- `/worksheets/debug/stream-test` - SSE stream parsing + +### 2. **Outside-In Development** +Start with UI/UX and work backward: +1. What does the user see? +2. What API does that need? +3. What database tables does that need? +4. What external services does that need? + +### 3. **One Feature at a Time** +Each PR should add exactly ONE user-facing capability: +- "User can upload image and see raw API response" +- "User can see parsed problem grades" +- "User can save and reload grading results" +- "User sees real-time progress updates" + +### 4. **Give User Control** +Every test page should have buttons/controls for: +- Triggering actions manually +- Viewing raw data +- Testing edge cases +- Comparing approaches (streaming vs non-streaming) + +### 5. **Make Debugging Easy** +- All API calls should be logged with request/response +- Socket events should be visible in UI +- Database queries should be logged +- Error messages should include full context + +### 6. **Get Feedback Early** +Show the user working pieces BEFORE integrating them: +- "Here's the API response - does this look right?" +- "Here's the socket connection - do you see events?" +- "Here's the progress UI - is this what you wanted?" + +## Technical Lessons Learned + +### OpenAI GPT-5 Responses API +- Response structure: `output[0]` = reasoning, `output[1]` = message +- Message content is a JSON string that needs parsing +- Streaming uses SSE with custom event types +- `json_schema` with `strict: true` enforces exact schema match + +### Socket.IO with Next.js +- Must specify correct path: `/api/socket` +- Server and client must match paths +- Events don't queue - client must connect before server emits + +### Streaming Challenges +- Node.js fetch has default timeouts +- Need AbortController for custom timeouts +- SSE parsing library (eventsource-parser) has version-specific API +- Variable scoping issues in async error handlers + +## Revised Specification for Next Attempt + +See `WORKSHEET_GRADING_SPEC_V2.md` (to be written) + +Key changes: +- Start with non-streaming +- Build debug pages first +- One deliverable per phase +- User tests each phase before next phase starts +- No integration until all pieces work independently + +## Conclusion + +**The implementation failed because there was no way to test it incrementally.** + +The correct approach is to build small, testable pieces that the user can interact with and give feedback on. Only after each piece is proven to work should we integrate them together. + +Next time: +1. Build a test page first +2. Get user feedback +3. Iterate on that page until it works perfectly +4. Only then integrate into production diff --git a/apps/web/.claude/WORKSHEET_GRADING_SPEC_V2.md b/apps/web/.claude/WORKSHEET_GRADING_SPEC_V2.md new file mode 100644 index 00000000..c8a77eda --- /dev/null +++ b/apps/web/.claude/WORKSHEET_GRADING_SPEC_V2.md @@ -0,0 +1,439 @@ +# Worksheet Grading System - Specification v2 + +**Status:** Not implemented - awaiting future development +**Created:** 2025-11-10 +**Replaces:** Failed v1 implementation + +## Core Goal + +**Enable teachers to upload photos of completed math worksheets and get automated grading with problem-by-problem feedback.** + +## Development Approach + +**Build test pages first, production pages second.** + +Every feature must have a debug/test page where the user can: +- Trigger the feature manually +- See raw data/responses +- Test edge cases +- Verify it works before integration + +## Phase 1: API Proof of Concept + +**Goal:** Prove OpenAI can grade worksheets + +### Deliverable: `/worksheets/debug/openai-test` page + +**UI Components:** +- Image upload input +- "Test OpenAI API" button +- Toggle: "Streaming" vs "Simple" +- Display sections: + - Request details (model, tokens, etc.) + - Raw API response (collapsible JSON) + - Parsed grades table + - Validation errors (if any) + - Timing information + +**Functionality:** +```typescript +// Non-streaming first +async function testOpenAI(imageFile: File) { + const response = await fetch('/api/debug/test-openai', { + method: 'POST', + body: formData + }) + + // Display raw response + // Display parsed grades + // Display any errors +} +``` + +**Success Criteria:** +- User uploads worksheet photo +- Sees raw OpenAI response +- Sees parsed problem grades +- All grades are correct +- Edge cases handled (blurry, no problems, etc.) + +**Blocked by:** Nothing - can start immediately + +--- + +## Phase 2: Socket.IO Infrastructure + +**Goal:** Prove real-time communication works + +### Deliverable: `/worksheets/debug/socket-test` page + +**UI Components:** +- Connection status indicator +- "Connect" / "Disconnect" buttons +- "Send Test Event" button +- Event log (scrollable, timestamped) +- Latency meter + +**Functionality:** +```typescript +function SocketTest() { + const [events, setEvents] = useState([]) + const [socket, setSocket] = useState(null) + + function connect() { + const s = io({ path: '/api/socket' }) + s.on('connect', () => addEvent('Connected')) + s.on('test-event', (data) => addEvent('Received', data)) + setSocket(s) + } + + function sendTest() { + socket.emit('test-event', { + timestamp: Date.now(), + message: 'Hello from client' + }) + } +} +``` + +**Success Criteria:** +- Socket connects successfully +- Test events are sent and received +- Latency is acceptable (<100ms) +- Reconnection works after disconnect +- No events are lost + +**Blocked by:** Nothing - independent of Phase 1 + +--- + +## Phase 3: Streaming Progress + +**Goal:** Add real-time progress to OpenAI calls + +### Enhancement to Phase 1 page + +**Add to `/worksheets/debug/openai-test`:** +- Progress bar with phases +- Token counter (live updates) +- Event log showing SSE events +- Comparison: "With Progress" vs "Without Progress" + +**Functionality:** +```typescript +async function testStreamingOpenAI(imageFile: File) { + const response = await fetch('/api/debug/test-openai-stream', { + method: 'POST', + body: formData + }) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value) + // Parse SSE events + // Update progress UI + // Display in event log + } +} +``` + +**Success Criteria:** +- User sees progress bar update in real-time +- Token counts increase smoothly +- All SSE event types are handled +- Final result matches non-streaming result +- No connection timeouts + +**Blocked by:** Phase 1 (need working OpenAI integration) + +--- + +## Phase 4: Database Persistence + +**Goal:** Save and retrieve grading results + +### Deliverable: `/worksheets/debug/storage-test` page + +**UI Components:** +- "Save Result to DB" button +- Saved results list (with IDs) +- "Load Result" button for each saved result +- Display: saved vs current result comparison + +**Database Schema:** +```sql +CREATE TABLE worksheet_attempts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + uploaded_image_url TEXT NOT NULL, + grading_status TEXT NOT NULL, -- 'pending', 'processing', 'completed', 'failed' + total_problems INTEGER, + correct_count INTEGER, + accuracy REAL, + error_patterns TEXT, -- JSON array + suggested_step_id TEXT, + ai_feedback TEXT, + graded_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE problem_attempts ( + id TEXT PRIMARY KEY, + attempt_id TEXT NOT NULL REFERENCES worksheet_attempts(id), + problem_index INTEGER NOT NULL, + operand_a INTEGER NOT NULL, + operand_b INTEGER NOT NULL, + correct_answer INTEGER NOT NULL, + student_answer INTEGER, + is_correct BOOLEAN NOT NULL, + error_type TEXT, -- 'computation', 'carry', 'alignment', etc. + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +**Success Criteria:** +- Results save to database +- Can retrieve saved results by ID +- Data integrity is maintained +- Can query by user/date +- No data loss + +**Blocked by:** Phase 1 (need working grades to save) + +--- + +## Phase 5: Socket + Streaming Integration + +**Goal:** Combine real-time progress with Socket.IO + +### Enhancement to Phase 3 + +**Modify `/worksheets/debug/openai-test`:** +- Add "Use Socket.IO" checkbox +- When enabled, progress updates emit via socket +- Multiple browser tabs can watch same grading +- Compare: HTTP streaming vs Socket.IO + +**Server logic:** +```typescript +// Server-side during grading +io.emit('grading:progress', { + attemptId, + phase: 'analyzing', + inputTokens: 1234, + outputTokens: 567, + message: 'Analyzing problems...' +}) +``` + +**Client logic:** +```typescript +socket.on('grading:progress', (data) => { + if (data.attemptId === currentAttemptId) { + updateProgressUI(data) + } +}) +``` + +**Success Criteria:** +- Socket progress updates work alongside SSE +- Multiple clients can watch same grading +- Progress is smooth and accurate +- No race conditions +- Handles client disconnect/reconnect + +**Blocked by:** Phases 2, 3 (need both working independently) + +--- + +## Phase 6: Production Upload Page + +**Goal:** Real user-facing upload interface + +### Deliverable: `/worksheets/upload` page + +**UI Components:** +- Three upload modes: + - File picker + - Camera capture + - QR code (advanced) +- Preview of uploaded image +- "Submit for Grading" button +- Redirect to results page + +**Functionality:** +- Validates image (size, format) +- Uploads to server +- Creates attempt record +- Starts grading process +- Redirects to `/worksheets/attempts/[id]` + +**Success Criteria:** +- All three upload modes work +- Image validation works +- Error messages are clear +- Loading states are shown +- Mobile camera works + +**Blocked by:** Phases 1, 4 (need API and storage) + +--- + +## Phase 7: Results Display Page + +**Goal:** Show grading results to user + +### Deliverable: `/worksheets/attempts/[attemptId]` page + +**UI Components:** +- Overall stats (X/Y correct, accuracy %) +- Problem-by-problem table: + - Problem (e.g., "45 + 27") + - Correct answer + - Student answer + - Status (✓ or ✗) + - Error type (if incorrect) +- AI feedback text +- Suggested next practice level +- "Grade Another" button + +**Real-time Updates:** +- Shows progress while grading +- Updates when grading completes +- Shows errors if grading fails + +**Success Criteria:** +- Results display correctly +- Real-time updates work +- Can handle pending/processing states +- Error states are clear +- Links to suggested practice + +**Blocked by:** Phases 4, 5, 6 (need storage, progress, upload) + +--- + +## Phase 8: Mastery Tracking (Optional) + +**Goal:** Track student progress over time + +### Deliverable: `/worksheets/progress` page + +**Features:** +- List of all attempts +- Progress chart over time +- Skill breakdown +- Weak areas identification + +**Database:** +```sql +CREATE TABLE mastery_profiles ( + user_id TEXT PRIMARY KEY, + current_step_id TEXT NOT NULL, + mastery_score REAL NOT NULL, + attempts_at_step INTEGER DEFAULT 0, + updated_at TIMESTAMP +); +``` + +**Success Criteria:** +- Can view progress over time +- Mastery score is accurate +- Recommended next step is helpful + +**Blocked by:** Phases 1-7 (need full system working) + +--- + +## Development Principles + +### 1. **Test Pages First** +Every feature has a `/worksheets/debug/*` test page before production page. + +### 2. **One Phase at a Time** +Complete each phase fully before starting the next. Get user approval before proceeding. + +### 3. **Independent Components** +Each phase should work standalone. If Phase 5 breaks, Phases 1-4 still work. + +### 4. **Raw Data Visibility** +All test pages show: +- Raw requests +- Raw responses +- Parsed data +- Validation results +- Timing information + +### 5. **Manual Control** +User can trigger every action manually from test pages. No automatic background processing until it's proven to work. + +### 6. **Clear Exit Criteria** +Each phase has explicit success criteria. User must verify before moving on. + +## Technical Stack + +**Core Technologies:** +- OpenAI GPT-5 Responses API (vision + reasoning) +- Socket.IO for real-time updates +- SSE for streaming progress +- SQLite + Drizzle ORM for storage +- Next.js App Router for UI + +**Key Libraries:** +- `socket.io` / `socket.io-client` - Real-time communication +- `eventsource-parser` (maybe) - SSE parsing if needed +- Standard Next.js/React + +## Migration from V1 + +**Files to keep:** +- Database schema (worksheet_attempts, problem_attempts, mastery_profiles) +- Basic OpenAI integration (non-streaming) + +**Files to remove/rewrite:** +- Streaming implementation (too complex, not tested) +- Socket progress system (built wrong order) +- Results page (built before API worked) + +**Files to create:** +- `/worksheets/debug/openai-test` +- `/worksheets/debug/socket-test` +- `/worksheets/debug/storage-test` + +## Success Metrics + +**After Phase 1:** User can grade a worksheet via test page +**After Phase 4:** User can save and reload results +**After Phase 7:** User can upload → grade → view results (full flow) +**After Phase 8:** User can track progress over time + +## Timeline Estimate + +- Phase 1: 2-4 hours +- Phase 2: 1-2 hours +- Phase 3: 2-3 hours +- Phase 4: 2-3 hours +- Phase 5: 2-3 hours +- Phase 6: 2-3 hours +- Phase 7: 2-3 hours +- Phase 8: 4-6 hours (optional) + +**Total:** ~15-25 hours for Phases 1-7 + +**Key difference from V1:** Each phase is independently testable and verifiable. + +## Next Steps + +When ready to implement: +1. User: "Start Phase 1" +2. Claude: Builds `/worksheets/debug/openai-test` page +3. User: Tests with real worksheets, provides feedback +4. Iterate until Phase 1 works perfectly +5. Move to Phase 2 + +**Do not start Phase 2 until Phase 1 is approved by user.** diff --git a/apps/web/.claude/WORKSHEET_GRADING_STATUS.md b/apps/web/.claude/WORKSHEET_GRADING_STATUS.md new file mode 100644 index 00000000..651271e3 --- /dev/null +++ b/apps/web/.claude/WORKSHEET_GRADING_STATUS.md @@ -0,0 +1,79 @@ +# Worksheet Grading System - Current Status + +**Date:** 2025-11-10 +**Status:** ⚠️ INCOMPLETE - DO NOT USE + +## What Exists + +The following files/features were partially implemented but **do not work correctly**: + +### Database Tables +- `worksheet_attempts` - Stores grading attempts +- `problem_attempts` - Stores individual problem results +- `mastery_profiles` - Tracks student progress +- `worksheet_settings` - User preferences + +**Status:** Tables exist but grading logic is broken + +### API Routes +- `/api/worksheets/upload` - Upload worksheet images +- `/api/worksheets/attempts/[attemptId]` - Get grading results + +**Status:** Upload works, grading is broken + +### Library Files +- `src/lib/ai/gradeWorksheet.ts` - OpenAI GPT-5 integration +- `src/lib/grading/processAttempt.ts` - Grading orchestration +- `src/lib/grading/updateMasteryProfile.ts` - Mastery tracking + +**Status:** Partially implemented, has bugs, incomplete + +### UI Pages +- `/worksheets/attempts/[attemptId]` - View results + +**Status:** UI exists but backend doesn't work + +## What's Broken + +1. **OpenAI Response Parsing** - Wrong output index, JSON parsing issues +2. **Streaming Progress** - Event parsing bugs, connection issues +3. **Socket.IO Integration** - Path configuration, event handling +4. **No Testing Infrastructure** - No way to test components independently +5. **No Debug UI** - No visibility into what's happening + +## Why It Failed + +**Built too much at once without incremental testing.** + +See `WORKSHEET_GRADING_POSTMORTEM.md` for detailed analysis. + +## Next Steps + +**When ready to tackle this again:** + +1. Read `WORKSHEET_GRADING_SPEC_V2.md` +2. Start with Phase 1: Build `/worksheets/debug/openai-test` +3. Get that working perfectly with user feedback +4. Only then move to Phase 2 + +**Do not attempt to fix the existing implementation.** Start fresh following the new spec. + +## Files to Reference + +- `WORKSHEET_GRADING_POSTMORTEM.md` - What went wrong and why +- `WORKSHEET_GRADING_SPEC_V2.md` - How to build it correctly next time +- `WORKSHEET_GRADING_STATUS.md` - This file (current status) + +## Migrations to Keep + +Migrations 0017-0020 created the worksheet tables. These can stay but the application logic needs to be rebuilt from scratch following the new approach. + +## Recommendation + +**Leave the existing code in place** but don't use it. When ready to implement: +1. Build test pages first (in `/worksheets/debug/`) +2. Get each piece working independently +3. Integrate only after all pieces work +4. Replace the broken production pages + +This way we keep the database schema but rebuild the logic correctly. diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 8a29869d..3713de7a 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -17,7 +17,26 @@ "WebFetch(domain:platform.openai.com)", "Bash(git rev-parse:*)", "Bash(npx drizzle-kit:*)", - "Bash(npm run db:migrate:*)" + "Bash(npm run db:migrate:*)", + "Bash(ssh:*)", + "Bash(git log:*)", + "Bash(gh run list:*)", + "Bash(gh api:*)", + "Bash(curl:*)", + "Bash(gh run view:*)", + "Bash(npm install:*)", + "Bash(pnpm add:*)", + "WebFetch(domain:www.dynamsoft.com)", + "Bash(npm run format:*)", + "Bash(npm run lint:fix:*)", + "Bash(npm run lint)", + "Bash(find:*)", + "Bash(for i in {0..10})", + "Bash(do echo \"=== Checking stash@{$i} ===\")", + "Bash(done)", + "mcp__sqlite__list_tables", + "mcp__sqlite__describe_table", + "Bash(npm test:*)" ], "deny": [], "ask": [] diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts index 5bf8314a..b656de81 100644 --- a/apps/web/.storybook/main.ts +++ b/apps/web/.storybook/main.ts @@ -1,29 +1,29 @@ -import type { StorybookConfig } from "@storybook/nextjs"; +import type { StorybookConfig } from '@storybook/nextjs' -import { dirname, join } from "path"; +import { dirname, join } from 'path' /** * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ function getAbsolutePath(value: string): any { - return dirname(require.resolve(join(value, "package.json"))); + return dirname(require.resolve(join(value, 'package.json'))) } const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], addons: [ - getAbsolutePath("@storybook/addon-docs"), - getAbsolutePath("@storybook/addon-onboarding"), + getAbsolutePath('@storybook/addon-docs'), + getAbsolutePath('@storybook/addon-onboarding'), ], framework: { - name: getAbsolutePath("@storybook/nextjs"), + name: getAbsolutePath('@storybook/nextjs'), options: { - nextConfigPath: "../next.config.js", + nextConfigPath: '../next.config.js', }, }, - staticDirs: ["../public"], + staticDirs: ['../public'], typescript: { - reactDocgen: "react-docgen-typescript", + reactDocgen: 'react-docgen-typescript', }, webpackFinal: async (config) => { // Handle PandaCSS styled-system imports @@ -31,25 +31,13 @@ const config: StorybookConfig = { config.resolve.alias = { ...config.resolve.alias, // Map styled-system imports to the actual directory - "../../styled-system/css": join( - __dirname, - "../styled-system/css/index.mjs", - ), - "../../styled-system/patterns": join( - __dirname, - "../styled-system/patterns/index.mjs", - ), - "../styled-system/css": join( - __dirname, - "../styled-system/css/index.mjs", - ), - "../styled-system/patterns": join( - __dirname, - "../styled-system/patterns/index.mjs", - ), - }; + '../../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'), + '../../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'), + '../styled-system/css': join(__dirname, '../styled-system/css/index.mjs'), + '../styled-system/patterns': join(__dirname, '../styled-system/patterns/index.mjs'), + } } - return config; + return config }, -}; -export default config; +} +export default config diff --git a/apps/web/.storybook/preview.ts b/apps/web/.storybook/preview.ts index 9a0a464d..2823e176 100644 --- a/apps/web/.storybook/preview.ts +++ b/apps/web/.storybook/preview.ts @@ -1,5 +1,5 @@ -import type { Preview } from "@storybook/nextjs"; -import "../styled-system/styles.css"; +import type { Preview } from '@storybook/nextjs' +import '../styled-system/styles.css' const preview: Preview = { parameters: { @@ -10,6 +10,6 @@ const preview: Preview = { }, }, }, -}; +} -export default preview; +export default preview diff --git a/apps/web/__tests__/api-abacus-settings.e2e.test.ts b/apps/web/__tests__/api-abacus-settings.e2e.test.ts index 74d6a2de..eac66291 100644 --- a/apps/web/__tests__/api-abacus-settings.e2e.test.ts +++ b/apps/web/__tests__/api-abacus-settings.e2e.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ -import { eq } from "drizzle-orm"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { db, schema } from "../src/db"; +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { db, schema } from '../src/db' /** * API Abacus Settings E2E Tests @@ -12,155 +12,152 @@ import { db, schema } from "../src/db"; * These tests verify the abacus-settings API endpoints work correctly. */ -describe("Abacus Settings API", () => { - let testUserId: string; - let testGuestId: string; +describe('Abacus Settings API', () => { + let testUserId: string + let testGuestId: string beforeEach(async () => { // Create a test user with unique guest ID - testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const [user] = await db - .insert(schema.users) - .values({ guestId: testGuestId }) - .returning(); - testUserId = user.id; - }); + testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` + const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning() + testUserId = user.id + }) afterEach(async () => { // Clean up: delete test user (cascade deletes settings) - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) + }) - describe("GET /api/abacus-settings", () => { - it("creates settings with defaults if none exist", async () => { + describe('GET /api/abacus-settings', () => { + it('creates settings with defaults if none exist', async () => { const [settings] = await db .insert(schema.abacusSettings) .values({ userId: testUserId }) - .returning(); + .returning() - expect(settings).toBeDefined(); - expect(settings.colorScheme).toBe("place-value"); - expect(settings.beadShape).toBe("diamond"); - expect(settings.colorPalette).toBe("default"); - expect(settings.hideInactiveBeads).toBe(false); - expect(settings.coloredNumerals).toBe(false); - expect(settings.scaleFactor).toBe(1.0); - expect(settings.showNumbers).toBe(true); - expect(settings.animated).toBe(true); - expect(settings.interactive).toBe(false); - expect(settings.gestures).toBe(false); - expect(settings.soundEnabled).toBe(true); - expect(settings.soundVolume).toBe(0.8); - }); + expect(settings).toBeDefined() + expect(settings.colorScheme).toBe('place-value') + expect(settings.beadShape).toBe('diamond') + expect(settings.colorPalette).toBe('default') + expect(settings.hideInactiveBeads).toBe(false) + expect(settings.coloredNumerals).toBe(false) + expect(settings.scaleFactor).toBe(1.0) + expect(settings.showNumbers).toBe(true) + expect(settings.animated).toBe(true) + expect(settings.interactive).toBe(false) + expect(settings.gestures).toBe(false) + expect(settings.soundEnabled).toBe(true) + expect(settings.soundVolume).toBe(0.8) + }) - it("returns existing settings", async () => { + it('returns existing settings', async () => { // Create settings await db.insert(schema.abacusSettings).values({ userId: testUserId, - colorScheme: "monochrome", - beadShape: "circle", + colorScheme: 'monochrome', + beadShape: 'circle', soundEnabled: false, soundVolume: 0.5, - }); + }) const settings = await db.query.abacusSettings.findFirst({ where: eq(schema.abacusSettings.userId, testUserId), - }); + }) - expect(settings).toBeDefined(); - expect(settings?.colorScheme).toBe("monochrome"); - expect(settings?.beadShape).toBe("circle"); - expect(settings?.soundEnabled).toBe(false); - expect(settings?.soundVolume).toBe(0.5); - }); - }); + expect(settings).toBeDefined() + expect(settings?.colorScheme).toBe('monochrome') + expect(settings?.beadShape).toBe('circle') + expect(settings?.soundEnabled).toBe(false) + expect(settings?.soundVolume).toBe(0.5) + }) + }) - describe("PATCH /api/abacus-settings", () => { - it("creates new settings if none exist", async () => { + describe('PATCH /api/abacus-settings', () => { + it('creates new settings if none exist', async () => { const [settings] = await db .insert(schema.abacusSettings) .values({ userId: testUserId, soundEnabled: false, }) - .returning(); + .returning() - expect(settings).toBeDefined(); - expect(settings.soundEnabled).toBe(false); - }); + expect(settings).toBeDefined() + expect(settings.soundEnabled).toBe(false) + }) - it("updates existing settings", async () => { + it('updates existing settings', async () => { // Create initial settings await db.insert(schema.abacusSettings).values({ userId: testUserId, - colorScheme: "place-value", - beadShape: "diamond", - }); + colorScheme: 'place-value', + beadShape: 'diamond', + }) // Update const [updated] = await db .update(schema.abacusSettings) .set({ - colorScheme: "heaven-earth", - beadShape: "square", + colorScheme: 'heaven-earth', + beadShape: 'square', }) .where(eq(schema.abacusSettings.userId, testUserId)) - .returning(); + .returning() - expect(updated.colorScheme).toBe("heaven-earth"); - expect(updated.beadShape).toBe("square"); - }); + expect(updated.colorScheme).toBe('heaven-earth') + expect(updated.beadShape).toBe('square') + }) - it("updates only provided fields", async () => { + it('updates only provided fields', async () => { // Create initial settings await db.insert(schema.abacusSettings).values({ userId: testUserId, - colorScheme: "place-value", + colorScheme: 'place-value', soundEnabled: true, soundVolume: 0.8, - }); + }) // Update only soundEnabled const [updated] = await db .update(schema.abacusSettings) .set({ soundEnabled: false }) .where(eq(schema.abacusSettings.userId, testUserId)) - .returning(); + .returning() - expect(updated.soundEnabled).toBe(false); - expect(updated.colorScheme).toBe("place-value"); // unchanged - expect(updated.soundVolume).toBe(0.8); // unchanged - }); + expect(updated.soundEnabled).toBe(false) + expect(updated.colorScheme).toBe('place-value') // unchanged + expect(updated.soundVolume).toBe(0.8) // unchanged + }) - it("prevents setting invalid userId via foreign key constraint", async () => { + it('prevents setting invalid userId via foreign key constraint', async () => { // Create initial settings await db.insert(schema.abacusSettings).values({ userId: testUserId, - }); + }) // Try to update with invalid userId - should fail await expect(async () => { await db .update(schema.abacusSettings) .set({ - userId: "HACKER_ID_INVALID", + userId: 'HACKER_ID_INVALID', soundEnabled: false, }) - .where(eq(schema.abacusSettings.userId, testUserId)); - }).rejects.toThrow(); - }); + .where(eq(schema.abacusSettings.userId, testUserId)) + }).rejects.toThrow() + }) - it("allows updating all display settings", async () => { + it('allows updating all display settings', async () => { await db.insert(schema.abacusSettings).values({ userId: testUserId, - }); + }) const [updated] = await db .update(schema.abacusSettings) .set({ - colorScheme: "alternating", - beadShape: "circle", - colorPalette: "colorblind", + colorScheme: 'alternating', + beadShape: 'circle', + colorPalette: 'colorblind', hideInactiveBeads: true, coloredNumerals: true, scaleFactor: 1.5, @@ -172,127 +169,124 @@ describe("Abacus Settings API", () => { soundVolume: 0.3, }) .where(eq(schema.abacusSettings.userId, testUserId)) - .returning(); + .returning() - expect(updated.colorScheme).toBe("alternating"); - expect(updated.beadShape).toBe("circle"); - expect(updated.colorPalette).toBe("colorblind"); - expect(updated.hideInactiveBeads).toBe(true); - expect(updated.coloredNumerals).toBe(true); - expect(updated.scaleFactor).toBe(1.5); - expect(updated.showNumbers).toBe(false); - expect(updated.animated).toBe(false); - expect(updated.interactive).toBe(true); - expect(updated.gestures).toBe(true); - expect(updated.soundEnabled).toBe(false); - expect(updated.soundVolume).toBe(0.3); - }); - }); + expect(updated.colorScheme).toBe('alternating') + expect(updated.beadShape).toBe('circle') + expect(updated.colorPalette).toBe('colorblind') + expect(updated.hideInactiveBeads).toBe(true) + expect(updated.coloredNumerals).toBe(true) + expect(updated.scaleFactor).toBe(1.5) + expect(updated.showNumbers).toBe(false) + expect(updated.animated).toBe(false) + expect(updated.interactive).toBe(true) + expect(updated.gestures).toBe(true) + expect(updated.soundEnabled).toBe(false) + expect(updated.soundVolume).toBe(0.3) + }) + }) - describe("Cascade delete behavior", () => { - it("deletes settings when user is deleted", async () => { + describe('Cascade delete behavior', () => { + it('deletes settings when user is deleted', async () => { // Create settings await db.insert(schema.abacusSettings).values({ userId: testUserId, soundEnabled: false, - }); + }) // Verify settings exist let settings = await db.query.abacusSettings.findFirst({ where: eq(schema.abacusSettings.userId, testUserId), - }); - expect(settings).toBeDefined(); + }) + expect(settings).toBeDefined() // Delete user - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) // Verify settings are gone settings = await db.query.abacusSettings.findFirst({ where: eq(schema.abacusSettings.userId, testUserId), - }); - expect(settings).toBeUndefined(); - }); - }); + }) + expect(settings).toBeUndefined() + }) + }) - describe("Data isolation", () => { - it("ensures settings are isolated per user", async () => { + describe('Data isolation', () => { + it('ensures settings are isolated per user', async () => { // Create another user - const testGuestId2 = `test-guest-2-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const [user2] = await db - .insert(schema.users) - .values({ guestId: testGuestId2 }) - .returning(); + const testGuestId2 = `test-guest-2-${Date.now()}-${Math.random().toString(36).slice(2)}` + const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning() try { // Create settings for both users await db.insert(schema.abacusSettings).values({ userId: testUserId, - colorScheme: "monochrome", - }); + colorScheme: 'monochrome', + }) await db.insert(schema.abacusSettings).values({ userId: user2.id, - colorScheme: "place-value", - }); + colorScheme: 'place-value', + }) // Verify isolation const settings1 = await db.query.abacusSettings.findFirst({ where: eq(schema.abacusSettings.userId, testUserId), - }); + }) const settings2 = await db.query.abacusSettings.findFirst({ where: eq(schema.abacusSettings.userId, user2.id), - }); + }) - expect(settings1?.colorScheme).toBe("monochrome"); - expect(settings2?.colorScheme).toBe("place-value"); + expect(settings1?.colorScheme).toBe('monochrome') + expect(settings2?.colorScheme).toBe('place-value') } finally { // Clean up second user - await db.delete(schema.users).where(eq(schema.users.id, user2.id)); + await db.delete(schema.users).where(eq(schema.users.id, user2.id)) } - }); - }); + }) + }) - describe("Security: userId injection prevention", () => { - it("rejects attempts to update settings with non-existent userId", async () => { + describe('Security: userId injection prevention', () => { + it('rejects attempts to update settings with non-existent userId', async () => { // Create initial settings await db.insert(schema.abacusSettings).values({ userId: testUserId, soundEnabled: true, - }); + }) // Attempt to inject a fake userId await expect(async () => { await db .update(schema.abacusSettings) .set({ - userId: "HACKER_ID_NON_EXISTENT", + userId: 'HACKER_ID_NON_EXISTENT', soundEnabled: false, }) - .where(eq(schema.abacusSettings.userId, testUserId)); - }).rejects.toThrow(/FOREIGN KEY constraint failed/); - }); + .where(eq(schema.abacusSettings.userId, testUserId)) + }).rejects.toThrow(/FOREIGN KEY constraint failed/) + }) it("prevents modifying another user's settings via userId injection", async () => { // Create victim user - const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}` const [victimUser] = await db .insert(schema.users) .values({ guestId: victimGuestId }) - .returning(); + .returning() try { // Create settings for both users await db.insert(schema.abacusSettings).values({ userId: testUserId, - colorScheme: "monochrome", + colorScheme: 'monochrome', soundEnabled: true, - }); + }) await db.insert(schema.abacusSettings).values({ userId: victimUser.id, - colorScheme: "place-value", + colorScheme: 'place-value', soundEnabled: true, - }); + }) // Attacker tries to change userId to victim's ID // This is rejected because userId is PRIMARY KEY (UNIQUE constraint) @@ -303,27 +297,27 @@ describe("Abacus Settings API", () => { userId: victimUser.id, // Trying to inject victim's ID soundEnabled: false, }) - .where(eq(schema.abacusSettings.userId, testUserId)); - }).rejects.toThrow(/UNIQUE constraint failed/); + .where(eq(schema.abacusSettings.userId, testUserId)) + }).rejects.toThrow(/UNIQUE constraint failed/) // Verify victim's settings are unchanged const victimSettings = await db.query.abacusSettings.findFirst({ where: eq(schema.abacusSettings.userId, victimUser.id), - }); - expect(victimSettings?.soundEnabled).toBe(true); - expect(victimSettings?.colorScheme).toBe("place-value"); + }) + expect(victimSettings?.soundEnabled).toBe(true) + expect(victimSettings?.colorScheme).toBe('place-value') } finally { - await db.delete(schema.users).where(eq(schema.users.id, victimUser.id)); + await db.delete(schema.users).where(eq(schema.users.id, victimUser.id)) } - }); + }) - it("prevents creating settings for another user via userId injection", async () => { + it('prevents creating settings for another user via userId injection', async () => { // Create victim user - const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}` const [victimUser] = await db .insert(schema.users) .values({ guestId: victimGuestId }) - .returning(); + .returning() try { // Try to create settings for victim with attacker's data @@ -333,18 +327,18 @@ describe("Abacus Settings API", () => { .insert(schema.abacusSettings) .values({ userId: victimUser.id, - colorScheme: "alternating", // Attacker's preference + colorScheme: 'alternating', // Attacker's preference }) - .returning(); + .returning() // This test shows that at the DB level, we CAN insert for any valid userId // The security comes from the API layer filtering userId from request body // and deriving it from the session cookie instead - expect(maliciousSettings.userId).toBe(victimUser.id); - expect(maliciousSettings.colorScheme).toBe("alternating"); + expect(maliciousSettings.userId).toBe(victimUser.id) + expect(maliciousSettings.colorScheme).toBe('alternating') } finally { - await db.delete(schema.users).where(eq(schema.users.id, victimUser.id)); + await db.delete(schema.users).where(eq(schema.users.id, victimUser.id)) } - }); - }); -}); + }) + }) +}) diff --git a/apps/web/__tests__/api-arcade-rooms.e2e.test.ts b/apps/web/__tests__/api-arcade-rooms.e2e.test.ts index 11a21151..04f8d469 100644 --- a/apps/web/__tests__/api-arcade-rooms.e2e.test.ts +++ b/apps/web/__tests__/api-arcade-rooms.e2e.test.ts @@ -2,11 +2,11 @@ * @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 { addRoomMember } from "../src/lib/arcade/room-membership"; +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 { addRoomMember } from '../src/lib/arcade/room-membership' /** * Arcade Rooms API E2E Tests @@ -18,458 +18,438 @@ import { addRoomMember } from "../src/lib/arcade/room-membership"; * - Room code lookups */ -describe("Arcade Rooms API", () => { - let testUserId1: string; - let testUserId2: string; - let testGuestId1: string; - let testGuestId2: string; - let testRoomId: string; +describe('Arcade Rooms API', () => { + let testUserId1: string + let testUserId2: string + let testGuestId1: string + let testGuestId2: string + let testRoomId: string beforeEach(async () => { // Create test users - testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; - testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; + testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` + testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` - const [user1] = await db - .insert(schema.users) - .values({ guestId: testGuestId1 }) - .returning(); - const [user2] = await db - .insert(schema.users) - .values({ guestId: testGuestId2 }) - .returning(); + const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning() + const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning() - testUserId1 = user1.id; - testUserId2 = user2.id; - }); + testUserId1 = user1.id + testUserId2 = user2.id + }) afterEach(async () => { // Clean up rooms (cascade deletes members) if (testRoomId) { - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, testRoomId)); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId)) } // Clean up users - await db.delete(schema.users).where(eq(schema.users.id, testUserId1)); - await db.delete(schema.users).where(eq(schema.users.id, testUserId2)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId1)) + await db.delete(schema.users).where(eq(schema.users.id, testUserId2)) + }) - describe("Room Creation", () => { - it("creates a room with valid data", async () => { + describe('Room Creation', () => { + it('creates a room with valid data', async () => { const room = await createRoom({ - name: "Test Room", + name: 'Test Room', createdBy: testGuestId1, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: { difficulty: 6 }, - }); + }) - testRoomId = room.id; + testRoomId = room.id - expect(room).toBeDefined(); - expect(room.name).toBe("Test Room"); - expect(room.createdBy).toBe(testGuestId1); - expect(room.gameName).toBe("matching"); - expect(room.status).toBe("lobby"); - expect(room.accessMode).toBe("open"); - expect(room.ttlMinutes).toBe(60); - expect(room.code).toMatch(/^[A-Z0-9]{6}$/); - }); + expect(room).toBeDefined() + expect(room.name).toBe('Test Room') + expect(room.createdBy).toBe(testGuestId1) + expect(room.gameName).toBe('matching') + expect(room.status).toBe('lobby') + expect(room.accessMode).toBe('open') + expect(room.ttlMinutes).toBe(60) + expect(room.code).toMatch(/^[A-Z0-9]{6}$/) + }) - it("creates room with custom TTL", async () => { + it('creates room with custom TTL', async () => { const room = await createRoom({ - name: "Custom TTL Room", + name: 'Custom TTL Room', createdBy: testGuestId1, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: {}, ttlMinutes: 120, - }); + }) - testRoomId = room.id; + testRoomId = room.id - expect(room.ttlMinutes).toBe(120); - }); + expect(room.ttlMinutes).toBe(120) + }) - it("generates unique room codes", async () => { + it('generates unique room codes', async () => { const room1 = await createRoom({ - name: "Room 1", + name: 'Room 1', createdBy: testGuestId1, - creatorName: "User 1", - gameName: "matching", + creatorName: 'User 1', + gameName: 'matching', gameConfig: {}, - }); + }) const room2 = await createRoom({ - name: "Room 2", + name: 'Room 2', createdBy: testGuestId2, - creatorName: "User 2", - gameName: "matching", + creatorName: 'User 2', + gameName: 'matching', gameConfig: {}, - }); + }) // Clean up both rooms - testRoomId = room1.id; - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, room2.id)); + testRoomId = room1.id + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id)) - expect(room1.code).not.toBe(room2.code); - }); - }); + expect(room1.code).not.toBe(room2.code) + }) + }) - describe("Room Retrieval", () => { + describe('Room Retrieval', () => { beforeEach(async () => { // Create a test room const room = await createRoom({ - name: "Retrieval Test Room", + name: 'Retrieval Test Room', createdBy: testGuestId1, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: {}, - }); - testRoomId = room.id; - }); + }) + testRoomId = room.id + }) - it("retrieves room by ID", async () => { + it('retrieves room by ID', async () => { const room = await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.id, testRoomId), - }); + }) - expect(room).toBeDefined(); - expect(room?.id).toBe(testRoomId); - expect(room?.name).toBe("Retrieval Test Room"); - }); + expect(room).toBeDefined() + expect(room?.id).toBe(testRoomId) + expect(room?.name).toBe('Retrieval Test Room') + }) - it("retrieves room by code", async () => { + it('retrieves room by code', async () => { const createdRoom = await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.id, testRoomId), - }); + }) const room = await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.code, createdRoom!.code), - }); + }) - expect(room).toBeDefined(); - expect(room?.id).toBe(testRoomId); - }); + expect(room).toBeDefined() + expect(room?.id).toBe(testRoomId) + }) - it("returns undefined for non-existent room", async () => { + it('returns undefined for non-existent room', async () => { const room = await db.query.arcadeRooms.findFirst({ - where: eq(schema.arcadeRooms.id, "nonexistent-room-id"), - }); + where: eq(schema.arcadeRooms.id, 'nonexistent-room-id'), + }) - expect(room).toBeUndefined(); - }); - }); + expect(room).toBeUndefined() + }) + }) - describe("Room Updates", () => { + describe('Room Updates', () => { beforeEach(async () => { const room = await createRoom({ - name: "Update Test Room", + name: 'Update Test Room', createdBy: testGuestId1, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: {}, - }); - testRoomId = room.id; - }); + }) + testRoomId = room.id + }) - it("updates room name", async () => { + it('updates room name', async () => { const [updated] = await db .update(schema.arcadeRooms) - .set({ name: "Updated Name" }) + .set({ name: 'Updated Name' }) .where(eq(schema.arcadeRooms.id, testRoomId)) - .returning(); + .returning() - expect(updated.name).toBe("Updated Name"); - }); + expect(updated.name).toBe('Updated Name') + }) - it("locks room", async () => { + it('locks room', async () => { const [updated] = await db .update(schema.arcadeRooms) - .set({ accessMode: "locked" }) + .set({ accessMode: 'locked' }) .where(eq(schema.arcadeRooms.id, testRoomId)) - .returning(); + .returning() - expect(updated.accessMode).toBe("locked"); - }); + expect(updated.accessMode).toBe('locked') + }) - it("updates room status", async () => { + it('updates room status', async () => { const [updated] = await db .update(schema.arcadeRooms) - .set({ status: "playing" }) + .set({ status: 'playing' }) .where(eq(schema.arcadeRooms.id, testRoomId)) - .returning(); + .returning() - expect(updated.status).toBe("playing"); - }); + expect(updated.status).toBe('playing') + }) - it("updates lastActivity on any change", async () => { + it('updates lastActivity on any change', async () => { const originalRoom = await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.id, testRoomId), - }); + }) // Wait a bit to ensure different timestamp (at least 1 second for SQLite timestamp resolution) - await new Promise((resolve) => setTimeout(resolve, 1100)); + await new Promise((resolve) => setTimeout(resolve, 1100)) const [updated] = await db .update(schema.arcadeRooms) - .set({ name: "Activity Test", lastActivity: new Date() }) + .set({ name: 'Activity Test', lastActivity: new Date() }) .where(eq(schema.arcadeRooms.id, testRoomId)) - .returning(); + .returning() - expect(updated.lastActivity.getTime()).toBeGreaterThan( - originalRoom!.lastActivity.getTime(), - ); - }); - }); + expect(updated.lastActivity.getTime()).toBeGreaterThan(originalRoom!.lastActivity.getTime()) + }) + }) - describe("Room Deletion", () => { - it("deletes room", async () => { + describe('Room Deletion', () => { + it('deletes room', async () => { const room = await createRoom({ - name: "Delete Test Room", + name: 'Delete Test Room', createdBy: testGuestId1, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: {}, - }); + }) - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, room.id)); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id)) const deleted = await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.id, room.id), - }); + }) - expect(deleted).toBeUndefined(); - }); + expect(deleted).toBeUndefined() + }) - it("cascades delete to room members", async () => { + it('cascades delete to room members', async () => { const room = await createRoom({ - name: "Cascade Test Room", + name: 'Cascade Test Room', createdBy: testGuestId1, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: {}, - }); + }) // Add member await addRoomMember({ roomId: room.id, userId: testGuestId1, - displayName: "Test User", - }); + displayName: 'Test User', + }) // Verify member exists const membersBefore = await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.roomId, room.id), - }); - expect(membersBefore).toHaveLength(1); + }) + expect(membersBefore).toHaveLength(1) // Delete room - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, room.id)); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id)) // Verify members deleted const membersAfter = await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.roomId, room.id), - }); - expect(membersAfter).toHaveLength(0); - }); - }); + }) + expect(membersAfter).toHaveLength(0) + }) + }) - describe("Room Members", () => { + describe('Room Members', () => { beforeEach(async () => { const room = await createRoom({ - name: "Members Test Room", + name: 'Members Test Room', createdBy: testGuestId1, - creatorName: "Test User 1", - gameName: "matching", + creatorName: 'Test User 1', + gameName: 'matching', gameConfig: {}, - }); - testRoomId = room.id; - }); + }) + testRoomId = room.id + }) - it("adds member to room", async () => { + it('adds member to room', async () => { const result = await addRoomMember({ roomId: testRoomId, userId: testGuestId1, - displayName: "Test User 1", + displayName: 'Test User 1', isCreator: true, - }); + }) - expect(result.member).toBeDefined(); - expect(result.member.roomId).toBe(testRoomId); - expect(result.member.userId).toBe(testGuestId1); - expect(result.member.displayName).toBe("Test User 1"); - expect(result.member.isCreator).toBe(true); - expect(result.member.isOnline).toBe(true); - }); + expect(result.member).toBeDefined() + expect(result.member.roomId).toBe(testRoomId) + expect(result.member.userId).toBe(testGuestId1) + expect(result.member.displayName).toBe('Test User 1') + expect(result.member.isCreator).toBe(true) + expect(result.member.isOnline).toBe(true) + }) - it("adds multiple members to room", async () => { + it('adds multiple members to room', async () => { await addRoomMember({ roomId: testRoomId, userId: testGuestId1, - displayName: "User 1", - }); + displayName: 'User 1', + }) await addRoomMember({ roomId: testRoomId, userId: testGuestId2, - displayName: "User 2", - }); + displayName: 'User 2', + }) const members = await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.roomId, testRoomId), - }); + }) - expect(members).toHaveLength(2); - }); + expect(members).toHaveLength(2) + }) - it("updates existing member instead of creating duplicate", async () => { + it('updates existing member instead of creating duplicate', async () => { // Add member first time await addRoomMember({ roomId: testRoomId, userId: testGuestId1, - displayName: "First Time", - }); + displayName: 'First Time', + }) // Add same member again await addRoomMember({ roomId: testRoomId, userId: testGuestId1, - displayName: "Second Time", - }); + displayName: 'Second Time', + }) const members = await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.roomId, testRoomId), - }); + }) // Should still only have 1 member - expect(members).toHaveLength(1); - }); + expect(members).toHaveLength(1) + }) - it("removes member from room", async () => { + it('removes member from room', async () => { const result = await addRoomMember({ roomId: testRoomId, userId: testGuestId1, - displayName: "Test User", - }); + displayName: 'Test User', + }) - await db - .delete(schema.roomMembers) - .where(eq(schema.roomMembers.id, result.member.id)); + await db.delete(schema.roomMembers).where(eq(schema.roomMembers.id, result.member.id)) const members = await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.roomId, testRoomId), - }); + }) - expect(members).toHaveLength(0); - }); + expect(members).toHaveLength(0) + }) - it("tracks online status", async () => { + it('tracks online status', async () => { const result = await addRoomMember({ roomId: testRoomId, userId: testGuestId1, - displayName: "Test User", - }); + displayName: 'Test User', + }) - expect(result.member.isOnline).toBe(true); + expect(result.member.isOnline).toBe(true) // Set offline const [updated] = await db .update(schema.roomMembers) .set({ isOnline: false }) .where(eq(schema.roomMembers.id, result.member.id)) - .returning(); + .returning() - expect(updated.isOnline).toBe(false); - }); - }); + expect(updated.isOnline).toBe(false) + }) + }) - describe("Access Control", () => { + describe('Access Control', () => { beforeEach(async () => { const room = await createRoom({ - name: "Access Test Room", + name: 'Access Test Room', createdBy: testGuestId1, - creatorName: "Creator", - gameName: "matching", + creatorName: 'Creator', + gameName: 'matching', gameConfig: {}, - }); - testRoomId = room.id; - }); + }) + testRoomId = room.id + }) - it("identifies room creator correctly", async () => { + it('identifies room creator correctly', async () => { const room = await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.id, testRoomId), - }); + }) - expect(room?.createdBy).toBe(testGuestId1); - }); + expect(room?.createdBy).toBe(testGuestId1) + }) - it("distinguishes creator from other users", async () => { + it('distinguishes creator from other users', async () => { const room = await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.id, testRoomId), - }); + }) - expect(room?.createdBy).not.toBe(testGuestId2); - }); - }); + expect(room?.createdBy).not.toBe(testGuestId2) + }) + }) - describe("Room Listing", () => { + describe('Room Listing', () => { beforeEach(async () => { // Create multiple test rooms const room1 = await createRoom({ - name: "Matching Room", + name: 'Matching Room', createdBy: testGuestId1, - creatorName: "User 1", - gameName: "matching", + creatorName: 'User 1', + gameName: 'matching', gameConfig: {}, - }); + }) const room2 = await createRoom({ - name: "Memory Quiz Room", + name: 'Memory Quiz Room', createdBy: testGuestId2, - creatorName: "User 2", - gameName: "memory-quiz", + creatorName: 'User 2', + gameName: 'memory-quiz', gameConfig: {}, - }); + }) - testRoomId = room1.id; + testRoomId = room1.id // Clean up room2 after test afterEach(async () => { - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, room2.id)); - }); - }); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id)) + }) + }) - it("lists all active rooms", async () => { + it('lists all active rooms', async () => { const rooms = await db.query.arcadeRooms.findMany({ - where: eq(schema.arcadeRooms.status, "lobby"), - }); + where: eq(schema.arcadeRooms.status, 'lobby'), + }) - expect(rooms.length).toBeGreaterThanOrEqual(2); - }); + expect(rooms.length).toBeGreaterThanOrEqual(2) + }) - it("excludes locked rooms from listing", async () => { + it('excludes locked rooms from listing', async () => { // Lock one room await db .update(schema.arcadeRooms) - .set({ accessMode: "locked" }) - .where(eq(schema.arcadeRooms.id, testRoomId)); + .set({ accessMode: 'locked' }) + .where(eq(schema.arcadeRooms.id, testRoomId)) const openRooms = await db.query.arcadeRooms.findMany({ - where: eq(schema.arcadeRooms.accessMode, "open"), - }); + where: eq(schema.arcadeRooms.accessMode, 'open'), + }) - expect(openRooms.every((r) => r.accessMode === "open")).toBe(true); - }); - }); -}); + expect(openRooms.every((r) => r.accessMode === 'open')).toBe(true) + }) + }) +}) diff --git a/apps/web/__tests__/api-players.e2e.test.ts b/apps/web/__tests__/api-players.e2e.test.ts index 20a3285e..bd768d67 100644 --- a/apps/web/__tests__/api-players.e2e.test.ts +++ b/apps/web/__tests__/api-players.e2e.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ -import { eq } from "drizzle-orm"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { db, schema } from "../src/db"; +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { db, schema } from '../src/db' /** * API Players E2E Tests @@ -13,33 +13,30 @@ import { db, schema } from "../src/db"; * They use the actual database and test the full request/response cycle. */ -describe("Players API", () => { - let testUserId: string; - let testGuestId: string; +describe('Players API', () => { + let testUserId: string + let testGuestId: string beforeEach(async () => { // Create a test user with unique guest ID - testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const [user] = await db - .insert(schema.users) - .values({ guestId: testGuestId }) - .returning(); - testUserId = user.id; - }); + testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` + const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning() + testUserId = user.id + }) afterEach(async () => { // Clean up: delete test user (cascade deletes players) - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) + }) - describe("POST /api/players", () => { - it("creates a player with valid data", async () => { + describe('POST /api/players', () => { + it('creates a player with valid data', async () => { const playerData = { - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: true, - }; + } // Simulate creating via DB (API would do this) const [player] = await db @@ -48,428 +45,422 @@ describe("Players API", () => { userId: testUserId, ...playerData, }) - .returning(); + .returning() - expect(player).toBeDefined(); - expect(player.name).toBe(playerData.name); - expect(player.emoji).toBe(playerData.emoji); - expect(player.color).toBe(playerData.color); - expect(player.isActive).toBe(true); - expect(player.userId).toBe(testUserId); - }); + expect(player).toBeDefined() + expect(player.name).toBe(playerData.name) + expect(player.emoji).toBe(playerData.emoji) + expect(player.color).toBe(playerData.color) + expect(player.isActive).toBe(true) + expect(player.userId).toBe(testUserId) + }) - it("sets isActive to false by default", async () => { + it('sets isActive to false by default', async () => { const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Inactive Player", - emoji: "😴", - color: "#999999", + name: 'Inactive Player', + emoji: '😴', + color: '#999999', }) - .returning(); + .returning() - expect(player.isActive).toBe(false); - }); - }); + expect(player.isActive).toBe(false) + }) + }) - describe("GET /api/players", () => { - it("returns all players for a user", async () => { + describe('GET /api/players', () => { + it('returns all players for a user', async () => { // Create multiple players await db.insert(schema.players).values([ { userId: testUserId, - name: "Player 1", - emoji: "😀", - color: "#3b82f6", + name: 'Player 1', + emoji: '😀', + color: '#3b82f6', }, { userId: testUserId, - name: "Player 2", - emoji: "😎", - color: "#8b5cf6", + name: 'Player 2', + emoji: '😎', + color: '#8b5cf6', }, - ]); + ]) const players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); + }) - expect(players).toHaveLength(2); - expect(players[0].name).toBe("Player 1"); - expect(players[1].name).toBe("Player 2"); - }); + expect(players).toHaveLength(2) + expect(players[0].name).toBe('Player 1') + expect(players[1].name).toBe('Player 2') + }) - it("returns empty array for user with no players", async () => { + it('returns empty array for user with no players', async () => { const players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); + }) - expect(players).toHaveLength(0); - }); - }); + expect(players).toHaveLength(0) + }) + }) - describe("PATCH /api/players/[id]", () => { - it("updates player fields", async () => { + describe('PATCH /api/players/[id]', () => { + it('updates player fields', async () => { const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Original Name", - emoji: "😀", - color: "#3b82f6", + name: 'Original Name', + emoji: '😀', + color: '#3b82f6', }) - .returning(); + .returning() const [updated] = await db .update(schema.players) .set({ - name: "Updated Name", - emoji: "🎉", + name: 'Updated Name', + emoji: '🎉', }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.name).toBe("Updated Name"); - expect(updated.emoji).toBe("🎉"); - expect(updated.color).toBe("#3b82f6"); // unchanged - }); + expect(updated.name).toBe('Updated Name') + expect(updated.emoji).toBe('🎉') + expect(updated.color).toBe('#3b82f6') // unchanged + }) - it("toggles isActive status", async () => { + it('toggles isActive status', async () => { const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: false, }) - .returning(); + .returning() const [updated] = await db .update(schema.players) .set({ isActive: true }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.isActive).toBe(true); - }); - }); + expect(updated.isActive).toBe(true) + }) + }) - describe("DELETE /api/players/[id]", () => { - it("deletes a player", async () => { + describe('DELETE /api/players/[id]', () => { + it('deletes a player', async () => { const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "To Delete", - emoji: "👋", - color: "#ef4444", + name: 'To Delete', + emoji: '👋', + color: '#ef4444', }) - .returning(); + .returning() const [deleted] = await db .delete(schema.players) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(deleted).toBeDefined(); - expect(deleted.id).toBe(player.id); + expect(deleted).toBeDefined() + expect(deleted.id).toBe(player.id) // Verify it's gone const found = await db.query.players.findFirst({ where: eq(schema.players.id, player.id), - }); - expect(found).toBeUndefined(); - }); - }); + }) + expect(found).toBeUndefined() + }) + }) - describe("Cascade delete behavior", () => { - it("deletes players when user is deleted", async () => { + describe('Cascade delete behavior', () => { + it('deletes players when user is deleted', async () => { // Create players await db.insert(schema.players).values([ { userId: testUserId, - name: "Player 1", - emoji: "😀", - color: "#3b82f6", + name: 'Player 1', + emoji: '😀', + color: '#3b82f6', }, { userId: testUserId, - name: "Player 2", - emoji: "😎", - color: "#8b5cf6", + name: 'Player 2', + emoji: '😎', + color: '#8b5cf6', }, - ]); + ]) // Verify players exist let players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); - expect(players).toHaveLength(2); + }) + expect(players).toHaveLength(2) // Delete user - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) // Verify players are gone players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); - expect(players).toHaveLength(0); - }); - }); + }) + expect(players).toHaveLength(0) + }) + }) - describe("Arcade Session: isActive Modification Restrictions", () => { - it("prevents isActive changes when user has an active arcade session", async () => { + describe('Arcade Session: isActive Modification Restrictions', () => { + it('prevents isActive changes when user has an active arcade session', async () => { // Create a player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: false, }) - .returning(); + .returning() // Create a test room for the session const [testRoom] = await db .insert(schema.arcadeRooms) .values({ code: `TEST-${Date.now()}`, - name: "Test Room", - gameName: "matching", + name: 'Test Room', + gameName: 'matching', gameConfig: JSON.stringify({}), - status: "lobby", + status: 'lobby', createdBy: testUserId, - creatorName: "Test User", + creatorName: 'Test User', ttlMinutes: 60, createdAt: new Date(), }) - .returning(); + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ roomId: testRoom.id, userId: testUserId, - currentGame: "matching", - gameUrl: "/arcade/matching", + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([player.id]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) // Attempt to update isActive should be prevented at API level // This test validates the logic that the API route implements const activeSession = await db.query.arcadeSessions.findFirst({ where: eq(schema.arcadeSessions.roomId, testRoom.id), - }); + }) - expect(activeSession).toBeDefined(); - expect(activeSession?.currentGame).toBe("matching"); + expect(activeSession).toBeDefined() + expect(activeSession?.currentGame).toBe('matching') // Clean up session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.roomId, testRoom.id)); - }); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id)) + }) - it("allows isActive changes when user has no active arcade session", async () => { + it('allows isActive changes when user has no active arcade session', async () => { // Create a player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: false, }) - .returning(); + .returning() // Verify no active session for this user const activeSession = await db.query.arcadeSessions.findFirst({ where: eq(schema.arcadeSessions.userId, testUserId), - }); + }) - expect(activeSession).toBeUndefined(); + expect(activeSession).toBeUndefined() // Should be able to update isActive const [updated] = await db .update(schema.players) .set({ isActive: true }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.isActive).toBe(true); - }); + expect(updated.isActive).toBe(true) + }) - it("allows non-isActive changes even with active session", async () => { + it('allows non-isActive changes even with active session', async () => { // Create a player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: true, }) - .returning(); + .returning() // Create a test room for the session const [testRoom] = await db .insert(schema.arcadeRooms) .values({ code: `TEST-${Date.now()}`, - name: "Test Room", - gameName: "matching", + name: 'Test Room', + gameName: 'matching', gameConfig: JSON.stringify({}), - status: "lobby", + status: 'lobby', createdBy: testUserId, - creatorName: "Test User", + creatorName: 'Test User', ttlMinutes: 60, createdAt: new Date(), }) - .returning(); + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ roomId: testRoom.id, userId: testUserId, - currentGame: "matching", - gameUrl: "/arcade/matching", + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([player.id]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) try { // Should be able to update name, emoji, color (non-isActive fields) const [updated] = await db .update(schema.players) .set({ - name: "Updated Name", - emoji: "🎉", - color: "#ff0000", + name: 'Updated Name', + emoji: '🎉', + color: '#ff0000', }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.name).toBe("Updated Name"); - expect(updated.emoji).toBe("🎉"); - expect(updated.color).toBe("#ff0000"); - expect(updated.isActive).toBe(true); // Unchanged + expect(updated.name).toBe('Updated Name') + expect(updated.emoji).toBe('🎉') + expect(updated.color).toBe('#ff0000') + expect(updated.isActive).toBe(true) // Unchanged } finally { // Clean up session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.roomId, testRoom.id)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id)) } - }); + }) - it("session ends, then isActive changes are allowed again", async () => { + it('session ends, then isActive changes are allowed again', async () => { // Create a player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: true, }) - .returning(); + .returning() // Create a test room for the session const [testRoom] = await db .insert(schema.arcadeRooms) .values({ code: `TEST-${Date.now()}`, - name: "Test Room", - gameName: "matching", + name: 'Test Room', + gameName: 'matching', gameConfig: JSON.stringify({}), - status: "lobby", + status: 'lobby', createdBy: testUserId, - creatorName: "Test User", + creatorName: 'Test User', ttlMinutes: 60, createdAt: new Date(), }) - .returning(); + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ roomId: testRoom.id, userId: testUserId, - currentGame: "matching", - gameUrl: "/arcade/matching", + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([player.id]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) // Verify session exists let activeSession = await db.query.arcadeSessions.findFirst({ where: eq(schema.arcadeSessions.roomId, testRoom.id), - }); - expect(activeSession).toBeDefined(); + }) + expect(activeSession).toBeDefined() // End the session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.roomId, testRoom.id)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, testRoom.id)) // Verify session is gone activeSession = await db.query.arcadeSessions.findFirst({ where: eq(schema.arcadeSessions.roomId, testRoom.id), - }); - expect(activeSession).toBeUndefined(); + }) + expect(activeSession).toBeUndefined() // Now should be able to update isActive const [updated] = await db .update(schema.players) .set({ isActive: false }) .where(eq(schema.players.id, player.id)) - .returning(); + .returning() - expect(updated.isActive).toBe(false); - }); - }); + expect(updated.isActive).toBe(false) + }) + }) - describe("Security: userId injection prevention", () => { - it("rejects creating player with non-existent userId", async () => { + describe('Security: userId injection prevention', () => { + it('rejects creating player with non-existent userId', async () => { // Attempt to create a player with a fake userId await expect(async () => { await db.insert(schema.players).values({ - userId: "HACKER_ID_NON_EXISTENT", - name: "Hacker Player", - emoji: "🦹", - color: "#ff0000", - }); - }).rejects.toThrow(/FOREIGN KEY constraint failed/); - }); + userId: 'HACKER_ID_NON_EXISTENT', + name: 'Hacker Player', + emoji: '🦹', + color: '#ff0000', + }) + }).rejects.toThrow(/FOREIGN KEY constraint failed/) + }) it("prevents modifying another user's player via userId injection (DB layer alone is insufficient)", async () => { // Create victim user and their player - const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const victimGuestId = `victim-${Date.now()}-${Math.random().toString(36).slice(2)}` const [victimUser] = await db .insert(schema.users) .values({ guestId: victimGuestId }) - .returning(); + .returning() try { // Create attacker's player @@ -477,22 +468,22 @@ describe("Players API", () => { .insert(schema.players) .values({ userId: testUserId, - name: "Attacker Player", - emoji: "😈", - color: "#ff0000", + name: 'Attacker Player', + emoji: '😈', + color: '#ff0000', }) - .returning(); + .returning() const [_victimPlayer] = await db .insert(schema.players) .values({ userId: victimUser.id, - name: "Victim Player", - emoji: "👤", - color: "#00ff00", + name: 'Victim Player', + emoji: '👤', + color: '#00ff00', isActive: true, }) - .returning(); + .returning() // IMPORTANT: At the DB level, changing userId to another valid userId SUCCEEDS // This is why API layer MUST filter userId from request body! @@ -500,64 +491,61 @@ describe("Players API", () => { .update(schema.players) .set({ userId: victimUser.id, // This WILL succeed at DB level! - name: "Stolen Player", + name: 'Stolen Player', }) .where(eq(schema.players.id, attackerPlayer.id)) - .returning(); + .returning() // The update succeeded - the player now belongs to victim! - expect(updated.userId).toBe(victimUser.id); - expect(updated.name).toBe("Stolen Player"); + expect(updated.userId).toBe(victimUser.id) + expect(updated.name).toBe('Stolen Player') // This test demonstrates why the API route MUST: // 1. Strip userId from request body // 2. Derive userId from session cookie // 3. Use WHERE clause to scope updates to current user's data only } finally { - await db.delete(schema.users).where(eq(schema.users.id, victimUser.id)); + await db.delete(schema.users).where(eq(schema.users.id, victimUser.id)) } - }); + }) - it("ensures players are isolated per user", async () => { + it('ensures players are isolated per user', async () => { // Create another user - const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const [user2] = await db - .insert(schema.users) - .values({ guestId: user2GuestId }) - .returning(); + const user2GuestId = `user2-${Date.now()}-${Math.random().toString(36).slice(2)}` + const [user2] = await db.insert(schema.users).values({ guestId: user2GuestId }).returning() try { // Create players for both users await db.insert(schema.players).values({ userId: testUserId, - name: "User 1 Player", - emoji: "🎮", - color: "#0000ff", - }); + name: 'User 1 Player', + emoji: '🎮', + color: '#0000ff', + }) await db.insert(schema.players).values({ userId: user2.id, - name: "User 2 Player", - emoji: "🎯", - color: "#ff00ff", - }); + name: 'User 2 Player', + emoji: '🎯', + color: '#ff00ff', + }) // Verify each user only sees their own players const user1Players = await db.query.players.findMany({ where: eq(schema.players.userId, testUserId), - }); + }) const user2Players = await db.query.players.findMany({ where: eq(schema.players.userId, user2.id), - }); + }) - expect(user1Players).toHaveLength(1); - expect(user1Players[0].name).toBe("User 1 Player"); + expect(user1Players).toHaveLength(1) + expect(user1Players[0].name).toBe('User 1 Player') - expect(user2Players).toHaveLength(1); - expect(user2Players[0].name).toBe("User 2 Player"); + expect(user2Players).toHaveLength(1) + expect(user2Players[0].name).toBe('User 2 Player') } finally { - await db.delete(schema.users).where(eq(schema.users.id, user2.id)); + await db.delete(schema.users).where(eq(schema.users.id, user2.id)) } - }); - }); -}); + }) + }) +}) diff --git a/apps/web/__tests__/api-user-stats.e2e.test.ts b/apps/web/__tests__/api-user-stats.e2e.test.ts index 423a1536..56add776 100644 --- a/apps/web/__tests__/api-user-stats.e2e.test.ts +++ b/apps/web/__tests__/api-user-stats.e2e.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ -import { eq } from "drizzle-orm"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { db, schema } from "../src/db"; +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { db, schema } from '../src/db' /** * API User Stats E2E Tests @@ -12,66 +12,60 @@ import { db, schema } from "../src/db"; * These tests verify the user-stats API endpoints work correctly. */ -describe("User Stats API", () => { - let testUserId: string; - let testGuestId: string; +describe('User Stats API', () => { + let testUserId: string + let testGuestId: string beforeEach(async () => { // Create a test user with unique guest ID - testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const [user] = await db - .insert(schema.users) - .values({ guestId: testGuestId }) - .returning(); - testUserId = user.id; - }); + testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` + const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning() + testUserId = user.id + }) afterEach(async () => { // Clean up: delete test user (cascade deletes stats) - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) + }) - describe("GET /api/user-stats", () => { - it("creates stats with defaults if none exist", async () => { - const [stats] = await db - .insert(schema.userStats) - .values({ userId: testUserId }) - .returning(); + describe('GET /api/user-stats', () => { + it('creates stats with defaults if none exist', async () => { + const [stats] = await db.insert(schema.userStats).values({ userId: testUserId }).returning() - expect(stats).toBeDefined(); - expect(stats.gamesPlayed).toBe(0); - expect(stats.totalWins).toBe(0); - expect(stats.favoriteGameType).toBeNull(); - expect(stats.bestTime).toBeNull(); - expect(stats.highestAccuracy).toBe(0); - }); + expect(stats).toBeDefined() + expect(stats.gamesPlayed).toBe(0) + expect(stats.totalWins).toBe(0) + expect(stats.favoriteGameType).toBeNull() + expect(stats.bestTime).toBeNull() + expect(stats.highestAccuracy).toBe(0) + }) - it("returns existing stats", async () => { + it('returns existing stats', async () => { // Create stats await db.insert(schema.userStats).values({ userId: testUserId, gamesPlayed: 10, totalWins: 7, - favoriteGameType: "abacus-numeral", + favoriteGameType: 'abacus-numeral', bestTime: 5000, highestAccuracy: 0.95, - }); + }) const stats = await db.query.userStats.findFirst({ where: eq(schema.userStats.userId, testUserId), - }); + }) - expect(stats).toBeDefined(); - expect(stats?.gamesPlayed).toBe(10); - expect(stats?.totalWins).toBe(7); - expect(stats?.favoriteGameType).toBe("abacus-numeral"); - expect(stats?.bestTime).toBe(5000); - expect(stats?.highestAccuracy).toBe(0.95); - }); - }); + expect(stats).toBeDefined() + expect(stats?.gamesPlayed).toBe(10) + expect(stats?.totalWins).toBe(7) + expect(stats?.favoriteGameType).toBe('abacus-numeral') + expect(stats?.bestTime).toBe(5000) + expect(stats?.highestAccuracy).toBe(0.95) + }) + }) - describe("PATCH /api/user-stats", () => { - it("creates new stats if none exist", async () => { + describe('PATCH /api/user-stats', () => { + it('creates new stats if none exist', async () => { const [stats] = await db .insert(schema.userStats) .values({ @@ -79,20 +73,20 @@ describe("User Stats API", () => { gamesPlayed: 1, totalWins: 1, }) - .returning(); + .returning() - expect(stats).toBeDefined(); - expect(stats.gamesPlayed).toBe(1); - expect(stats.totalWins).toBe(1); - }); + expect(stats).toBeDefined() + expect(stats.gamesPlayed).toBe(1) + expect(stats.totalWins).toBe(1) + }) - it("updates existing stats", async () => { + it('updates existing stats', async () => { // Create initial stats await db.insert(schema.userStats).values({ userId: testUserId, gamesPlayed: 5, totalWins: 3, - }); + }) // Update const [updated] = await db @@ -100,55 +94,55 @@ describe("User Stats API", () => { .set({ gamesPlayed: 6, totalWins: 4, - favoriteGameType: "complement-pairs", + favoriteGameType: 'complement-pairs', }) .where(eq(schema.userStats.userId, testUserId)) - .returning(); + .returning() - expect(updated.gamesPlayed).toBe(6); - expect(updated.totalWins).toBe(4); - expect(updated.favoriteGameType).toBe("complement-pairs"); - }); + expect(updated.gamesPlayed).toBe(6) + expect(updated.totalWins).toBe(4) + expect(updated.favoriteGameType).toBe('complement-pairs') + }) - it("updates only provided fields", async () => { + it('updates only provided fields', async () => { // Create initial stats await db.insert(schema.userStats).values({ userId: testUserId, gamesPlayed: 10, totalWins: 5, bestTime: 3000, - }); + }) // Update only gamesPlayed const [updated] = await db .update(schema.userStats) .set({ gamesPlayed: 11 }) .where(eq(schema.userStats.userId, testUserId)) - .returning(); + .returning() - expect(updated.gamesPlayed).toBe(11); - expect(updated.totalWins).toBe(5); // unchanged - expect(updated.bestTime).toBe(3000); // unchanged - }); + expect(updated.gamesPlayed).toBe(11) + expect(updated.totalWins).toBe(5) // unchanged + expect(updated.bestTime).toBe(3000) // unchanged + }) - it("allows setting favoriteGameType", async () => { + it('allows setting favoriteGameType', async () => { await db.insert(schema.userStats).values({ userId: testUserId, - }); + }) const [updated] = await db .update(schema.userStats) - .set({ favoriteGameType: "abacus-numeral" }) + .set({ favoriteGameType: 'abacus-numeral' }) .where(eq(schema.userStats.userId, testUserId)) - .returning(); + .returning() - expect(updated.favoriteGameType).toBe("abacus-numeral"); - }); + expect(updated.favoriteGameType).toBe('abacus-numeral') + }) - it("allows setting bestTime and highestAccuracy", async () => { + it('allows setting bestTime and highestAccuracy', async () => { await db.insert(schema.userStats).values({ userId: testUserId, - }); + }) const [updated] = await db .update(schema.userStats) @@ -157,36 +151,36 @@ describe("User Stats API", () => { highestAccuracy: 0.98, }) .where(eq(schema.userStats.userId, testUserId)) - .returning(); + .returning() - expect(updated.bestTime).toBe(2500); - expect(updated.highestAccuracy).toBe(0.98); - }); - }); + expect(updated.bestTime).toBe(2500) + expect(updated.highestAccuracy).toBe(0.98) + }) + }) - describe("Cascade delete behavior", () => { - it("deletes stats when user is deleted", async () => { + describe('Cascade delete behavior', () => { + it('deletes stats when user is deleted', async () => { // Create stats await db.insert(schema.userStats).values({ userId: testUserId, gamesPlayed: 10, totalWins: 5, - }); + }) // Verify stats exist let stats = await db.query.userStats.findFirst({ where: eq(schema.userStats.userId, testUserId), - }); - expect(stats).toBeDefined(); + }) + expect(stats).toBeDefined() // Delete user - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) // Verify stats are gone stats = await db.query.userStats.findFirst({ where: eq(schema.userStats.userId, testUserId), - }); - expect(stats).toBeUndefined(); - }); - }); -}); + }) + expect(stats).toBeUndefined() + }) + }) +}) diff --git a/apps/web/__tests__/join-invitation-acceptance.e2e.test.ts b/apps/web/__tests__/join-invitation-acceptance.e2e.test.ts index 71616ba0..c0cc5760 100644 --- a/apps/web/__tests__/join-invitation-acceptance.e2e.test.ts +++ b/apps/web/__tests__/join-invitation-acceptance.e2e.test.ts @@ -2,15 +2,12 @@ * @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"; +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 @@ -23,440 +20,418 @@ import { addRoomMember } from "../src/lib/arcade/room-membership"; * 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; +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)}`; + 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(); + 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; - }); + hostUserId = host.id + guestUserId = guest.id + }) afterEach(async () => { // Clean up invitations if (roomId) { - await db - .delete(schema.roomInvitations) - .where(eq(schema.roomInvitations.roomId, 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)); + 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)); - }); + 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 () => { + 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", + name: 'Restricted Room', createdBy: hostGuestId, - creatorName: "Host User", - gameName: "rithmomachia", + creatorName: 'Host User', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "restricted", // Requires invitation - }); - roomId = room.id; + accessMode: 'restricted', // Requires invitation + }) + roomId = room.id // 2. Host invites guest const invitation = await createInvitation({ roomId, userId: guestUserId, - userName: "Guest User", + userName: 'Guest User', invitedBy: hostUserId, - invitedByName: "Host User", - invitationType: "manual", - }); + invitedByName: 'Host User', + invitationType: 'manual', + }) // 3. Verify invitation is pending - expect(invitation.status).toBe("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"); + 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", + 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); + 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(); - }); + 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 () => { + 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", + name: 'Room KHS3AE', createdBy: hostGuestId, - creatorName: "Host", - gameName: "rithmomachia", + creatorName: 'Host', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "restricted", - }); + accessMode: 'restricted', + }) const roomB = await createRoom({ - name: "Room SFK3GD", + name: 'Room SFK3GD', createdBy: hostGuestId, - creatorName: "Host", - gameName: "rithmomachia", + creatorName: 'Host', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "open", // Guest can join without invitation - }); + accessMode: 'open', // Guest can join without invitation + }) - roomId = roomA.id; // For cleanup + roomId = roomA.id // For cleanup // 2. Invite guest to Room A const invitationA = await createInvitation({ roomId: roomA.id, userId: guestUserId, - userName: "Guest", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - }); + 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); + 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); + const { acceptInvitation } = await import('../src/lib/arcade/room-invitations') + await acceptInvitation(invitationA.id) await addRoomMember({ roomId: roomA.id, userId: guestGuestId, - displayName: "Guest", + 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 + 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", + displayName: 'Guest', isCreator: false, - }); + }) // Clean up - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, roomB.id)); - }); - }); + 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 () => { + 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", + name: 'Room 1', createdBy: hostGuestId, - creatorName: "Host", - gameName: "rithmomachia", + creatorName: 'Host', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "restricted", - }); + accessMode: 'restricted', + }) const room2 = await createRoom({ - name: "Room 2", + name: 'Room 2', createdBy: hostGuestId, - creatorName: "Host", - gameName: "rithmomachia", + creatorName: 'Host', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "restricted", - }); + accessMode: 'restricted', + }) const room3 = await createRoom({ - name: "Room 3", + name: 'Room 3', createdBy: hostGuestId, - creatorName: "Host", - gameName: "rithmomachia", + creatorName: 'Host', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "restricted", - }); + accessMode: 'restricted', + }) - roomId = room1.id; // For cleanup + roomId = room1.id // For cleanup // Invite to all 3 const inv1 = await createInvitation({ roomId: room1.id, userId: guestUserId, - userName: "Guest", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - }); + invitedByName: 'Host', + invitationType: 'manual', + }) const inv2 = await createInvitation({ roomId: room2.id, userId: guestUserId, - userName: "Guest", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - }); + invitedByName: 'Host', + invitationType: 'manual', + }) const inv3 = await createInvitation({ roomId: room3.id, userId: guestUserId, - userName: "Guest", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - }); + 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); + '../src/lib/arcade/room-invitations' + ) + let pending = await getUserPendingInvitations(guestUserId) + expect(pending).toHaveLength(3) // Accept invitation 1 and join - await acceptInvitation(inv1.id); + 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); + 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)); - }); - }); + 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 () => { + 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", + name: 'Host Room', createdBy: hostGuestId, - creatorName: "Host User", - gameName: "rithmomachia", + creatorName: 'Host User', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "restricted", - }); - roomId = room.id; + accessMode: 'restricted', + }) + roomId = room.id // Host joins their own room await addRoomMember({ roomId, userId: hostGuestId, - displayName: "Host User", + 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(); - }); - }); + 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 () => { + describe('Edge cases', () => { + it('handles multiple invitations from same host to same guest (updates, not duplicates)', async () => { const room = await createRoom({ - name: "Test Room", + name: 'Test Room', createdBy: hostGuestId, - creatorName: "Host", - gameName: "rithmomachia", + creatorName: 'Host', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "restricted", - }); - roomId = room.id; + accessMode: 'restricted', + }) + roomId = room.id // Send first invitation const inv1 = await createInvitation({ roomId, userId: guestUserId, - userName: "Guest", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - message: "First message", - }); + invitedByName: 'Host', + invitationType: 'manual', + message: 'First message', + }) // Send second invitation (should update, not create new) const inv2 = await createInvitation({ roomId, userId: guestUserId, - userName: "Guest", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - message: "Second message", - }); + 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"); + 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)); + .where(eq(schema.roomInvitations.roomId, roomId)) - expect(allInvitations).toHaveLength(1); - }); + expect(allInvitations).toHaveLength(1) + }) - it("re-sends invitation after previous was declined", async () => { + it('re-sends invitation after previous was declined', async () => { const room = await createRoom({ - name: "Test Room", + name: 'Test Room', createdBy: hostGuestId, - creatorName: "Host", - gameName: "rithmomachia", + creatorName: 'Host', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "restricted", - }); - roomId = room.id; + accessMode: 'restricted', + }) + roomId = room.id // First invitation const inv1 = await createInvitation({ roomId, userId: guestUserId, - userName: "Guest", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - }); + invitedByName: 'Host', + invitationType: 'manual', + }) // Guest declines const { declineInvitation, getUserPendingInvitations } = await import( - "../src/lib/arcade/room-invitations" - ); - await declineInvitation(inv1.id); + '../src/lib/arcade/room-invitations' + ) + await declineInvitation(inv1.id) // Should not be in pending list - let pending = await getUserPendingInvitations(guestUserId); - expect(pending).toHaveLength(0); + let pending = await getUserPendingInvitations(guestUserId) + expect(pending).toHaveLength(0) // Host sends new invitation (should reset to pending) await createInvitation({ roomId, userId: guestUserId, - userName: "Guest", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - }); + 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"); - }); + pending = await getUserPendingInvitations(guestUserId) + expect(pending).toHaveLength(1) + expect(pending[0].status).toBe('pending') + }) - it("accepts invitations to OPEN rooms (not just restricted)", async () => { + 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", + name: 'Open Room', createdBy: hostGuestId, - creatorName: "Host", - gameName: "rithmomachia", + creatorName: 'Host', + gameName: 'rithmomachia', gameConfig: {}, - accessMode: "open", // Open access - no invitation required to join - }); - roomId = openRoom.id; + 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", + userName: 'Guest', invitedBy: hostUserId, - invitedByName: "Host", - invitationType: "manual", - }); + 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); + '../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", + displayName: 'Guest', isCreator: false, - }); + }) // Simulate the join API accepting the invitation - await acceptInvitation(inv.id); + 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 + 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"); - }); - }); -}); + const acceptedInv = await getInvitation(openRoom.id, guestUserId) + expect(acceptedInv?.status).toBe('accepted') + }) + }) +}) diff --git a/apps/web/__tests__/middleware.e2e.test.ts b/apps/web/__tests__/middleware.e2e.test.ts index 4aee1c81..1a2635c0 100644 --- a/apps/web/__tests__/middleware.e2e.test.ts +++ b/apps/web/__tests__/middleware.e2e.test.ts @@ -2,135 +2,135 @@ * @vitest-environment node */ -import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it } from "vitest"; -import { GUEST_COOKIE_NAME, verifyGuestToken } from "../src/lib/guest-token"; -import { middleware } from "../src/middleware"; +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it } from 'vitest' +import { GUEST_COOKIE_NAME, verifyGuestToken } from '../src/lib/guest-token' +import { middleware } from '../src/middleware' -describe("Middleware E2E", () => { +describe('Middleware E2E', () => { beforeEach(() => { - process.env.AUTH_SECRET = "test-secret-for-middleware"; - }); + process.env.AUTH_SECRET = 'test-secret-for-middleware' + }) - it("sets guest cookie on first request", async () => { - const req = new NextRequest("http://localhost:3000/"); - const res = await middleware(req); + it('sets guest cookie on first request', async () => { + const req = new NextRequest('http://localhost:3000/') + const res = await middleware(req) - const cookie = res.cookies.get(GUEST_COOKIE_NAME); + const cookie = res.cookies.get(GUEST_COOKIE_NAME) - expect(cookie).toBeDefined(); - expect(cookie?.value).toBeDefined(); - expect(cookie?.httpOnly).toBe(true); - expect(cookie?.sameSite).toBe("lax"); - expect(cookie?.path).toBe("/"); - }); + expect(cookie).toBeDefined() + expect(cookie?.value).toBeDefined() + expect(cookie?.httpOnly).toBe(true) + expect(cookie?.sameSite).toBe('lax') + expect(cookie?.path).toBe('/') + }) - it("creates valid guest token", async () => { - const req = new NextRequest("http://localhost:3000/"); - const res = await middleware(req); + it('creates valid guest token', async () => { + const req = new NextRequest('http://localhost:3000/') + const res = await middleware(req) - const cookie = res.cookies.get(GUEST_COOKIE_NAME); - expect(cookie).toBeDefined(); + const cookie = res.cookies.get(GUEST_COOKIE_NAME) + expect(cookie).toBeDefined() // Verify the token is valid - const verified = await verifyGuestToken(cookie!.value); - expect(verified.sid).toBeDefined(); - expect(typeof verified.sid).toBe("string"); - }); + const verified = await verifyGuestToken(cookie!.value) + expect(verified.sid).toBeDefined() + expect(typeof verified.sid).toBe('string') + }) - it("preserves existing guest cookie", async () => { + it('preserves existing guest cookie', async () => { // First request - creates cookie - const req1 = new NextRequest("http://localhost:3000/"); - const res1 = await middleware(req1); - const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME); + const req1 = new NextRequest('http://localhost:3000/') + const res1 = await middleware(req1) + const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME) // Second request - with existing cookie - const req2 = new NextRequest("http://localhost:3000/"); - req2.cookies.set(GUEST_COOKIE_NAME, cookie1!.value); - const res2 = await middleware(req2); + const req2 = new NextRequest('http://localhost:3000/') + req2.cookies.set(GUEST_COOKIE_NAME, cookie1!.value) + const res2 = await middleware(req2) - const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME); + const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME) // Cookie should not be set again (preserves existing) - expect(cookie2).toBeUndefined(); - }); + expect(cookie2).toBeUndefined() + }) - it("sets different guest IDs for different visitors", async () => { - const req1 = new NextRequest("http://localhost:3000/"); - const req2 = new NextRequest("http://localhost:3000/"); + it('sets different guest IDs for different visitors', async () => { + const req1 = new NextRequest('http://localhost:3000/') + const req2 = new NextRequest('http://localhost:3000/') - const res1 = await middleware(req1); - const res2 = await middleware(req2); + const res1 = await middleware(req1) + const res2 = await middleware(req2) - const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME); - const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME); + const cookie1 = res1.cookies.get(GUEST_COOKIE_NAME) + const cookie2 = res2.cookies.get(GUEST_COOKIE_NAME) - const verified1 = await verifyGuestToken(cookie1!.value); - const verified2 = await verifyGuestToken(cookie2!.value); + const verified1 = await verifyGuestToken(cookie1!.value) + const verified2 = await verifyGuestToken(cookie2!.value) // Different visitors get different guest IDs - expect(verified1.sid).not.toBe(verified2.sid); - }); + expect(verified1.sid).not.toBe(verified2.sid) + }) - it("sets secure flag in production", async () => { - const originalEnv = process.env.NODE_ENV; - Object.defineProperty(process.env, "NODE_ENV", { - value: "production", + it('sets secure flag in production', async () => { + const originalEnv = process.env.NODE_ENV + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'production', configurable: true, - }); + }) - const req = new NextRequest("http://localhost:3000/"); - const res = await middleware(req); + const req = new NextRequest('http://localhost:3000/') + const res = await middleware(req) - const cookie = res.cookies.get(GUEST_COOKIE_NAME); - expect(cookie?.secure).toBe(true); + const cookie = res.cookies.get(GUEST_COOKIE_NAME) + expect(cookie?.secure).toBe(true) - Object.defineProperty(process.env, "NODE_ENV", { + Object.defineProperty(process.env, 'NODE_ENV', { value: originalEnv, configurable: true, - }); - }); + }) + }) - it("does not set secure flag in development", async () => { - const originalEnv = process.env.NODE_ENV; - Object.defineProperty(process.env, "NODE_ENV", { - value: "development", + it('does not set secure flag in development', async () => { + const originalEnv = process.env.NODE_ENV + Object.defineProperty(process.env, 'NODE_ENV', { + value: 'development', configurable: true, - }); + }) - const req = new NextRequest("http://localhost:3000/"); - const res = await middleware(req); + const req = new NextRequest('http://localhost:3000/') + const res = await middleware(req) - const cookie = res.cookies.get(GUEST_COOKIE_NAME); - expect(cookie?.secure).toBe(false); + const cookie = res.cookies.get(GUEST_COOKIE_NAME) + expect(cookie?.secure).toBe(false) - Object.defineProperty(process.env, "NODE_ENV", { + Object.defineProperty(process.env, 'NODE_ENV', { value: originalEnv, configurable: true, - }); - }); + }) + }) - it("sets maxAge correctly", async () => { - const req = new NextRequest("http://localhost:3000/"); - const res = await middleware(req); + it('sets maxAge correctly', async () => { + const req = new NextRequest('http://localhost:3000/') + const res = await middleware(req) - const cookie = res.cookies.get(GUEST_COOKIE_NAME); - expect(cookie?.maxAge).toBe(60 * 60 * 24 * 30); // 30 days - }); + const cookie = res.cookies.get(GUEST_COOKIE_NAME) + expect(cookie?.maxAge).toBe(60 * 60 * 24 * 30) // 30 days + }) - it("runs on valid paths", async () => { + it('runs on valid paths', async () => { const paths = [ - "http://localhost:3000/", - "http://localhost:3000/games", - "http://localhost:3000/tutorial-editor", - "http://localhost:3000/some/deep/path", - ]; + 'http://localhost:3000/', + 'http://localhost:3000/games', + 'http://localhost:3000/tutorial-editor', + 'http://localhost:3000/some/deep/path', + ] for (const path of paths) { - const req = new NextRequest(path); - const res = await middleware(req); - const cookie = res.cookies.get(GUEST_COOKIE_NAME); - expect(cookie).toBeDefined(); + const req = new NextRequest(path) + const res = await middleware(req) + const cookie = res.cookies.get(GUEST_COOKIE_NAME) + expect(cookie).toBeDefined() } - }); -}); + }) +}) diff --git a/apps/web/__tests__/orphaned-session.e2e.test.ts b/apps/web/__tests__/orphaned-session.e2e.test.ts index ed2fd89f..2d0422ae 100644 --- a/apps/web/__tests__/orphaned-session.e2e.test.ts +++ b/apps/web/__tests__/orphaned-session.e2e.test.ts @@ -1,14 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { eq } from "drizzle-orm"; -import { db, schema } from "../src/db"; -import { - createArcadeSession, - getArcadeSession, -} from "../src/lib/arcade/session-manager"; -import { - cleanupExpiredRooms, - createRoom, -} from "../src/lib/arcade/room-manager"; +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { db, schema } from '../src/db' +import { createArcadeSession, getArcadeSession } from '../src/lib/arcade/session-manager' +import { cleanupExpiredRooms, createRoom } from '../src/lib/arcade/room-manager' /** * E2E Test: Orphaned Session After Room TTL Deletion @@ -20,10 +14,10 @@ import { * 4. System should NOT redirect to the orphaned game * 5. User should see the arcade lobby normally */ -describe("E2E: Orphaned Session Cleanup on Navigation", () => { - const testUserId = "e2e-user-id"; - const testGuestId = "e2e-guest-id"; - let testRoomId: string; +describe('E2E: Orphaned Session Cleanup on Navigation', () => { + const testUserId = 'e2e-user-id' + const testGuestId = 'e2e-guest-id' + let testRoomId: string beforeEach(async () => { // Create test user (simulating new or returning visitor) @@ -34,63 +28,59 @@ describe("E2E: Orphaned Session Cleanup on Navigation", () => { guestId: testGuestId, createdAt: new Date(), }) - .onConflictDoNothing(); - }); + .onConflictDoNothing() + }) afterEach(async () => { // Clean up test data - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.userId, testUserId)); - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testUserId)) + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) if (testRoomId) { try { - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, testRoomId)); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId)) } catch { // Room may already be deleted } } - }); + }) - it("should not redirect user to orphaned game after room TTL cleanup", async () => { + it('should not redirect user to orphaned game after room TTL cleanup', async () => { // === SETUP PHASE === // User creates or joins a room const room = await createRoom({ - name: "My Game Room", + name: 'My Game Room', createdBy: testGuestId, - creatorName: "Test Player", - gameName: "matching", - gameConfig: { difficulty: 6, gameType: "abacus-numeral", turnTimer: 30 }, + creatorName: 'Test Player', + gameName: 'matching', + gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 }, ttlMinutes: 1, // Short TTL for testing - }); - testRoomId = room.id; + }) + testRoomId = room.id // User starts a game session const session = await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", + gameName: 'matching', + gameUrl: '/arcade/matching', initialState: { - gamePhase: "playing", + gamePhase: 'playing', cards: [], gameCards: [], flippedCards: [], matchedPairs: 0, totalPairs: 6, - currentPlayer: "player-1", + currentPlayer: 'player-1', difficulty: 6, - gameType: "abacus-numeral", + gameType: 'abacus-numeral', turnTimer: 30, }, - activePlayers: ["player-1"], + activePlayers: ['player-1'], roomId: room.id, - }); + }) // Verify session was created - expect(session).toBeDefined(); - expect(session.roomId).toBe(room.id); + expect(session).toBeDefined() + expect(session.roomId).toBe(room.id) // === TTL EXPIRATION PHASE === // Simulate time passing - room's TTL expires @@ -100,118 +90,114 @@ describe("E2E: Orphaned Session Cleanup on Navigation", () => { .set({ lastActivity: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago }) - .where(eq(schema.arcadeRooms.id, room.id)); + .where(eq(schema.arcadeRooms.id, room.id)) // Run cleanup (simulating background cleanup job) - const deletedCount = await cleanupExpiredRooms(); - expect(deletedCount).toBeGreaterThan(0); // Room should be deleted + const deletedCount = await cleanupExpiredRooms() + expect(deletedCount).toBeGreaterThan(0) // Room should be deleted // === USER NAVIGATION PHASE === // User navigates to /arcade (arcade lobby) // Client checks for active session - const activeSession = await getArcadeSession(testGuestId); + const activeSession = await getArcadeSession(testGuestId) // === ASSERTION PHASE === // Expected behavior: NO active session returned // This prevents redirect to /arcade/matching which would be broken - expect(activeSession).toBeUndefined(); + expect(activeSession).toBeUndefined() // Verify the orphaned session was cleaned up from database const [orphanedSessionCheck] = await db .select() .from(schema.arcadeSessions) .where(eq(schema.arcadeSessions.userId, testUserId)) - .limit(1); + .limit(1) - expect(orphanedSessionCheck).toBeUndefined(); - }); + expect(orphanedSessionCheck).toBeUndefined() + }) - it("should allow user to start new game after orphaned session cleanup", async () => { + it('should allow user to start new game after orphaned session cleanup', async () => { // === SETUP: Create and orphan a session === const oldRoom = await createRoom({ - name: "Old Room", + name: 'Old Room', createdBy: testGuestId, - creatorName: "Test Player", - gameName: "matching", + creatorName: 'Test Player', + gameName: 'matching', gameConfig: { difficulty: 6 }, ttlMinutes: 1, - }); + }) await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { gamePhase: "setup" }, - activePlayers: ["player-1"], + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { gamePhase: 'setup' }, + activePlayers: ['player-1'], roomId: oldRoom.id, - }); + }) // Delete room (TTL cleanup) - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, oldRoom.id)); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, oldRoom.id)) // === ACTION: User tries to access arcade === - const orphanedSession = await getArcadeSession(testGuestId); - expect(orphanedSession).toBeUndefined(); // Orphan cleaned up + const orphanedSession = await getArcadeSession(testGuestId) + expect(orphanedSession).toBeUndefined() // Orphan cleaned up // === ACTION: User creates new room and session === const newRoom = await createRoom({ - name: "New Room", + name: 'New Room', createdBy: testGuestId, - creatorName: "Test Player", - gameName: "matching", + creatorName: 'Test Player', + gameName: 'matching', gameConfig: { difficulty: 8 }, ttlMinutes: 60, - }); - testRoomId = newRoom.id; + }) + testRoomId = newRoom.id const newSession = await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { gamePhase: "setup" }, - activePlayers: ["player-1", "player-2"], + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { gamePhase: 'setup' }, + activePlayers: ['player-1', 'player-2'], roomId: newRoom.id, - }); + }) // === ASSERTION: New session works correctly === - expect(newSession).toBeDefined(); - expect(newSession.roomId).toBe(newRoom.id); + expect(newSession).toBeDefined() + expect(newSession.roomId).toBe(newRoom.id) - const activeSession = await getArcadeSession(testGuestId); - expect(activeSession).toBeDefined(); - expect(activeSession?.roomId).toBe(newRoom.id); - }); + const activeSession = await getArcadeSession(testGuestId) + expect(activeSession).toBeDefined() + expect(activeSession?.roomId).toBe(newRoom.id) + }) - it("should handle race condition: getArcadeSession called while room is being deleted", async () => { + it('should handle race condition: getArcadeSession called while room is being deleted', async () => { // Create room and session const room = await createRoom({ - name: "Race Condition Room", + name: 'Race Condition Room', createdBy: testGuestId, - creatorName: "Test Player", - gameName: "matching", + creatorName: 'Test Player', + gameName: 'matching', gameConfig: { difficulty: 6 }, ttlMinutes: 60, - }); - testRoomId = room.id; + }) + testRoomId = room.id await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { gamePhase: "setup" }, - activePlayers: ["player-1"], + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { gamePhase: 'setup' }, + activePlayers: ['player-1'], roomId: room.id, - }); + }) // Simulate race: delete room while getArcadeSession is checking - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, room.id)); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room.id)) // Should gracefully handle and return undefined - const result = await getArcadeSession(testGuestId); - expect(result).toBeUndefined(); - }); -}); + const result = await getArcadeSession(testGuestId) + expect(result).toBeUndefined() + }) +}) diff --git a/apps/web/__tests__/room-realtime-updates.e2e.test.ts b/apps/web/__tests__/room-realtime-updates.e2e.test.ts index 0c5285b3..20d520fa 100644 --- a/apps/web/__tests__/room-realtime-updates.e2e.test.ts +++ b/apps/web/__tests__/room-realtime-updates.e2e.test.ts @@ -2,23 +2,15 @@ * @vitest-environment node */ -import { createServer } from "http"; -import { eq } from "drizzle-orm"; -import { io as ioClient, type Socket } from "socket.io-client"; -import { - afterEach, - beforeEach, - describe, - expect, - it, - afterAll, - beforeAll, -} from "vitest"; -import { db, schema } from "../src/db"; -import { createRoom } from "../src/lib/arcade/room-manager"; -import { addRoomMember } from "../src/lib/arcade/room-membership"; -import { initializeSocketServer } from "../src/socket-server"; -import type { Server as SocketIOServerType } from "socket.io"; +import { createServer } from 'http' +import { eq } from 'drizzle-orm' +import { io as ioClient, type Socket } from 'socket.io-client' +import { afterEach, beforeEach, describe, expect, it, afterAll, beforeAll } from 'vitest' +import { db, schema } from '../src/db' +import { createRoom } from '../src/lib/arcade/room-manager' +import { addRoomMember } from '../src/lib/arcade/room-membership' +import { initializeSocketServer } from '../src/socket-server' +import type { Server as SocketIOServerType } from 'socket.io' /** * Real-time Room Updates E2E Tests @@ -27,385 +19,353 @@ import type { Server as SocketIOServerType } from "socket.io"; * Simulates multiple connected users and verifies they receive real-time updates. */ -describe("Room Real-time Updates", () => { - let testUserId1: string; - let testUserId2: string; - let testGuestId1: string; - let testGuestId2: string; - let testRoomId: string; - let socket1: Socket; - let httpServer: any; - let io: SocketIOServerType; - let serverPort: number; +describe('Room Real-time Updates', () => { + let testUserId1: string + let testUserId2: string + let testGuestId1: string + let testGuestId2: string + let testRoomId: string + let socket1: Socket + let httpServer: any + let io: SocketIOServerType + let serverPort: number beforeAll(async () => { // Create HTTP server and initialize Socket.IO for testing - httpServer = createServer(); - io = initializeSocketServer(httpServer); + httpServer = createServer() + io = initializeSocketServer(httpServer) // Find an available port await new Promise((resolve) => { httpServer.listen(0, () => { - serverPort = (httpServer.address() as any).port; - console.log(`Test socket server listening on port ${serverPort}`); - resolve(); - }); - }); - }); + serverPort = (httpServer.address() as any).port + console.log(`Test socket server listening on port ${serverPort}`) + resolve() + }) + }) + }) afterAll(async () => { // Close all socket connections if (io) { - io.close(); + io.close() } if (httpServer) { await new Promise((resolve) => { - httpServer.close(() => resolve()); - }); + httpServer.close(() => resolve()) + }) } - }); + }) beforeEach(async () => { // Create test users - testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; - testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; + testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` + testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` - const [user1] = await db - .insert(schema.users) - .values({ guestId: testGuestId1 }) - .returning(); - const [user2] = await db - .insert(schema.users) - .values({ guestId: testGuestId2 }) - .returning(); + const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning() + const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning() - testUserId1 = user1.id; - testUserId2 = user2.id; + testUserId1 = user1.id + testUserId2 = user2.id // Create a test room const room = await createRoom({ - name: "Realtime Test Room", + name: 'Realtime Test Room', createdBy: testGuestId1, - creatorName: "User 1", - gameName: "matching", + creatorName: 'User 1', + gameName: 'matching', gameConfig: { difficulty: 6 }, ttlMinutes: 60, - }); - testRoomId = room.id; - }); + }) + testRoomId = room.id + }) afterEach(async () => { // Disconnect sockets if (socket1?.connected) { - socket1.disconnect(); + socket1.disconnect() } // Clean up room members - await db - .delete(schema.roomMembers) - .where(eq(schema.roomMembers.roomId, testRoomId)); + await db.delete(schema.roomMembers).where(eq(schema.roomMembers.roomId, testRoomId)) // Clean up rooms if (testRoomId) { - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, testRoomId)); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId)) } // Clean up users - await db.delete(schema.users).where(eq(schema.users.id, testUserId1)); - await db.delete(schema.users).where(eq(schema.users.id, testUserId2)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId1)) + await db.delete(schema.users).where(eq(schema.users.id, testUserId2)) + }) - it("should broadcast member-joined when a user joins via API", async () => { + it('should broadcast member-joined when a user joins via API', async () => { // User 1 joins the room via API first (this is what happens when they click "Join Room") await addRoomMember({ roomId: testRoomId, userId: testGuestId1, - displayName: "User 1", + displayName: 'User 1', isCreator: false, - }); + }) // User 1 connects to socket socket1 = ioClient(`http://localhost:${serverPort}`, { - path: "/api/socket", - transports: ["websocket"], - }); + path: '/api/socket', + transports: ['websocket'], + }) // Wait for socket to connect await new Promise((resolve, reject) => { - socket1.on("connect", () => resolve()); - socket1.on("connect_error", (err) => reject(err)); - setTimeout(() => reject(new Error("Connection timeout")), 2000); - }); + socket1.on('connect', () => resolve()) + socket1.on('connect_error', (err) => reject(err)) + setTimeout(() => reject(new Error('Connection timeout')), 2000) + }) // Small delay to ensure event handlers are set up - await new Promise((resolve) => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)) // Set up listener for room-joined BEFORE emitting const roomJoinedPromise = new Promise((resolve, reject) => { - socket1.on("room-joined", () => resolve()); - socket1.on("room-error", (err) => reject(new Error(err.error))); - setTimeout(() => reject(new Error("Room-joined timeout")), 3000); - }); + socket1.on('room-joined', () => resolve()) + socket1.on('room-error', (err) => reject(new Error(err.error))) + setTimeout(() => reject(new Error('Room-joined timeout')), 3000) + }) // Now emit the join-room event - socket1.emit("join-room", { roomId: testRoomId, userId: testGuestId1 }); + socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 }) // Wait for confirmation - await roomJoinedPromise; + await roomJoinedPromise // Set up listener for member-joined event BEFORE User 2 joins const memberJoinedPromise = new Promise((resolve, reject) => { - socket1.on("member-joined", (data) => { - resolve(data); - }); - setTimeout( - () => reject(new Error("Timeout waiting for member-joined event")), - 3000, - ); - }); + socket1.on('member-joined', (data) => { + resolve(data) + }) + setTimeout(() => reject(new Error('Timeout waiting for member-joined event')), 3000) + }) // User 2 joins the room via addRoomMember const { member: newMember } = await addRoomMember({ roomId: testRoomId, userId: testGuestId2, - displayName: "User 2", + displayName: 'User 2', isCreator: false, - }); + }) // Manually trigger the broadcast (this is what the API route SHOULD do) - const { getRoomMembers } = await import( - "../src/lib/arcade/room-membership" - ); - const { getRoomActivePlayers } = await import( - "../src/lib/arcade/player-manager" - ); + const { getRoomMembers } = await import('../src/lib/arcade/room-membership') + const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager') - const members = await getRoomMembers(testRoomId); - const memberPlayers = await getRoomActivePlayers(testRoomId); + const members = await getRoomMembers(testRoomId) + const memberPlayers = await getRoomActivePlayers(testRoomId) - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } - io.to(`room:${testRoomId}`).emit("member-joined", { + io.to(`room:${testRoomId}`).emit('member-joined', { roomId: testRoomId, userId: testGuestId2, members, memberPlayers: memberPlayersObj, - }); + }) // Wait for the socket broadcast with timeout - const data = await memberJoinedPromise; + const data = await memberJoinedPromise // Verify the broadcast data - expect(data).toBeDefined(); - expect(data.roomId).toBe(testRoomId); - expect(data.userId).toBe(testGuestId2); - expect(data.members).toBeDefined(); - expect(Array.isArray(data.members)).toBe(true); + expect(data).toBeDefined() + expect(data.roomId).toBe(testRoomId) + expect(data.userId).toBe(testGuestId2) + expect(data.members).toBeDefined() + expect(Array.isArray(data.members)).toBe(true) // Verify both users are in the members list - const memberUserIds = data.members.map((m: any) => m.userId); - expect(memberUserIds).toContain(testGuestId1); - expect(memberUserIds).toContain(testGuestId2); + const memberUserIds = data.members.map((m: any) => m.userId) + expect(memberUserIds).toContain(testGuestId1) + expect(memberUserIds).toContain(testGuestId2) // Verify the new member details - const addedMember = data.members.find( - (m: any) => m.userId === testGuestId2, - ); - expect(addedMember).toBeDefined(); - expect(addedMember.displayName).toBe("User 2"); - expect(addedMember.roomId).toBe(testRoomId); - }); + const addedMember = data.members.find((m: any) => m.userId === testGuestId2) + expect(addedMember).toBeDefined() + expect(addedMember.displayName).toBe('User 2') + expect(addedMember.roomId).toBe(testRoomId) + }) - it("should broadcast member-left when a user leaves via API", async () => { + it('should broadcast member-left when a user leaves via API', async () => { // User 1 joins the room first await addRoomMember({ roomId: testRoomId, userId: testGuestId1, - displayName: "User 1", + displayName: 'User 1', isCreator: false, - }); + }) // User 2 joins the room await addRoomMember({ roomId: testRoomId, userId: testGuestId2, - displayName: "User 2", + displayName: 'User 2', isCreator: false, - }); + }) // User 1 connects to socket socket1 = ioClient(`http://localhost:${serverPort}`, { - path: "/api/socket", - transports: ["websocket"], - }); + path: '/api/socket', + transports: ['websocket'], + }) await new Promise((resolve) => { - socket1.on("connect", () => resolve()); - }); + socket1.on('connect', () => resolve()) + }) - socket1.emit("join-room", { roomId: testRoomId, userId: testGuestId1 }); + socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 }) await new Promise((resolve) => { - socket1.on("room-joined", () => resolve()); - }); + socket1.on('room-joined', () => resolve()) + }) // Set up listener for member-left event const memberLeftPromise = new Promise((resolve) => { - socket1.on("member-left", (data) => { - resolve(data); - }); - }); + socket1.on('member-left', (data) => { + resolve(data) + }) + }) // User 2 leaves the room via API - await db - .delete(schema.roomMembers) - .where(eq(schema.roomMembers.userId, testGuestId2)); + await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2)) // Manually trigger the leave broadcast (simulating what the API does) - const { getSocketIO } = await import("../src/lib/socket-io"); - const io = await getSocketIO(); + const { getSocketIO } = await import('../src/lib/socket-io') + const io = await getSocketIO() if (io) { - const { getRoomMembers } = await import( - "../src/lib/arcade/room-membership" - ); - const { getRoomActivePlayers } = await import( - "../src/lib/arcade/player-manager" - ); + const { getRoomMembers } = await import('../src/lib/arcade/room-membership') + const { getRoomActivePlayers } = await import('../src/lib/arcade/player-manager') - const members = await getRoomMembers(testRoomId); - const memberPlayers = await getRoomActivePlayers(testRoomId); + const members = await getRoomMembers(testRoomId) + const memberPlayers = await getRoomActivePlayers(testRoomId) - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } - io.to(`room:${testRoomId}`).emit("member-left", { + io.to(`room:${testRoomId}`).emit('member-left', { roomId: testRoomId, userId: testGuestId2, members, memberPlayers: memberPlayersObj, - }); + }) } // Wait for the socket broadcast with timeout const data = await Promise.race([ memberLeftPromise, new Promise((_, reject) => - setTimeout( - () => reject(new Error("Timeout waiting for member-left event")), - 2000, - ), + setTimeout(() => reject(new Error('Timeout waiting for member-left event')), 2000) ), - ]); + ]) // Verify the broadcast data - expect(data).toBeDefined(); - expect(data.roomId).toBe(testRoomId); - expect(data.userId).toBe(testGuestId2); - expect(data.members).toBeDefined(); - expect(Array.isArray(data.members)).toBe(true); + expect(data).toBeDefined() + expect(data.roomId).toBe(testRoomId) + expect(data.userId).toBe(testGuestId2) + expect(data.members).toBeDefined() + expect(Array.isArray(data.members)).toBe(true) // Verify User 2 is no longer in the members list - const memberUserIds = data.members.map((m: any) => m.userId); - expect(memberUserIds).toContain(testGuestId1); - expect(memberUserIds).not.toContain(testGuestId2); - }); + const memberUserIds = data.members.map((m: any) => m.userId) + expect(memberUserIds).toContain(testGuestId1) + expect(memberUserIds).not.toContain(testGuestId2) + }) - it("should update both members and players lists in member-joined broadcast", async () => { + it('should update both members and players lists in member-joined broadcast', async () => { // Create an active player for User 2 const [player2] = await db .insert(schema.players) .values({ userId: testUserId2, - name: "Player 2", - emoji: "🎮", - color: "#3b82f6", + name: 'Player 2', + emoji: '🎮', + color: '#3b82f6', isActive: true, }) - .returning(); + .returning() // User 1 connects and joins room socket1 = ioClient(`http://localhost:${serverPort}`, { - path: "/api/socket", - transports: ["websocket"], - }); + path: '/api/socket', + transports: ['websocket'], + }) await new Promise((resolve) => { - socket1.on("connect", () => resolve()); - }); + socket1.on('connect', () => resolve()) + }) - socket1.emit("join-room", { roomId: testRoomId, userId: testGuestId1 }); + socket1.emit('join-room', { roomId: testRoomId, userId: testGuestId1 }) await new Promise((resolve) => { - socket1.on("room-joined", () => resolve()); - }); + socket1.on('room-joined', () => resolve()) + }) const memberJoinedPromise = new Promise((resolve) => { - socket1.on("member-joined", (data) => { - resolve(data); - }); - }); + socket1.on('member-joined', (data) => { + resolve(data) + }) + }) // User 2 joins via API await addRoomMember({ roomId: testRoomId, userId: testGuestId2, - displayName: "User 2", + displayName: 'User 2', isCreator: false, - }); + }) // Manually trigger the broadcast (simulating what the API does) - const { getRoomMembers: getRoomMembers3 } = await import( - "../src/lib/arcade/room-membership" - ); + const { getRoomMembers: getRoomMembers3 } = await import('../src/lib/arcade/room-membership') const { getRoomActivePlayers: getRoomActivePlayers3 } = await import( - "../src/lib/arcade/player-manager" - ); + '../src/lib/arcade/player-manager' + ) - const members2 = await getRoomMembers3(testRoomId); - const memberPlayers2 = await getRoomActivePlayers3(testRoomId); + const members2 = await getRoomMembers3(testRoomId) + const memberPlayers2 = await getRoomActivePlayers3(testRoomId) - const memberPlayersObj2: Record = {}; + const memberPlayersObj2: Record = {} for (const [uid, players] of memberPlayers2.entries()) { - memberPlayersObj2[uid] = players; + memberPlayersObj2[uid] = players } - io.to(`room:${testRoomId}`).emit("member-joined", { + io.to(`room:${testRoomId}`).emit('member-joined', { roomId: testRoomId, userId: testGuestId2, members: members2, memberPlayers: memberPlayersObj2, - }); + }) const data = await Promise.race([ memberJoinedPromise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout")), 2000), - ), - ]); + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)), + ]) // Verify members list is updated - expect(data.members).toBeDefined(); - const memberUserIds = data.members.map((m: any) => m.userId); - expect(memberUserIds).toContain(testGuestId2); + expect(data.members).toBeDefined() + const memberUserIds = data.members.map((m: any) => m.userId) + expect(memberUserIds).toContain(testGuestId2) // Verify players list is updated - expect(data.memberPlayers).toBeDefined(); - expect(data.memberPlayers[testGuestId2]).toBeDefined(); - expect(Array.isArray(data.memberPlayers[testGuestId2])).toBe(true); + expect(data.memberPlayers).toBeDefined() + expect(data.memberPlayers[testGuestId2]).toBeDefined() + expect(Array.isArray(data.memberPlayers[testGuestId2])).toBe(true) // User 2's players should include the active player we created - const user2Players = data.memberPlayers[testGuestId2]; - expect(user2Players.length).toBeGreaterThan(0); - expect(user2Players.some((p: any) => p.id === player2.id)).toBe(true); + const user2Players = data.memberPlayers[testGuestId2] + expect(user2Players.length).toBeGreaterThan(0) + expect(user2Players.some((p: any) => p.id === player2.id)).toBe(true) // Clean up player - await db.delete(schema.players).where(eq(schema.players.id, player2.id)); - }); -}); + await db.delete(schema.players).where(eq(schema.players.id, player2.id)) + }) +}) diff --git a/apps/web/biome.jsonc b/apps/web/biome.jsonc index bb5b18d7..e1b33f1e 100644 --- a/apps/web/biome.jsonc +++ b/apps/web/biome.jsonc @@ -4,7 +4,7 @@ "enabled": true, "indentStyle": "space", "indentWidth": 2, - "lineWidth": 100, + "lineWidth": 100 }, "linter": { "enabled": true, @@ -16,19 +16,19 @@ "noLabelWithoutControl": "off", "noStaticElementInteractions": "off", "useKeyWithClickEvents": "off", - "useSemanticElements": "off", + "useSemanticElements": "off" }, "suspicious": { "noExplicitAny": "off", "noArrayIndexKey": "off", "noImplicitAnyLet": "off", "noAssignInExpressions": "off", - "useIterableCallbackReturn": "off", + "useIterableCallbackReturn": "off" }, "style": { "useNodejsImportProtocol": "off", "noNonNullAssertion": "off", - "noDescendingSpecificity": "off", + "noDescendingSpecificity": "off" }, "correctness": { "noUnusedVariables": "off", @@ -39,31 +39,31 @@ "noInvalidUseBeforeDeclaration": "off", "useHookAtTopLevel": "off", "noNestedComponentDefinitions": "off", - "noUnreachable": "off", + "noUnreachable": "off" }, "security": { - "noDangerouslySetInnerHtml": "off", + "noDangerouslySetInnerHtml": "off" }, "performance": { - "noAccumulatingSpread": "off", - }, - }, + "noAccumulatingSpread": "off" + } + } }, "files": { - "ignoreUnknown": true, + "ignoreUnknown": true }, "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true, - "defaultBranch": "main", + "defaultBranch": "main" }, "javascript": { "formatter": { "quoteStyle": "single", "jsxQuoteStyle": "double", "semicolons": "asNeeded", - "trailingCommas": "es5", - }, - }, + "trailingCommas": "es5" + } + } } diff --git a/apps/web/data/uploads/1b18daa4-e6fb-44c8-8d3d-c96c9f5afa01.jpg b/apps/web/data/uploads/1b18daa4-e6fb-44c8-8d3d-c96c9f5afa01.jpg new file mode 100644 index 00000000..87fd9a31 Binary files /dev/null and b/apps/web/data/uploads/1b18daa4-e6fb-44c8-8d3d-c96c9f5afa01.jpg differ diff --git a/apps/web/data/uploads/39ed57c6-fadd-4845-8caf-67189c97ddc1.jpg b/apps/web/data/uploads/39ed57c6-fadd-4845-8caf-67189c97ddc1.jpg new file mode 100644 index 00000000..5e8ee7d2 Binary files /dev/null and b/apps/web/data/uploads/39ed57c6-fadd-4845-8caf-67189c97ddc1.jpg differ diff --git a/apps/web/data/uploads/5c5bdde8-ad09-42cc-841d-1358032a061d.jpg b/apps/web/data/uploads/5c5bdde8-ad09-42cc-841d-1358032a061d.jpg new file mode 100644 index 00000000..7fcd3cd3 Binary files /dev/null and b/apps/web/data/uploads/5c5bdde8-ad09-42cc-841d-1358032a061d.jpg differ diff --git a/apps/web/data/uploads/788e16eb-81c7-48a3-981b-a6ed631c7485.jpg b/apps/web/data/uploads/788e16eb-81c7-48a3-981b-a6ed631c7485.jpg new file mode 100644 index 00000000..d9c1c510 Binary files /dev/null and b/apps/web/data/uploads/788e16eb-81c7-48a3-981b-a6ed631c7485.jpg differ diff --git a/apps/web/data/uploads/7ba7f6bd-dae7-4d9f-ad35-724b058397a8.jpg b/apps/web/data/uploads/7ba7f6bd-dae7-4d9f-ad35-724b058397a8.jpg new file mode 100644 index 00000000..6d8f16b1 Binary files /dev/null and b/apps/web/data/uploads/7ba7f6bd-dae7-4d9f-ad35-724b058397a8.jpg differ diff --git a/apps/web/data/uploads/86a7312f-da59-4b94-a377-f8ea94192c4e.jpg b/apps/web/data/uploads/86a7312f-da59-4b94-a377-f8ea94192c4e.jpg new file mode 100644 index 00000000..a8213cd4 Binary files /dev/null and b/apps/web/data/uploads/86a7312f-da59-4b94-a377-f8ea94192c4e.jpg differ diff --git a/apps/web/data/uploads/88bf1d94-795b-4093-a691-d11f29769d22.jpg b/apps/web/data/uploads/88bf1d94-795b-4093-a691-d11f29769d22.jpg new file mode 100644 index 00000000..e25f80ba Binary files /dev/null and b/apps/web/data/uploads/88bf1d94-795b-4093-a691-d11f29769d22.jpg differ diff --git a/apps/web/data/uploads/8e3f6ed1-f8ac-45af-9205-84eed4686087.jpg b/apps/web/data/uploads/8e3f6ed1-f8ac-45af-9205-84eed4686087.jpg new file mode 100644 index 00000000..05651ef9 Binary files /dev/null and b/apps/web/data/uploads/8e3f6ed1-f8ac-45af-9205-84eed4686087.jpg differ diff --git a/apps/web/data/uploads/901726bb-2e72-4888-a576-7e62d8a48167.jpg b/apps/web/data/uploads/901726bb-2e72-4888-a576-7e62d8a48167.jpg new file mode 100644 index 00000000..7f4d0268 Binary files /dev/null and b/apps/web/data/uploads/901726bb-2e72-4888-a576-7e62d8a48167.jpg differ diff --git a/apps/web/data/uploads/9063c46b-a336-4ea7-be15-47b353d02132.jpg b/apps/web/data/uploads/9063c46b-a336-4ea7-be15-47b353d02132.jpg new file mode 100644 index 00000000..d93bafad Binary files /dev/null and b/apps/web/data/uploads/9063c46b-a336-4ea7-be15-47b353d02132.jpg differ diff --git a/apps/web/data/uploads/9fc2f199-ec4d-46b0-bf2b-8c760a1d958b.jpg b/apps/web/data/uploads/9fc2f199-ec4d-46b0-bf2b-8c760a1d958b.jpg new file mode 100644 index 00000000..91b1168e Binary files /dev/null and b/apps/web/data/uploads/9fc2f199-ec4d-46b0-bf2b-8c760a1d958b.jpg differ diff --git a/apps/web/data/uploads/b4d2ed4d-9773-4153-9209-616f37d359f8.jpg b/apps/web/data/uploads/b4d2ed4d-9773-4153-9209-616f37d359f8.jpg new file mode 100644 index 00000000..1e229722 Binary files /dev/null and b/apps/web/data/uploads/b4d2ed4d-9773-4153-9209-616f37d359f8.jpg differ diff --git a/apps/web/data/uploads/e1d38f96-8988-4bbf-8a90-51ba9cf17826.jpg b/apps/web/data/uploads/e1d38f96-8988-4bbf-8a90-51ba9cf17826.jpg new file mode 100644 index 00000000..cafc6c26 Binary files /dev/null and b/apps/web/data/uploads/e1d38f96-8988-4bbf-8a90-51ba9cf17826.jpg differ diff --git a/apps/web/data/uploads/e80fd031-c8ea-4faa-ba3e-5981c58bcc78.jpg b/apps/web/data/uploads/e80fd031-c8ea-4faa-ba3e-5981c58bcc78.jpg new file mode 100644 index 00000000..e284a7db Binary files /dev/null and b/apps/web/data/uploads/e80fd031-c8ea-4faa-ba3e-5981c58bcc78.jpg differ diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index ccca8b3c..c7176122 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -1,12 +1,12 @@ -import type { Config } from "drizzle-kit"; +import type { Config } from 'drizzle-kit' export default { - schema: "./src/db/schema/index.ts", - out: "./drizzle", - dialect: "sqlite", + schema: './src/db/schema/index.ts', + out: './drizzle', + dialect: 'sqlite', dbCredentials: { - url: process.env.DATABASE_URL || "./data/sqlite.db", + url: process.env.DATABASE_URL || './data/sqlite.db', }, verbose: true, strict: true, -} satisfies Config; +} satisfies Config diff --git a/apps/web/drizzle/meta/0020_snapshot.json b/apps/web/drizzle/meta/0020_snapshot.json index f0fe507b..d25c8942 100644 --- a/apps/web/drizzle/meta/0020_snapshot.json +++ b/apps/web/drizzle/meta/0020_snapshot.json @@ -116,13 +116,9 @@ "abacus_settings_user_id_users_id_fk": { "name": "abacus_settings_user_id_users_id_fk", "tableFrom": "abacus_settings", - "columnsFrom": [ - "user_id" - ], + "columnsFrom": ["user_id"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -240,9 +236,7 @@ "indexes": { "arcade_rooms_code_unique": { "name": "arcade_rooms_code_unique", - "columns": [ - "code" - ], + "columns": ["code"], "isUnique": true } }, @@ -339,26 +333,18 @@ "arcade_sessions_room_id_arcade_rooms_id_fk": { "name": "arcade_sessions_room_id_arcade_rooms_id_fk", "tableFrom": "arcade_sessions", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" }, "arcade_sessions_user_id_users_id_fk": { "name": "arcade_sessions_user_id_users_id_fk", "tableFrom": "arcade_sessions", - "columnsFrom": [ - "user_id" - ], + "columnsFrom": ["user_id"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -424,9 +410,7 @@ "indexes": { "players_user_id_idx": { "name": "players_user_id_idx", - "columns": [ - "user_id" - ], + "columns": ["user_id"], "isUnique": false } }, @@ -434,13 +418,9 @@ "players_user_id_users_id_fk": { "name": "players_user_id_users_id_fk", "tableFrom": "players", - "columnsFrom": [ - "user_id" - ], + "columnsFrom": ["user_id"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -514,9 +494,7 @@ "indexes": { "idx_room_members_user_id_unique": { "name": "idx_room_members_user_id_unique", - "columns": [ - "user_id" - ], + "columns": ["user_id"], "isUnique": true } }, @@ -524,13 +502,9 @@ "room_members_room_id_arcade_rooms_id_fk": { "name": "room_members_room_id_arcade_rooms_id_fk", "tableFrom": "room_members", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -605,13 +579,9 @@ "room_member_history_room_id_arcade_rooms_id_fk": { "name": "room_member_history_room_id_arcade_rooms_id_fk", "tableFrom": "room_member_history", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -713,10 +683,7 @@ "indexes": { "idx_room_invitations_user_room": { "name": "idx_room_invitations_user_room", - "columns": [ - "user_id", - "room_id" - ], + "columns": ["user_id", "room_id"], "isUnique": true } }, @@ -724,13 +691,9 @@ "room_invitations_room_id_arcade_rooms_id_fk": { "name": "room_invitations_room_id_arcade_rooms_id_fk", "tableFrom": "room_invitations", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -833,13 +796,9 @@ "room_reports_room_id_arcade_rooms_id_fk": { "name": "room_reports_room_id_arcade_rooms_id_fk", "tableFrom": "room_reports", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -918,10 +877,7 @@ "indexes": { "idx_room_bans_user_room": { "name": "idx_room_bans_user_room", - "columns": [ - "user_id", - "room_id" - ], + "columns": ["user_id", "room_id"], "isUnique": true } }, @@ -929,13 +885,9 @@ "room_bans_room_id_arcade_rooms_id_fk": { "name": "room_bans_room_id_arcade_rooms_id_fk", "tableFrom": "room_bans", - "columnsFrom": [ - "room_id" - ], + "columnsFrom": ["room_id"], "tableTo": "arcade_rooms", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -998,13 +950,9 @@ "user_stats_user_id_users_id_fk": { "name": "user_stats_user_id_users_id_fk", "tableFrom": "user_stats", - "columnsFrom": [ - "user_id" - ], + "columnsFrom": ["user_id"], "tableTo": "users", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "no action", "onDelete": "cascade" } @@ -1062,16 +1010,12 @@ "indexes": { "users_guest_id_unique": { "name": "users_guest_id_unique", - "columns": [ - "guest_id" - ], + "columns": ["guest_id"], "isUnique": true }, "users_email_unique": { "name": "users_email_unique", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true } }, @@ -1091,4 +1035,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index cc30afba..d7b23fb8 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -150,4 +150,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/web/e2e/arcade-modal-session.spec.ts b/apps/web/e2e/arcade-modal-session.spec.ts index 8fb99df6..73bcd3ea 100644 --- a/apps/web/e2e/arcade-modal-session.spec.ts +++ b/apps/web/e2e/arcade-modal-session.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test' /** * Arcade Modal Session E2E Tests @@ -10,363 +10,329 @@ import { expect, test } from "@playwright/test"; * - "Return to Arcade" button properly ends sessions */ -test.describe("Arcade Modal Session - Redirects", () => { +test.describe('Arcade Modal Session - Redirects', () => { test.beforeEach(async ({ page }) => { // Clear arcade session before each test - await page.goto("/arcade"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade') + await page.waitForLoadState('networkidle') // Click "Return to Arcade" button if it exists (to clear any existing session) - const returnButton = page.locator('button:has-text("Return to Arcade")'); + const returnButton = page.locator('button:has-text("Return to Arcade")') if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await returnButton.click(); - await page.waitForLoadState("networkidle"); + await returnButton.click() + await page.waitForLoadState('networkidle') } - }); + }) - test("should stay on arcade lobby when no active session", async ({ - page, - }) => { - await page.goto("/arcade"); - await page.waitForLoadState("networkidle"); + test('should stay on arcade lobby when no active session', async ({ page }) => { + await page.goto('/arcade') + await page.waitForLoadState('networkidle') // Should see "Champion Arena" title - const title = page.locator('h1:has-text("Champion Arena")'); - await expect(title).toBeVisible(); + const title = page.locator('h1:has-text("Champion Arena")') + await expect(title).toBeVisible() // Should be able to select players - const playerSection = page.locator("text=/Player|Select|Add/i"); - await expect(playerSection.first()).toBeVisible(); - }); + const playerSection = page.locator('text=/Player|Select|Add/i') + await expect(playerSection.first()).toBeVisible() + }) - test("should redirect from arcade to active game when session exists", async ({ - page, - }) => { + test('should redirect from arcade to active game when session exists', async ({ page }) => { // Start a game to create a session - await page.goto("/arcade"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade') + await page.waitForLoadState('networkidle') // Find and click a player card to activate - const playerCard = page.locator('[data-testid="player-card"]').first(); + const playerCard = page.locator('[data-testid="player-card"]').first() if (await playerCard.isVisible({ timeout: 2000 }).catch(() => false)) { - await playerCard.click(); - await page.waitForTimeout(500); + await playerCard.click() + await page.waitForTimeout(500) } // Navigate to matching game to create session - await page.goto("/arcade/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade/matching') + await page.waitForLoadState('networkidle') // Start the game (click Start button if visible) - const startButton = page.locator('button:has-text("Start")'); + const startButton = page.locator('button:has-text("Start")') if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await startButton.click(); - await page.waitForTimeout(1000); + await startButton.click() + await page.waitForTimeout(1000) } // Try to navigate back to arcade lobby - await page.goto("/arcade"); - await page.waitForTimeout(2000); // Give time for redirect + await page.goto('/arcade') + await page.waitForTimeout(2000) // Give time for redirect // Should be redirected back to the game - await expect(page).toHaveURL(/\/arcade\/matching/); - const gameTitle = page.locator('h1:has-text("Memory Pairs")'); - await expect(gameTitle).toBeVisible(); - }); + await expect(page).toHaveURL(/\/arcade\/matching/) + const gameTitle = page.locator('h1:has-text("Memory Pairs")') + await expect(gameTitle).toBeVisible() + }) - test("should redirect to correct game when navigating to wrong game", async ({ - page, - }) => { + test('should redirect to correct game when navigating to wrong game', async ({ page }) => { // Create a session with matching game - await page.goto("/arcade"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade') + await page.waitForLoadState('networkidle') // Activate a player - const addPlayerButton = page.locator( - 'button:has-text("Add Player"), button:has-text("+")', - ); + const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")') if ( await addPlayerButton .first() .isVisible({ timeout: 2000 }) .catch(() => false) ) { - await addPlayerButton.first().click(); - await page.waitForTimeout(500); + await addPlayerButton.first().click() + await page.waitForTimeout(500) } // Go to matching game - await page.goto("/arcade/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade/matching') + await page.waitForLoadState('networkidle') // Start game if needed - const startButton = page.locator('button:has-text("Start")'); + const startButton = page.locator('button:has-text("Start")') if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await startButton.click(); - await page.waitForTimeout(1000); + await startButton.click() + await page.waitForTimeout(1000) } // Try to navigate to a different game - await page.goto("/arcade/memory-quiz"); - await page.waitForTimeout(2000); // Give time for redirect + await page.goto('/arcade/memory-quiz') + await page.waitForTimeout(2000) // Give time for redirect // Should be redirected back to matching - await expect(page).toHaveURL(/\/arcade\/matching/); - }); + await expect(page).toHaveURL(/\/arcade\/matching/) + }) - test("should NOT redirect when on correct game page", async ({ page }) => { + test('should NOT redirect when on correct game page', async ({ page }) => { // Navigate to matching game - await page.goto("/arcade/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade/matching') + await page.waitForLoadState('networkidle') // Should stay on matching page - await expect(page).toHaveURL(/\/arcade\/matching/); - const gameTitle = page.locator('h1:has-text("Memory Pairs")'); - await expect(gameTitle).toBeVisible(); - }); -}); + await expect(page).toHaveURL(/\/arcade\/matching/) + const gameTitle = page.locator('h1:has-text("Memory Pairs")') + await expect(gameTitle).toBeVisible() + }) +}) -test.describe("Arcade Modal Session - Player Modification Blocking", () => { +test.describe('Arcade Modal Session - Player Modification Blocking', () => { test.beforeEach(async ({ page }) => { // Clear session - await page.goto("/arcade"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade') + await page.waitForLoadState('networkidle') - const returnButton = page.locator('button:has-text("Return to Arcade")'); + const returnButton = page.locator('button:has-text("Return to Arcade")') if (await returnButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await returnButton.click(); - await page.waitForLoadState("networkidle"); + await returnButton.click() + await page.waitForLoadState('networkidle') } - }); + }) - test("should allow player modification in arcade lobby with no session", async ({ - page, - }) => { - await page.goto("/arcade"); - await page.waitForLoadState("networkidle"); + test('should allow player modification in arcade lobby with no session', async ({ page }) => { + await page.goto('/arcade') + await page.waitForLoadState('networkidle') // Look for add player button (should be enabled) - const addPlayerButton = page.locator( - 'button:has-text("Add Player"), button:has-text("+")', - ); - const firstButton = addPlayerButton.first(); + const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")') + const firstButton = addPlayerButton.first() if (await firstButton.isVisible({ timeout: 2000 }).catch(() => false)) { // Should be clickable - await expect(firstButton).toBeEnabled(); + await expect(firstButton).toBeEnabled() // Try to click it - await firstButton.click(); - await page.waitForTimeout(500); + await firstButton.click() + await page.waitForTimeout(500) // Should see player added - const activePlayer = page.locator('[data-testid="active-player"]'); - await expect(activePlayer.first()).toBeVisible({ timeout: 3000 }); + const activePlayer = page.locator('[data-testid="active-player"]') + await expect(activePlayer.first()).toBeVisible({ timeout: 3000 }) } - }); + }) - test("should block player modification during active game", async ({ - page, - }) => { + test('should block player modification during active game', async ({ page }) => { // Start a game - await page.goto("/arcade/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade/matching') + await page.waitForLoadState('networkidle') // Start game - const startButton = page.locator('button:has-text("Start")'); + const startButton = page.locator('button:has-text("Start")') if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await startButton.click(); - await page.waitForTimeout(1000); + await startButton.click() + await page.waitForTimeout(1000) } // Look for player modification controls // They should be disabled or have reduced opacity - const playerControls = page.locator( - '[data-testid="player-controls"], .player-list', - ); + const playerControls = page.locator('[data-testid="player-controls"], .player-list') if (await playerControls.isVisible({ timeout: 1000 }).catch(() => false)) { // Check if controls have pointer-events: none or low opacity const opacity = await playerControls.evaluate((el) => { - return window.getComputedStyle(el).opacity; - }); + return window.getComputedStyle(el).opacity + }) // If controls are visible, they should be dimmed (opacity < 1) if (parseFloat(opacity) < 1) { - expect(parseFloat(opacity)).toBeLessThan(1); + expect(parseFloat(opacity)).toBeLessThan(1) } } // "Add Player" button should not be visible during game - const addPlayerButton = page.locator('button:has-text("Add Player")'); + const addPlayerButton = page.locator('button:has-text("Add Player")') if (await addPlayerButton.isVisible({ timeout: 500 }).catch(() => false)) { // If visible, should be disabled - const isDisabled = await addPlayerButton.isDisabled(); - expect(isDisabled).toBe(true); + const isDisabled = await addPlayerButton.isDisabled() + expect(isDisabled).toBe(true) } - }); + }) - test('should show "Return to Arcade" button during game', async ({ - page, - }) => { + test('should show "Return to Arcade" button during game', async ({ page }) => { // Start a game - await page.goto("/arcade/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade/matching') + await page.waitForLoadState('networkidle') // Look for "Return to Arcade" button - const returnButton = page.locator('button:has-text("Return to Arcade")'); + const returnButton = page.locator('button:has-text("Return to Arcade")') // During game setup, might see "Setup" button instead - const setupButton = page.locator('button:has-text("Setup")'); + const setupButton = page.locator('button:has-text("Setup")') // One of these should be visible - const hasReturnButton = await returnButton - .isVisible({ timeout: 2000 }) - .catch(() => false); - const hasSetupButton = await setupButton - .isVisible({ timeout: 2000 }) - .catch(() => false); + const hasReturnButton = await returnButton.isVisible({ timeout: 2000 }).catch(() => false) + const hasSetupButton = await setupButton.isVisible({ timeout: 2000 }).catch(() => false) - expect(hasReturnButton || hasSetupButton).toBe(true); - }); + expect(hasReturnButton || hasSetupButton).toBe(true) + }) - test('should NOT show "Setup" button in arcade lobby with no session', async ({ - page, - }) => { - await page.goto("/arcade"); - await page.waitForLoadState("networkidle"); + test('should NOT show "Setup" button in arcade lobby with no session', async ({ page }) => { + await page.goto('/arcade') + await page.waitForLoadState('networkidle') // Should NOT see "Return to Arcade" or "Setup" button in lobby - const returnButton = page.locator('button:has-text("Return to Arcade")'); - const setupButton = page.locator('button:has-text("Setup")'); + const returnButton = page.locator('button:has-text("Return to Arcade")') + const setupButton = page.locator('button:has-text("Setup")') - const hasReturnButton = await returnButton - .isVisible({ timeout: 1000 }) - .catch(() => false); - const hasSetupButton = await setupButton - .isVisible({ timeout: 1000 }) - .catch(() => false); + const hasReturnButton = await returnButton.isVisible({ timeout: 1000 }).catch(() => false) + const hasSetupButton = await setupButton.isVisible({ timeout: 1000 }).catch(() => false) // Neither should be visible in empty lobby - expect(hasReturnButton).toBe(false); - expect(hasSetupButton).toBe(false); - }); -}); + expect(hasReturnButton).toBe(false) + expect(hasSetupButton).toBe(false) + }) +}) -test.describe("Arcade Modal Session - Return to Arcade Button", () => { +test.describe('Arcade Modal Session - Return to Arcade Button', () => { test.beforeEach(async ({ page }) => { // Clear session - await page.goto("/arcade"); - await page.waitForLoadState("networkidle"); - }); + await page.goto('/arcade') + await page.waitForLoadState('networkidle') + }) test('should end session and return to arcade when clicking "Return to Arcade"', async ({ page, }) => { // Start a game - await page.goto("/arcade/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade/matching') + await page.waitForLoadState('networkidle') // Start game if needed - const startButton = page.locator('button:has-text("Start")'); + const startButton = page.locator('button:has-text("Start")') if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await startButton.click(); - await page.waitForTimeout(1000); + await startButton.click() + await page.waitForTimeout(1000) } // Find and click "Return to Arcade" button - const returnButton = page.locator('button:has-text("Return to Arcade")'); + const returnButton = page.locator('button:has-text("Return to Arcade")') if (await returnButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await returnButton.click(); - await page.waitForTimeout(1000); + await returnButton.click() + await page.waitForTimeout(1000) // Should be redirected to arcade lobby - await expect(page).toHaveURL(/\/arcade\/?$/); + await expect(page).toHaveURL(/\/arcade\/?$/) // Should see arcade lobby title - const title = page.locator('h1:has-text("Champion Arena")'); - await expect(title).toBeVisible(); + const title = page.locator('h1:has-text("Champion Arena")') + await expect(title).toBeVisible() // Now should be able to modify players again - const addPlayerButton = page.locator( - 'button:has-text("Add Player"), button:has-text("+")', - ); + const addPlayerButton = page.locator('button:has-text("Add Player"), button:has-text("+")') if ( await addPlayerButton .first() .isVisible({ timeout: 2000 }) .catch(() => false) ) { - await expect(addPlayerButton.first()).toBeEnabled(); + await expect(addPlayerButton.first()).toBeEnabled() } } - }); + }) - test("should allow navigating to different game after returning to arcade", async ({ - page, - }) => { + test('should allow navigating to different game after returning to arcade', async ({ page }) => { // Start matching game - await page.goto("/arcade/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade/matching') + await page.waitForLoadState('networkidle') // Return to arcade const returnButton = page.locator( - 'button:has-text("Return to Arcade"), button:has-text("Setup")', - ); + 'button:has-text("Return to Arcade"), button:has-text("Setup")' + ) if ( await returnButton .first() .isVisible({ timeout: 2000 }) .catch(() => false) ) { - await returnButton.first().click(); - await page.waitForTimeout(1000); + await returnButton.first().click() + await page.waitForTimeout(1000) } // Should be in arcade lobby - await expect(page).toHaveURL(/\/arcade\/?$/); + await expect(page).toHaveURL(/\/arcade\/?$/) // Now navigate to different game - should NOT redirect back to matching - await page.goto("/arcade/memory-quiz"); - await page.waitForTimeout(2000); + await page.goto('/arcade/memory-quiz') + await page.waitForTimeout(2000) // Should stay on memory-quiz (not redirect back to matching) - await expect(page).toHaveURL(/\/arcade\/memory-quiz/); + await expect(page).toHaveURL(/\/arcade\/memory-quiz/) // Should see memory quiz title - const title = page.locator('h1:has-text("Memory Lightning")'); - await expect(title).toBeVisible({ timeout: 3000 }); - }); -}); + const title = page.locator('h1:has-text("Memory Lightning")') + await expect(title).toBeVisible({ timeout: 3000 }) + }) +}) -test.describe("Arcade Modal Session - Session Persistence", () => { - test("should maintain active session across page reloads", async ({ - page, - }) => { +test.describe('Arcade Modal Session - Session Persistence', () => { + test('should maintain active session across page reloads', async ({ page }) => { // Start a game - await page.goto("/arcade/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/arcade/matching') + await page.waitForLoadState('networkidle') // Start game - const startButton = page.locator('button:has-text("Start")'); + const startButton = page.locator('button:has-text("Start")') if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await startButton.click(); - await page.waitForTimeout(1000); + await startButton.click() + await page.waitForTimeout(1000) } // Reload the page - await page.reload(); - await page.waitForLoadState("networkidle"); + await page.reload() + await page.waitForLoadState('networkidle') // Should still be on matching game - await expect(page).toHaveURL(/\/arcade\/matching/); - const gameTitle = page.locator('h1:has-text("Memory Pairs")'); - await expect(gameTitle).toBeVisible(); + await expect(page).toHaveURL(/\/arcade\/matching/) + const gameTitle = page.locator('h1:has-text("Memory Pairs")') + await expect(gameTitle).toBeVisible() // Try to navigate to arcade - await page.goto("/arcade"); - await page.waitForTimeout(2000); + await page.goto('/arcade') + await page.waitForTimeout(2000) // Should be redirected back to matching - await expect(page).toHaveURL(/\/arcade\/matching/); - }); -}); + await expect(page).toHaveURL(/\/arcade\/matching/) + }) +}) diff --git a/apps/web/e2e/join-room-flow.spec.ts b/apps/web/e2e/join-room-flow.spec.ts index 6cb300fa..fbb2719d 100644 --- a/apps/web/e2e/join-room-flow.spec.ts +++ b/apps/web/e2e/join-room-flow.spec.ts @@ -1,333 +1,296 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test' -test.describe("Join Room Flow", () => { - test.describe("Room Creation", () => { - test("should create a room from the game page", async ({ page }) => { +test.describe('Join Room Flow', () => { + test.describe('Room Creation', () => { + test('should create a room from the game page', async ({ page }) => { // Navigate to a game - await page.goto("/games/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/games/matching') + await page.waitForLoadState('networkidle') // Click the (+) Add Player button to open the popover - const addPlayerButton = page.locator('button[title="Add player"]'); - await expect(addPlayerButton).toBeVisible(); - await addPlayerButton.click(); + const addPlayerButton = page.locator('button[title="Add player"]') + await expect(addPlayerButton).toBeVisible() + await addPlayerButton.click() // Wait for popover to appear - await page.waitForTimeout(300); + await page.waitForTimeout(300) // Click the "Play Online" or "Invite Players" tab - const onlineTab = page.locator( - 'button:has-text("Play Online"), button:has-text("Invite")', - ); - await expect(onlineTab.first()).toBeVisible(); - await onlineTab.first().click(); + const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")') + await expect(onlineTab.first()).toBeVisible() + await onlineTab.first().click() // Click "Create New Room" button - const createRoomButton = page.locator( - 'button:has-text("Create New Room")', - ); - await expect(createRoomButton).toBeVisible(); - await createRoomButton.click(); + const createRoomButton = page.locator('button:has-text("Create New Room")') + await expect(createRoomButton).toBeVisible() + await createRoomButton.click() // Wait for room creation to complete - await page.waitForTimeout(1000); + await page.waitForTimeout(1000) // Verify we're now in a room - should see room info in nav - const roomInfo = page.locator("text=/Room|Code/i"); - await expect(roomInfo).toBeVisible({ timeout: 5000 }); - }); - }); + const roomInfo = page.locator('text=/Room|Code/i') + await expect(roomInfo).toBeVisible({ timeout: 5000 }) + }) + }) - test.describe("Join Room by Code", () => { - let roomCode: string; + test.describe('Join Room by Code', () => { + let roomCode: string test.beforeEach(async ({ page }) => { // Create a room first - await page.goto("/games/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/games/matching') + await page.waitForLoadState('networkidle') - const addPlayerButton = page.locator('button[title="Add player"]'); - await addPlayerButton.click(); - await page.waitForTimeout(300); + const addPlayerButton = page.locator('button[title="Add player"]') + await addPlayerButton.click() + await page.waitForTimeout(300) - const onlineTab = page.locator( - 'button:has-text("Play Online"), button:has-text("Invite")', - ); - await onlineTab.first().click(); + const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")') + await onlineTab.first().click() - const createRoomButton = page.locator( - 'button:has-text("Create New Room")', - ); - await createRoomButton.click(); - await page.waitForTimeout(1000); + const createRoomButton = page.locator('button:has-text("Create New Room")') + await createRoomButton.click() + await page.waitForTimeout(1000) // Extract the room code from the page - const roomCodeElement = page.locator("text=/[A-Z]{3}-[0-9]{3}/"); - await expect(roomCodeElement).toBeVisible({ timeout: 5000 }); - const roomCodeText = await roomCodeElement.textContent(); - roomCode = roomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ""; - expect(roomCode).toMatch(/[A-Z]{3}-[0-9]{3}/); - }); + const roomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/') + await expect(roomCodeElement).toBeVisible({ timeout: 5000 }) + const roomCodeText = await roomCodeElement.textContent() + roomCode = roomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || '' + expect(roomCode).toMatch(/[A-Z]{3}-[0-9]{3}/) + }) - test("should join room via direct URL", async ({ page, context }) => { + test('should join room via direct URL', async ({ page, context }) => { // Open a new page (simulating a different user) - const newPage = await context.newPage(); + const newPage = await context.newPage() // Navigate to the join URL - await newPage.goto(`/join/${roomCode}`); - await newPage.waitForLoadState("networkidle"); + await newPage.goto(`/join/${roomCode}`) + await newPage.waitForLoadState('networkidle') // Should show "Joining room..." or redirect to game - await newPage.waitForTimeout(1000); + await newPage.waitForTimeout(1000) // Should now be in the room - const url = newPage.url(); - expect(url).toContain("/arcade"); - }); + const url = newPage.url() + expect(url).toContain('/arcade') + }) - test("should show error for invalid room code", async ({ - page, - context, - }) => { - const newPage = await context.newPage(); + test('should show error for invalid room code', async ({ page, context }) => { + const newPage = await context.newPage() // Try to join with invalid code - await newPage.goto("/join/INVALID"); - await newPage.waitForLoadState("networkidle"); + await newPage.goto('/join/INVALID') + await newPage.waitForLoadState('networkidle') // Should show error message - const errorMessage = newPage.locator("text=/not found|failed/i"); - await expect(errorMessage).toBeVisible({ timeout: 5000 }); - }); + const errorMessage = newPage.locator('text=/not found|failed/i') + await expect(errorMessage).toBeVisible({ timeout: 5000 }) + }) - test("should show confirmation when switching rooms", async ({ page }) => { + test('should show confirmation when switching rooms', async ({ page }) => { // User is already in a room from beforeEach // Try to join a different room (we'll create another one) - const addPlayerButton = page.locator('button[title="Add player"]'); - await addPlayerButton.click(); - await page.waitForTimeout(300); + const addPlayerButton = page.locator('button[title="Add player"]') + await addPlayerButton.click() + await page.waitForTimeout(300) - const onlineTab = page.locator( - 'button:has-text("Play Online"), button:has-text("Invite")', - ); - await onlineTab.first().click(); + const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")') + await onlineTab.first().click() - const createRoomButton = page.locator( - 'button:has-text("Create New Room")', - ); - await createRoomButton.click(); - await page.waitForTimeout(1000); + const createRoomButton = page.locator('button:has-text("Create New Room")') + await createRoomButton.click() + await page.waitForTimeout(1000) // Get the new room code - const newRoomCodeElement = page.locator("text=/[A-Z]{3}-[0-9]{3}/"); - await expect(newRoomCodeElement).toBeVisible({ timeout: 5000 }); - const newRoomCodeText = await newRoomCodeElement.textContent(); - const newRoomCode = - newRoomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ""; + const newRoomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/') + await expect(newRoomCodeElement).toBeVisible({ timeout: 5000 }) + const newRoomCodeText = await newRoomCodeElement.textContent() + const newRoomCode = newRoomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || '' // Navigate to join the new room - await page.goto(`/join/${newRoomCode}`); - await page.waitForLoadState("networkidle"); + await page.goto(`/join/${newRoomCode}`) + await page.waitForLoadState('networkidle') // Should show room switch confirmation - const confirmationDialog = page.locator( - "text=/Switch Rooms?|already in another room/i", - ); - await expect(confirmationDialog).toBeVisible({ timeout: 3000 }); + const confirmationDialog = page.locator('text=/Switch Rooms?|already in another room/i') + await expect(confirmationDialog).toBeVisible({ timeout: 3000 }) // Should show both room codes - await expect(page.locator(`text=${roomCode}`)).toBeVisible(); - await expect(page.locator(`text=${newRoomCode}`)).toBeVisible(); + await expect(page.locator(`text=${roomCode}`)).toBeVisible() + await expect(page.locator(`text=${newRoomCode}`)).toBeVisible() // Click "Switch Rooms" button - const switchButton = page.locator('button:has-text("Switch Rooms")'); - await expect(switchButton).toBeVisible(); - await switchButton.click(); + const switchButton = page.locator('button:has-text("Switch Rooms")') + await expect(switchButton).toBeVisible() + await switchButton.click() // Should navigate to the new room - await page.waitForTimeout(1000); - const url = page.url(); - expect(url).toContain("/arcade"); - }); + await page.waitForTimeout(1000) + const url = page.url() + expect(url).toContain('/arcade') + }) - test("should stay in current room when canceling switch", async ({ - page, - }) => { + test('should stay in current room when canceling switch', async ({ page }) => { // User is already in a room from beforeEach - const originalRoomCode = roomCode; + const originalRoomCode = roomCode // Create another room to try switching to - const addPlayerButton = page.locator('button[title="Add player"]'); - await addPlayerButton.click(); - await page.waitForTimeout(300); + const addPlayerButton = page.locator('button[title="Add player"]') + await addPlayerButton.click() + await page.waitForTimeout(300) - const onlineTab = page.locator( - 'button:has-text("Play Online"), button:has-text("Invite")', - ); - await onlineTab.first().click(); + const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")') + await onlineTab.first().click() - const createRoomButton = page.locator( - 'button:has-text("Create New Room")', - ); - await createRoomButton.click(); - await page.waitForTimeout(1000); + const createRoomButton = page.locator('button:has-text("Create New Room")') + await createRoomButton.click() + await page.waitForTimeout(1000) - const newRoomCodeElement = page.locator("text=/[A-Z]{3}-[0-9]{3}/"); - const newRoomCodeText = await newRoomCodeElement.textContent(); - const newRoomCode = - newRoomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || ""; + const newRoomCodeElement = page.locator('text=/[A-Z]{3}-[0-9]{3}/') + const newRoomCodeText = await newRoomCodeElement.textContent() + const newRoomCode = newRoomCodeText?.match(/[A-Z]{3}-[0-9]{3}/)?.[0] || '' // Navigate to join the new room - await page.goto(`/join/${newRoomCode}`); - await page.waitForLoadState("networkidle"); + await page.goto(`/join/${newRoomCode}`) + await page.waitForLoadState('networkidle') // Should show confirmation - const confirmationDialog = page.locator("text=/Switch Rooms?/i"); - await expect(confirmationDialog).toBeVisible({ timeout: 3000 }); + const confirmationDialog = page.locator('text=/Switch Rooms?/i') + await expect(confirmationDialog).toBeVisible({ timeout: 3000 }) // Click "Cancel" - const cancelButton = page.locator('button:has-text("Cancel")'); - await expect(cancelButton).toBeVisible(); - await cancelButton.click(); + const cancelButton = page.locator('button:has-text("Cancel")') + await expect(cancelButton).toBeVisible() + await cancelButton.click() // Should stay on original room - await page.waitForTimeout(500); - const url = page.url(); - expect(url).toContain("/arcade"); + await page.waitForTimeout(500) + const url = page.url() + expect(url).toContain('/arcade') // Should still see original room code - await expect(page.locator(`text=${originalRoomCode}`)).toBeVisible(); - }); - }); + await expect(page.locator(`text=${originalRoomCode}`)).toBeVisible() + }) + }) - test.describe("Join Room Input Validation", () => { - test("should format room code as user types", async ({ page }) => { - await page.goto("/games/matching"); - await page.waitForLoadState("networkidle"); + test.describe('Join Room Input Validation', () => { + test('should format room code as user types', async ({ page }) => { + await page.goto('/games/matching') + await page.waitForLoadState('networkidle') // Open the add player popover - const addPlayerButton = page.locator('button[title="Add player"]'); - await addPlayerButton.click(); - await page.waitForTimeout(300); + const addPlayerButton = page.locator('button[title="Add player"]') + await addPlayerButton.click() + await page.waitForTimeout(300) // Switch to Play Online tab - const onlineTab = page.locator('button:has-text("Play Online")'); + const onlineTab = page.locator('button:has-text("Play Online")') if (await onlineTab.isVisible()) { - await onlineTab.click(); + await onlineTab.click() } // Find the room code input - const codeInput = page.locator('input[placeholder*="ABC"]'); - await expect(codeInput).toBeVisible({ timeout: 3000 }); + const codeInput = page.locator('input[placeholder*="ABC"]') + await expect(codeInput).toBeVisible({ timeout: 3000 }) // Type a room code - await codeInput.fill("abc123"); + await codeInput.fill('abc123') // Should be formatted as ABC-123 - const inputValue = await codeInput.inputValue(); - expect(inputValue).toBe("ABC-123"); - }); + const inputValue = await codeInput.inputValue() + expect(inputValue).toBe('ABC-123') + }) - test("should validate room code in real-time", async ({ page }) => { - await page.goto("/games/matching"); - await page.waitForLoadState("networkidle"); + test('should validate room code in real-time', async ({ page }) => { + await page.goto('/games/matching') + await page.waitForLoadState('networkidle') - const addPlayerButton = page.locator('button[title="Add player"]'); - await addPlayerButton.click(); - await page.waitForTimeout(300); + const addPlayerButton = page.locator('button[title="Add player"]') + await addPlayerButton.click() + await page.waitForTimeout(300) - const onlineTab = page.locator('button:has-text("Play Online")'); + const onlineTab = page.locator('button:has-text("Play Online")') if (await onlineTab.isVisible()) { - await onlineTab.click(); + await onlineTab.click() } - const codeInput = page.locator('input[placeholder*="ABC"]'); - await expect(codeInput).toBeVisible({ timeout: 3000 }); + const codeInput = page.locator('input[placeholder*="ABC"]') + await expect(codeInput).toBeVisible({ timeout: 3000 }) // Type an invalid code - await codeInput.fill("INVALID"); + await codeInput.fill('INVALID') // Should show validation icon (❌) - await page.waitForTimeout(500); - const validationIcon = page.locator("text=/❌|Room not found/i"); - await expect(validationIcon).toBeVisible({ timeout: 3000 }); - }); - }); + await page.waitForTimeout(500) + const validationIcon = page.locator('text=/❌|Room not found/i') + await expect(validationIcon).toBeVisible({ timeout: 3000 }) + }) + }) - test.describe("Recent Rooms List", () => { - test("should show recently joined rooms", async ({ page }) => { + test.describe('Recent Rooms List', () => { + test('should show recently joined rooms', async ({ page }) => { // Create and join a room - await page.goto("/games/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/games/matching') + await page.waitForLoadState('networkidle') - const addPlayerButton = page.locator('button[title="Add player"]'); - await addPlayerButton.click(); - await page.waitForTimeout(300); + const addPlayerButton = page.locator('button[title="Add player"]') + await addPlayerButton.click() + await page.waitForTimeout(300) - const onlineTab = page.locator( - 'button:has-text("Play Online"), button:has-text("Invite")', - ); - await onlineTab.first().click(); + const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")') + await onlineTab.first().click() - const createRoomButton = page.locator( - 'button:has-text("Create New Room")', - ); - await createRoomButton.click(); - await page.waitForTimeout(1000); + const createRoomButton = page.locator('button:has-text("Create New Room")') + await createRoomButton.click() + await page.waitForTimeout(1000) // Leave the room - const leaveButton = page.locator( - 'button:has-text("Leave"), button:has-text("Quit")', - ); + const leaveButton = page.locator('button:has-text("Leave"), button:has-text("Quit")') if (await leaveButton.isVisible()) { - await leaveButton.click(); - await page.waitForTimeout(500); + await leaveButton.click() + await page.waitForTimeout(500) } // Open the popover again - await addPlayerButton.click(); - await page.waitForTimeout(300); + await addPlayerButton.click() + await page.waitForTimeout(300) - await onlineTab.first().click(); + await onlineTab.first().click() // Should see "Recent Rooms" section - const recentRoomsSection = page.locator("text=/Recent Rooms/i"); - await expect(recentRoomsSection).toBeVisible({ timeout: 3000 }); + const recentRoomsSection = page.locator('text=/Recent Rooms/i') + await expect(recentRoomsSection).toBeVisible({ timeout: 3000 }) // Should see at least one room in the list - const roomListItem = page.locator("text=/[A-Z]{3}-[0-9]{3}/"); - await expect(roomListItem.first()).toBeVisible(); - }); - }); + const roomListItem = page.locator('text=/[A-Z]{3}-[0-9]{3}/') + await expect(roomListItem.first()).toBeVisible() + }) + }) - test.describe("Room Ownership", () => { - test("creator should see room controls", async ({ page }) => { + test.describe('Room Ownership', () => { + test('creator should see room controls', async ({ page }) => { // Create a room - await page.goto("/games/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/games/matching') + await page.waitForLoadState('networkidle') - const addPlayerButton = page.locator('button[title="Add player"]'); - await addPlayerButton.click(); - await page.waitForTimeout(300); + const addPlayerButton = page.locator('button[title="Add player"]') + await addPlayerButton.click() + await page.waitForTimeout(300) - const onlineTab = page.locator( - 'button:has-text("Play Online"), button:has-text("Invite")', - ); - await onlineTab.first().click(); + const onlineTab = page.locator('button:has-text("Play Online"), button:has-text("Invite")') + await onlineTab.first().click() - const createRoomButton = page.locator( - 'button:has-text("Create New Room")', - ); - await createRoomButton.click(); - await page.waitForTimeout(1000); + const createRoomButton = page.locator('button:has-text("Create New Room")') + await createRoomButton.click() + await page.waitForTimeout(1000) // Creator should see room management controls // (e.g., leave room, room settings, etc.) - const roomControls = page.locator( - 'button:has-text("Leave"), button:has-text("Settings")', - ); - await expect(roomControls.first()).toBeVisible({ timeout: 3000 }); - }); - }); -}); + const roomControls = page.locator('button:has-text("Leave"), button:has-text("Settings")') + await expect(roomControls.first()).toBeVisible({ timeout: 3000 }) + }) + }) +}) diff --git a/apps/web/e2e/mini-nav-persistence.spec.ts b/apps/web/e2e/mini-nav-persistence.spec.ts index 98d448e0..ec0f9082 100644 --- a/apps/web/e2e/mini-nav-persistence.spec.ts +++ b/apps/web/e2e/mini-nav-persistence.spec.ts @@ -1,117 +1,115 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test' -test.describe("Mini Navigation Game Name Persistence", () => { - test("should not show game name when navigating back to games page from a specific game", async ({ +test.describe('Mini Navigation Game Name Persistence', () => { + test('should not show game name when navigating back to games page from a specific game', async ({ page, }) => { // Override baseURL for this test to match running dev server - const baseURL = "http://localhost:3000"; + const baseURL = 'http://localhost:3000' // Start at home page - await page.goto(baseURL); + await page.goto(baseURL) // Navigate to games page - should not have game name in mini nav - await page.click('a[href="/games"]'); - await page.waitForURL("/games"); + await page.click('a[href="/games"]') + await page.waitForURL('/games') // Check that mini nav doesn't show game name initially - const initialGameName = page.locator('[data-testid="mini-nav-game-name"]'); - await expect(initialGameName).not.toBeVisible(); + const initialGameName = page.locator('[data-testid="mini-nav-game-name"]') + await expect(initialGameName).not.toBeVisible() // Navigate to Memory Pairs game - await page.click('a[href="/games/matching"]'); - await page.waitForURL("/games/matching"); + await page.click('a[href="/games/matching"]') + await page.waitForURL('/games/matching') // Verify game name appears in mini nav - const memoryPairsName = page.locator("text=🧩 Memory Pairs"); - await expect(memoryPairsName).toBeVisible(); + const memoryPairsName = page.locator('text=🧩 Memory Pairs') + await expect(memoryPairsName).toBeVisible() // Navigate back to games page using mini nav - await page.click('a[href="/games"]'); - await page.waitForURL("/games"); + await page.click('a[href="/games"]') + await page.waitForURL('/games') // BUG: Game name should disappear but it persists // This test should FAIL initially, demonstrating the bug - await expect(memoryPairsName).not.toBeVisible(); + await expect(memoryPairsName).not.toBeVisible() // Also test with Memory Lightning game - await page.click('a[href="/games/memory-quiz"]'); - await page.waitForURL("/games/memory-quiz"); + await page.click('a[href="/games/memory-quiz"]') + await page.waitForURL('/games/memory-quiz') // Verify Memory Lightning name appears - const memoryLightningName = page.locator("text=🧠 Memory Lightning"); - await expect(memoryLightningName).toBeVisible(); + const memoryLightningName = page.locator('text=🧠 Memory Lightning') + await expect(memoryLightningName).toBeVisible() // Navigate back to games page - await page.click('a[href="/games"]'); - await page.waitForURL("/games"); + await page.click('a[href="/games"]') + await page.waitForURL('/games') // Game name should disappear - await expect(memoryLightningName).not.toBeVisible(); - }); + await expect(memoryLightningName).not.toBeVisible() + }) - test("should show correct game name when switching between different games", async ({ - page, - }) => { + test('should show correct game name when switching between different games', async ({ page }) => { // Override baseURL for this test to match running dev server - const baseURL = "http://localhost:3000"; + const baseURL = 'http://localhost:3000' // Start at Memory Pairs - await page.goto(`${baseURL}/games/matching`); - await expect(page.locator("text=🧩 Memory Pairs")).toBeVisible(); + await page.goto(`${baseURL}/games/matching`) + await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible() // Switch to Memory Lightning - await page.click('a[href="/games/memory-quiz"]'); - await page.waitForURL("/games/memory-quiz"); + await page.click('a[href="/games/memory-quiz"]') + await page.waitForURL('/games/memory-quiz') // Should show Memory Lightning and NOT Memory Pairs - await expect(page.locator("text=🧠 Memory Lightning")).toBeVisible(); - await expect(page.locator("text=🧩 Memory Pairs")).not.toBeVisible(); + await expect(page.locator('text=🧠 Memory Lightning')).toBeVisible() + await expect(page.locator('text=🧩 Memory Pairs')).not.toBeVisible() // Switch back to Memory Pairs - await page.click('a[href="/games/matching"]'); - await page.waitForURL("/games/matching"); + await page.click('a[href="/games/matching"]') + await page.waitForURL('/games/matching') // Should show Memory Pairs and NOT Memory Lightning - await expect(page.locator("text=🧩 Memory Pairs")).toBeVisible(); - await expect(page.locator("text=🧠 Memory Lightning")).not.toBeVisible(); - }); + await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible() + await expect(page.locator('text=🧠 Memory Lightning')).not.toBeVisible() + }) - test("should not persist game name when navigating through intermediate pages", async ({ + test('should not persist game name when navigating through intermediate pages', async ({ page, }) => { // Override baseURL for this test to match running dev server - const baseURL = "http://localhost:3000"; + const baseURL = 'http://localhost:3000' // Start at Memory Pairs game - should show game name - await page.goto(`${baseURL}/games/matching`); - const memoryPairsName = page.locator("text=🧩 Memory Pairs"); - await expect(memoryPairsName).toBeVisible(); + await page.goto(`${baseURL}/games/matching`) + const memoryPairsName = page.locator('text=🧩 Memory Pairs') + await expect(memoryPairsName).toBeVisible() // Navigate to Guide page - game name should disappear - await page.click('a[href="/guide"]'); - await page.waitForURL("/guide"); - await expect(memoryPairsName).not.toBeVisible(); + await page.click('a[href="/guide"]') + await page.waitForURL('/guide') + await expect(memoryPairsName).not.toBeVisible() // Navigate to Games page - game name should still be gone - await page.click('a[href="/games"]'); - await page.waitForURL("/games"); - await expect(memoryPairsName).not.toBeVisible(); + await page.click('a[href="/games"]') + await page.waitForURL('/games') + await expect(memoryPairsName).not.toBeVisible() // Test another path: Game -> Create -> Games - await page.goto(`${baseURL}/games/memory-quiz`); - const memoryLightningName = page.locator("text=🧠 Memory Lightning"); - await expect(memoryLightningName).toBeVisible(); + await page.goto(`${baseURL}/games/memory-quiz`) + const memoryLightningName = page.locator('text=🧠 Memory Lightning') + await expect(memoryLightningName).toBeVisible() // Navigate to Create page - await page.click('a[href="/create"]'); - await page.waitForURL("/create"); - await expect(memoryLightningName).not.toBeVisible(); + await page.click('a[href="/create"]') + await page.waitForURL('/create') + await expect(memoryLightningName).not.toBeVisible() // Navigate to Games page - should not show any game name - await page.click('a[href="/games"]'); - await page.waitForURL("/games"); - await expect(memoryLightningName).not.toBeVisible(); - await expect(memoryPairsName).not.toBeVisible(); - }); -}); + await page.click('a[href="/games"]') + await page.waitForURL('/games') + await expect(memoryLightningName).not.toBeVisible() + await expect(memoryPairsName).not.toBeVisible() + }) +}) diff --git a/apps/web/e2e/nav-slot.spec.ts b/apps/web/e2e/nav-slot.spec.ts index 8e616c17..69e612a6 100644 --- a/apps/web/e2e/nav-slot.spec.ts +++ b/apps/web/e2e/nav-slot.spec.ts @@ -1,87 +1,77 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test' -test.describe("Game navigation slots", () => { - test("should show Memory Pairs game name in nav when navigating to matching game", async ({ +test.describe('Game navigation slots', () => { + test('should show Memory Pairs game name in nav when navigating to matching game', async ({ page, }) => { - await page.goto("/games/matching"); + await page.goto('/games/matching') // Wait for the page to load - await page.waitForLoadState("networkidle"); + await page.waitForLoadState('networkidle') // Look for the game name in the navigation - const gameNav = page.locator( - '[data-testid="nav-slot"], h1:has-text("Memory Pairs")', - ); - await expect(gameNav).toBeVisible(); - await expect(gameNav).toContainText("Memory Pairs"); - }); + const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Pairs")') + await expect(gameNav).toBeVisible() + await expect(gameNav).toContainText('Memory Pairs') + }) - test("should show Memory Lightning game name in nav when navigating to memory quiz", async ({ + test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({ page, }) => { - await page.goto("/games/memory-quiz"); + await page.goto('/games/memory-quiz') // Wait for the page to load - await page.waitForLoadState("networkidle"); + await page.waitForLoadState('networkidle') // Look for the game name in the navigation - const gameNav = page.locator( - '[data-testid="nav-slot"], h1:has-text("Memory Lightning")', - ); - await expect(gameNav).toBeVisible(); - await expect(gameNav).toContainText("Memory Lightning"); - }); + const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Lightning")') + await expect(gameNav).toBeVisible() + await expect(gameNav).toContainText('Memory Lightning') + }) - test("should maintain game name in nav after page reload", async ({ - page, - }) => { + test('should maintain game name in nav after page reload', async ({ page }) => { // Navigate to matching game - await page.goto("/games/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/games/matching') + await page.waitForLoadState('networkidle') // Verify game name appears - const gameNav = page.locator('h1:has-text("Memory Pairs")'); - await expect(gameNav).toBeVisible(); + const gameNav = page.locator('h1:has-text("Memory Pairs")') + await expect(gameNav).toBeVisible() // Reload the page - await page.reload(); - await page.waitForLoadState("networkidle"); + await page.reload() + await page.waitForLoadState('networkidle') // Verify game name still appears after reload - await expect(gameNav).toBeVisible(); - await expect(gameNav).toContainText("Memory Pairs"); - }); + await expect(gameNav).toBeVisible() + await expect(gameNav).toContainText('Memory Pairs') + }) - test("should show different game names when navigating between games", async ({ - page, - }) => { + test('should show different game names when navigating between games', async ({ page }) => { // Start with matching game - await page.goto("/games/matching"); - await page.waitForLoadState("networkidle"); + await page.goto('/games/matching') + await page.waitForLoadState('networkidle') - const matchingNav = page.locator('h1:has-text("Memory Pairs")'); - await expect(matchingNav).toBeVisible(); + const matchingNav = page.locator('h1:has-text("Memory Pairs")') + await expect(matchingNav).toBeVisible() // Navigate to memory quiz - await page.goto("/games/memory-quiz"); - await page.waitForLoadState("networkidle"); + await page.goto('/games/memory-quiz') + await page.waitForLoadState('networkidle') - const quizNav = page.locator('h1:has-text("Memory Lightning")'); - await expect(quizNav).toBeVisible(); + const quizNav = page.locator('h1:has-text("Memory Lightning")') + await expect(quizNav).toBeVisible() // Verify the matching game name is gone - await expect(matchingNav).not.toBeVisible(); - }); + await expect(matchingNav).not.toBeVisible() + }) - test("should not show game name on non-game pages", async ({ page }) => { - await page.goto("/"); - await page.waitForLoadState("networkidle"); + test('should not show game name on non-game pages', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') // Should not see any game names on the home page - const gameNavs = page.locator( - 'h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")', - ); - await expect(gameNavs).toHaveCount(0); - }); -}); + const gameNavs = page.locator('h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")') + await expect(gameNavs).toHaveCount(0) + }) +}) diff --git a/apps/web/e2e/sound-settings-persistence.spec.ts b/apps/web/e2e/sound-settings-persistence.spec.ts index 5e636446..df774e6e 100644 --- a/apps/web/e2e/sound-settings-persistence.spec.ts +++ b/apps/web/e2e/sound-settings-persistence.spec.ts @@ -1,19 +1,17 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test' -test.describe("Sound Settings Persistence", () => { +test.describe('Sound Settings Persistence', () => { test.beforeEach(async ({ page }) => { // Clear localStorage before each test - await page.goto("/"); - await page.evaluate(() => localStorage.clear()); - }); + await page.goto('/') + await page.evaluate(() => localStorage.clear()) + }) - test("should persist sound enabled setting to localStorage", async ({ - page, - }) => { - await page.goto("/games/memory-quiz"); + test('should persist sound enabled setting to localStorage', async ({ page }) => { + await page.goto('/games/memory-quiz') // Open style dropdown - await page.getByRole("button", { name: /style/i }).click(); + await page.getByRole('button', { name: /style/i }).click() // Find and toggle the sound switch (should be off by default) const soundSwitch = page @@ -21,109 +19,103 @@ test.describe("Sound Settings Persistence", () => { .filter({ hasText: /sound/i }) .or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })) .or(page.getByLabel(/sound/i)) - .or(page.locator("button").filter({ hasText: /sound/i })) - .first(); + .or(page.locator('button').filter({ hasText: /sound/i })) + .first() - await soundSwitch.click(); + await soundSwitch.click() // Check localStorage was updated const storedConfig = await page.evaluate(() => { - const stored = localStorage.getItem("soroban-abacus-display-config"); - return stored ? JSON.parse(stored) : null; - }); + const stored = localStorage.getItem('soroban-abacus-display-config') + return stored ? JSON.parse(stored) : null + }) - expect(storedConfig).toBeTruthy(); - expect(storedConfig.soundEnabled).toBe(true); + expect(storedConfig).toBeTruthy() + expect(storedConfig.soundEnabled).toBe(true) // Reload page and verify setting persists - await page.reload(); - await page.getByRole("button", { name: /style/i }).click(); + await page.reload() + await page.getByRole('button', { name: /style/i }).click() const soundSwitchAfterReload = page .locator('[role="switch"]') .filter({ hasText: /sound/i }) .or(page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })) .or(page.getByLabel(/sound/i)) - .or(page.locator("button").filter({ hasText: /sound/i })) - .first(); + .or(page.locator('button').filter({ hasText: /sound/i })) + .first() - await expect(soundSwitchAfterReload).toBeChecked(); - }); + await expect(soundSwitchAfterReload).toBeChecked() + }) - test("should persist sound volume setting to localStorage", async ({ - page, - }) => { - await page.goto("/games/memory-quiz"); + test('should persist sound volume setting to localStorage', async ({ page }) => { + await page.goto('/games/memory-quiz') // Open style dropdown - await page.getByRole("button", { name: /style/i }).click(); + await page.getByRole('button', { name: /style/i }).click() // Find volume slider const volumeSlider = page .locator('input[type="range"]') .or(page.locator('[role="slider"]')) - .first(); + .first() // Set volume to a specific value (e.g., 0.6) - await volumeSlider.fill("60"); // Assuming 0-100 range + await volumeSlider.fill('60') // Assuming 0-100 range // Check localStorage was updated const storedConfig = await page.evaluate(() => { - const stored = localStorage.getItem("soroban-abacus-display-config"); - return stored ? JSON.parse(stored) : null; - }); + const stored = localStorage.getItem('soroban-abacus-display-config') + return stored ? JSON.parse(stored) : null + }) - expect(storedConfig).toBeTruthy(); - expect(storedConfig.soundVolume).toBeCloseTo(0.6, 1); + expect(storedConfig).toBeTruthy() + expect(storedConfig.soundVolume).toBeCloseTo(0.6, 1) // Reload page and verify setting persists - await page.reload(); - await page.getByRole("button", { name: /style/i }).click(); + await page.reload() + await page.getByRole('button', { name: /style/i }).click() const volumeSliderAfterReload = page .locator('input[type="range"]') .or(page.locator('[role="slider"]')) - .first(); + .first() - const volumeValue = await volumeSliderAfterReload.inputValue(); - expect(parseFloat(volumeValue)).toBeCloseTo(60, 0); // Allow for some variance - }); + const volumeValue = await volumeSliderAfterReload.inputValue() + expect(parseFloat(volumeValue)).toBeCloseTo(60, 0) // Allow for some variance + }) - test("should load default sound settings when localStorage is empty", async ({ - page, - }) => { - await page.goto("/games/memory-quiz"); + test('should load default sound settings when localStorage is empty', async ({ page }) => { + await page.goto('/games/memory-quiz') // Check that default settings are loaded const storedConfig = await page.evaluate(() => { - const stored = localStorage.getItem("soroban-abacus-display-config"); - return stored ? JSON.parse(stored) : null; - }); + const stored = localStorage.getItem('soroban-abacus-display-config') + return stored ? JSON.parse(stored) : null + }) // Should have default values: soundEnabled: true, soundVolume: 0.8 - expect(storedConfig).toBeTruthy(); - expect(storedConfig.soundEnabled).toBe(true); - expect(storedConfig.soundVolume).toBe(0.8); - }); + expect(storedConfig).toBeTruthy() + expect(storedConfig.soundEnabled).toBe(true) + expect(storedConfig.soundVolume).toBe(0.8) + }) - test("should handle invalid localStorage data gracefully", async ({ - page, - }) => { + test('should handle invalid localStorage data gracefully', async ({ page }) => { // Set invalid localStorage data - await page.goto("/"); + await page.goto('/') await page.evaluate(() => { - localStorage.setItem("soroban-abacus-display-config", "invalid-json"); - }); + localStorage.setItem('soroban-abacus-display-config', 'invalid-json') + }) - await page.goto("/games/memory-quiz"); + await page.goto('/games/memory-quiz') // Should fall back to defaults and not crash const storedConfig = await page.evaluate(() => { - const stored = localStorage.getItem("soroban-abacus-display-config"); - return stored ? JSON.parse(stored) : null; - }); + const stored = localStorage.getItem('soroban-abacus-display-config') + return stored ? JSON.parse(stored) : null + }) - expect(storedConfig.soundEnabled).toBe(true); - expect(storedConfig.soundVolume).toBe(0.8); - }); -}); + expect(storedConfig.soundEnabled).toBe(true) + expect(storedConfig.soundVolume).toBe(0.8) + }) +}) diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 472bba8a..043112d5 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -1,51 +1,44 @@ // Minimal ESLint flat config ONLY for react-hooks rules -import tsParser from "@typescript-eslint/parser"; -import reactHooks from "eslint-plugin-react-hooks"; +import tsParser from '@typescript-eslint/parser' +import reactHooks from 'eslint-plugin-react-hooks' const config = [ { - ignores: [ - "dist", - ".next", - "coverage", - "node_modules", - "styled-system", - "storybook-static", - ], + ignores: ['dist', '.next', 'coverage', 'node_modules', 'styled-system', 'storybook-static'], }, { - files: ["**/*.tsx", "**/*.ts", "**/*.jsx", "**/*.js"], + files: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'], languageOptions: { parser: tsParser, ecmaVersion: 2022, - sourceType: "module", + sourceType: 'module', globals: { - React: "readonly", - JSX: "readonly", - console: "readonly", - process: "readonly", - module: "readonly", - require: "readonly", - window: "readonly", - document: "readonly", - localStorage: "readonly", - sessionStorage: "readonly", - fetch: "readonly", - global: "readonly", - Buffer: "readonly", - __dirname: "readonly", - __filename: "readonly", + React: 'readonly', + JSX: 'readonly', + console: 'readonly', + process: 'readonly', + module: 'readonly', + require: 'readonly', + window: 'readonly', + document: 'readonly', + localStorage: 'readonly', + sessionStorage: 'readonly', + fetch: 'readonly', + global: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', }, }, plugins: { - "react-hooks": reactHooks, + 'react-hooks': reactHooks, }, rules: { - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "off", + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'off', }, }, -]; +] -export default config; +export default config diff --git a/apps/web/panda.config.ts b/apps/web/panda.config.ts index 77e20f36..292b7b88 100644 --- a/apps/web/panda.config.ts +++ b/apps/web/panda.config.ts @@ -1,192 +1,188 @@ -import { defineConfig } from "@pandacss/dev"; +import { defineConfig } from '@pandacss/dev' export default defineConfig({ // Whether to use css reset preflight: true, // Where to look for your css declarations - include: ["./src/**/*.{js,jsx,ts,tsx}", "./pages/**/*.{js,jsx,ts,tsx}"], + include: ['./src/**/*.{js,jsx,ts,tsx}', './pages/**/*.{js,jsx,ts,tsx}'], // Files to exclude exclude: [], // The output directory for your css system - outdir: "styled-system", + outdir: 'styled-system', // The JSX framework to use - jsxFramework: "react", + jsxFramework: 'react', theme: { extend: { tokens: { colors: { brand: { - 50: { value: "#f0f9ff" }, - 100: { value: "#e0f2fe" }, - 200: { value: "#bae6fd" }, - 300: { value: "#7dd3fc" }, - 400: { value: "#38bdf8" }, - 500: { value: "#0ea5e9" }, - 600: { value: "#0284c7" }, - 700: { value: "#0369a1" }, - 800: { value: "#075985" }, - 900: { value: "#0c4a6e" }, + 50: { value: '#f0f9ff' }, + 100: { value: '#e0f2fe' }, + 200: { value: '#bae6fd' }, + 300: { value: '#7dd3fc' }, + 400: { value: '#38bdf8' }, + 500: { value: '#0ea5e9' }, + 600: { value: '#0284c7' }, + 700: { value: '#0369a1' }, + 800: { value: '#075985' }, + 900: { value: '#0c4a6e' }, }, soroban: { - wood: { value: "#8B4513" }, - bead: { value: "#2C1810" }, - inactive: { value: "#D3D3D3" }, - bar: { value: "#654321" }, + wood: { value: '#8B4513' }, + bead: { value: '#2C1810' }, + inactive: { value: '#D3D3D3' }, + bar: { value: '#654321' }, }, }, fonts: { body: { - value: - 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', }, heading: { - value: - 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + value: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', }, mono: { - value: - 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace', + value: 'Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace', }, }, shadows: { card: { - value: - "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", + value: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', }, - modal: { value: "0 25px 50px -12px rgba(0, 0, 0, 0.25)" }, + modal: { value: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' }, }, animations: { // Shake animation for errors (web_generator.py line 3419) - shake: { value: "shake 0.5s ease-in-out" }, + shake: { value: 'shake 0.5s ease-in-out' }, // Pulse animation for success feedback (line 2004) - successPulse: { value: "successPulse 0.5s ease" }, - pulse: { value: "pulse 2s infinite" }, + successPulse: { value: 'successPulse 0.5s ease' }, + pulse: { value: 'pulse 2s infinite' }, // Error shake with larger amplitude (line 2009) - errorShake: { value: "errorShake 0.5s ease" }, + errorShake: { value: 'errorShake 0.5s ease' }, // Bounce animations (line 6271, 5065) - bounce: { value: "bounce 1s infinite alternate" }, - bounceIn: { value: "bounceIn 1s ease-out" }, + bounce: { value: 'bounce 1s infinite alternate' }, + bounceIn: { value: 'bounceIn 1s ease-out' }, // Glow animation (line 6260) - glow: { value: "glow 1s ease-in-out infinite alternate" }, + glow: { value: 'glow 1s ease-in-out infinite alternate' }, }, }, // Semantic color tokens for light/dark theme support semanticTokens: { colors: { // Background colors - "bg.canvas": { + 'bg.canvas': { value: { - base: "#ffffff", - _dark: "#0f172a", + base: '#ffffff', + _dark: '#0f172a', }, }, - "bg.surface": { + 'bg.surface': { value: { - base: "#f8fafc", - _dark: "#1e293b", + base: '#f8fafc', + _dark: '#1e293b', }, }, - "bg.subtle": { + 'bg.subtle': { value: { - base: "#f1f5f9", - _dark: "#334155", + base: '#f1f5f9', + _dark: '#334155', }, }, - "bg.muted": { + 'bg.muted': { value: { - base: "#e2e8f0", - _dark: "#475569", + base: '#e2e8f0', + _dark: '#475569', }, }, // Text colors - "text.primary": { + 'text.primary': { value: { - base: "#0f172a", - _dark: "#f1f5f9", + base: '#0f172a', + _dark: '#f1f5f9', }, }, - "text.secondary": { + 'text.secondary': { value: { - base: "#475569", - _dark: "#cbd5e1", + base: '#475569', + _dark: '#cbd5e1', }, }, - "text.muted": { + 'text.muted': { value: { - base: "#64748b", - _dark: "#94a3b8", + base: '#64748b', + _dark: '#94a3b8', }, }, - "text.inverse": { + 'text.inverse': { value: { - base: "#ffffff", - _dark: "#0f172a", + base: '#ffffff', + _dark: '#0f172a', }, }, // Border colors - "border.default": { + 'border.default': { value: { - base: "#e2e8f0", - _dark: "#334155", + base: '#e2e8f0', + _dark: '#334155', }, }, - "border.muted": { + 'border.muted': { value: { - base: "#f1f5f9", - _dark: "#1e293b", + base: '#f1f5f9', + _dark: '#1e293b', }, }, - "border.emphasis": { + 'border.emphasis': { value: { - base: "#cbd5e1", - _dark: "#475569", + base: '#cbd5e1', + _dark: '#475569', }, }, // Accent colors (purple theme) - "accent.default": { + 'accent.default': { value: { - base: "#7c3aed", - _dark: "#a78bfa", + base: '#7c3aed', + _dark: '#a78bfa', }, }, - "accent.emphasis": { + 'accent.emphasis': { value: { - base: "#6d28d9", - _dark: "#c4b5fd", + base: '#6d28d9', + _dark: '#c4b5fd', }, }, - "accent.muted": { + 'accent.muted': { value: { - base: "#f5f3ff", - _dark: "rgba(139, 92, 246, 0.15)", + base: '#f5f3ff', + _dark: 'rgba(139, 92, 246, 0.15)', }, }, - "accent.subtle": { + 'accent.subtle': { value: { - base: "#ede9fe", - _dark: "rgba(139, 92, 246, 0.1)", + base: '#ede9fe', + _dark: 'rgba(139, 92, 246, 0.1)', }, }, // Interactive states - "interactive.hover": { + 'interactive.hover': { value: { - base: "#f8fafc", - _dark: "#334155", + base: '#f8fafc', + _dark: '#334155', }, }, - "interactive.active": { + 'interactive.active': { value: { - base: "#f1f5f9", - _dark: "#475569", + base: '#f1f5f9', + _dark: '#475569', }, }, }, @@ -194,43 +190,42 @@ export default defineConfig({ keyframes: { // Shake - horizontal oscillation for errors (line 3419) shake: { - "0%, 100%": { transform: "translateX(0)" }, - "25%": { transform: "translateX(-5px)" }, - "75%": { transform: "translateX(5px)" }, + '0%, 100%': { transform: 'translateX(0)' }, + '25%': { transform: 'translateX(-5px)' }, + '75%': { transform: 'translateX(5px)' }, }, // Success pulse - gentle scale for correct answers (line 2004) successPulse: { - "0%, 100%": { transform: "scale(1)" }, - "50%": { transform: "scale(1.05)" }, + '0%, 100%': { transform: 'scale(1)' }, + '50%': { transform: 'scale(1.05)' }, }, // Pulse - continuous breathing effect (line 6255) pulse: { - "0%, 100%": { transform: "scale(1)" }, - "50%": { transform: "scale(1.05)" }, + '0%, 100%': { transform: 'scale(1)' }, + '50%': { transform: 'scale(1.05)' }, }, // Error shake - stronger horizontal oscillation (line 2009) errorShake: { - "0%, 100%": { transform: "translateX(0)" }, - "25%": { transform: "translateX(-10px)" }, - "75%": { transform: "translateX(10px)" }, + '0%, 100%': { transform: 'translateX(0)' }, + '25%': { transform: 'translateX(-10px)' }, + '75%': { transform: 'translateX(10px)' }, }, // Bounce - vertical oscillation (line 6271) bounce: { - "0%, 100%": { transform: "translateY(0)" }, - "50%": { transform: "translateY(-10px)" }, + '0%, 100%': { transform: 'translateY(0)' }, + '50%': { transform: 'translateY(-10px)' }, }, // Bounce in - entry animation with scale and rotate (line 6265) bounceIn: { - "0%": { transform: "scale(0.3) rotate(-10deg)", opacity: "0" }, - "50%": { transform: "scale(1.1) rotate(5deg)" }, - "100%": { transform: "scale(1) rotate(0deg)", opacity: "1" }, + '0%': { transform: 'scale(0.3) rotate(-10deg)', opacity: '0' }, + '50%': { transform: 'scale(1.1) rotate(5deg)' }, + '100%': { transform: 'scale(1) rotate(0deg)', opacity: '1' }, }, // Glow - expanding box shadow (line 6260) glow: { - "0%": { boxShadow: "0 0 5px rgba(255, 255, 255, 0.5)" }, - "100%": { - boxShadow: - "0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)", + '0%': { boxShadow: '0 0 5px rgba(255, 255, 255, 0.5)' }, + '100%': { + boxShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.6)', }, }, }, @@ -244,4 +239,4 @@ export default defineConfig({ light: '[data-theme="light"] &, .light &', }, }, -}); +}) diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 5bf9f84b..1dd7d53d 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,27 +1,27 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from '@playwright/test' export default defineConfig({ - testDir: "./e2e", + testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: "html", + reporter: 'html', use: { - baseURL: "http://localhost:3002", - trace: "on-first-retry", + baseURL: 'http://localhost:3002', + trace: 'on-first-retry', }, projects: [ { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, }, ], webServer: { - command: "pnpm dev", - url: "http://localhost:3002", + command: 'pnpm dev', + url: 'http://localhost:3002', reuseExistingServer: !process.env.CI, }, -}); +}) diff --git a/apps/web/scripts/generate-build-info.js b/apps/web/scripts/generate-build-info.js index cc56edf9..8b52762e 100755 --- a/apps/web/scripts/generate-build-info.js +++ b/apps/web/scripts/generate-build-info.js @@ -5,34 +5,29 @@ * This script captures git commit, branch, timestamp, and other metadata */ -const { execSync } = require("child_process"); -const fs = require("fs"); -const path = require("path"); +const { execSync } = require('child_process') +const fs = require('fs') +const path = require('path') function exec(command) { try { - return execSync(command, { encoding: "utf-8" }).trim(); + return execSync(command, { encoding: 'utf-8' }).trim() } catch (_error) { - return null; + return null } } function getBuildInfo() { // Try to get git info from environment variables first (for Docker builds) // Fall back to git commands (for local development) - const gitCommit = process.env.GIT_COMMIT || exec("git rev-parse HEAD"); - const gitCommitShort = - process.env.GIT_COMMIT_SHORT || exec("git rev-parse --short HEAD"); - const gitBranch = - process.env.GIT_BRANCH || exec("git rev-parse --abbrev-ref HEAD"); - const gitTag = - process.env.GIT_TAG || - exec("git describe --tags --exact-match 2>/dev/null"); + const gitCommit = process.env.GIT_COMMIT || exec('git rev-parse HEAD') + const gitCommitShort = process.env.GIT_COMMIT_SHORT || exec('git rev-parse --short HEAD') + const gitBranch = process.env.GIT_BRANCH || exec('git rev-parse --abbrev-ref HEAD') + const gitTag = process.env.GIT_TAG || exec('git describe --tags --exact-match 2>/dev/null') const gitDirty = - process.env.GIT_DIRTY === "true" || - exec('git diff --quiet || echo "dirty"') === "dirty"; + process.env.GIT_DIRTY === 'true' || exec('git diff --quiet || echo "dirty"') === 'dirty' - const packageJson = require("../package.json"); + const packageJson = require('../package.json') return { version: packageJson.version, @@ -45,28 +40,22 @@ function getBuildInfo() { tag: gitTag, isDirty: gitDirty, }, - environment: process.env.NODE_ENV || "development", + environment: process.env.NODE_ENV || 'development', buildNumber: process.env.BUILD_NUMBER || null, nodeVersion: process.version, - }; + } } -const buildInfo = getBuildInfo(); -const outputPath = path.join( - __dirname, - "..", - "src", - "generated", - "build-info.json", -); +const buildInfo = getBuildInfo() +const outputPath = path.join(__dirname, '..', 'src', 'generated', 'build-info.json') // Ensure directory exists -const dir = path.dirname(outputPath); +const dir = path.dirname(outputPath) if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + fs.mkdirSync(dir, { recursive: true }) } -fs.writeFileSync(outputPath, JSON.stringify(buildInfo, null, 2)); +fs.writeFileSync(outputPath, JSON.stringify(buildInfo, null, 2)) -console.log("✅ Build info generated:", outputPath); -console.log(JSON.stringify(buildInfo, null, 2)); +console.log('✅ Build info generated:', outputPath) +console.log(JSON.stringify(buildInfo, null, 2)) diff --git a/apps/web/scripts/generateAbacusIcons.tsx b/apps/web/scripts/generateAbacusIcons.tsx index 8bcf09b8..4e85281e 100644 --- a/apps/web/scripts/generateAbacusIcons.tsx +++ b/apps/web/scripts/generateAbacusIcons.tsx @@ -7,20 +7,20 @@ * SVG output as the interactive client-side version (without animations). */ -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { writeFileSync } from "fs"; -import { join } from "path"; -import { AbacusReact } from "@soroban/abacus-react"; +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { writeFileSync } from 'fs' +import { join } from 'path' +import { AbacusReact } from '@soroban/abacus-react' // Extract just the SVG element content from rendered output function extractSvgContent(markup: string): string { // Find the opening tags - const svgMatch = markup.match(/]*>([\s\S]*?)<\/svg>/); + const svgMatch = markup.match(/]*>([\s\S]*?)<\/svg>/) if (!svgMatch) { - throw new Error("No SVG element found in rendered output"); + throw new Error('No SVG element found in rendered output') } - return svgMatch[1]; // Return just the inner content + return svgMatch[1] // Return just the inner content } // Generate the favicon (icon.svg) - single column showing value 5 @@ -34,27 +34,27 @@ function generateFavicon(): string { interactive={false} showNumbers={false} customStyles={{ - heavenBeads: { fill: "#7c2d12", stroke: "#451a03", strokeWidth: 1 }, - earthBeads: { fill: "#7c2d12", stroke: "#451a03", strokeWidth: 1 }, + heavenBeads: { fill: '#7c2d12', stroke: '#451a03', strokeWidth: 1 }, + earthBeads: { fill: '#7c2d12', stroke: '#451a03', strokeWidth: 1 }, columnPosts: { - fill: "#451a03", - stroke: "#292524", + fill: '#451a03', + stroke: '#292524', strokeWidth: 2, }, reckoningBar: { - fill: "#292524", - stroke: "#292524", + fill: '#292524', + stroke: '#292524', strokeWidth: 3, }, }} - />, - ); + /> + ) // Extract just the SVG content (without div wrapper) - let svgContent = extractSvgContent(abacusMarkup); + let svgContent = extractSvgContent(abacusMarkup) // Remove !important from CSS (production code policy) - svgContent = svgContent.replace(/\s*!important/g, ""); + svgContent = svgContent.replace(/\s*!important/g, '') // Wrap in SVG with proper viewBox for favicon sizing // AbacusReact with 1 column + scaleFactor 1.0 = ~25×120px @@ -68,7 +68,7 @@ function generateFavicon(): string { ${svgContent} -`; +` } // Generate the Open Graph image (og-image.svg) @@ -83,46 +83,46 @@ function generateOGImage(): string { showNumbers={false} customStyles={{ columnPosts: { - fill: "rgb(255, 255, 255)", - stroke: "rgb(200, 200, 200)", + fill: 'rgb(255, 255, 255)', + stroke: 'rgb(200, 200, 200)', strokeWidth: 2, }, reckoningBar: { - fill: "rgb(255, 255, 255)", - stroke: "rgb(200, 200, 200)", + fill: 'rgb(255, 255, 255)', + stroke: 'rgb(200, 200, 200)', strokeWidth: 3, }, columns: { 0: { // Ones place (rightmost) - Blue - heavenBeads: { fill: "#60a5fa", stroke: "#3b82f6", strokeWidth: 1 }, - earthBeads: { fill: "#60a5fa", stroke: "#3b82f6", strokeWidth: 1 }, + heavenBeads: { fill: '#60a5fa', stroke: '#3b82f6', strokeWidth: 1 }, + earthBeads: { fill: '#60a5fa', stroke: '#3b82f6', strokeWidth: 1 }, }, 1: { // Tens place - Green - heavenBeads: { fill: "#4ade80", stroke: "#22c55e", strokeWidth: 1 }, - earthBeads: { fill: "#4ade80", stroke: "#22c55e", strokeWidth: 1 }, + heavenBeads: { fill: '#4ade80', stroke: '#22c55e', strokeWidth: 1 }, + earthBeads: { fill: '#4ade80', stroke: '#22c55e', strokeWidth: 1 }, }, 2: { // Hundreds place - Yellow/Gold - heavenBeads: { fill: "#fbbf24", stroke: "#f59e0b", strokeWidth: 1 }, - earthBeads: { fill: "#fbbf24", stroke: "#f59e0b", strokeWidth: 1 }, + heavenBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 1 }, + earthBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 1 }, }, 3: { // Thousands place (leftmost) - Purple - heavenBeads: { fill: "#c084fc", stroke: "#a855f7", strokeWidth: 1 }, - earthBeads: { fill: "#c084fc", stroke: "#a855f7", strokeWidth: 1 }, + heavenBeads: { fill: '#c084fc', stroke: '#a855f7', strokeWidth: 1 }, + earthBeads: { fill: '#c084fc', stroke: '#a855f7', strokeWidth: 1 }, }, }, }} - />, - ); + /> + ) // Extract just the SVG content (without div wrapper) - let svgContent = extractSvgContent(abacusMarkup); + let svgContent = extractSvgContent(abacusMarkup) // Remove !important from CSS (production code policy) - svgContent = svgContent.replace(/\s*!important/g, ""); + svgContent = svgContent.replace(/\s*!important/g, '') return ` @@ -199,24 +199,22 @@ function generateOGImage(): string { -`; +` } // Main execution -const appDir = __dirname.replace("/scripts", ""); +const appDir = __dirname.replace('/scripts', '') try { - console.log("Generating Open Graph image from AbacusReact..."); - const ogImageSvg = generateOGImage(); - writeFileSync(join(appDir, "public", "og-image.svg"), ogImageSvg); - console.log("✓ Generated public/og-image.svg"); + console.log('Generating Open Graph image from AbacusReact...') + const ogImageSvg = generateOGImage() + writeFileSync(join(appDir, 'public', 'og-image.svg'), ogImageSvg) + console.log('✓ Generated public/og-image.svg') - console.log("\n✅ Icon generated successfully!"); - console.log( - "\nNote: Day-of-month favicons are generated on-demand by src/app/icon/route.tsx", - ); - console.log("which calls scripts/generateDayIcon.tsx as a subprocess."); + console.log('\n✅ Icon generated successfully!') + console.log('\nNote: Day-of-month favicons are generated on-demand by src/app/icon/route.tsx') + console.log('which calls scripts/generateDayIcon.tsx as a subprocess.') } catch (error) { - console.error("❌ Error generating icons:", error); - process.exit(1); + console.error('❌ Error generating icons:', error) + process.exit(1) } diff --git a/apps/web/scripts/generateAllDayIcons.tsx b/apps/web/scripts/generateAllDayIcons.tsx index d33d6fca..c3ea0156 100644 --- a/apps/web/scripts/generateAllDayIcons.tsx +++ b/apps/web/scripts/generateAllDayIcons.tsx @@ -8,19 +8,19 @@ */ // biome-ignore lint/correctness/noUnusedImports: React is required for JSX transform -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { writeFileSync, mkdirSync } from "fs"; -import { join } from "path"; -import { AbacusStatic } from "@soroban/abacus-react"; +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { writeFileSync, mkdirSync } from 'fs' +import { join } from 'path' +import { AbacusStatic } from '@soroban/abacus-react' // Extract just the SVG element from rendered output function extractSvgElement(markup: string): string { - const svgMatch = markup.match(/]*>[\s\S]*?<\/svg>/); + const svgMatch = markup.match(/]*>[\s\S]*?<\/svg>/) if (!svgMatch) { - throw new Error("No SVG element found in rendered output"); + throw new Error('No SVG element found in rendered output') } - return svgMatch[0]; + return svgMatch[0] } // Generate a single day icon @@ -44,97 +44,95 @@ function generateDayIcon(day: number): string { }} customStyles={{ columnPosts: { - fill: "#1c1917", - stroke: "#0c0a09", + fill: '#1c1917', + stroke: '#0c0a09', strokeWidth: 2, }, reckoningBar: { - fill: "#1c1917", - stroke: "#0c0a09", + fill: '#1c1917', + stroke: '#0c0a09', strokeWidth: 3, }, columns: { 0: { // Ones place - Gold (royal theme) - heavenBeads: { fill: "#fbbf24", stroke: "#f59e0b", strokeWidth: 2 }, - earthBeads: { fill: "#fbbf24", stroke: "#f59e0b", strokeWidth: 2 }, + heavenBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 2 }, + earthBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 2 }, }, 1: { // Tens place - Purple (royal theme) - heavenBeads: { fill: "#a855f7", stroke: "#7e22ce", strokeWidth: 2 }, - earthBeads: { fill: "#a855f7", stroke: "#7e22ce", strokeWidth: 2 }, + heavenBeads: { fill: '#a855f7', stroke: '#7e22ce', strokeWidth: 2 }, + earthBeads: { fill: '#a855f7', stroke: '#7e22ce', strokeWidth: 2 }, }, }, }} - />, - ); + /> + ) // Extract the cropped SVG - let croppedSvg = extractSvgElement(abacusMarkup); + let croppedSvg = extractSvgElement(abacusMarkup) // Remove !important from CSS (production code policy) - croppedSvg = croppedSvg.replace(/\s*!important/g, ""); + croppedSvg = croppedSvg.replace(/\s*!important/g, '') // Parse width and height from the cropped SVG - const widthMatch = croppedSvg.match(/width="([^"]+)"/); - const heightMatch = croppedSvg.match(/height="([^"]+)"/); + const widthMatch = croppedSvg.match(/width="([^"]+)"/) + const heightMatch = croppedSvg.match(/height="([^"]+)"/) if (!widthMatch || !heightMatch) { - throw new Error("Could not parse dimensions from cropped SVG"); + throw new Error('Could not parse dimensions from cropped SVG') } - const croppedWidth = parseFloat(widthMatch[1]); - const croppedHeight = parseFloat(heightMatch[1]); + const croppedWidth = parseFloat(widthMatch[1]) + const croppedHeight = parseFloat(heightMatch[1]) // Calculate scale to fit cropped region into 96x96 (leaving room for border) - const targetSize = 96; - const scale = Math.min(targetSize / croppedWidth, targetSize / croppedHeight); + const targetSize = 96 + const scale = Math.min(targetSize / croppedWidth, targetSize / croppedHeight) // Center in 100x100 canvas - const scaledWidth = croppedWidth * scale; - const scaledHeight = croppedHeight * scale; - const offsetX = (100 - scaledWidth) / 2; - const offsetY = (100 - scaledHeight) / 2; + const scaledWidth = croppedWidth * scale + const scaledHeight = croppedHeight * scale + const offsetX = (100 - scaledWidth) / 2 + const offsetY = (100 - scaledHeight) / 2 // Wrap in 100x100 SVG canvas for favicon // Extract viewBox from cropped SVG to preserve it - const viewBoxMatch = croppedSvg.match(/viewBox="([^"]+)"/); - const viewBox = viewBoxMatch - ? viewBoxMatch[1] - : `0 0 ${croppedWidth} ${croppedHeight}`; + const viewBoxMatch = croppedSvg.match(/viewBox="([^"]+)"/) + const viewBox = viewBoxMatch ? viewBoxMatch[1] : `0 0 ${croppedWidth} ${croppedHeight}` return ` - + - ${croppedSvg.match(/]*>([\s\S]*?)<\/svg>/)?.[1] || ""} + ${croppedSvg.match(/]*>([\s\S]*?)<\/svg>/)?.[1] || ''} -`; +` } // Main execution -const publicDir = join(__dirname, "..", "public"); -const iconsDir = join(publicDir, "icons"); +const publicDir = join(__dirname, '..', 'public') +const iconsDir = join(publicDir, 'icons') try { // Ensure icons directory exists - mkdirSync(iconsDir, { recursive: true }); + mkdirSync(iconsDir, { recursive: true }) - console.log("Generating all 31 day-of-month favicons...\n"); + console.log('Generating all 31 day-of-month favicons...\n') // Generate all 31 days for (let day = 1; day <= 31; day++) { - const svg = generateDayIcon(day); - const filename = `icon-day-${day.toString().padStart(2, "0")}.svg`; - const filepath = join(iconsDir, filename); - writeFileSync(filepath, svg); - console.log(`✓ Generated ${filename}`); + const svg = generateDayIcon(day) + const filename = `icon-day-${day.toString().padStart(2, '0')}.svg` + const filepath = join(iconsDir, filename) + writeFileSync(filepath, svg) + console.log(`✓ Generated ${filename}`) } - console.log("\n✅ All day icons generated successfully!"); - console.log(` Location: public/icons/icon-day-*.svg`); + console.log('\n✅ All day icons generated successfully!') + console.log(` Location: public/icons/icon-day-*.svg`) } catch (error) { - console.error("❌ Error generating day icons:", error); - process.exit(1); + console.error('❌ Error generating day icons:', error) + process.exit(1) } diff --git a/apps/web/scripts/generateBlogExamples.ts b/apps/web/scripts/generateBlogExamples.ts index b006328e..d0e1ce7a 100644 --- a/apps/web/scripts/generateBlogExamples.ts +++ b/apps/web/scripts/generateBlogExamples.ts @@ -1,102 +1,97 @@ // Script to generate example worksheet images for the blog post // Shows different scaffolding levels for the 2D difficulty blog post -import fs from "fs"; -import path from "path"; -import { generateWorksheetPreview } from "../src/app/create/worksheets/addition/generatePreview"; -import { DIFFICULTY_PROFILES } from "../src/app/create/worksheets/addition/difficultyProfiles"; +import fs from 'fs' +import path from 'path' +import { generateWorksheetPreview } from '../src/app/create/worksheets/addition/generatePreview' +import { DIFFICULTY_PROFILES } from '../src/app/create/worksheets/addition/difficultyProfiles' // Output directory -const outputDir = path.join( - process.cwd(), - "public", - "blog", - "difficulty-examples", -); +const outputDir = path.join(process.cwd(), 'public', 'blog', 'difficulty-examples') // Ensure output directory exists if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }) } // Generate examples with SAME regrouping level but different scaffolding // This clearly shows how scaffolding changes while keeping problem complexity constant const examples = [ { - name: "full-scaffolding", - filename: "full-scaffolding.svg", - description: "Full Scaffolding: Maximum visual support", + name: 'full-scaffolding', + filename: 'full-scaffolding.svg', + description: 'Full Scaffolding: Maximum visual support', // Use medium regrouping with full scaffolding config: { pAllStart: 0.3, pAnyStart: 0.7, displayRules: { - carryBoxes: "always" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "always" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'always' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'always' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, }, }, { - name: "medium-scaffolding", - filename: "medium-scaffolding.svg", - description: "Medium Scaffolding: Strategic support", + name: 'medium-scaffolding', + filename: 'medium-scaffolding.svg', + description: 'Medium Scaffolding: Strategic support', config: { pAllStart: 0.3, pAnyStart: 0.7, displayRules: { - carryBoxes: "whenRegrouping" as const, - answerBoxes: "always" as const, - placeValueColors: "when3PlusDigits" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'whenRegrouping' as const, + answerBoxes: 'always' as const, + placeValueColors: 'when3PlusDigits' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, }, }, { - name: "minimal-scaffolding", - filename: "minimal-scaffolding.svg", - description: "Minimal Scaffolding: Carry boxes only", + name: 'minimal-scaffolding', + filename: 'minimal-scaffolding.svg', + description: 'Minimal Scaffolding: Carry boxes only', config: { pAllStart: 0.3, pAnyStart: 0.7, displayRules: { - carryBoxes: "whenMultipleRegroups" as const, - answerBoxes: "never" as const, - placeValueColors: "never" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'whenMultipleRegroups' as const, + answerBoxes: 'never' as const, + placeValueColors: 'never' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, }, }, { - name: "no-scaffolding", - filename: "no-scaffolding.svg", - description: "No Scaffolding: Students work independently", + name: 'no-scaffolding', + filename: 'no-scaffolding.svg', + description: 'No Scaffolding: Students work independently', config: { pAllStart: 0.3, pAnyStart: 0.7, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "never" as const, - placeValueColors: "never" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'never' as const, + placeValueColors: 'never' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, }, }, -] as const; +] as const -console.log("Generating blog example worksheets...\n"); +console.log('Generating blog example worksheets...\n') for (const example of examples) { - console.log(`Generating ${example.description}...`); + console.log(`Generating ${example.description}...`) const config = { pAllStart: example.config.pAllStart, @@ -105,28 +100,28 @@ for (const example of examples) { problemsPerPage: 4, pages: 1, cols: 2, - }; + } try { - const result = generateWorksheetPreview(config); + const result = generateWorksheetPreview(config) if (!result.success || !result.pages || result.pages.length === 0) { - console.error(`Failed to generate ${example.name}:`, result.error); - continue; + console.error(`Failed to generate ${example.name}:`, result.error) + continue } // Get the first page's SVG - const svg = result.pages[0]; + const svg = result.pages[0] // Save to file - const outputPath = path.join(outputDir, example.filename); - fs.writeFileSync(outputPath, svg, "utf-8"); + const outputPath = path.join(outputDir, example.filename) + fs.writeFileSync(outputPath, svg, 'utf-8') - console.log(` ✓ Saved to ${outputPath}`); + console.log(` ✓ Saved to ${outputPath}`) } catch (error) { - console.error(` ✗ Error generating ${example.name}:`, error); + console.error(` ✗ Error generating ${example.name}:`, error) } } -console.log("\nDone! Example worksheets generated."); -console.log(`\nFiles saved to: ${outputDir}`); +console.log('\nDone! Example worksheets generated.') +console.log(`\nFiles saved to: ${outputDir}`) diff --git a/apps/web/scripts/generateDayIcon.tsx b/apps/web/scripts/generateDayIcon.tsx index f2f9cb4d..ae97bb29 100644 --- a/apps/web/scripts/generateDayIcon.tsx +++ b/apps/web/scripts/generateDayIcon.tsx @@ -6,26 +6,26 @@ * Example: npx tsx scripts/generateDayIcon.tsx 15 */ -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { AbacusStatic } from "@soroban/abacus-react"; +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { AbacusStatic } from '@soroban/abacus-react' // Extract just the SVG element from rendered output function extractSvgElement(markup: string): string { - const svgMatch = markup.match(/]*>[\s\S]*?<\/svg>/); + const svgMatch = markup.match(/]*>[\s\S]*?<\/svg>/) if (!svgMatch) { - throw new Error("No SVG element found in rendered output"); + throw new Error('No SVG element found in rendered output') } - return svgMatch[0]; + return svgMatch[0] } // Get day from command line argument -const day = parseInt(process.argv[2], 10); +const day = parseInt(process.argv[2], 10) if (!day || day < 1 || day > 31) { - console.error("Usage: npx tsx scripts/generateDayIcon.tsx "); - console.error("Example: npx tsx scripts/generateDayIcon.tsx 15"); - process.exit(1); + console.error('Usage: npx tsx scripts/generateDayIcon.tsx ') + console.error('Example: npx tsx scripts/generateDayIcon.tsx 15') + process.exit(1) } // Render 2-column abacus showing day of month @@ -48,73 +48,71 @@ const abacusMarkup = renderToStaticMarkup( }} customStyles={{ columnPosts: { - fill: "#1c1917", - stroke: "#0c0a09", + fill: '#1c1917', + stroke: '#0c0a09', strokeWidth: 2, }, reckoningBar: { - fill: "#1c1917", - stroke: "#0c0a09", + fill: '#1c1917', + stroke: '#0c0a09', strokeWidth: 3, }, columns: { 0: { // Ones place - Gold (royal theme) - heavenBeads: { fill: "#fbbf24", stroke: "#f59e0b", strokeWidth: 2 }, - earthBeads: { fill: "#fbbf24", stroke: "#f59e0b", strokeWidth: 2 }, + heavenBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 2 }, + earthBeads: { fill: '#fbbf24', stroke: '#f59e0b', strokeWidth: 2 }, }, 1: { // Tens place - Purple (royal theme) - heavenBeads: { fill: "#a855f7", stroke: "#7e22ce", strokeWidth: 2 }, - earthBeads: { fill: "#a855f7", stroke: "#7e22ce", strokeWidth: 2 }, + heavenBeads: { fill: '#a855f7', stroke: '#7e22ce', strokeWidth: 2 }, + earthBeads: { fill: '#a855f7', stroke: '#7e22ce', strokeWidth: 2 }, }, }, }} - />, -); + /> +) // Extract the cropped SVG -let croppedSvg = extractSvgElement(abacusMarkup); +let croppedSvg = extractSvgElement(abacusMarkup) // Remove !important from CSS (production code policy) -croppedSvg = croppedSvg.replace(/\s*!important/g, ""); +croppedSvg = croppedSvg.replace(/\s*!important/g, '') // Parse width and height from the cropped SVG -const widthMatch = croppedSvg.match(/width="([^"]+)"/); -const heightMatch = croppedSvg.match(/height="([^"]+)"/); +const widthMatch = croppedSvg.match(/width="([^"]+)"/) +const heightMatch = croppedSvg.match(/height="([^"]+)"/) if (!widthMatch || !heightMatch) { - throw new Error("Could not parse dimensions from cropped SVG"); + throw new Error('Could not parse dimensions from cropped SVG') } -const croppedWidth = parseFloat(widthMatch[1]); -const croppedHeight = parseFloat(heightMatch[1]); +const croppedWidth = parseFloat(widthMatch[1]) +const croppedHeight = parseFloat(heightMatch[1]) // Calculate scale to fit cropped region into 96x96 (leaving room for border) -const targetSize = 96; -const scale = Math.min(targetSize / croppedWidth, targetSize / croppedHeight); +const targetSize = 96 +const scale = Math.min(targetSize / croppedWidth, targetSize / croppedHeight) // Center in 100x100 canvas -const scaledWidth = croppedWidth * scale; -const scaledHeight = croppedHeight * scale; -const offsetX = (100 - scaledWidth) / 2; -const offsetY = (100 - scaledHeight) / 2; +const scaledWidth = croppedWidth * scale +const scaledHeight = croppedHeight * scale +const offsetX = (100 - scaledWidth) / 2 +const offsetY = (100 - scaledHeight) / 2 // Wrap in 100x100 SVG canvas for favicon // Extract viewBox from cropped SVG to preserve it -const viewBoxMatch = croppedSvg.match(/viewBox="([^"]+)"/); -const viewBox = viewBoxMatch - ? viewBoxMatch[1] - : `0 0 ${croppedWidth} ${croppedHeight}`; +const viewBoxMatch = croppedSvg.match(/viewBox="([^"]+)"/) +const viewBox = viewBoxMatch ? viewBoxMatch[1] : `0 0 ${croppedWidth} ${croppedHeight}` const svg = ` - + - ${croppedSvg.match(/]*>([\s\S]*?)<\/svg>/)?.[1] || ""} + ${croppedSvg.match(/]*>([\s\S]*?)<\/svg>/)?.[1] || ''} -`; +` // Output to stdout so parent process can capture it -process.stdout.write(svg); +process.stdout.write(svg) diff --git a/apps/web/scripts/generateMultiDigitExamples.ts b/apps/web/scripts/generateMultiDigitExamples.ts index bbf47477..ac1ddc4b 100644 --- a/apps/web/scripts/generateMultiDigitExamples.ts +++ b/apps/web/scripts/generateMultiDigitExamples.ts @@ -1,42 +1,37 @@ // Script to generate multi-digit (>2 digits) worksheet examples for the blog post // Shows how scaffolding adapts to different digit ranges -import fs from "fs"; -import path from "path"; -import { generateWorksheetPreview } from "../src/app/create/worksheets/addition/generatePreview"; +import fs from 'fs' +import path from 'path' +import { generateWorksheetPreview } from '../src/app/create/worksheets/addition/generatePreview' // Output directory -const outputDir = path.join( - process.cwd(), - "public", - "blog", - "multi-digit-examples", -); +const outputDir = path.join(process.cwd(), 'public', 'blog', 'multi-digit-examples') // Ensure output directory exists if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }) } // Generate examples showing different digit ranges and adaptive scaffolding const examples = [ { - name: "two-digit-addition", - filename: "two-digit.svg", - description: "2-digit addition (baseline)", + name: 'two-digit-addition', + filename: 'two-digit.svg', + description: '2-digit addition (baseline)', config: { - operator: "addition" as const, + operator: 'addition' as const, pAllStart: 0.0, pAnyStart: 0.5, digitRange: { min: 2, max: 2 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "always" as const, - answerBoxes: "always" as const, - placeValueColors: "never" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'always' as const, + answerBoxes: 'always' as const, + placeValueColors: 'never' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: false, showBorrowingHints: false, @@ -44,22 +39,22 @@ const examples = [ }, }, { - name: "three-digit-with-colors", - filename: "three-digit-colors.svg", - description: "3-digit addition with place value colors", + name: 'three-digit-with-colors', + filename: 'three-digit-colors.svg', + description: '3-digit addition with place value colors', config: { - operator: "addition" as const, + operator: 'addition' as const, pAllStart: 0.0, pAnyStart: 0.5, digitRange: { min: 3, max: 3 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "always" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'always' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: false, showBorrowingHints: false, @@ -68,22 +63,22 @@ const examples = [ }, }, { - name: "four-digit-addition", - filename: "four-digit.svg", - description: "4-digit addition with adaptive scaffolding", + name: 'four-digit-addition', + filename: 'four-digit.svg', + description: '4-digit addition with adaptive scaffolding', config: { - operator: "addition" as const, + operator: 'addition' as const, pAllStart: 0.0, pAnyStart: 0.6, digitRange: { min: 4, max: 4 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "always" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'always' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: false, showBorrowingHints: false, @@ -92,22 +87,22 @@ const examples = [ }, }, { - name: "five-digit-addition", - filename: "five-digit.svg", - description: "5-digit addition (maximum complexity)", + name: 'five-digit-addition', + filename: 'five-digit.svg', + description: '5-digit addition (maximum complexity)', config: { - operator: "addition" as const, + operator: 'addition' as const, pAllStart: 0.3, pAnyStart: 0.8, digitRange: { min: 5, max: 5 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "always" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'always' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: false, showBorrowingHints: false, @@ -116,22 +111,22 @@ const examples = [ }, }, { - name: "mixed-digit-range", - filename: "mixed-range.svg", - description: "Mixed problem sizes (2-4 digits)", + name: 'mixed-digit-range', + filename: 'mixed-range.svg', + description: 'Mixed problem sizes (2-4 digits)', config: { - operator: "addition" as const, + operator: 'addition' as const, pAllStart: 0.0, pAnyStart: 0.5, digitRange: { min: 2, max: 4 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "always" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'always' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: false, showBorrowingHints: false, @@ -140,33 +135,33 @@ const examples = [ }, }, { - name: "three-digit-subtraction", - filename: "three-digit-subtraction.svg", - description: "3-digit subtraction with borrowing", + name: 'three-digit-subtraction', + filename: 'three-digit-subtraction.svg', + description: '3-digit subtraction with borrowing', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 0.0, pAnyStart: 0.8, digitRange: { min: 3, max: 3 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: true, showBorrowingHints: false, }, }, -] as const; +] as const -console.log("Generating multi-digit example worksheets...\n"); +console.log('Generating multi-digit example worksheets...\n') for (const example of examples) { - console.log(`Generating ${example.description}...`); + console.log(`Generating ${example.description}...`) const config = { ...example.config, @@ -174,29 +169,29 @@ for (const example of examples) { pages: 1, cols: 2, seed: 54321, // Fixed seed for consistent examples - }; + } try { - const result = generateWorksheetPreview(config); + const result = generateWorksheetPreview(config) if (!result.success || !result.pages || result.pages.length === 0) { - console.error(`Failed to generate ${example.name}:`, result.error); - console.error(`Details:`, result.details); - continue; + console.error(`Failed to generate ${example.name}:`, result.error) + console.error(`Details:`, result.details) + continue } // Get the first page's SVG - const svg = result.pages[0]; + const svg = result.pages[0] // Save to file - const outputPath = path.join(outputDir, example.filename); - fs.writeFileSync(outputPath, svg, "utf-8"); + const outputPath = path.join(outputDir, example.filename) + fs.writeFileSync(outputPath, svg, 'utf-8') - console.log(` ✓ Saved to ${outputPath}`); + console.log(` ✓ Saved to ${outputPath}`) } catch (error) { - console.error(` ✗ Error generating ${example.name}:`, error); + console.error(` ✗ Error generating ${example.name}:`, error) } } -console.log("\nDone! Multi-digit example worksheets generated."); -console.log(`\nFiles saved to: ${outputDir}`); +console.log('\nDone! Multi-digit example worksheets generated.') +console.log(`\nFiles saved to: ${outputDir}`) diff --git a/apps/web/scripts/generateSubtractionExamples.ts b/apps/web/scripts/generateSubtractionExamples.ts index 03c8ec0f..1519c65c 100644 --- a/apps/web/scripts/generateSubtractionExamples.ts +++ b/apps/web/scripts/generateSubtractionExamples.ts @@ -1,174 +1,169 @@ // Script to generate subtraction worksheet examples for the blog post // Shows different scaffolding levels for subtraction problems -import fs from "fs"; -import path from "path"; -import { generateWorksheetPreview } from "../src/app/create/worksheets/addition/generatePreview"; +import fs from 'fs' +import path from 'path' +import { generateWorksheetPreview } from '../src/app/create/worksheets/addition/generatePreview' // Output directory -const outputDir = path.join( - process.cwd(), - "public", - "blog", - "subtraction-examples", -); +const outputDir = path.join(process.cwd(), 'public', 'blog', 'subtraction-examples') // Ensure output directory exists if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }) } // Generate examples showing different subtraction scaffolding options const examples = [ { - name: "subtraction-no-borrowing", - filename: "no-borrowing.svg", - description: "Simple subtraction (no borrowing needed)", + name: 'subtraction-no-borrowing', + filename: 'no-borrowing.svg', + description: 'Simple subtraction (no borrowing needed)', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 0.0, // No borrowing problems pAnyStart: 0.0, digitRange: { min: 2, max: 2 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "never" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'never' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: false, showBorrowingHints: false, }, }, { - name: "subtraction-with-borrow-notation", - filename: "with-borrow-notation.svg", - description: "Subtraction with borrow notation boxes", + name: 'subtraction-with-borrow-notation', + filename: 'with-borrow-notation.svg', + description: 'Subtraction with borrow notation boxes', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 0.0, pAnyStart: 1.0, // Some borrowing digitRange: { min: 2, max: 2 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: true, showBorrowingHints: false, }, }, { - name: "subtraction-with-hints", - filename: "with-borrowing-hints.svg", - description: "Subtraction with borrow notation and hints", + name: 'subtraction-with-hints', + filename: 'with-borrowing-hints.svg', + description: 'Subtraction with borrow notation and hints', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 0.0, pAnyStart: 1.0, // Some borrowing digitRange: { min: 2, max: 2 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: true, showBorrowingHints: true, }, }, { - name: "subtraction-multiple-borrows", - filename: "multiple-borrows.svg", - description: "Complex subtraction with multiple borrows", + name: 'subtraction-multiple-borrows', + filename: 'multiple-borrows.svg', + description: 'Complex subtraction with multiple borrows', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 1.0, // All problems require borrowing pAnyStart: 1.0, digitRange: { min: 3, max: 3 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: true, showBorrowingHints: false, }, }, { - name: "subtraction-single-borrow", - filename: "single-borrow-ones.svg", - description: "Single borrow in ones place only", + name: 'subtraction-single-borrow', + filename: 'single-borrow-ones.svg', + description: 'Single borrow in ones place only', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 0.0, pAnyStart: 1.0, // Some borrowing digitRange: { min: 2, max: 2 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: true, showBorrowingHints: false, }, }, { - name: "subtraction-comparison-no-notation", - filename: "comparison-no-notation.svg", - description: "Borrowing problems WITHOUT notation boxes", + name: 'subtraction-comparison-no-notation', + filename: 'comparison-no-notation.svg', + description: 'Borrowing problems WITHOUT notation boxes', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 0.0, pAnyStart: 1.0, digitRange: { min: 2, max: 2 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "never" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'never' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: false, showBorrowingHints: false, }, }, { - name: "subtraction-comparison-with-notation", - filename: "comparison-with-notation.svg", - description: "Same problems WITH notation boxes", + name: 'subtraction-comparison-with-notation', + filename: 'comparison-with-notation.svg', + description: 'Same problems WITH notation boxes', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 0.0, pAnyStart: 1.0, digitRange: { min: 2, max: 2 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "never" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'never' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: true, showBorrowingHints: false, @@ -176,44 +171,44 @@ const examples = [ }, }, { - name: "subtraction-cascading-borrows", - filename: "cascading-borrows.svg", - description: "Cascading borrows across multiple places", + name: 'subtraction-cascading-borrows', + filename: 'cascading-borrows.svg', + description: 'Cascading borrows across multiple places', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 1.0, pAnyStart: 1.0, digitRange: { min: 4, max: 4 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: true, showBorrowingHints: false, }, }, { - name: "subtraction-hints-detail", - filename: "hints-detail.svg", - description: "Detailed view of borrowing hints", + name: 'subtraction-hints-detail', + filename: 'hints-detail.svg', + description: 'Detailed view of borrowing hints', config: { - operator: "subtraction" as const, + operator: 'subtraction' as const, pAllStart: 0.0, pAnyStart: 1.0, digitRange: { min: 3, max: 3 }, - mode: "manual" as const, + mode: 'manual' as const, displayRules: { - carryBoxes: "never" as const, - answerBoxes: "always" as const, - placeValueColors: "always" as const, - tenFrames: "never" as const, - problemNumbers: "always" as const, - cellBorders: "always" as const, + carryBoxes: 'never' as const, + answerBoxes: 'always' as const, + placeValueColors: 'always' as const, + tenFrames: 'never' as const, + problemNumbers: 'always' as const, + cellBorders: 'always' as const, }, showBorrowNotation: true, showBorrowingHints: true, @@ -221,12 +216,12 @@ const examples = [ cols: 1, }, }, -] as const; +] as const -console.log("Generating subtraction example worksheets...\n"); +console.log('Generating subtraction example worksheets...\n') for (const example of examples) { - console.log(`Generating ${example.description}...`); + console.log(`Generating ${example.description}...`) const config = { problemsPerPage: 4, @@ -234,29 +229,29 @@ for (const example of examples) { cols: 2, seed: 12345, // Fixed seed for consistent examples ...example.config, // Spread example config last so it can override defaults - }; + } try { - const result = generateWorksheetPreview(config); + const result = generateWorksheetPreview(config) if (!result.success || !result.pages || result.pages.length === 0) { - console.error(`Failed to generate ${example.name}:`, result.error); - console.error(`Details:`, result.details); - continue; + console.error(`Failed to generate ${example.name}:`, result.error) + console.error(`Details:`, result.details) + continue } // Get the first page's SVG - const svg = result.pages[0]; + const svg = result.pages[0] // Save to file - const outputPath = path.join(outputDir, example.filename); - fs.writeFileSync(outputPath, svg, "utf-8"); + const outputPath = path.join(outputDir, example.filename) + fs.writeFileSync(outputPath, svg, 'utf-8') - console.log(` ✓ Saved to ${outputPath}`); + console.log(` ✓ Saved to ${outputPath}`) } catch (error) { - console.error(` ✗ Error generating ${example.name}:`, error); + console.error(` ✗ Error generating ${example.name}:`, error) } } -console.log("\nDone! Subtraction example worksheets generated."); -console.log(`\nFiles saved to: ${outputDir}`); +console.log('\nDone! Subtraction example worksheets generated.') +console.log(`\nFiles saved to: ${outputDir}`) diff --git a/apps/web/scripts/generateTenFrameExamples.ts b/apps/web/scripts/generateTenFrameExamples.ts index f50d8d4c..329bf684 100644 --- a/apps/web/scripts/generateTenFrameExamples.ts +++ b/apps/web/scripts/generateTenFrameExamples.ts @@ -13,37 +13,32 @@ // 3. Compile to SVG using typst // 4. Save to public/blog/[your-post-name]/ -import fs from "fs"; -import path from "path"; -import { execSync } from "child_process"; +import fs from 'fs' +import path from 'path' +import { execSync } from 'child_process' import { generateTypstHelpers, generateProblemStackFunction, -} from "../src/app/create/worksheets/addition/typstHelpers"; +} from '../src/app/create/worksheets/addition/typstHelpers' // Output directory -const outputDir = path.join( - process.cwd(), - "public", - "blog", - "ten-frame-examples", -); +const outputDir = path.join(process.cwd(), 'public', 'blog', 'ten-frame-examples') // Ensure output directory exists if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(outputDir, { recursive: true }) } interface ExampleOptions { - showCarryBoxes?: boolean; - showAnswerBoxes?: boolean; - showPlaceValueColors?: boolean; - showTenFrames?: boolean; - showProblemNumbers?: boolean; - transparentBackground?: boolean; - fontSize?: number; - addend1: number; - addend2: number; + showCarryBoxes?: boolean + showAnswerBoxes?: boolean + showPlaceValueColors?: boolean + showTenFrames?: boolean + showProblemNumbers?: boolean + transparentBackground?: boolean + fontSize?: number + addend1: number + addend2: number } /** @@ -52,26 +47,26 @@ interface ExampleOptions { * Extracted here so we can generate static examples for blog posts */ function generateExampleTypst(config: ExampleOptions): string { - const a = config.addend1; - const b = config.addend2; - const fontSize = config.fontSize || 16; - const cellSize = 0.45; // Slightly larger for blog examples vs UI previews (0.35) + const a = config.addend1 + const b = config.addend2 + const fontSize = config.fontSize || 16 + const cellSize = 0.45 // Slightly larger for blog examples vs UI previews (0.35) // Boolean flags matching worksheet generator - const showCarries = config.showCarryBoxes ?? false; - const showAnswers = config.showAnswerBoxes ?? false; - const showColors = config.showPlaceValueColors ?? false; - const showNumbers = config.showProblemNumbers ?? false; - const showTenFrames = config.showTenFrames ?? false; - const showTenFramesForAll = false; // Not used for blog examples - const transparentBg = config.transparentBackground ?? false; + const showCarries = config.showCarryBoxes ?? false + const showAnswers = config.showAnswerBoxes ?? false + const showColors = config.showPlaceValueColors ?? false + const showNumbers = config.showProblemNumbers ?? false + const showTenFrames = config.showTenFrames ?? false + const showTenFramesForAll = false // Not used for blog examples + const transparentBg = config.transparentBackground ?? false return String.raw` -#set page(width: auto, height: auto, margin: 12pt, fill: ${transparentBg ? "none" : "white"}) +#set page(width: auto, height: auto, margin: 12pt, fill: ${transparentBg ? 'none' : 'white'}) #set text(size: ${fontSize}pt, font: "New Computer Modern Math") #let heavy-stroke = 0.8pt -#let show-ten-frames-for-all = ${showTenFramesForAll ? "true" : "false"} +#let show-ten-frames-for-all = ${showTenFramesForAll ? 'true' : 'false'} ${generateTypstHelpers(cellSize)} @@ -87,7 +82,7 @@ ${generateProblemStackFunction(cellSize)} #align(center + horizon)[ #problem-stack( a, b, aT, aO, bT, bO, - ${showNumbers ? "0" : "none"}, + ${showNumbers ? '0' : 'none'}, ${showCarries}, ${showAnswers}, ${showColors}, @@ -95,16 +90,16 @@ ${generateProblemStackFunction(cellSize)} ${showNumbers} ) ] -`; +` } // Generate examples showing ten-frames in action // Use problems that WILL have regrouping to show ten-frames const examples = [ { - name: "with-ten-frames", - filename: "with-ten-frames.svg", - description: "With Ten-Frames: Visual scaffolding for regrouping", + name: 'with-ten-frames', + filename: 'with-ten-frames.svg', + description: 'With Ten-Frames: Visual scaffolding for regrouping', options: { addend1: 47, addend2: 38, // 7+8=15 requires regrouping, will show ten-frames @@ -117,9 +112,9 @@ const examples = [ }, }, { - name: "without-ten-frames", - filename: "without-ten-frames.svg", - description: "Without Ten-Frames: Abstract representation", + name: 'without-ten-frames', + filename: 'without-ten-frames.svg', + description: 'Without Ten-Frames: Abstract representation', options: { addend1: 47, addend2: 38, // Same problem, no ten-frames @@ -132,9 +127,9 @@ const examples = [ }, }, { - name: "beginner-with-ten-frames", - filename: "beginner-ten-frames.svg", - description: "Beginner: Learning regrouping with ten-frames", + name: 'beginner-with-ten-frames', + filename: 'beginner-ten-frames.svg', + description: 'Beginner: Learning regrouping with ten-frames', options: { addend1: 28, addend2: 15, // 8+5=13 requires regrouping @@ -147,9 +142,9 @@ const examples = [ }, }, { - name: "ten-frames-both-columns", - filename: "ten-frames-both-columns.svg", - description: "Ten-frames in both columns: Double regrouping", + name: 'ten-frames-both-columns', + filename: 'ten-frames-both-columns.svg', + description: 'Ten-frames in both columns: Double regrouping', options: { addend1: 57, addend2: 68, // Both ones (7+8=15) and tens (5+6+1=12) regroup @@ -161,22 +156,22 @@ const examples = [ transparentBackground: true, }, }, -] as const; +] as const -console.log("Generating ten-frame example images (single problems)...\n"); +console.log('Generating ten-frame example images (single problems)...\n') for (const example of examples) { - console.log(`Generating ${example.description}...`); + console.log(`Generating ${example.description}...`) try { - const typstSource = generateExampleTypst(example.options); + const typstSource = generateExampleTypst(example.options) // Compile to SVG - let svg = execSync("typst compile --format svg - -", { + let svg = execSync('typst compile --format svg - -', { input: typstSource, - encoding: "utf8", + encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, - }); + }) // Post-process: Make SVG visible on dark background // - Digits on white cells should stay BLACK @@ -184,23 +179,23 @@ for (const example of examples) { // - Structural elements (borders, bars) should be WHITE svg = svg .replace(/stroke="#000000"/g, 'stroke="rgba(255, 255, 255, 0.8)"') - .replace(/stroke="#0000004d"/g, 'stroke="rgba(255, 255, 255, 0.4)"'); + .replace(/stroke="#0000004d"/g, 'stroke="rgba(255, 255, 255, 0.4)"') // Replace operator (+) fill specifically to white svg = svg.replace( /(]*fill=")#000000/g, - "$1rgba(255, 255, 255, 0.9)", - ); + '$1rgba(255, 255, 255, 0.9)' + ) // Save to file - const outputPath = path.join(outputDir, example.filename); - fs.writeFileSync(outputPath, svg, "utf-8"); + const outputPath = path.join(outputDir, example.filename) + fs.writeFileSync(outputPath, svg, 'utf-8') - console.log(` ✓ Saved to ${outputPath}`); + console.log(` ✓ Saved to ${outputPath}`) } catch (error) { - console.error(` ✗ Error generating ${example.name}:`, error); + console.error(` ✗ Error generating ${example.name}:`, error) } } -console.log("\nDone! Ten-frame example images generated."); -console.log(`\nFiles saved to: ${outputDir}`); +console.log('\nDone! Ten-frame example images generated.') +console.log(`\nFiles saved to: ${outputDir}`) diff --git a/apps/web/scripts/parseBoardCSV.js b/apps/web/scripts/parseBoardCSV.js index ffc001f4..aeeffefc 100644 --- a/apps/web/scripts/parseBoardCSV.js +++ b/apps/web/scripts/parseBoardCSV.js @@ -4,83 +4,83 @@ * Test script to parse the Rithmomachia board CSV and verify the layout. */ -const fs = require("fs"); -const path = require("path"); +const fs = require('fs') +const path = require('path') const csvPath = path.join( process.env.HOME, - "Downloads", - "rithmomachia board setup - Sheet1 (1).csv", -); + 'Downloads', + 'rithmomachia board setup - Sheet1 (1).csv' +) function parseCSV(csvContent) { - const lines = csvContent.trim().split("\n"); - const pieces = []; + 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; + const colorRowIndex = rankIndex * 3 + const shapeRowIndex = rankIndex * 3 + 1 + const numberRowIndex = rankIndex * 3 + 2 - if (numberRowIndex >= lines.length) break; + if (numberRowIndex >= lines.length) break - const colorRow = lines[colorRowIndex].split(","); - const shapeRow = lines[shapeRowIndex].split(","); - const numberRow = lines[numberRowIndex].split(","); + 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(); + 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; + 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}`; + 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"; + 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"; + 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; + console.warn(`Unknown shape "${shape}" at ${square}`) + continue } // Parse value/pyramid faces - if (pieceType === "P") { + 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], + 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; + console.warn(`Missing number for non-Pyramid ${shape} at ${square}`) + continue } - const value = parseInt(numberStr, 10); + const value = parseInt(numberStr, 10) if (isNaN(value)) { - console.warn(`Invalid number "${numberStr}" at ${square}`); - continue; + console.warn(`Invalid number "${numberStr}" at ${square}`) + continue } pieces.push({ @@ -88,120 +88,119 @@ function parseCSV(csvContent) { type: pieceType, value, square, - }); + }) } } } - return pieces; + return pieces } function generateBoardDisplay(pieces) { - const lines = []; + const lines = [] - lines.push("\n=== Board Layout (Game Orientation) ==="); - lines.push("BLACK (top)\n"); + 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", - ); + ' 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} `; + 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); + 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} `; + const val = piece.type === 'P' ? ' P' : piece.value.toString().padStart(3, ' ') + line += ` ${piece.color}${piece.type}${val} ` } else { - line += " ---- "; + line += ' ---- ' } } - lines.push(line); + lines.push(line) } - lines.push("\nWHITE (bottom)\n"); + lines.push('\nWHITE (bottom)\n') - return lines.join("\n"); + return lines.join('\n') } function generateColumnSummaries(pieces) { - const lines = []; + const lines = [] - lines.push("\n=== Column-by-Column Summary ===\n"); + lines.push('\n=== Column-by-Column Summary ===\n') for (let colCode = 65; colCode <= 80; colCode++) { - const col = String.fromCharCode(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; - }); + const rowA = parseInt(a.square.substring(1)) + const rowB = parseInt(b.square.substring(1)) + return rowA - rowB + }) - if (columnPieces.length === 0) continue; + if (columnPieces.length === 0) continue - const color = columnPieces[0].color === "B" ? "BLACK" : "WHITE"; - lines.push(`Column ${col} (${color}):`); + 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})`); + const val = piece.type === 'P' ? 'P[36,25,16,4]' : piece.value + lines.push(` ${piece.square}: ${piece.type}(${val})`) } - lines.push(""); + lines.push('') } - return lines.join("\n"); + return lines.join('\n') } function countPieces(pieces) { - const blackPieces = pieces.filter((p) => p.color === "B"); - const whitePieces = pieces.filter((p) => p.color === "W"); + 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 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); + const blackCounts = countByType(blackPieces) + const whiteCounts = countByType(whitePieces) - console.log("\n=== Piece Counts ==="); + console.log('\n=== Piece Counts ===') console.log( - `Black: ${blackPieces.length} total (C:${blackCounts.C}, T:${blackCounts.T}, S:${blackCounts.S}, P:${blackCounts.P})`, - ); + `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})`, - ); + `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); + 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); + 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}`); + '..', + '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); + console.error('Error:', error.message) + process.exit(1) } diff --git a/apps/web/scripts/traceDifficultyPath.ts b/apps/web/scripts/traceDifficultyPath.ts index 2ebea71d..83af2e68 100644 --- a/apps/web/scripts/traceDifficultyPath.ts +++ b/apps/web/scripts/traceDifficultyPath.ts @@ -6,104 +6,102 @@ import { findScaffoldingIndex, REGROUPING_PROGRESSION, SCAFFOLDING_PROGRESSION, -} from "../src/app/create/worksheets/addition/difficultyProfiles"; +} from '../src/app/create/worksheets/addition/difficultyProfiles' // Start from beginner let state = { pAnyStart: DIFFICULTY_PROFILES.beginner.regrouping.pAnyStart, pAllStart: DIFFICULTY_PROFILES.beginner.regrouping.pAllStart, displayRules: DIFFICULTY_PROFILES.beginner.displayRules, -}; +} -console.log("=== MAKE HARDER PATH ===\n"); -console.log("Format: (regroupingIdx, scaffoldingIdx) - description\n"); +console.log('=== MAKE HARDER PATH ===\n') +console.log('Format: (regroupingIdx, scaffoldingIdx) - description\n') -const harderPath: Array<{ r: number; s: number; desc: string }> = []; +const harderPath: Array<{ r: number; s: number; desc: string }> = [] // Record starting point -let rIdx = findRegroupingIndex(state.pAnyStart, state.pAllStart); -let sIdx = findScaffoldingIndex(state.displayRules); -harderPath.push({ r: rIdx, s: sIdx, desc: "START (beginner)" }); -console.log(`(${rIdx}, ${sIdx}) - START (beginner)`); +let rIdx = findRegroupingIndex(state.pAnyStart, state.pAllStart) +let sIdx = findScaffoldingIndex(state.displayRules) +harderPath.push({ r: rIdx, s: sIdx, desc: 'START (beginner)' }) +console.log(`(${rIdx}, ${sIdx}) - START (beginner)`) // Click "Make Harder" 30 times or until max for (let i = 0; i < 30; i++) { - const result = makeHarder(state); + const result = makeHarder(state) - const newR = findRegroupingIndex(result.pAnyStart, result.pAllStart); - const newS = findScaffoldingIndex(result.displayRules); + const newR = findRegroupingIndex(result.pAnyStart, result.pAllStart) + const newS = findScaffoldingIndex(result.displayRules) if (newR === rIdx && newS === sIdx) { - console.log(`\n(${newR}, ${newS}) - ${result.changeDescription} (STOPPED)`); - break; + console.log(`\n(${newR}, ${newS}) - ${result.changeDescription} (STOPPED)`) + break } - rIdx = newR; - sIdx = newS; - state = result; + rIdx = newR + sIdx = newS + state = result - harderPath.push({ r: rIdx, s: sIdx, desc: result.changeDescription }); - console.log(`(${rIdx}, ${sIdx}) - ${result.changeDescription}`); + harderPath.push({ r: rIdx, s: sIdx, desc: result.changeDescription }) + console.log(`(${rIdx}, ${sIdx}) - ${result.changeDescription}`) } -console.log("\n\n=== PATH VISUALIZATION ===\n"); -console.log("Regrouping Index →"); -console.log("Scaffolding ↓\n"); +console.log('\n\n=== PATH VISUALIZATION ===\n') +console.log('Regrouping Index →') +console.log('Scaffolding ↓\n') // Create 2D grid visualization -const grid: string[][] = []; +const grid: string[][] = [] for (let s = 0; s <= 12; s++) { - grid[s] = []; + grid[s] = [] for (let r = 0; r <= 18; r++) { - grid[s][r] = " ·"; + grid[s][r] = ' ·' } } // Mark path harderPath.forEach((point, idx) => { if (idx === 0) { - grid[point.s][point.r] = " S"; // Start + grid[point.s][point.r] = ' S' // Start } else if (idx === harderPath.length - 1) { - grid[point.s][point.r] = " E"; // End + grid[point.s][point.r] = ' E' // End } else { - grid[point.s][point.r] = `${idx.toString().padStart(3)}`; + grid[point.s][point.r] = `${idx.toString().padStart(3)}` } -}); +}) // Mark presets const presets = [ - { label: "BEG", profile: DIFFICULTY_PROFILES.beginner }, - { label: "EAR", profile: DIFFICULTY_PROFILES.earlyLearner }, - { label: "INT", profile: DIFFICULTY_PROFILES.intermediate }, - { label: "ADV", profile: DIFFICULTY_PROFILES.advanced }, - { label: "EXP", profile: DIFFICULTY_PROFILES.expert }, -]; + { label: 'BEG', profile: DIFFICULTY_PROFILES.beginner }, + { label: 'EAR', profile: DIFFICULTY_PROFILES.earlyLearner }, + { label: 'INT', profile: DIFFICULTY_PROFILES.intermediate }, + { label: 'ADV', profile: DIFFICULTY_PROFILES.advanced }, + { label: 'EXP', profile: DIFFICULTY_PROFILES.expert }, +] presets.forEach((preset) => { const r = findRegroupingIndex( preset.profile.regrouping.pAnyStart, - preset.profile.regrouping.pAllStart, - ); - const s = findScaffoldingIndex(preset.profile.displayRules); + preset.profile.regrouping.pAllStart + ) + const s = findScaffoldingIndex(preset.profile.displayRules) // Only mark if not already part of path - const onPath = harderPath.some((p) => p.r === r && p.s === s); + const onPath = harderPath.some((p) => p.r === r && p.s === s) if (!onPath) { - grid[s][r] = preset.label; + grid[s][r] = preset.label } -}); +}) // Print grid (inverted so scaffolding increases upward) -console.log( - " 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", -); +console.log(' 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18') for (let s = 12; s >= 0; s--) { - console.log(`${s.toString().padStart(2)} ${grid[s].join("")}`); + console.log(`${s.toString().padStart(2)} ${grid[s].join('')}`) } -console.log("\nLegend:"); -console.log(" S = Start (beginner)"); -console.log(" E = End (maximum)"); -console.log(" 1-29 = Step number"); -console.log(" BEG/EAR/INT/ADV/EXP = Preset profiles"); -console.log(" · = Not visited"); +console.log('\nLegend:') +console.log(' S = Start (beginner)') +console.log(' E = End (maximum)') +console.log(' 1-29 = Step number') +console.log(' BEG/EAR/INT/ADV/EXP = Preset profiles') +console.log(' · = Not visited') diff --git a/apps/web/scripts/validateTypstRefactoring.ts b/apps/web/scripts/validateTypstRefactoring.ts index 386f2cfe..12f7fcfb 100644 --- a/apps/web/scripts/validateTypstRefactoring.ts +++ b/apps/web/scripts/validateTypstRefactoring.ts @@ -6,98 +6,93 @@ * produces identical Typst output to ensure no regressions. */ -import { generateSubtractionProblemStackFunction } from "../src/app/create/worksheets/addition/typstHelpers"; -import { generateTypstHelpers } from "../src/app/create/worksheets/addition/typstHelpers"; -import { generatePlaceValueColors } from "../src/app/create/worksheets/addition/typstHelpers"; +import { generateSubtractionProblemStackFunction } from '../src/app/create/worksheets/addition/typstHelpers' +import { generateTypstHelpers } from '../src/app/create/worksheets/addition/typstHelpers' +import { generatePlaceValueColors } from '../src/app/create/worksheets/addition/typstHelpers' -console.log("🔍 Validating typstHelpers refactoring...\n"); +console.log('🔍 Validating typstHelpers refactoring...\n') // Test 1: Check that functions are exported and callable -console.log("✓ Test 1: Functions are exported"); +console.log('✓ Test 1: Functions are exported') console.log( - ` - generateSubtractionProblemStackFunction: ${typeof generateSubtractionProblemStackFunction}`, -); -console.log(` - generateTypstHelpers: ${typeof generateTypstHelpers}`); -console.log(` - generatePlaceValueColors: ${typeof generatePlaceValueColors}`); + ` - generateSubtractionProblemStackFunction: ${typeof generateSubtractionProblemStackFunction}` +) +console.log(` - generateTypstHelpers: ${typeof generateTypstHelpers}`) +console.log(` - generatePlaceValueColors: ${typeof generatePlaceValueColors}`) -if (typeof generateSubtractionProblemStackFunction !== "function") { - console.error( - "❌ generateSubtractionProblemStackFunction is not a function!", - ); - process.exit(1); +if (typeof generateSubtractionProblemStackFunction !== 'function') { + console.error('❌ generateSubtractionProblemStackFunction is not a function!') + process.exit(1) } // Test 2: Generate sample Typst code -console.log("\n✓ Test 2: Generate sample Typst code"); -const cellSize = 0.55; -const maxDigits = 3; +console.log('\n✓ Test 2: Generate sample Typst code') +const cellSize = 0.55 +const maxDigits = 3 -const helpers = generateTypstHelpers(cellSize); -console.log(` - Helper functions: ${helpers.length} characters`); +const helpers = generateTypstHelpers(cellSize) +console.log(` - Helper functions: ${helpers.length} characters`) -const colors = generatePlaceValueColors(); -console.log(` - Color definitions: ${colors.length} characters`); +const colors = generatePlaceValueColors() +console.log(` - Color definitions: ${colors.length} characters`) -const problemStack = generateSubtractionProblemStackFunction( - cellSize, - maxDigits, -); -console.log(` - Problem stack function: ${problemStack.length} characters`); +const problemStack = generateSubtractionProblemStackFunction(cellSize, maxDigits) +console.log(` - Problem stack function: ${problemStack.length} characters`) // Test 3: Verify key features are present -console.log("\n✓ Test 3: Verify key features in generated Typst"); +console.log('\n✓ Test 3: Verify key features in generated Typst') const checks = [ - { name: "Borrow boxes row", pattern: /Borrow boxes row/ }, - { name: "Minuend row", pattern: /Minuend row/ }, - { name: "Subtrahend row", pattern: /Subtrahend row/ }, - { name: "Answer boxes", pattern: /Answer boxes/ }, - { name: "Ten-frames", pattern: /Ten-frames row/ }, - { name: "Borrowing hints", pattern: /show-borrowing-hints/ }, - { name: "Arrow rendering", pattern: /path\(/ }, - { name: "Place value colors", pattern: /place-colors/ }, - { name: "Scratch work boxes", pattern: /dotted.*paint: gray/ }, -]; + { name: 'Borrow boxes row', pattern: /Borrow boxes row/ }, + { name: 'Minuend row', pattern: /Minuend row/ }, + { name: 'Subtrahend row', pattern: /Subtrahend row/ }, + { name: 'Answer boxes', pattern: /Answer boxes/ }, + { name: 'Ten-frames', pattern: /Ten-frames row/ }, + { name: 'Borrowing hints', pattern: /show-borrowing-hints/ }, + { name: 'Arrow rendering', pattern: /path\(/ }, + { name: 'Place value colors', pattern: /place-colors/ }, + { name: 'Scratch work boxes', pattern: /dotted.*paint: gray/ }, +] -let allPassed = true; +let allPassed = true for (const check of checks) { - const found = check.pattern.test(problemStack); + const found = check.pattern.test(problemStack) if (found) { - console.log(` ✓ ${check.name}`); + console.log(` ✓ ${check.name}`) } else { - console.log(` ❌ ${check.name} - NOT FOUND`); - allPassed = false; + console.log(` ❌ ${check.name} - NOT FOUND`) + allPassed = false } } // Test 4: Verify structure -console.log("\n✓ Test 4: Verify Typst structure"); +console.log('\n✓ Test 4: Verify Typst structure') const structureChecks = [ - { name: "Function definition", pattern: /#let subtraction-problem-stack\(/ }, - { name: "Grid structure", pattern: /grid\(/ }, - { name: "Stack structure", pattern: /stack\(/ }, - { name: "Problem number display", pattern: /problem-number-display/ }, -]; + { name: 'Function definition', pattern: /#let subtraction-problem-stack\(/ }, + { name: 'Grid structure', pattern: /grid\(/ }, + { name: 'Stack structure', pattern: /stack\(/ }, + { name: 'Problem number display', pattern: /problem-number-display/ }, +] for (const check of structureChecks) { - const found = check.pattern.test(problemStack); + const found = check.pattern.test(problemStack) if (found) { - console.log(` ✓ ${check.name}`); + console.log(` ✓ ${check.name}`) } else { - console.log(` ❌ ${check.name} - NOT FOUND`); - allPassed = false; + console.log(` ❌ ${check.name} - NOT FOUND`) + allPassed = false } } // Summary -console.log("\n" + "=".repeat(60)); +console.log('\n' + '='.repeat(60)) if (allPassed) { - console.log("✅ All validation checks passed!"); - console.log("\nThe refactored code generates valid Typst output with all"); - console.log("expected features present."); - process.exit(0); + console.log('✅ All validation checks passed!') + console.log('\nThe refactored code generates valid Typst output with all') + console.log('expected features present.') + process.exit(0) } else { - console.log("❌ Some validation checks failed!"); - console.log("\nPlease review the output above for details."); - process.exit(1); + console.log('❌ Some validation checks failed!') + console.log('\nPlease review the output above for details.') + process.exit(1) } diff --git a/apps/web/server.js b/apps/web/server.js index 43271652..3c9703d2 100644 --- a/apps/web/server.js +++ b/apps/web/server.js @@ -1,77 +1,75 @@ -const { createServer } = require("http"); -const { parse } = require("url"); -const next = require("next"); +const { createServer } = require('http') +const { parse } = require('url') +const next = require('next') -const dev = process.env.NODE_ENV !== "production"; -const hostname = "localhost"; -const port = parseInt(process.env.PORT || "3000", 10); +const dev = process.env.NODE_ENV !== 'production' +const hostname = 'localhost' +const port = parseInt(process.env.PORT || '3000', 10) -const app = next({ dev, hostname, port }); -const handle = app.getRequestHandler(); +const app = next({ dev, hostname, port }) +const handle = app.getRequestHandler() // Run migrations before starting server -console.log("🔄 Running database migrations..."); -const { migrate } = require("drizzle-orm/better-sqlite3/migrator"); -const { db } = require("./dist/db/index"); +console.log('🔄 Running database migrations...') +const { migrate } = require('drizzle-orm/better-sqlite3/migrator') +const { db } = require('./dist/db/index') try { - migrate(db, { migrationsFolder: "./drizzle" }); - console.log("✅ Migrations complete"); + migrate(db, { migrationsFolder: './drizzle' }) + console.log('✅ Migrations complete') } catch (error) { - console.error("❌ Migration failed:", error); - process.exit(1); + console.error('❌ Migration failed:', error) + process.exit(1) } app.prepare().then(() => { const server = createServer(async (req, res) => { try { - const parsedUrl = parse(req.url, true); - await handle(req, res, parsedUrl); + const parsedUrl = parse(req.url, true) + await handle(req, res, parsedUrl) } catch (err) { - console.error("Error occurred handling", req.url, err); - res.statusCode = 500; - res.end("internal server error"); + console.error('Error occurred handling', req.url, err) + res.statusCode = 500 + res.end('internal server error') } - }); + }) // Debug: Check upgrade handlers at each stage - console.log("📊 Stage 1 - After server creation:"); - console.log(` Upgrade handlers: ${server.listeners("upgrade").length}`); + console.log('📊 Stage 1 - After server creation:') + console.log(` Upgrade handlers: ${server.listeners('upgrade').length}`) // Initialize Socket.IO - const { initializeSocketServer } = require("./dist/socket-server"); + const { initializeSocketServer } = require('./dist/socket-server') - console.log("📊 Stage 2 - Before initializeSocketServer:"); - console.log(` Upgrade handlers: ${server.listeners("upgrade").length}`); + console.log('📊 Stage 2 - Before initializeSocketServer:') + console.log(` Upgrade handlers: ${server.listeners('upgrade').length}`) - initializeSocketServer(server); + initializeSocketServer(server) - console.log("📊 Stage 3 - After initializeSocketServer:"); - const allHandlers = server.listeners("upgrade"); - console.log(` Upgrade handlers: ${allHandlers.length}`); + 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)`, - ); - }); + 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); + 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`); + 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); - }; + return originalEmit(event, ...args) + } server - .once("error", (err) => { - console.error(err); - process.exit(1); + .once('error', (err) => { + console.error(err) + process.exit(1) }) .listen(port, () => { - console.log(`> Ready on http://${hostname}:${port}`); - }); -}); + console.log(`> Ready on http://${hostname}:${port}`) + }) +}) diff --git a/apps/web/src/app/__tests__/layout.nav.test.tsx b/apps/web/src/app/__tests__/layout.nav.test.tsx index 6f777cea..a4df87ff 100644 --- a/apps/web/src/app/__tests__/layout.nav.test.tsx +++ b/apps/web/src/app/__tests__/layout.nav.test.tsx @@ -1,33 +1,33 @@ -import { render, screen } from "@testing-library/react"; -import RootLayout from "../layout"; +import { render, screen } from '@testing-library/react' +import RootLayout from '../layout' // Mock ClientProviders -vi.mock("../../components/ClientProviders", () => ({ +vi.mock('../../components/ClientProviders', () => ({ ClientProviders: ({ children }: { children: React.ReactNode }) => (
{children}
), -})); +})) -describe("RootLayout", () => { - it("renders children with ClientProviders", () => { - const pageContent =
Page content
; +describe('RootLayout', () => { + it('renders children with ClientProviders', () => { + const pageContent =
Page content
- render({pageContent}); + render({pageContent}) - expect(screen.getByTestId("client-providers")).toBeInTheDocument(); - expect(screen.getByText("Page content")).toBeInTheDocument(); - }); + expect(screen.getByTestId('client-providers')).toBeInTheDocument() + expect(screen.getByText('Page content')).toBeInTheDocument() + }) - it("renders html and body tags", () => { - const pageContent =
Test content
; + it('renders html and body tags', () => { + const pageContent =
Test content
- const { container } = render({pageContent}); + const { container } = render({pageContent}) - const html = container.querySelector("html"); - const body = container.querySelector("body"); + const html = container.querySelector('html') + const body = container.querySelector('body') - expect(html).toBeInTheDocument(); - expect(html).toHaveAttribute("lang", "en"); - expect(body).toBeInTheDocument(); - }); -}); + expect(html).toBeInTheDocument() + expect(html).toHaveAttribute('lang', 'en') + expect(body).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/app/abacus-test/page.tsx b/apps/web/src/app/abacus-test/page.tsx index 12a9b314..2aac91a4 100644 --- a/apps/web/src/app/abacus-test/page.tsx +++ b/apps/web/src/app/abacus-test/page.tsx @@ -1,48 +1,48 @@ -"use client"; +'use client' -import { AbacusReact } from "@soroban/abacus-react"; -import { useState } from "react"; -import { css } from "../../../styled-system/css"; +import { AbacusReact } from '@soroban/abacus-react' +import { useState } from 'react' +import { css } from '../../../styled-system/css' export default function AbacusTestPage() { - const [value, setValue] = useState(0); - const [debugInfo, setDebugInfo] = useState(""); + const [value, setValue] = useState(0) + const [debugInfo, setDebugInfo] = useState('') const handleValueChange = (newValue: number) => { - setValue(newValue); - setDebugInfo(`Value changed to: ${newValue}`); - console.log("Abacus value:", newValue); - }; + setValue(newValue) + setDebugInfo(`Value changed to: ${newValue}`) + console.log('Abacus value:', newValue) + } return (
{/* Debug info */}
Current Value: {value}
@@ -50,14 +50,14 @@ export default function AbacusTestPage() {
- ); + ) } diff --git a/apps/web/src/app/api/abacus-settings/route.ts b/apps/web/src/app/api/abacus-settings/route.ts index 21d3aaf7..11e1c09d 100644 --- a/apps/web/src/app/api/abacus-settings/route.ts +++ b/apps/web/src/app/api/abacus-settings/route.ts @@ -1,8 +1,8 @@ -import { eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db } from "@/db"; -import * as schema from "@/db/schema"; -import { getViewerId } from "@/lib/viewer"; +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db } from '@/db' +import * as schema from '@/db/schema' +import { getViewerId } from '@/lib/viewer' /** * GET /api/abacus-settings @@ -10,30 +10,27 @@ import { getViewerId } from "@/lib/viewer"; */ export async function GET() { try { - const viewerId = await getViewerId(); - const user = await getOrCreateUser(viewerId); + const viewerId = await getViewerId() + const user = await getOrCreateUser(viewerId) // Find or create abacus settings let settings = await db.query.abacusSettings.findFirst({ where: eq(schema.abacusSettings.userId, user.id), - }); + }) // If no settings exist, create with defaults if (!settings) { const [newSettings] = await db .insert(schema.abacusSettings) .values({ userId: user.id }) - .returning(); - settings = newSettings; + .returning() + settings = newSettings } - return NextResponse.json({ settings }); + return NextResponse.json({ settings }) } catch (error) { - console.error("Failed to fetch abacus settings:", error); - return NextResponse.json( - { error: "Failed to fetch abacus settings" }, - { status: 500 }, - ); + console.error('Failed to fetch abacus settings:', error) + return NextResponse.json({ error: 'Failed to fetch abacus settings' }, { status: 500 }) } } @@ -43,26 +40,26 @@ export async function GET() { */ export async function PATCH(req: NextRequest) { try { - const viewerId = await getViewerId(); - const body = await req.json(); + const viewerId = await getViewerId() + const body = await req.json() // Security: Strip userId from request body - it must come from session only - const { userId: _, ...updates } = body; + const { userId: _, ...updates } = body - const user = await getOrCreateUser(viewerId); + const user = await getOrCreateUser(viewerId) // Ensure settings exist const existingSettings = await db.query.abacusSettings.findFirst({ where: eq(schema.abacusSettings.userId, user.id), - }); + }) if (!existingSettings) { // Create new settings with updates const [newSettings] = await db .insert(schema.abacusSettings) .values({ userId: user.id, ...updates }) - .returning(); - return NextResponse.json({ settings: newSettings }); + .returning() + return NextResponse.json({ settings: newSettings }) } // Update existing settings @@ -70,15 +67,12 @@ export async function PATCH(req: NextRequest) { .update(schema.abacusSettings) .set(updates) .where(eq(schema.abacusSettings.userId, user.id)) - .returning(); + .returning() - return NextResponse.json({ settings: updatedSettings }); + return NextResponse.json({ settings: updatedSettings }) } catch (error) { - console.error("Failed to update abacus settings:", error); - return NextResponse.json( - { error: "Failed to update abacus settings" }, - { status: 500 }, - ); + console.error('Failed to update abacus settings:', error) + return NextResponse.json({ error: 'Failed to update abacus settings' }, { status: 500 }) } } @@ -89,7 +83,7 @@ async function getOrCreateUser(viewerId: string) { // Try to find existing user by guest ID let user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) // If no user exists, create one if (!user) { @@ -98,10 +92,10 @@ async function getOrCreateUser(viewerId: string) { .values({ guestId: viewerId, }) - .returning(); + .returning() - user = newUser; + user = newUser } - return user; + return user } diff --git a/apps/web/src/app/api/arcade-session/__tests__/route.test.ts b/apps/web/src/app/api/arcade-session/__tests__/route.test.ts index 56e3af10..51405980 100644 --- a/apps/web/src/app/api/arcade-session/__tests__/route.test.ts +++ b/apps/web/src/app/api/arcade-session/__tests__/route.test.ts @@ -1,14 +1,14 @@ -import { eq } from "drizzle-orm"; -import { NextRequest } from "next/server"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { db, schema } from "@/db"; -import { deleteArcadeSession } from "@/lib/arcade/session-manager"; -import { DELETE, GET, POST } from "../route"; +import { eq } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { db, schema } from '@/db' +import { deleteArcadeSession } from '@/lib/arcade/session-manager' +import { DELETE, GET, POST } from '../route' -describe("Arcade Session API Routes", () => { - const testUserId = "test-user-for-api-routes"; - const testGuestId = "test-guest-id-api-routes"; - const baseUrl = "http://localhost:3000"; +describe('Arcade Session API Routes', () => { + const testUserId = 'test-user-for-api-routes' + const testGuestId = 'test-guest-id-api-routes' + const baseUrl = 'http://localhost:3000' beforeEach(async () => { // Create test user @@ -19,167 +19,158 @@ describe("Arcade Session API Routes", () => { guestId: testGuestId, createdAt: new Date(), }) - .onConflictDoNothing(); - }); + .onConflictDoNothing() + }) afterEach(async () => { // Clean up - await deleteArcadeSession(testUserId); - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); - }); + await deleteArcadeSession(testUserId) + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) + }) - describe("POST /api/arcade-session", () => { - it("should create a new session", async () => { + describe('POST /api/arcade-session', () => { + it('should create a new session', async () => { const request = new NextRequest(`${baseUrl}/api/arcade-session`, { - method: "POST", + method: 'POST', body: JSON.stringify({ userId: testUserId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { test: "state" }, + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { test: 'state' }, activePlayers: [1], }), - }); + }) - const response = await POST(request); - const data = await response.json(); + const response = await POST(request) + const data = await response.json() - expect(response.status).toBe(200); - expect(data.session).toBeDefined(); - expect(data.session.currentGame).toBe("matching"); - expect(data.session.version).toBe(1); - }); + expect(response.status).toBe(200) + expect(data.session).toBeDefined() + expect(data.session.currentGame).toBe('matching') + expect(data.session.version).toBe(1) + }) - it("should return 400 for missing fields", async () => { + it('should return 400 for missing fields', async () => { const request = new NextRequest(`${baseUrl}/api/arcade-session`, { - method: "POST", + method: 'POST', body: JSON.stringify({ userId: testUserId, // Missing required fields }), - }); + }) - const response = await POST(request); - const data = await response.json(); + const response = await POST(request) + const data = await response.json() - expect(response.status).toBe(400); - expect(data.error).toBe("Missing required fields"); - }); + expect(response.status).toBe(400) + expect(data.error).toBe('Missing required fields') + }) - it("should return 500 for non-existent user (foreign key constraint)", async () => { + it('should return 500 for non-existent user (foreign key constraint)', async () => { const request = new NextRequest(`${baseUrl}/api/arcade-session`, { - method: "POST", + method: 'POST', body: JSON.stringify({ - userId: "non-existent-user", - gameName: "matching", - gameUrl: "/arcade/matching", + userId: 'non-existent-user', + gameName: 'matching', + gameUrl: '/arcade/matching', initialState: {}, activePlayers: [1], }), - }); + }) - const response = await POST(request); + const response = await POST(request) - expect(response.status).toBe(500); - }); - }); + expect(response.status).toBe(500) + }) + }) - describe("GET /api/arcade-session", () => { - it("should retrieve an existing session", async () => { + describe('GET /api/arcade-session', () => { + it('should retrieve an existing session', async () => { // Create session first const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, { - method: "POST", + method: 'POST', body: JSON.stringify({ userId: testUserId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { test: "state" }, + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { test: 'state' }, activePlayers: [1], }), - }); - await POST(createRequest); + }) + await POST(createRequest) // Now retrieve it - const request = new NextRequest( - `${baseUrl}/api/arcade-session?userId=${testUserId}`, - ); + const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`) - const response = await GET(request); - const data = await response.json(); + const response = await GET(request) + const data = await response.json() - expect(response.status).toBe(200); - expect(data.session).toBeDefined(); - expect(data.session.currentGame).toBe("matching"); - }); + expect(response.status).toBe(200) + expect(data.session).toBeDefined() + expect(data.session.currentGame).toBe('matching') + }) - it("should return 404 for non-existent session", async () => { - const request = new NextRequest( - `${baseUrl}/api/arcade-session?userId=non-existent`, - ); + it('should return 404 for non-existent session', async () => { + const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=non-existent`) - const response = await GET(request); + const response = await GET(request) - expect(response.status).toBe(404); - }); + expect(response.status).toBe(404) + }) - it("should return 400 for missing userId", async () => { - const request = new NextRequest(`${baseUrl}/api/arcade-session`); + it('should return 400 for missing userId', async () => { + const request = new NextRequest(`${baseUrl}/api/arcade-session`) - const response = await GET(request); - const data = await response.json(); + const response = await GET(request) + const data = await response.json() - expect(response.status).toBe(400); - expect(data.error).toBe("userId required"); - }); - }); + expect(response.status).toBe(400) + expect(data.error).toBe('userId required') + }) + }) - describe("DELETE /api/arcade-session", () => { - it("should delete an existing session", async () => { + describe('DELETE /api/arcade-session', () => { + it('should delete an existing session', async () => { // Create session first const createRequest = new NextRequest(`${baseUrl}/api/arcade-session`, { - method: "POST", + method: 'POST', body: JSON.stringify({ userId: testUserId, - gameName: "matching", - gameUrl: "/arcade/matching", + gameName: 'matching', + gameUrl: '/arcade/matching', initialState: {}, activePlayers: [1], }), - }); - await POST(createRequest); + }) + await POST(createRequest) // Now delete it - const request = new NextRequest( - `${baseUrl}/api/arcade-session?userId=${testUserId}`, - { - method: "DELETE", - }, - ); + const request = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`, { + method: 'DELETE', + }) - const response = await DELETE(request); - const data = await response.json(); + const response = await DELETE(request) + const data = await response.json() - expect(response.status).toBe(200); - expect(data.success).toBe(true); + expect(response.status).toBe(200) + expect(data.success).toBe(true) // Verify it's deleted - const getRequest = new NextRequest( - `${baseUrl}/api/arcade-session?userId=${testUserId}`, - ); - const getResponse = await GET(getRequest); - expect(getResponse.status).toBe(404); - }); + const getRequest = new NextRequest(`${baseUrl}/api/arcade-session?userId=${testUserId}`) + const getResponse = await GET(getRequest) + expect(getResponse.status).toBe(404) + }) - it("should return 400 for missing userId", async () => { + it('should return 400 for missing userId', async () => { const request = new NextRequest(`${baseUrl}/api/arcade-session`, { - method: "DELETE", - }); + method: 'DELETE', + }) - const response = await DELETE(request); - const data = await response.json(); + const response = await DELETE(request) + const data = await response.json() - expect(response.status).toBe(400); - expect(data.error).toBe("userId required"); - }); - }); -}); + expect(response.status).toBe(400) + expect(data.error).toBe('userId required') + }) + }) +}) diff --git a/apps/web/src/app/api/arcade-session/route.ts b/apps/web/src/app/api/arcade-session/route.ts index 2a683ce3..d0fd2946 100644 --- a/apps/web/src/app/api/arcade-session/route.ts +++ b/apps/web/src/app/api/arcade-session/route.ts @@ -1,10 +1,10 @@ -import { type NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from 'next/server' import { createArcadeSession, deleteArcadeSession, getArcadeSession, -} from "@/lib/arcade/session-manager"; -import type { GameName } from "@/lib/arcade/validation"; +} from '@/lib/arcade/session-manager' +import type { GameName } from '@/lib/arcade/validation' /** * GET /api/arcade-session?userId=xxx @@ -12,16 +12,16 @@ import type { GameName } from "@/lib/arcade/validation"; */ export async function GET(request: NextRequest) { try { - const userId = request.nextUrl.searchParams.get("userId"); + const userId = request.nextUrl.searchParams.get('userId') if (!userId) { - return NextResponse.json({ error: "userId required" }, { status: 400 }); + return NextResponse.json({ error: 'userId required' }, { status: 400 }) } - const session = await getArcadeSession(userId); + const session = await getArcadeSession(userId) if (!session) { - return NextResponse.json({ error: "No active session" }, { status: 404 }); + return NextResponse.json({ error: 'No active session' }, { status: 404 }) } return NextResponse.json({ @@ -33,13 +33,10 @@ export async function GET(request: NextRequest) { version: session.version, expiresAt: session.expiresAt, }, - }); + }) } catch (error) { - console.error("Error fetching arcade session:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); + console.error('Error fetching arcade session:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -49,25 +46,17 @@ export async function GET(request: NextRequest) { */ export async function POST(request: NextRequest) { try { - const body = await request.json(); - const { userId, gameName, gameUrl, initialState, activePlayers, roomId } = - body; + const body = await request.json() + const { userId, gameName, gameUrl, initialState, activePlayers, roomId } = body - if ( - !userId || - !gameName || - !gameUrl || - !initialState || - !activePlayers || - !roomId - ) { + if (!userId || !gameName || !gameUrl || !initialState || !activePlayers || !roomId) { return NextResponse.json( { error: - "Missing required fields (userId, gameName, gameUrl, initialState, activePlayers, roomId)", + 'Missing required fields (userId, gameName, gameUrl, initialState, activePlayers, roomId)', }, - { status: 400 }, - ); + { status: 400 } + ) } const session = await createArcadeSession({ @@ -77,7 +66,7 @@ export async function POST(request: NextRequest) { initialState, activePlayers, roomId, - }); + }) return NextResponse.json({ session: { @@ -88,13 +77,10 @@ export async function POST(request: NextRequest) { version: session.version, expiresAt: session.expiresAt, }, - }); + }) } catch (error) { - console.error("Error creating arcade session:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); + console.error('Error creating arcade session:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -104,20 +90,17 @@ export async function POST(request: NextRequest) { */ export async function DELETE(request: NextRequest) { try { - const userId = request.nextUrl.searchParams.get("userId"); + const userId = request.nextUrl.searchParams.get('userId') if (!userId) { - return NextResponse.json({ error: "userId required" }, { status: 400 }); + return NextResponse.json({ error: 'userId required' }, { status: 400 }) } - await deleteArcadeSession(userId); + await deleteArcadeSession(userId) - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true }) } catch (error) { - console.error("Error deleting arcade session:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); + console.error('Error deleting arcade session:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade-session/types.ts b/apps/web/src/app/api/arcade-session/types.ts index c3c43ac6..5694fbb8 100644 --- a/apps/web/src/app/api/arcade-session/types.ts +++ b/apps/web/src/app/api/arcade-session/types.ts @@ -4,15 +4,15 @@ export interface ArcadeSessionResponse { session: { - currentGame: string; - gameUrl: string; - gameState: unknown; - activePlayers: number[]; - version: number; - expiresAt: Date | string; - }; + currentGame: string + gameUrl: string + gameState: unknown + activePlayers: number[] + version: number + expiresAt: Date | string + } } export interface ArcadeSessionErrorResponse { - error: string; + error: string } diff --git a/apps/web/src/app/api/arcade/invitations/pending/route.ts b/apps/web/src/app/api/arcade/invitations/pending/route.ts index 206d858c..65976413 100644 --- a/apps/web/src/app/api/arcade/invitations/pending/route.ts +++ b/apps/web/src/app/api/arcade/invitations/pending/route.ts @@ -1,7 +1,7 @@ -import { eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getViewerId } from "@/lib/viewer"; +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getViewerId } from '@/lib/viewer' /** * GET /api/arcade/invitations/pending @@ -10,7 +10,7 @@ import { getViewerId } from "@/lib/viewer"; */ export async function GET(req: NextRequest) { try { - const viewerId = await getViewerId(); + const viewerId = await getViewerId() // Get pending invitations with room details const invitations = await db @@ -30,35 +30,26 @@ export async function GET(req: NextRequest) { expiresAt: schema.roomInvitations.expiresAt, }) .from(schema.roomInvitations) - .innerJoin( - schema.arcadeRooms, - eq(schema.roomInvitations.roomId, schema.arcadeRooms.id), - ) + .innerJoin(schema.arcadeRooms, eq(schema.roomInvitations.roomId, schema.arcadeRooms.id)) .where(eq(schema.roomInvitations.userId, viewerId)) - .orderBy(schema.roomInvitations.createdAt); + .orderBy(schema.roomInvitations.createdAt) // Get all active bans for this user (bans are deleted when unbanned, so any existing ban is active) const activeBans = await db .select({ roomId: schema.roomBans.roomId }) .from(schema.roomBans) - .where(eq(schema.roomBans.userId, viewerId)); + .where(eq(schema.roomBans.userId, viewerId)) - const bannedRoomIds = new Set(activeBans.map((ban) => ban.roomId)); + const bannedRoomIds = new Set(activeBans.map((ban) => ban.roomId)) // Filter to only pending invitations, excluding banned rooms const pendingInvitations = invitations.filter( - (inv) => inv.status === "pending" && !bannedRoomIds.has(inv.roomId), - ); + (inv) => inv.status === 'pending' && !bannedRoomIds.has(inv.roomId) + ) - return NextResponse.json( - { invitations: pendingInvitations }, - { status: 200 }, - ); + return NextResponse.json({ invitations: pendingInvitations }, { status: 200 }) } catch (error: any) { - console.error("Failed to get pending invitations:", error); - return NextResponse.json( - { error: "Failed to get pending invitations" }, - { status: 500 }, - ); + console.error('Failed to get pending invitations:', error) + return NextResponse.json({ error: 'Failed to get pending invitations' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/ban/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/ban/route.ts index 3d700054..6f6918b1 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/ban/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/ban/route.ts @@ -1,19 +1,15 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { - banUserFromRoom, - getRoomBans, - unbanUserFromRoom, -} from "@/lib/arcade/room-moderation"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getRoomActivePlayers } from "@/lib/arcade/player-manager"; -import { getUserRoomHistory } from "@/lib/arcade/room-member-history"; -import { createInvitation } from "@/lib/arcade/room-invitations"; -import { getViewerId } from "@/lib/viewer"; -import { getSocketIO } from "@/lib/socket-io"; +import { type NextRequest, NextResponse } from 'next/server' +import { banUserFromRoom, getRoomBans, unbanUserFromRoom } from '@/lib/arcade/room-moderation' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getRoomActivePlayers } from '@/lib/arcade/player-manager' +import { getUserRoomHistory } from '@/lib/arcade/room-member-history' +import { createInvitation } from '@/lib/arcade/room-invitations' +import { getViewerId } from '@/lib/viewer' +import { getSocketIO } from '@/lib/socket-io' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/ban @@ -25,60 +21,44 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() // Validate required fields if (!body.userId || !body.reason) { return NextResponse.json( - { error: "Missing required fields: userId, reason" }, - { status: 400 }, - ); + { error: 'Missing required fields: userId, reason' }, + { status: 400 } + ) } // Validate reason - const validReasons = [ - "harassment", - "cheating", - "inappropriate-name", - "spam", - "afk", - "other", - ]; + const validReasons = ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other'] if (!validReasons.includes(body.reason)) { - return NextResponse.json({ error: "Invalid reason" }, { status: 400 }); + return NextResponse.json({ error: 'Invalid reason' }, { status: 400 }) } // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can ban users" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can ban users' }, { status: 403 }) } // Can't ban yourself if (body.userId === viewerId) { - return NextResponse.json( - { error: "Cannot ban yourself" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Cannot ban yourself' }, { status: 400 }) } // Get the user to ban (they might not be in the room anymore) - const targetUser = members.find((m) => m.userId === body.userId); - const userName = targetUser?.displayName || body.userId.slice(-4); + const targetUser = members.find((m) => m.userId === body.userId) + const userName = targetUser?.displayName || body.userId.slice(-4) // Ban the user await banUserFromRoom({ @@ -89,48 +69,48 @@ export async function POST(req: NextRequest, context: RouteContext) { bannedByName: currentMember.displayName, reason: body.reason, notes: body.notes, - }); + }) // Broadcast updates via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { // Get updated member list - const updatedMembers = await getRoomMembers(roomId); - const memberPlayers = await getRoomActivePlayers(roomId); + const updatedMembers = await getRoomMembers(roomId) + const memberPlayers = await getRoomActivePlayers(roomId) // Convert memberPlayers Map to object for JSON serialization - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } // Tell the banned user they've been removed - io.to(`user:${body.userId}`).emit("banned-from-room", { + io.to(`user:${body.userId}`).emit('banned-from-room', { roomId, bannedBy: currentMember.displayName, reason: body.reason, - }); + }) // Notify everyone else in the room - io.to(`room:${roomId}`).emit("member-left", { + io.to(`room:${roomId}`).emit('member-left', { roomId, userId: body.userId, members: updatedMembers, memberPlayers: memberPlayersObj, - reason: "banned", - }); + reason: 'banned', + }) - console.log(`[Ban API] User ${body.userId} banned from room ${roomId}`); + console.log(`[Ban API] User ${body.userId} banned from room ${roomId}`) } catch (socketError) { - console.error("[Ban API] Failed to broadcast ban:", socketError); + console.error('[Ban API] Failed to broadcast ban:', socketError) } } - return NextResponse.json({ success: true }, { status: 200 }); + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { - console.error("Failed to ban user:", error); - return NextResponse.json({ error: "Failed to ban user" }, { status: 500 }); + console.error('Failed to ban user:', error) + return NextResponse.json({ error: 'Failed to ban user' }, { status: 500 }) } } @@ -142,41 +122,32 @@ export async function POST(req: NextRequest, context: RouteContext) { */ export async function DELETE(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() // Validate required fields if (!body.userId) { - return NextResponse.json( - { error: "Missing required field: userId" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Missing required field: userId' }, { status: 400 }) } // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can unban users" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can unban users' }, { status: 403 }) } // Unban the user - await unbanUserFromRoom(roomId, body.userId); + await unbanUserFromRoom(roomId, body.userId) // Auto-invite the unbanned user back to the room - const history = await getUserRoomHistory(roomId, body.userId); + const history = await getUserRoomHistory(roomId, body.userId) if (history) { const invitation = await createInvitation({ roomId, @@ -184,15 +155,15 @@ export async function DELETE(req: NextRequest, context: RouteContext) { userName: history.displayName, invitedBy: viewerId, invitedByName: currentMember.displayName, - invitationType: "auto-unban", - message: "You have been unbanned and are welcome to rejoin.", - }); + invitationType: 'auto-unban', + message: 'You have been unbanned and are welcome to rejoin.', + }) // Broadcast invitation via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { - io.to(`user:${body.userId}`).emit("room-invitation-received", { + io.to(`user:${body.userId}`).emit('room-invitation-received', { invitation: { id: invitation.id, roomId: invitation.roomId, @@ -200,29 +171,23 @@ export async function DELETE(req: NextRequest, context: RouteContext) { invitedByName: invitation.invitedByName, message: invitation.message, createdAt: invitation.createdAt, - invitationType: "auto-unban", + invitationType: 'auto-unban', }, - }); + }) console.log( - `[Unban API] Auto-invited user ${body.userId} after unban from room ${roomId}`, - ); + `[Unban API] Auto-invited user ${body.userId} after unban from room ${roomId}` + ) } catch (socketError) { - console.error( - "[Unban API] Failed to broadcast invitation:", - socketError, - ); + console.error('[Unban API] Failed to broadcast invitation:', socketError) } } } - return NextResponse.json({ success: true }, { status: 200 }); + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { - console.error("Failed to unban user:", error); - return NextResponse.json( - { error: "Failed to unban user" }, - { status: 500 }, - ); + console.error('Failed to unban user:', error) + return NextResponse.json({ error: 'Failed to unban user' }, { status: 500 }) } } @@ -232,33 +197,27 @@ export async function DELETE(req: NextRequest, context: RouteContext) { */ export async function GET(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can view bans" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can view bans' }, { status: 403 }) } // Get all bans - const bans = await getRoomBans(roomId); + const bans = await getRoomBans(roomId) - return NextResponse.json({ bans }, { status: 200 }); + return NextResponse.json({ bans }, { status: 200 }) } catch (error: any) { - console.error("Failed to get bans:", error); - return NextResponse.json({ error: "Failed to get bans" }, { status: 500 }); + console.error('Failed to get bans:', error) + return NextResponse.json({ error: 'Failed to get bans' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/deactivate-player/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/deactivate-player/route.ts index a4e120ee..1a382974 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/deactivate-player/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/deactivate-player/route.ts @@ -1,16 +1,12 @@ -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"; +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 }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/deactivate-player @@ -19,135 +15,109 @@ type RouteContext = { * - playerId: string - The player to deactivate */ export async function POST(req: NextRequest, context: RouteContext) { - console.log("[Deactivate Player API] POST request received"); + console.log('[Deactivate Player API] POST request received') try { - const { roomId } = await context.params; - console.log("[Deactivate Player API] roomId:", roomId); + const { roomId } = await context.params + console.log('[Deactivate Player API] roomId:', roomId) - const viewerId = await getViewerId(); - console.log("[Deactivate Player API] viewerId:", viewerId); + const viewerId = await getViewerId() + console.log('[Deactivate Player API] viewerId:', viewerId) - const body = await req.json(); - console.log("[Deactivate Player API] body:", body); + 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 }, - ); + 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); + 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); + 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 }, - ); + 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 }, - ); + 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); + 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 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] Player userId:', player.userId) console.log( - "[Deactivate Player API] Room member userIds:", - members.map((m) => m.userId), - ); + '[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", - ); + 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.", + error: 'Cannot deactivate your own players. Use the player controls in the nav.', }, - { status: 400 }, - ); + { 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", - ); + console.log('[Deactivate Player API] Player validation passed, proceeding with deactivation') // Deactivate the player - await setPlayerActiveStatus(body.playerId, false); + await setPlayerActiveStatus(body.playerId, false) // Broadcast updates via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { // Get updated player list - const memberPlayers = await getRoomActivePlayers(roomId); + const memberPlayers = await getRoomActivePlayers(roomId) // Convert memberPlayers Map to object for JSON serialization - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } // Notify everyone in the room about the player update - io.to(`room:${roomId}`).emit("player-deactivated", { + 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}`, - ); + `[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.error('[Deactivate Player API] Failed to broadcast deactivation:', socketError) } } - console.log("[Deactivate Player API] Success - returning 200"); - return NextResponse.json({ success: true }, { status: 200 }); + 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 }, - ); + 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 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/history/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/history/route.ts index 26e30b0e..39b7f2d7 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/history/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/history/route.ts @@ -1,11 +1,11 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getRoomHistoricalMembersWithStatus } from "@/lib/arcade/room-member-history"; -import { getViewerId } from "@/lib/viewer"; +import { type NextRequest, NextResponse } from 'next/server' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getRoomHistoricalMembersWithStatus } from '@/lib/arcade/room-member-history' +import { getViewerId } from '@/lib/viewer' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * GET /api/arcade/rooms/:roomId/history @@ -14,36 +14,27 @@ type RouteContext = { */ export async function GET(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can view room history" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can view room history' }, { status: 403 }) } // Get all historical members with status - const historicalMembers = await getRoomHistoricalMembersWithStatus(roomId); + const historicalMembers = await getRoomHistoricalMembersWithStatus(roomId) - return NextResponse.json({ historicalMembers }, { status: 200 }); + return NextResponse.json({ historicalMembers }, { status: 200 }) } catch (error: any) { - console.error("Failed to get room history:", error); - return NextResponse.json( - { error: "Failed to get room history" }, - { status: 500 }, - ); + console.error('Failed to get room history:', error) + return NextResponse.json({ error: 'Failed to get room history' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/invite/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/invite/route.ts index 05b32fc9..92edb170 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/invite/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/invite/route.ts @@ -1,18 +1,18 @@ -import { type NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from 'next/server' import { createInvitation, declineInvitation, getInvitation, getRoomInvitations, -} from "@/lib/arcade/room-invitations"; -import { getRoomById } from "@/lib/arcade/room-manager"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getSocketIO } from "@/lib/socket-io"; -import { getViewerId } from "@/lib/viewer"; +} from '@/lib/arcade/room-invitations' +import { getRoomById } from '@/lib/arcade/room-manager' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getSocketIO } from '@/lib/socket-io' +import { getViewerId } from '@/lib/viewer' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/invite @@ -24,65 +24,53 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() // Validate required fields if (!body.userId || !body.userName) { return NextResponse.json( - { error: "Missing required fields: userId, userName" }, - { status: 400 }, - ); + { error: 'Missing required fields: userId, userName' }, + { status: 400 } + ) } // Get room to check access mode - const room = await getRoomById(roomId); + const room = await getRoomById(roomId) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } // Cannot invite to retired rooms - if (room.accessMode === "retired") { + if (room.accessMode === 'retired') { return NextResponse.json( - { error: "Cannot send invitations to retired rooms" }, - { status: 403 }, - ); + { error: 'Cannot send invitations to retired rooms' }, + { status: 403 } + ) } // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can send invitations" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can send invitations' }, { status: 403 }) } // Can't invite yourself if (body.userId === viewerId) { - return NextResponse.json( - { error: "Cannot invite yourself" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Cannot invite yourself' }, { status: 400 }) } // Can't invite someone who's already in the room - const targetUser = members.find((m) => m.userId === body.userId); + const targetUser = members.find((m) => m.userId === body.userId) if (targetUser) { - return NextResponse.json( - { error: "User is already in this room" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'User is already in this room' }, { status: 400 }) } // Create invitation @@ -92,16 +80,16 @@ export async function POST(req: NextRequest, context: RouteContext) { userName: body.userName, invitedBy: viewerId, invitedByName: currentMember.displayName, - invitationType: "manual", + invitationType: 'manual', message: body.message, - }); + }) // Broadcast invitation via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { // Send to the invited user's channel - io.to(`user:${body.userId}`).emit("room-invitation-received", { + io.to(`user:${body.userId}`).emit('room-invitation-received', { invitation: { id: invitation.id, roomId: invitation.roomId, @@ -110,26 +98,18 @@ export async function POST(req: NextRequest, context: RouteContext) { message: invitation.message, createdAt: invitation.createdAt, }, - }); + }) - console.log( - `[Invite API] Sent invitation to user ${body.userId} for room ${roomId}`, - ); + console.log(`[Invite API] Sent invitation to user ${body.userId} for room ${roomId}`) } catch (socketError) { - console.error( - "[Invite API] Failed to broadcast invitation:", - socketError, - ); + console.error('[Invite API] Failed to broadcast invitation:', socketError) } } - return NextResponse.json({ invitation }, { status: 200 }); + return NextResponse.json({ invitation }, { status: 200 }) } catch (error: any) { - console.error("Failed to send invitation:", error); - return NextResponse.json( - { error: "Failed to send invitation" }, - { status: 500 }, - ); + console.error('Failed to send invitation:', error) + return NextResponse.json({ error: 'Failed to send invitation' }, { status: 500 }) } } @@ -139,37 +119,28 @@ export async function POST(req: NextRequest, context: RouteContext) { */ export async function GET(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can view invitations" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can view invitations' }, { status: 403 }) } // Get all invitations - const invitations = await getRoomInvitations(roomId); + const invitations = await getRoomInvitations(roomId) - return NextResponse.json({ invitations }, { status: 200 }); + return NextResponse.json({ invitations }, { status: 200 }) } catch (error: any) { - console.error("Failed to get invitations:", error); - return NextResponse.json( - { error: "Failed to get invitations" }, - { status: 500 }, - ); + console.error('Failed to get invitations:', error) + return NextResponse.json({ error: 'Failed to get invitations' }, { status: 500 }) } } @@ -179,35 +150,26 @@ export async function GET(req: NextRequest, context: RouteContext) { */ export async function DELETE(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() // Check if there's an invitation for this user - const invitation = await getInvitation(roomId, viewerId); + const invitation = await getInvitation(roomId, viewerId) if (!invitation) { - return NextResponse.json( - { error: "No invitation found for this room" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'No invitation found for this room' }, { status: 404 }) } - if (invitation.status !== "pending") { - return NextResponse.json( - { error: "Invitation is not pending" }, - { status: 400 }, - ); + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Invitation is not pending' }, { status: 400 }) } // Decline the invitation - await declineInvitation(invitation.id); + await declineInvitation(invitation.id) - return NextResponse.json({ success: true }, { status: 200 }); + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { - console.error("Failed to decline invitation:", error); - return NextResponse.json( - { error: "Failed to decline invitation" }, - { status: 500 }, - ); + console.error('Failed to decline invitation:', error) + return NextResponse.json({ error: 'Failed to decline invitation' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/join-request/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/join-request/route.ts index 346a5fd8..a26f46dc 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/join-request/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/join-request/route.ts @@ -1,17 +1,14 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { eq } from "drizzle-orm"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { - createJoinRequest, - getJoinRequest, -} from "@/lib/arcade/room-join-requests"; -import { getViewerId } from "@/lib/viewer"; -import { getSocketIO } from "@/lib/socket-io"; +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { eq } from 'drizzle-orm' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { createJoinRequest, getJoinRequest } from '@/lib/arcade/room-join-requests' +import { getViewerId } from '@/lib/viewer' +import { getSocketIO } from '@/lib/socket-io' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/join-request @@ -21,16 +18,13 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() // Validate required fields if (!body.userName) { - return NextResponse.json( - { error: "Missing required field: userName" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Missing required field: userName' }, { status: 400 }) } // Get room details @@ -38,37 +32,34 @@ export async function POST(req: NextRequest, context: RouteContext) { .select() .from(schema.arcadeRooms) .where(eq(schema.arcadeRooms.id, roomId)) - .limit(1); + .limit(1) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } // Check if room is approval-only - if (room.accessMode !== "approval-only") { + if (room.accessMode !== 'approval-only') { return NextResponse.json( - { error: "This room does not require approval to join" }, - { status: 400 }, - ); + { error: 'This room does not require approval to join' }, + { status: 400 } + ) } // Check if user is already in the room - const members = await getRoomMembers(roomId); - const existingMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const existingMember = members.find((m) => m.userId === viewerId) if (existingMember) { - return NextResponse.json( - { error: "You are already in this room" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'You are already in this room' }, { status: 400 }) } // Check if user already has a pending request - const existingRequest = await getJoinRequest(roomId, viewerId); - if (existingRequest && existingRequest.status === "pending") { + const existingRequest = await getJoinRequest(roomId, viewerId) + if (existingRequest && existingRequest.status === 'pending') { return NextResponse.json( - { error: "You already have a pending join request" }, - { status: 400 }, - ); + { error: 'You already have a pending join request' }, + { status: 400 } + ) } // Create join request @@ -76,16 +67,16 @@ export async function POST(req: NextRequest, context: RouteContext) { roomId, userId: viewerId, userName: body.userName, - }); + }) // Broadcast to host via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { // Get host user ID - const host = members.find((m) => m.isCreator); + const host = members.find((m) => m.isCreator) if (host) { - io.to(`user:${host.userId}`).emit("join-request-received", { + io.to(`user:${host.userId}`).emit('join-request-received', { roomId, request: { id: request.id, @@ -93,26 +84,18 @@ export async function POST(req: NextRequest, context: RouteContext) { userName: request.userName, requestedAt: request.requestedAt, }, - }); + }) } - console.log( - `[Join Request API] User ${viewerId} requested to join room ${roomId}`, - ); + console.log(`[Join Request API] User ${viewerId} requested to join room ${roomId}`) } catch (socketError) { - console.error( - "[Join Request API] Failed to broadcast request:", - socketError, - ); + console.error('[Join Request API] Failed to broadcast request:', socketError) } } - return NextResponse.json({ request }, { status: 200 }); + return NextResponse.json({ request }, { status: 200 }) } catch (error: any) { - console.error("Failed to create join request:", error); - return NextResponse.json( - { error: "Failed to create join request" }, - { status: 500 }, - ); + console.error('Failed to create join request:', error) + return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/[requestId]/approve/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/[requestId]/approve/route.ts index b0296088..383c2c87 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/[requestId]/approve/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/[requestId]/approve/route.ts @@ -1,14 +1,14 @@ -import { eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { approveJoinRequest } from "@/lib/arcade/room-join-requests"; -import { getViewerId } from "@/lib/viewer"; -import { getSocketIO } from "@/lib/socket-io"; +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { approveJoinRequest } from '@/lib/arcade/room-join-requests' +import { getViewerId } from '@/lib/viewer' +import { getSocketIO } from '@/lib/socket-io' type RouteContext = { - params: Promise<{ roomId: string; requestId: string }>; -}; + params: Promise<{ roomId: string; requestId: string }> +} /** * POST /api/arcade/rooms/:roomId/join-requests/:requestId/approve @@ -16,25 +16,22 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId, requestId } = await context.params; - const viewerId = await getViewerId(); + const { roomId, requestId } = await context.params + const viewerId = await getViewerId() // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { return NextResponse.json( - { error: "Only the host can approve join requests" }, - { status: 403 }, - ); + { error: 'Only the host can approve join requests' }, + { status: 403 } + ) } // Get the request @@ -42,56 +39,40 @@ export async function POST(req: NextRequest, context: RouteContext) { .select() .from(schema.roomJoinRequests) .where(eq(schema.roomJoinRequests.id, requestId)) - .limit(1); + .limit(1) if (!request) { - return NextResponse.json( - { error: "Join request not found" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'Join request not found' }, { status: 404 }) } - if (request.status !== "pending") { - return NextResponse.json( - { error: "Join request is not pending" }, - { status: 400 }, - ); + if (request.status !== 'pending') { + return NextResponse.json({ error: 'Join request is not pending' }, { status: 400 }) } // Approve the request - const approvedRequest = await approveJoinRequest( - requestId, - viewerId, - currentMember.displayName, - ); + const approvedRequest = await approveJoinRequest(requestId, viewerId, currentMember.displayName) // Notify the requesting user via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { - io.to(`user:${request.userId}`).emit("join-request-approved", { + io.to(`user:${request.userId}`).emit('join-request-approved', { roomId, requestId, approvedBy: currentMember.displayName, - }); + }) console.log( - `[Approve Join Request API] Request ${requestId} approved for user ${request.userId} to join room ${roomId}`, - ); + `[Approve Join Request API] Request ${requestId} approved for user ${request.userId} to join room ${roomId}` + ) } catch (socketError) { - console.error( - "[Approve Join Request API] Failed to broadcast approval:", - socketError, - ); + console.error('[Approve Join Request API] Failed to broadcast approval:', socketError) } } - return NextResponse.json({ request: approvedRequest }, { status: 200 }); + return NextResponse.json({ request: approvedRequest }, { status: 200 }) } catch (error: any) { - console.error("Failed to approve join request:", error); - return NextResponse.json( - { error: "Failed to approve join request" }, - { status: 500 }, - ); + console.error('Failed to approve join request:', error) + return NextResponse.json({ error: 'Failed to approve join request' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/[requestId]/deny/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/[requestId]/deny/route.ts index d87c9f7b..18f82b2a 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/[requestId]/deny/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/[requestId]/deny/route.ts @@ -1,14 +1,14 @@ -import { eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { denyJoinRequest } from "@/lib/arcade/room-join-requests"; -import { getViewerId } from "@/lib/viewer"; -import { getSocketIO } from "@/lib/socket-io"; +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { denyJoinRequest } from '@/lib/arcade/room-join-requests' +import { getViewerId } from '@/lib/viewer' +import { getSocketIO } from '@/lib/socket-io' type RouteContext = { - params: Promise<{ roomId: string; requestId: string }>; -}; + params: Promise<{ roomId: string; requestId: string }> +} /** * POST /api/arcade/rooms/:roomId/join-requests/:requestId/deny @@ -16,25 +16,19 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId, requestId } = await context.params; - const viewerId = await getViewerId(); + const { roomId, requestId } = await context.params + const viewerId = await getViewerId() // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can deny join requests" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can deny join requests' }, { status: 403 }) } // Get the request @@ -42,56 +36,40 @@ export async function POST(req: NextRequest, context: RouteContext) { .select() .from(schema.roomJoinRequests) .where(eq(schema.roomJoinRequests.id, requestId)) - .limit(1); + .limit(1) if (!request) { - return NextResponse.json( - { error: "Join request not found" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'Join request not found' }, { status: 404 }) } - if (request.status !== "pending") { - return NextResponse.json( - { error: "Join request is not pending" }, - { status: 400 }, - ); + if (request.status !== 'pending') { + return NextResponse.json({ error: 'Join request is not pending' }, { status: 400 }) } // Deny the request - const deniedRequest = await denyJoinRequest( - requestId, - viewerId, - currentMember.displayName, - ); + const deniedRequest = await denyJoinRequest(requestId, viewerId, currentMember.displayName) // Notify the requesting user via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { - io.to(`user:${request.userId}`).emit("join-request-denied", { + io.to(`user:${request.userId}`).emit('join-request-denied', { roomId, requestId, deniedBy: currentMember.displayName, - }); + }) console.log( - `[Deny Join Request API] Request ${requestId} denied for user ${request.userId} to join room ${roomId}`, - ); + `[Deny Join Request API] Request ${requestId} denied for user ${request.userId} to join room ${roomId}` + ) } catch (socketError) { - console.error( - "[Deny Join Request API] Failed to broadcast denial:", - socketError, - ); + console.error('[Deny Join Request API] Failed to broadcast denial:', socketError) } } - return NextResponse.json({ request: deniedRequest }, { status: 200 }); + return NextResponse.json({ request: deniedRequest }, { status: 200 }) } catch (error: any) { - console.error("Failed to deny join request:", error); - return NextResponse.json( - { error: "Failed to deny join request" }, - { status: 500 }, - ); + console.error('Failed to deny join request:', error) + return NextResponse.json({ error: 'Failed to deny join request' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/route.ts index 1d2a6b09..53556b24 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/join-requests/route.ts @@ -1,16 +1,13 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { - createJoinRequest, - getPendingJoinRequests, -} from "@/lib/arcade/room-join-requests"; -import { getRoomById } from "@/lib/arcade/room-manager"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getSocketIO } from "@/lib/socket-io"; -import { getViewerId } from "@/lib/viewer"; +import { type NextRequest, NextResponse } from 'next/server' +import { createJoinRequest, getPendingJoinRequests } from '@/lib/arcade/room-join-requests' +import { getRoomById } from '@/lib/arcade/room-manager' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getSocketIO } from '@/lib/socket-io' +import { getViewerId } from '@/lib/viewer' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * GET /api/arcade/rooms/:roomId/join-requests @@ -18,37 +15,28 @@ type RouteContext = { */ export async function GET(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can view join requests" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can view join requests' }, { status: 403 }) } // Get all pending requests - const requests = await getPendingJoinRequests(roomId); + const requests = await getPendingJoinRequests(roomId) - return NextResponse.json({ requests }, { status: 200 }); + return NextResponse.json({ requests }, { status: 200 }) } catch (error: any) { - console.error("Failed to get join requests:", error); - return NextResponse.json( - { error: "Failed to get join requests" }, - { status: 500 }, - ); + console.error('Failed to get join requests:', error) + return NextResponse.json({ error: 'Failed to get join requests' }, { status: 500 }) } } @@ -60,33 +48,33 @@ export async function GET(req: NextRequest, context: RouteContext) { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json().catch(() => ({})); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json().catch(() => ({})) // Get room to verify it exists - const room = await getRoomById(roomId); + const room = await getRoomById(roomId) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } // Verify room is approval-only - if (room.accessMode !== "approval-only") { + if (room.accessMode !== 'approval-only') { return NextResponse.json( - { error: "This room does not require approval to join" }, - { status: 400 }, - ); + { error: 'This room does not require approval to join' }, + { status: 400 } + ) } // Get or generate display name - const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`; + const displayName = body.displayName || `Guest ${viewerId.slice(-4)}` // Validate display name length if (displayName.length > 50) { return NextResponse.json( - { error: "Display name too long (max 50 characters)" }, - { status: 400 }, - ); + { error: 'Display name too long (max 50 characters)' }, + { status: 400 } + ) } // Create join request @@ -94,18 +82,18 @@ export async function POST(req: NextRequest, context: RouteContext) { roomId, userId: viewerId, userName: displayName, - }); + }) console.log( - `[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`, - ); + `[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}` + ) // Broadcast to the room host (creator) only via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { // Send notification only to the room creator's user channel - io.to(`user:${room.createdBy}`).emit("join-request-submitted", { + io.to(`user:${room.createdBy}`).emit('join-request-submitted', { roomId, request: { id: request.id, @@ -113,26 +101,20 @@ export async function POST(req: NextRequest, context: RouteContext) { userName: request.userName, createdAt: request.requestedAt, }, - }); + }) console.log( - `[Join Requests] Broadcasted join-request-submitted to room creator ${room.createdBy}`, - ); + `[Join Requests] Broadcasted join-request-submitted to room creator ${room.createdBy}` + ) } catch (socketError) { // Log but don't fail the request if socket broadcast fails - console.error( - "[Join Requests] Failed to broadcast join-request-submitted:", - socketError, - ); + console.error('[Join Requests] Failed to broadcast join-request-submitted:', socketError) } } - return NextResponse.json({ request }, { status: 201 }); + return NextResponse.json({ request }, { status: 201 }) } catch (error: any) { - console.error("Failed to create join request:", error); - return NextResponse.json( - { error: "Failed to create join request" }, - { status: 500 }, - ); + console.error('Failed to create join request:', error) + return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/join/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/join/route.ts index 71cc9ec7..e37ebf6f 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/join/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/join/route.ts @@ -1,20 +1,17 @@ -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 { getJoinRequest } from "@/lib/arcade/room-join-requests"; -import { getRoomById, touchRoom } from "@/lib/arcade/room-manager"; -import { addRoomMember, getRoomMembers } from "@/lib/arcade/room-membership"; -import { isUserBanned } from "@/lib/arcade/room-moderation"; -import { getSocketIO } from "@/lib/socket-io"; -import { getViewerId } from "@/lib/viewer"; +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 { getJoinRequest } from '@/lib/arcade/room-join-requests' +import { getRoomById, touchRoom } from '@/lib/arcade/room-manager' +import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership' +import { isUserBanned } from '@/lib/arcade/room-moderation' +import { getSocketIO } from '@/lib/socket-io' +import { getViewerId } from '@/lib/viewer' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/join @@ -25,162 +22,139 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json().catch(() => ({})); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json().catch(() => ({})) - console.log( - `[Join API] User ${viewerId} attempting to join room ${roomId}`, - ); + console.log(`[Join API] User ${viewerId} attempting to join room ${roomId}`) // Get room - const room = await getRoomById(roomId); + 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} 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}"`, - ); + `[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); + const banned = await isUserBanned(roomId, viewerId) if (banned) { - return NextResponse.json( - { error: "You are banned from this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 }) } // Check if user is already a member (for locked/retired room access) - const members = await getRoomMembers(roomId); - const isExistingMember = members.some((m) => m.userId === viewerId); - const isRoomCreator = room.createdBy === viewerId; + const members = await getRoomMembers(roomId) + 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; + 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; + 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}`, - ); + `[Join API] Found pending invitation ${invitation.id} for user ${viewerId} in room ${roomId}` + ) } // Validate access mode switch (room.accessMode) { - case "locked": + case 'locked': // Allow existing members to continue using the room, but block new members if (!isExistingMember) { return NextResponse.json( - { error: "This room is locked and not accepting new members" }, - { status: 403 }, - ); + { error: 'This room is locked and not accepting new members' }, + { status: 403 } + ) } - break; + break - case "retired": + case 'retired': // Only the room creator can access retired rooms if (!isRoomCreator) { return NextResponse.json( { - error: - "This room has been retired and is only accessible to the owner", + error: 'This room has been retired and is only accessible to the owner', }, - { status: 410 }, - ); + { status: 410 } + ) } - break; + break - case "password": { + case 'password': { if (!body.password) { return NextResponse.json( - { error: "Password required to join this room" }, - { status: 401 }, - ); + { error: 'Password required to join this room' }, + { status: 401 } + ) } if (!room.password) { - return NextResponse.json( - { error: "Room password not configured" }, - { status: 500 }, - ); + return NextResponse.json({ error: 'Room password not configured' }, { status: 500 }) } - const passwordMatch = await bcrypt.compare( - body.password, - room.password, - ); + const passwordMatch = await bcrypt.compare(body.password, room.password) if (!passwordMatch) { - return NextResponse.json( - { error: "Incorrect password" }, - { status: 401 }, - ); + return NextResponse.json({ error: 'Incorrect password' }, { status: 401 }) } - break; + break } - case "restricted": { - console.log( - `[Join API] Room is restricted, checking invitation for user ${viewerId}`, - ); + 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`, - ); + console.log(`[Join API] No valid pending invitation, rejecting join`) return NextResponse.json( - { error: "You need a valid invitation to join this room" }, - { status: 403 }, - ); + { error: 'You need a valid invitation to join this room' }, + { status: 403 } + ) } - console.log( - `[Join API] Valid invitation found, will accept after member added`, - ); + console.log(`[Join API] Valid invitation found, will accept after member added`) } else { - console.log( - `[Join API] User is room creator, skipping invitation check`, - ); + console.log(`[Join API] User is room creator, skipping invitation check`) } - break; + break } - case "approval-only": { + case 'approval-only': { // Room creator can always rejoin their own room without approval if (!isRoomCreator) { // Check for approved join request - const joinRequest = await getJoinRequest(roomId, viewerId); - if (!joinRequest || joinRequest.status !== "approved") { + const joinRequest = await getJoinRequest(roomId, viewerId) + if (!joinRequest || joinRequest.status !== 'approved') { return NextResponse.json( - { error: "Your join request must be approved by the host" }, - { status: 403 }, - ); + { error: 'Your join request must be approved by the host' }, + { 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; + joinRequestToAccept = joinRequest.id } - break; + break } default: // No additional checks needed - break; + break } // Get or generate display name - const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`; + const displayName = body.displayName || `Guest ${viewerId.slice(-4)}` // Validate display name length if (displayName.length > 50) { return NextResponse.json( - { error: "Display name too long (max 50 characters)" }, - { status: 400 }, - ); + { error: 'Display name too long (max 50 characters)' }, + { status: 400 } + ) } // Add member (with auto-leave logic for modal room enforcement) @@ -189,60 +163,53 @@ export async function POST(req: NextRequest, context: RouteContext) { userId: viewerId, displayName, isCreator: false, - }); + }) // Mark invitation as accepted (if applicable) if (invitationToAccept) { - await acceptInvitation(invitationToAccept); - console.log( - `[Join API] Accepted invitation ${invitationToAccept} for user ${viewerId}`, - ); + 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); + const activePlayers = await getActivePlayers(viewerId) // Update room activity to refresh TTL - await touchRoom(roomId); + await touchRoom(roomId) // Broadcast to all users in the room via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { - const members = await getRoomMembers(roomId); - const memberPlayers = await getRoomActivePlayers(roomId); + const members = await getRoomMembers(roomId) + const memberPlayers = await getRoomActivePlayers(roomId) // Convert memberPlayers Map to object for JSON serialization - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } // Broadcast to all users in this room - io.to(`room:${roomId}`).emit("member-joined", { + io.to(`room:${roomId}`).emit('member-joined', { roomId, userId: viewerId, members, memberPlayers: memberPlayersObj, - }); + }) - console.log( - `[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`, - ); + console.log(`[Join API] Broadcasted member-joined for user ${viewerId} in room ${roomId}`) } catch (socketError) { // Log but don't fail the request if socket broadcast fails - console.error( - "[Join API] Failed to broadcast member-joined:", - socketError, - ); + console.error('[Join API] Failed to broadcast member-joined:', socketError) } } // Build response with auto-leave info if applicable console.log( - `[Join API] Successfully added user ${viewerId} to room ${roomId} (invitation=${invitationToAccept ? "accepted" : "N/A"})`, - ); + `[Join API] Successfully added user ${viewerId} to room ${roomId} (invitation=${invitationToAccept ? 'accepted' : 'N/A'})` + ) return NextResponse.json( { @@ -257,27 +224,27 @@ export async function POST(req: NextRequest, context: RouteContext) { } : undefined, }, - { status: 201 }, - ); + { status: 201 } + ) } catch (error: any) { - console.error("Failed to join room:", error); + console.error('Failed to join room:', error) // Handle specific constraint violation error - if (error.message?.includes("ROOM_MEMBERSHIP_CONFLICT")) { + if (error.message?.includes('ROOM_MEMBERSHIP_CONFLICT')) { return NextResponse.json( { - error: "You are already in another room", - code: "ROOM_MEMBERSHIP_CONFLICT", + error: 'You are already in another room', + code: 'ROOM_MEMBERSHIP_CONFLICT', message: - "You can only be in one room at a time. Please leave your current room before joining a new one.", + 'You can only be in one room at a time. Please leave your current room before joining a new one.', userMessage: - "⚠️ Already in Another Room\n\nYou can only be in one room at a time. Please refresh the page and try again.", + '⚠️ Already in Another Room\n\nYou can only be in one room at a time. Please refresh the page and try again.', }, - { status: 409 }, // 409 Conflict - ); + { status: 409 } // 409 Conflict + ) } // Generic error - return NextResponse.json({ error: "Failed to join room" }, { status: 500 }); + return NextResponse.json({ error: 'Failed to join room' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/kick/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/kick/route.ts index b19996dd..fdf285ec 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/kick/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/kick/route.ts @@ -1,13 +1,13 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { kickUserFromRoom } from "@/lib/arcade/room-moderation"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getRoomActivePlayers } from "@/lib/arcade/player-manager"; -import { getViewerId } from "@/lib/viewer"; -import { getSocketIO } from "@/lib/socket-io"; +import { type NextRequest, NextResponse } from 'next/server' +import { kickUserFromRoom } from '@/lib/arcade/room-moderation' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getRoomActivePlayers } from '@/lib/arcade/player-manager' +import { getViewerId } from '@/lib/viewer' +import { getSocketIO } from '@/lib/socket-io' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/kick @@ -17,96 +17,79 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() // Validate required fields if (!body.userId) { - return NextResponse.json( - { error: "Missing required field: userId" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Missing required field: userId' }, { status: 400 }) } // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can kick users" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can kick users' }, { status: 403 }) } // Can't kick yourself if (body.userId === viewerId) { - return NextResponse.json( - { error: "Cannot kick yourself" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Cannot kick yourself' }, { status: 400 }) } // Verify the user to kick is in the room - const targetUser = members.find((m) => m.userId === body.userId); + const targetUser = members.find((m) => m.userId === body.userId) if (!targetUser) { - return NextResponse.json( - { error: "User is not in this room" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'User is not in this room' }, { status: 404 }) } // Kick the user - await kickUserFromRoom(roomId, body.userId); + await kickUserFromRoom(roomId, body.userId) // Broadcast updates via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { // Get updated member list - const updatedMembers = await getRoomMembers(roomId); - const memberPlayers = await getRoomActivePlayers(roomId); + const updatedMembers = await getRoomMembers(roomId) + const memberPlayers = await getRoomActivePlayers(roomId) // Convert memberPlayers Map to object for JSON serialization - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } // Tell the kicked user they've been removed - io.to(`user:${body.userId}`).emit("kicked-from-room", { + io.to(`user:${body.userId}`).emit('kicked-from-room', { roomId, kickedBy: currentMember.displayName, - }); + }) // Notify everyone else in the room - io.to(`room:${roomId}`).emit("member-left", { + io.to(`room:${roomId}`).emit('member-left', { roomId, userId: body.userId, members: updatedMembers, memberPlayers: memberPlayersObj, - reason: "kicked", - }); + reason: 'kicked', + }) - console.log( - `[Kick API] User ${body.userId} kicked from room ${roomId}`, - ); + console.log(`[Kick API] User ${body.userId} kicked from room ${roomId}`) } catch (socketError) { - console.error("[Kick API] Failed to broadcast kick:", socketError); + console.error('[Kick API] Failed to broadcast kick:', socketError) } } - return NextResponse.json({ success: true }, { status: 200 }); + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { - console.error("Failed to kick user:", error); - return NextResponse.json({ error: "Failed to kick user" }, { status: 500 }); + console.error('Failed to kick user:', error) + return NextResponse.json({ error: 'Failed to kick user' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/leave/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/leave/route.ts index 5d97bf91..1f9571d5 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/leave/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/leave/route.ts @@ -1,17 +1,13 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getRoomById } from "@/lib/arcade/room-manager"; -import { - getRoomMembers, - isMember, - removeMember, -} from "@/lib/arcade/room-membership"; -import { getRoomActivePlayers } from "@/lib/arcade/player-manager"; -import { getViewerId } from "@/lib/viewer"; -import { getSocketIO } from "@/lib/socket-io"; +import { type NextRequest, NextResponse } from 'next/server' +import { getRoomById } from '@/lib/arcade/room-manager' +import { getRoomMembers, isMember, removeMember } from '@/lib/arcade/room-membership' +import { getRoomActivePlayers } from '@/lib/arcade/player-manager' +import { getViewerId } from '@/lib/viewer' +import { getSocketIO } from '@/lib/socket-io' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/leave @@ -19,66 +15,55 @@ type RouteContext = { */ export async function POST(_req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() // Get room - const room = await getRoomById(roomId); + const room = await getRoomById(roomId) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } // Check if member - const isMemberOfRoom = await isMember(roomId, viewerId); + const isMemberOfRoom = await isMember(roomId, viewerId) if (!isMemberOfRoom) { - return NextResponse.json( - { error: "Not a member of this room" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Not a member of this room' }, { status: 400 }) } // Remove member - await removeMember(roomId, viewerId); + await removeMember(roomId, viewerId) // Broadcast to all remaining users in the room via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { - const members = await getRoomMembers(roomId); - const memberPlayers = await getRoomActivePlayers(roomId); + const members = await getRoomMembers(roomId) + const memberPlayers = await getRoomActivePlayers(roomId) // Convert memberPlayers Map to object for JSON serialization - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } // Broadcast to all users in this room - io.to(`room:${roomId}`).emit("member-left", { + io.to(`room:${roomId}`).emit('member-left', { roomId, userId: viewerId, members, memberPlayers: memberPlayersObj, - }); + }) - console.log( - `[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`, - ); + console.log(`[Leave API] Broadcasted member-left for user ${viewerId} in room ${roomId}`) } catch (socketError) { // Log but don't fail the request if socket broadcast fails - console.error( - "[Leave API] Failed to broadcast member-left:", - socketError, - ); + console.error('[Leave API] Failed to broadcast member-left:', socketError) } } - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true }) } catch (error) { - console.error("Failed to leave room:", error); - return NextResponse.json( - { error: "Failed to leave room" }, - { status: 500 }, - ); + console.error('Failed to leave room:', error) + return NextResponse.json({ error: 'Failed to leave room' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/members/[userId]/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/members/[userId]/route.ts index dcdafa7b..1e732ce9 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/members/[userId]/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/members/[userId]/route.ts @@ -1,11 +1,11 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getRoomById, isRoomCreator } from "@/lib/arcade/room-manager"; -import { isMember, removeMember } from "@/lib/arcade/room-membership"; -import { getViewerId } from "@/lib/viewer"; +import { type NextRequest, NextResponse } from 'next/server' +import { getRoomById, isRoomCreator } from '@/lib/arcade/room-manager' +import { isMember, removeMember } from '@/lib/arcade/room-membership' +import { getViewerId } from '@/lib/viewer' type RouteContext = { - params: Promise<{ roomId: string; userId: string }>; -}; + params: Promise<{ roomId: string; userId: string }> +} /** * DELETE /api/arcade/rooms/:roomId/members/:userId @@ -13,50 +13,38 @@ type RouteContext = { */ export async function DELETE(_req: NextRequest, context: RouteContext) { try { - const { roomId, userId } = await context.params; - const viewerId = await getViewerId(); + const { roomId, userId } = await context.params + const viewerId = await getViewerId() // Get room - const room = await getRoomById(roomId); + const room = await getRoomById(roomId) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } // Check if requester is room creator - const isCreator = await isRoomCreator(roomId, viewerId); + const isCreator = await isRoomCreator(roomId, viewerId) if (!isCreator) { - return NextResponse.json( - { error: "Only room creator can kick members" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only room creator can kick members' }, { status: 403 }) } // Cannot kick self if (userId === viewerId) { - return NextResponse.json( - { error: "Cannot kick yourself" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Cannot kick yourself' }, { status: 400 }) } // Check if target user is a member - const isTargetMember = await isMember(roomId, userId); + const isTargetMember = await isMember(roomId, userId) if (!isTargetMember) { - return NextResponse.json( - { error: "User is not a member of this room" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'User is not a member of this room' }, { status: 404 }) } // Remove member - await removeMember(roomId, userId); + await removeMember(roomId, userId) - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true }) } catch (error) { - console.error("Failed to kick member:", error); - return NextResponse.json( - { error: "Failed to kick member" }, - { status: 500 }, - ); + console.error('Failed to kick member:', error) + return NextResponse.json({ error: 'Failed to kick member' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/members/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/members/route.ts index b8acc09c..1fe6d856 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/members/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/members/route.ts @@ -1,13 +1,10 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getRoomById } from "@/lib/arcade/room-manager"; -import { - getOnlineMemberCount, - getRoomMembers, -} from "@/lib/arcade/room-membership"; +import { type NextRequest, NextResponse } from 'next/server' +import { getRoomById } from '@/lib/arcade/room-manager' +import { getOnlineMemberCount, getRoomMembers } from '@/lib/arcade/room-membership' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * GET /api/arcade/rooms/:roomId/members @@ -15,27 +12,24 @@ type RouteContext = { */ export async function GET(_req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; + const { roomId } = await context.params // Get room - const room = await getRoomById(roomId); + const room = await getRoomById(roomId) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } // Get members - const members = await getRoomMembers(roomId); - const onlineCount = await getOnlineMemberCount(roomId); + const members = await getRoomMembers(roomId) + const onlineCount = await getOnlineMemberCount(roomId) return NextResponse.json({ members, onlineCount, - }); + }) } catch (error) { - console.error("Failed to fetch members:", error); - return NextResponse.json( - { error: "Failed to fetch members" }, - { status: 500 }, - ); + console.error('Failed to fetch members:', error) + return NextResponse.json({ error: 'Failed to fetch members' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/report/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/report/route.ts index 7322dec4..8d8e468f 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/report/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/report/route.ts @@ -1,12 +1,12 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { createReport } from "@/lib/arcade/room-moderation"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getViewerId } from "@/lib/viewer"; -import { getSocketIO } from "@/lib/socket-io"; +import { type NextRequest, NextResponse } from 'next/server' +import { createReport } from '@/lib/arcade/room-moderation' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getViewerId } from '@/lib/viewer' +import { getSocketIO } from '@/lib/socket-io' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/report @@ -18,56 +18,40 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() // Validate required fields if (!body.reportedUserId || !body.reason) { return NextResponse.json( - { error: "Missing required fields: reportedUserId, reason" }, - { status: 400 }, - ); + { error: 'Missing required fields: reportedUserId, reason' }, + { status: 400 } + ) } // Validate reason - const validReasons = [ - "harassment", - "cheating", - "inappropriate-name", - "spam", - "afk", - "other", - ]; + const validReasons = ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other'] if (!validReasons.includes(body.reason)) { - return NextResponse.json({ error: "Invalid reason" }, { status: 400 }); + return NextResponse.json({ error: 'Invalid reason' }, { status: 400 }) } // Can't report yourself if (body.reportedUserId === viewerId) { - return NextResponse.json( - { error: "Cannot report yourself" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Cannot report yourself' }, { status: 400 }) } // Get room members to verify both users are in the room and get names - const members = await getRoomMembers(roomId); - const reporter = members.find((m) => m.userId === viewerId); - const reported = members.find((m) => m.userId === body.reportedUserId); + const members = await getRoomMembers(roomId) + const reporter = members.find((m) => m.userId === viewerId) + const reported = members.find((m) => m.userId === body.reportedUserId) if (!reporter) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!reported) { - return NextResponse.json( - { error: "Reported user is not in this room" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'Reported user is not in this room' }, { status: 404 }) } // Create report @@ -79,16 +63,16 @@ export async function POST(req: NextRequest, context: RouteContext) { reportedUserName: reported.displayName, reason: body.reason, details: body.details, - }); + }) // Notify host via socket (find the host) - const host = members.find((m) => m.isCreator); + const host = members.find((m) => m.isCreator) if (host) { - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { // Send notification only to the host - io.to(`user:${host.userId}`).emit("report-submitted", { + io.to(`user:${host.userId}`).emit('report-submitted', { roomId, report: { id: report.id, @@ -98,19 +82,16 @@ export async function POST(req: NextRequest, context: RouteContext) { reason: report.reason, createdAt: report.createdAt, }, - }); + }) } catch (socketError) { - console.error("[Report API] Failed to notify host:", socketError); + console.error('[Report API] Failed to notify host:', socketError) } } } - return NextResponse.json({ success: true, report }, { status: 201 }); + return NextResponse.json({ success: true, report }, { status: 201 }) } catch (error: any) { - console.error("Failed to submit report:", error); - return NextResponse.json( - { error: "Failed to submit report" }, - { status: 500 }, - ); + console.error('Failed to submit report:', error) + return NextResponse.json({ error: 'Failed to submit report' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/reports/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/reports/route.ts index 9005651d..1cd70354 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/reports/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/reports/route.ts @@ -1,11 +1,11 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getAllReports } from "@/lib/arcade/room-moderation"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getViewerId } from "@/lib/viewer"; +import { type NextRequest, NextResponse } from 'next/server' +import { getAllReports } from '@/lib/arcade/room-moderation' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getViewerId } from '@/lib/viewer' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * GET /api/arcade/rooms/:roomId/reports @@ -13,36 +13,27 @@ type RouteContext = { */ export async function GET(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() // Check if user is the host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { - return NextResponse.json( - { error: "Only the host can view reports" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only the host can view reports' }, { status: 403 }) } // Get all reports - const reports = await getAllReports(roomId); + const reports = await getAllReports(roomId) - return NextResponse.json({ reports }, { status: 200 }); + return NextResponse.json({ reports }, { status: 200 }) } catch (error: any) { - console.error("Failed to get reports:", error); - return NextResponse.json( - { error: "Failed to get reports" }, - { status: 500 }, - ); + console.error('Failed to get reports:', error) + return NextResponse.json({ error: 'Failed to get reports' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/route.ts index b623937b..3e448695 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/route.ts @@ -1,18 +1,18 @@ -import { type NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from 'next/server' import { deleteRoom, getRoomById, isRoomCreator, touchRoom, updateRoom, -} from "@/lib/arcade/room-manager"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getActivePlayers } from "@/lib/arcade/player-manager"; -import { getViewerId } from "@/lib/viewer"; +} from '@/lib/arcade/room-manager' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getActivePlayers } from '@/lib/arcade/player-manager' +import { getViewerId } from '@/lib/viewer' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * GET /api/arcade/rooms/:roomId @@ -20,45 +20,42 @@ type RouteContext = { */ export async function GET(_req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() - const room = await getRoomById(roomId); + const room = await getRoomById(roomId) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } - const members = await getRoomMembers(roomId); - const canModerate = await isRoomCreator(roomId, viewerId); + const members = await getRoomMembers(roomId) + const canModerate = await isRoomCreator(roomId, viewerId) // Fetch active players for each member // This creates a map of userId -> Player[] - const memberPlayers: Record = {}; + const memberPlayers: Record = {} for (const member of members) { - const activePlayers = await getActivePlayers(member.userId); - memberPlayers[member.userId] = activePlayers; + const activePlayers = await getActivePlayers(member.userId) + memberPlayers[member.userId] = activePlayers } // Update room activity when viewing (keeps active rooms fresh) - await touchRoom(roomId); + await touchRoom(roomId) // Prepare room data - include displayPassword only for room creator const roomData = canModerate ? room // Creator gets full room data including displayPassword - : { ...room, displayPassword: undefined }; // Others don't see displayPassword + : { ...room, displayPassword: undefined } // Others don't see displayPassword return NextResponse.json({ room: roomData, members, memberPlayers, // Map of userId -> active Player[] for each member canModerate, - }); + }) } catch (error) { - console.error("Failed to fetch room:", error); - return NextResponse.json( - { error: "Failed to fetch room" }, - { status: 500 }, - ); + console.error('Failed to fetch room:', error) + return NextResponse.json({ error: 'Failed to fetch room' }, { status: 500 }) } } @@ -73,56 +70,44 @@ export async function GET(_req: NextRequest, context: RouteContext) { */ export async function PATCH(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() // Check if user is room creator - const isCreator = await isRoomCreator(roomId, viewerId); + const isCreator = await isRoomCreator(roomId, viewerId) if (!isCreator) { - return NextResponse.json( - { error: "Only room creator can update room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only room creator can update room' }, { status: 403 }) } // Validate name length if provided if (body.name && body.name.length > 50) { - return NextResponse.json( - { error: "Room name too long (max 50 characters)" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 }) } // Validate status if provided - if ( - body.status && - !["lobby", "playing", "finished"].includes(body.status) - ) { - return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + if (body.status && !['lobby', 'playing', 'finished'].includes(body.status)) { + return NextResponse.json({ error: 'Invalid status' }, { status: 400 }) } const updates: { - name?: string; - status?: "lobby" | "playing" | "finished"; - } = {}; + name?: string + status?: 'lobby' | 'playing' | 'finished' + } = {} - if (body.name !== undefined) updates.name = body.name; - if (body.status !== undefined) updates.status = body.status; + if (body.name !== undefined) updates.name = body.name + if (body.status !== undefined) updates.status = body.status - const room = await updateRoom(roomId, updates); + const room = await updateRoom(roomId, updates) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } - return NextResponse.json({ room }); + return NextResponse.json({ room }) } catch (error) { - console.error("Failed to update room:", error); - return NextResponse.json( - { error: "Failed to update room" }, - { status: 500 }, - ); + console.error('Failed to update room:', error) + return NextResponse.json({ error: 'Failed to update room' }, { status: 500 }) } } @@ -132,26 +117,20 @@ export async function PATCH(req: NextRequest, context: RouteContext) { */ export async function DELETE(_req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); + const { roomId } = await context.params + const viewerId = await getViewerId() // Check if user is room creator - const isCreator = await isRoomCreator(roomId, viewerId); + const isCreator = await isRoomCreator(roomId, viewerId) if (!isCreator) { - return NextResponse.json( - { error: "Only room creator can delete room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'Only room creator can delete room' }, { status: 403 }) } - await deleteRoom(roomId); + await deleteRoom(roomId) - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true }) } catch (error) { - console.error("Failed to delete room:", error); - return NextResponse.json( - { error: "Failed to delete room" }, - { status: 500 }, - ); + console.error('Failed to delete room:', error) + return NextResponse.json({ error: 'Failed to delete room' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/settings/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/settings/route.ts index f24971bc..55d83787 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/settings/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/settings/route.ts @@ -1,22 +1,19 @@ -import bcrypt from "bcryptjs"; -import { and, eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getRoomActivePlayers } from "@/lib/arcade/player-manager"; -import { recordRoomMemberHistory } from "@/lib/arcade/room-member-history"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getSocketIO } from "@/lib/socket-io"; -import { getViewerId } from "@/lib/viewer"; -import { - getAllGameConfigs, - setGameConfig, -} from "@/lib/arcade/game-config-helpers"; -import { isValidGameName } from "@/lib/arcade/validators"; -import type { GameName } from "@/lib/arcade/validators"; +import bcrypt from 'bcryptjs' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getRoomActivePlayers } from '@/lib/arcade/player-manager' +import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getSocketIO } from '@/lib/socket-io' +import { getViewerId } from '@/lib/viewer' +import { getAllGameConfigs, setGameConfig } from '@/lib/arcade/game-config-helpers' +import { isValidGameName } from '@/lib/arcade/validators' +import type { GameName } from '@/lib/arcade/validators' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * PATCH /api/arcade/rooms/:roomId/settings @@ -37,49 +34,46 @@ type RouteContext = { */ export async function PATCH(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() console.log( - "[Settings API] PATCH request received:", + '[Settings API] PATCH request received:', JSON.stringify( { roomId, body, }, null, - 2, - ), - ); + 2 + ) + ) // Read current room state from database BEFORE any changes const [currentRoom] = await db .select() .from(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, roomId)); + .where(eq(schema.arcadeRooms.id, roomId)) console.log( - "[Settings API] Current room state in database BEFORE update:", + '[Settings API] Current room state in database BEFORE update:', JSON.stringify( { gameName: currentRoom?.gameName, gameConfig: currentRoom?.gameConfig, }, null, - 2, - ), - ); + 2 + ) + ) // Check if user is a room member - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } // Determine which settings are being changed @@ -89,42 +83,38 @@ export async function PATCH(req: NextRequest, context: RouteContext) { 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.)", + error: 'Only the host can change room settings (name, access mode, game selection, etc.)', }, - { status: 403 }, - ); + { status: 403 } + ) } // Validate accessMode if provided const validAccessModes = [ - "open", - "locked", - "retired", - "password", - "restricted", - "approval-only", - ]; + 'open', + 'locked', + 'retired', + 'password', + 'restricted', + 'approval-only', + ] if (body.accessMode && !validAccessModes.includes(body.accessMode)) { - return NextResponse.json( - { error: "Invalid access mode" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 }) } // Validate password requirements - if (body.accessMode === "password" && !body.password) { + if (body.accessMode === 'password' && !body.password) { return NextResponse.json( - { error: "Password is required for password-protected rooms" }, - { status: 400 }, - ); + { error: 'Password is required for password-protected rooms' }, + { status: 400 } + ) } // Validate gameName if provided - check against validator registry at runtime @@ -134,33 +124,33 @@ export async function PATCH(req: NextRequest, context: RouteContext) { { error: `Invalid game name: ${body.gameName}. Game must have a registered validator.`, }, - { status: 400 }, - ); + { status: 400 } + ) } } // Prepare update data - const updateData: Record = {}; + const updateData: Record = {} if (body.accessMode !== undefined) { - updateData.accessMode = body.accessMode; + updateData.accessMode = body.accessMode } // Hash password if provided if (body.password !== undefined) { - if (body.password === null || body.password === "") { - updateData.password = null; // Clear password - updateData.displayPassword = null; // Also clear display password + if (body.password === null || body.password === '') { + updateData.password = null // Clear password + updateData.displayPassword = null // Also clear display password } else { - const hashedPassword = await bcrypt.hash(body.password, 10); - updateData.password = hashedPassword; - updateData.displayPassword = body.password; // Store plain text for display + const hashedPassword = await bcrypt.hash(body.password, 10) + updateData.password = hashedPassword + updateData.displayPassword = body.password // Store plain text for display } } // Update game selection if provided if (body.gameName !== undefined) { - updateData.gameName = body.gameName; + updateData.gameName = body.gameName } // Handle game config updates - write to new room_game_configs table @@ -168,87 +158,76 @@ export async function PATCH(req: NextRequest, context: RouteContext) { // body.gameConfig is expected to be nested by game name: { matching: {...}, memory-quiz: {...} } // Extract each game's config and write to the new table for (const [gameName, config] of Object.entries(body.gameConfig)) { - if (config && typeof config === "object") { - await setGameConfig(roomId, gameName as GameName, config); - console.log( - `[Settings API] Wrote ${gameName} config to room_game_configs table`, - ); + if (config && typeof config === 'object') { + await setGameConfig(roomId, gameName as GameName, config) + console.log(`[Settings API] Wrote ${gameName} config to room_game_configs table`) } } } console.log( - "[Settings API] Update data to be written to database:", - JSON.stringify(updateData, null, 2), - ); + '[Settings API] Update data to be written to database:', + JSON.stringify(updateData, null, 2) + ) // If game is being changed (or cleared), delete the existing arcade session // This ensures a fresh session will be created with the new game settings if (body.gameName !== undefined) { - console.log( - `[Settings API] Deleting existing arcade session for room ${roomId}`, - ); - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.roomId, roomId)); + console.log(`[Settings API] Deleting existing arcade session for room ${roomId}`) + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId)) } // Update room settings (only if there's something to update) - let updatedRoom = currentRoom; + let updatedRoom = currentRoom if (Object.keys(updateData).length > 0) { - [updatedRoom] = await db + ;[updatedRoom] = await db .update(schema.arcadeRooms) .set(updateData) .where(eq(schema.arcadeRooms.id, roomId)) - .returning(); + .returning() } // Get aggregated game configs from new table - const gameConfig = await getAllGameConfigs(roomId); + const gameConfig = await getAllGameConfigs(roomId) console.log( - "[Settings API] Room state in database AFTER update:", + '[Settings API] Room state in database AFTER update:', JSON.stringify( { gameName: updatedRoom.gameName, gameConfig, }, null, - 2, - ), - ); + 2 + ) + ) // Broadcast game change to all room members if (body.gameName !== undefined) { - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { - console.log( - `[Settings API] Broadcasting game change to room ${roomId}: ${body.gameName}`, - ); + console.log(`[Settings API] Broadcasting game change to room ${roomId}: ${body.gameName}`) const broadcastData: { - roomId: string; - gameName: string | null; - gameConfig?: Record; + roomId: string + gameName: string | null + gameConfig?: Record } = { roomId, gameName: body.gameName, gameConfig, // Include aggregated configs from new table - }; + } - io.to(`room:${roomId}`).emit("room-game-changed", broadcastData); + io.to(`room:${roomId}`).emit('room-game-changed', broadcastData) } catch (socketError) { - console.error( - "[Settings API] Failed to broadcast game change:", - socketError, - ); + console.error('[Settings API] Failed to broadcast game change:', socketError) } } } // If setting to retired, expel all non-owner members - if (body.accessMode === "retired") { - const nonOwnerMembers = members.filter((m) => !m.isCreator); + if (body.accessMode === 'retired') { + const nonOwnerMembers = members.filter((m) => !m.isCreator) if (nonOwnerMembers.length > 0) { // Remove all non-owner members from the room @@ -256,9 +235,9 @@ export async function PATCH(req: NextRequest, context: RouteContext) { and( eq(schema.roomMembers.roomId, roomId), // Delete all members except the creator - eq(schema.roomMembers.isCreator, false), - ), - ); + eq(schema.roomMembers.isCreator, false) + ) + ) // Record in history for each expelled member for (const member of nonOwnerMembers) { @@ -266,50 +245,47 @@ export async function PATCH(req: NextRequest, context: RouteContext) { roomId, userId: member.userId, displayName: member.displayName, - action: "left", - }); + action: 'left', + }) } // Broadcast updates via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { // Get updated member list (should only be the owner now) - const updatedMembers = await getRoomMembers(roomId); - const memberPlayers = await getRoomActivePlayers(roomId); + const updatedMembers = await getRoomMembers(roomId) + const memberPlayers = await getRoomActivePlayers(roomId) // Convert memberPlayers Map to object for JSON serialization - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } // Notify each expelled member for (const member of nonOwnerMembers) { - io.to(`user:${member.userId}`).emit("kicked-from-room", { + io.to(`user:${member.userId}`).emit('kicked-from-room', { roomId, kickedBy: currentMember.displayName, - reason: "Room has been retired", - }); + reason: 'Room has been retired', + }) } // Notify the owner that members were expelled - io.to(`room:${roomId}`).emit("member-left", { + io.to(`room:${roomId}`).emit('member-left', { roomId, userId: nonOwnerMembers.map((m) => m.userId), members: updatedMembers, memberPlayers: memberPlayersObj, - reason: "room-retired", - }); + reason: 'room-retired', + }) console.log( - `[Settings API] Expelled ${nonOwnerMembers.length} members from retired room ${roomId}`, - ); + `[Settings API] Expelled ${nonOwnerMembers.length} members from retired room ${roomId}` + ) } catch (socketError) { - console.error( - "[Settings API] Failed to broadcast member expulsion:", - socketError, - ); + console.error('[Settings API] Failed to broadcast member expulsion:', socketError) } } } @@ -322,13 +298,10 @@ export async function PATCH(req: NextRequest, context: RouteContext) { gameConfig, // Include aggregated configs from new table }, }, - { status: 200 }, - ); + { status: 200 } + ) } catch (error: any) { - console.error("Failed to update room settings:", error); - return NextResponse.json( - { error: "Failed to update room settings" }, - { status: 500 }, - ); + console.error('Failed to update room settings:', error) + return NextResponse.json({ error: 'Failed to update room settings' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/[roomId]/transfer-ownership/route.ts b/apps/web/src/app/api/arcade/rooms/[roomId]/transfer-ownership/route.ts index 33801822..6dc4b9a9 100644 --- a/apps/web/src/app/api/arcade/rooms/[roomId]/transfer-ownership/route.ts +++ b/apps/web/src/app/api/arcade/rooms/[roomId]/transfer-ownership/route.ts @@ -1,13 +1,13 @@ -import { eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getViewerId } from "@/lib/viewer"; -import { getSocketIO } from "@/lib/socket-io"; +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getViewerId } from '@/lib/viewer' +import { getSocketIO } from '@/lib/socket-io' type RouteContext = { - params: Promise<{ roomId: string }>; -}; + params: Promise<{ roomId: string }> +} /** * POST /api/arcade/rooms/:roomId/transfer-ownership @@ -17,64 +17,52 @@ type RouteContext = { */ export async function POST(req: NextRequest, context: RouteContext) { try { - const { roomId } = await context.params; - const viewerId = await getViewerId(); - const body = await req.json(); + const { roomId } = await context.params + const viewerId = await getViewerId() + const body = await req.json() // Validate required fields if (!body.newOwnerId) { - return NextResponse.json( - { error: "Missing required field: newOwnerId" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Missing required field: newOwnerId' }, { status: 400 }) } // Check if user is the current host - const members = await getRoomMembers(roomId); - const currentMember = members.find((m) => m.userId === viewerId); + const members = await getRoomMembers(roomId) + const currentMember = members.find((m) => m.userId === viewerId) if (!currentMember) { - return NextResponse.json( - { error: "You are not in this room" }, - { status: 403 }, - ); + return NextResponse.json({ error: 'You are not in this room' }, { status: 403 }) } if (!currentMember.isCreator) { return NextResponse.json( - { error: "Only the current host can transfer ownership" }, - { status: 403 }, - ); + { error: 'Only the current host can transfer ownership' }, + { status: 403 } + ) } // Can't transfer to yourself if (body.newOwnerId === viewerId) { - return NextResponse.json( - { error: "You are already the owner" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'You are already the owner' }, { status: 400 }) } // Verify new owner is in the room - const newOwner = members.find((m) => m.userId === body.newOwnerId); + const newOwner = members.find((m) => m.userId === body.newOwnerId) if (!newOwner) { - return NextResponse.json( - { error: "New owner must be a member of the room" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'New owner must be a member of the room' }, { status: 404 }) } // Remove isCreator from current owner await db .update(schema.roomMembers) .set({ isCreator: false }) - .where(eq(schema.roomMembers.id, currentMember.id)); + .where(eq(schema.roomMembers.id, currentMember.id)) // Set isCreator on new owner await db .update(schema.roomMembers) .set({ isCreator: true }) - .where(eq(schema.roomMembers.id, newOwner.id)); + .where(eq(schema.roomMembers.id, newOwner.id)) // Update room createdBy field await db @@ -83,39 +71,33 @@ export async function POST(req: NextRequest, context: RouteContext) { createdBy: body.newOwnerId, creatorName: newOwner.displayName, }) - .where(eq(schema.arcadeRooms.id, roomId)); + .where(eq(schema.arcadeRooms.id, roomId)) // Broadcast ownership transfer via socket - const io = await getSocketIO(); + const io = await getSocketIO() if (io) { try { - const updatedMembers = await getRoomMembers(roomId); + const updatedMembers = await getRoomMembers(roomId) - io.to(`room:${roomId}`).emit("ownership-transferred", { + io.to(`room:${roomId}`).emit('ownership-transferred', { roomId, oldOwnerId: viewerId, newOwnerId: body.newOwnerId, newOwnerName: newOwner.displayName, members: updatedMembers, - }); + }) console.log( - `[Ownership Transfer] Room ${roomId} ownership transferred from ${viewerId} to ${body.newOwnerId}`, - ); + `[Ownership Transfer] Room ${roomId} ownership transferred from ${viewerId} to ${body.newOwnerId}` + ) } catch (socketError) { - console.error( - "[Ownership Transfer] Failed to broadcast transfer:", - socketError, - ); + console.error('[Ownership Transfer] Failed to broadcast transfer:', socketError) } } - return NextResponse.json({ success: true }, { status: 200 }); + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { - console.error("Failed to transfer ownership:", error); - return NextResponse.json( - { error: "Failed to transfer ownership" }, - { status: 500 }, - ); + console.error('Failed to transfer ownership:', error) + return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/code/[code]/route.ts b/apps/web/src/app/api/arcade/rooms/code/[code]/route.ts index 388a638d..e2a5f657 100644 --- a/apps/web/src/app/api/arcade/rooms/code/[code]/route.ts +++ b/apps/web/src/app/api/arcade/rooms/code/[code]/route.ts @@ -1,10 +1,10 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getRoomByCode } from "@/lib/arcade/room-manager"; -import { normalizeRoomCode } from "@/lib/arcade/room-code"; +import { type NextRequest, NextResponse } from 'next/server' +import { getRoomByCode } from '@/lib/arcade/room-manager' +import { normalizeRoomCode } from '@/lib/arcade/room-code' type RouteContext = { - params: Promise<{ code: string }>; -}; + params: Promise<{ code: string }> +} /** * GET /api/arcade/rooms/code/:code @@ -12,31 +12,28 @@ type RouteContext = { */ export async function GET(_req: NextRequest, context: RouteContext) { try { - const { code } = await context.params; + const { code } = await context.params // Normalize the code (uppercase, remove spaces/dashes) - const normalizedCode = normalizeRoomCode(code); + const normalizedCode = normalizeRoomCode(code) // Get room - const room = await getRoomByCode(normalizedCode); + const room = await getRoomByCode(normalizedCode) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } // Generate redirect URL - const baseUrl = process.env.NEXT_PUBLIC_URL || "http://localhost:3000"; - const redirectUrl = `${baseUrl}/arcade/rooms/${room.id}`; + const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000' + const redirectUrl = `${baseUrl}/arcade/rooms/${room.id}` return NextResponse.json({ roomId: room.id, redirectUrl, room, - }); + }) } catch (error) { - console.error("Failed to find room by code:", error); - return NextResponse.json( - { error: "Failed to find room by code" }, - { status: 500 }, - ); + console.error('Failed to find room by code:', error) + return NextResponse.json({ error: 'Failed to find room by code' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/current/route.ts b/apps/web/src/app/api/arcade/rooms/current/route.ts index f2e91038..4a085e65 100644 --- a/apps/web/src/app/api/arcade/rooms/current/route.ts +++ b/apps/web/src/app/api/arcade/rooms/current/route.ts @@ -1,10 +1,10 @@ -import { NextResponse } from "next/server"; -import { getUserRooms } from "@/lib/arcade/room-membership"; -import { getRoomById } from "@/lib/arcade/room-manager"; -import { getRoomMembers } from "@/lib/arcade/room-membership"; -import { getRoomActivePlayers } from "@/lib/arcade/player-manager"; -import { getViewerId } from "@/lib/viewer"; -import { getAllGameConfigs } from "@/lib/arcade/game-config-helpers"; +import { NextResponse } from 'next/server' +import { getUserRooms } from '@/lib/arcade/room-membership' +import { getRoomById } from '@/lib/arcade/room-manager' +import { getRoomMembers } from '@/lib/arcade/room-membership' +import { getRoomActivePlayers } from '@/lib/arcade/player-manager' +import { getViewerId } from '@/lib/viewer' +import { getAllGameConfigs } from '@/lib/arcade/game-config-helpers' /** * GET /api/arcade/rooms/current @@ -12,28 +12,28 @@ import { getAllGameConfigs } from "@/lib/arcade/game-config-helpers"; */ export async function GET() { try { - const userId = await getViewerId(); + const userId = await getViewerId() // Get all rooms user is in (should be at most 1 due to modal room enforcement) - const roomIds = await getUserRooms(userId); + const roomIds = await getUserRooms(userId) if (roomIds.length === 0) { - return NextResponse.json({ room: null }, { status: 200 }); + return NextResponse.json({ room: null }, { status: 200 }) } - const roomId = roomIds[0]; + const roomId = roomIds[0] // Get room data - const room = await getRoomById(roomId); + const room = await getRoomById(roomId) if (!room) { - return NextResponse.json({ error: "Room not found" }, { status: 404 }); + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) } // Get game configs from new room_game_configs table - const gameConfig = await getAllGameConfigs(roomId); + const gameConfig = await getAllGameConfigs(roomId) console.log( - "[Current Room API] Room data READ from database:", + '[Current Room API] Room data READ from database:', JSON.stringify( { roomId, @@ -41,20 +41,20 @@ export async function GET() { gameConfig, }, null, - 2, - ), - ); + 2 + ) + ) // Get members - const members = await getRoomMembers(roomId); + const members = await getRoomMembers(roomId) // Get active players for all members - const memberPlayers = await getRoomActivePlayers(roomId); + const memberPlayers = await getRoomActivePlayers(roomId) // Convert Map to object for JSON serialization - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } return NextResponse.json({ @@ -64,12 +64,9 @@ export async function GET() { }, members, memberPlayers: memberPlayersObj, - }); + }) } catch (error) { - console.error("[Current Room API] Error:", error); - return NextResponse.json( - { error: "Failed to fetch current room" }, - { status: 500 }, - ); + console.error('[Current Room API] Error:', error) + return NextResponse.json({ error: 'Failed to fetch current room' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/arcade/rooms/route.ts b/apps/web/src/app/api/arcade/rooms/route.ts index c1bb32c9..8e34b438 100644 --- a/apps/web/src/app/api/arcade/rooms/route.ts +++ b/apps/web/src/app/api/arcade/rooms/route.ts @@ -1,13 +1,9 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { createRoom, listActiveRooms } from "@/lib/arcade/room-manager"; -import { - addRoomMember, - getRoomMembers, - isMember, -} from "@/lib/arcade/room-membership"; -import { getRoomActivePlayers } from "@/lib/arcade/player-manager"; -import { getViewerId } from "@/lib/viewer"; -import { hasValidator, type GameName } from "@/lib/arcade/validators"; +import { type NextRequest, NextResponse } from 'next/server' +import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager' +import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership' +import { getRoomActivePlayers } from '@/lib/arcade/player-manager' +import { getViewerId } from '@/lib/viewer' +import { hasValidator, type GameName } from '@/lib/arcade/validators' /** * GET /api/arcade/rooms @@ -17,22 +13,22 @@ import { hasValidator, type GameName } from "@/lib/arcade/validators"; */ export async function GET(req: NextRequest) { try { - const { searchParams } = new URL(req.url); - const gameName = searchParams.get("gameName") as GameName | null; + const { searchParams } = new URL(req.url) + const gameName = searchParams.get('gameName') as GameName | null - const viewerId = await getViewerId(); - const rooms = await listActiveRooms(gameName || undefined); + const viewerId = await getViewerId() + const rooms = await listActiveRooms(gameName || undefined) // Enrich with member counts, player counts, and membership status const roomsWithCounts = await Promise.all( rooms.map(async (room) => { - const members = await getRoomMembers(room.id); - const playerMap = await getRoomActivePlayers(room.id); - const userIsMember = await isMember(room.id, viewerId); + const members = await getRoomMembers(room.id) + const playerMap = await getRoomActivePlayers(room.id) + const userIsMember = await isMember(room.id, viewerId) - let totalPlayers = 0; + let totalPlayers = 0 for (const players of playerMap.values()) { - totalPlayers += players.length; + totalPlayers += players.length } return { @@ -47,17 +43,14 @@ export async function GET(req: NextRequest) { memberCount: members.length, playerCount: totalPlayers, isMember: userIsMember, - }; - }), - ); + } + }) + ) - return NextResponse.json({ rooms: roomsWithCounts }); + return NextResponse.json({ rooms: roomsWithCounts }) } catch (error) { - console.error("Failed to fetch rooms:", error); - return NextResponse.json( - { error: "Failed to fetch rooms" }, - { status: 500 }, - ); + console.error('Failed to fetch rooms:', error) + return NextResponse.json({ error: 'Failed to fetch rooms' }, { status: 500 }) } } @@ -74,58 +67,49 @@ export async function GET(req: NextRequest) { */ export async function POST(req: NextRequest) { try { - const viewerId = await getViewerId(); - const body = await req.json(); + const viewerId = await getViewerId() + const body = await req.json() // Validate game name if provided (gameName is now optional) if (body.gameName) { if (!hasValidator(body.gameName)) { - return NextResponse.json( - { error: "Invalid game name" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Invalid game name' }, { status: 400 }) } } // Validate name length (if provided) if (body.name && body.name.length > 50) { - return NextResponse.json( - { error: "Room name too long (max 50 characters)" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 }) } // Normalize empty name to null - const roomName = body.name?.trim() || null; + const roomName = body.name?.trim() || null // Validate access mode if (body.accessMode) { const validAccessModes = [ - "open", - "password", - "approval-only", - "restricted", - "locked", - "retired", - ]; + 'open', + 'password', + 'approval-only', + 'restricted', + 'locked', + 'retired', + ] if (!validAccessModes.includes(body.accessMode)) { - return NextResponse.json( - { error: "Invalid access mode" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 }) } } // Validate password if provided - if (body.accessMode === "password" && !body.password) { + if (body.accessMode === 'password' && !body.password) { return NextResponse.json( - { error: "Password is required for password-protected rooms" }, - { status: 400 }, - ); + { error: 'Password is required for password-protected rooms' }, + { status: 400 } + ) } // Get display name from body or generate from viewerId - const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}`; + const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}` // Create room const room = await createRoom({ @@ -137,7 +121,7 @@ export async function POST(req: NextRequest) { ttlMinutes: body.ttlMinutes, accessMode: body.accessMode, password: body.password, - }); + }) // Add creator as first member await addRoomMember({ @@ -145,21 +129,21 @@ export async function POST(req: NextRequest) { userId: viewerId, displayName, isCreator: true, - }); + }) // Get members and active players for the response - const members = await getRoomMembers(room.id); - const memberPlayers = await getRoomActivePlayers(room.id); + const members = await getRoomMembers(room.id) + const memberPlayers = await getRoomActivePlayers(room.id) // Convert Map to object for JSON serialization - const memberPlayersObj: Record = {}; + const memberPlayersObj: Record = {} for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; + memberPlayersObj[uid] = players } // Generate join URL - const baseUrl = process.env.NEXT_PUBLIC_URL || "http://localhost:3000"; - const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`; + const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000' + const joinUrl = `${baseUrl}/arcade/rooms/${room.id}` return NextResponse.json( { @@ -168,13 +152,10 @@ export async function POST(req: NextRequest) { memberPlayers: memberPlayersObj, joinUrl, }, - { status: 201 }, - ); + { status: 201 } + ) } catch (error) { - console.error("Failed to create room:", error); - return NextResponse.json( - { error: "Failed to create room" }, - { status: 500 }, - ); + console.error('Failed to create room:', error) + return NextResponse.json({ error: 'Failed to create room' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts index 69dce348..17ccc4f8 100644 --- a/apps/web/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts @@ -12,6 +12,6 @@ * - etc. */ -import { handlers } from "@/auth"; +import { handlers } from '@/auth' -export const { GET, POST } = handlers; +export const { GET, POST } = handlers diff --git a/apps/web/src/app/api/blog/featured/route.ts b/apps/web/src/app/api/blog/featured/route.ts index 3b566235..ce71c475 100644 --- a/apps/web/src/app/api/blog/featured/route.ts +++ b/apps/web/src/app/api/blog/featured/route.ts @@ -1,15 +1,12 @@ -import { NextResponse } from "next/server"; -import { getFeaturedPosts } from "@/lib/blog"; +import { NextResponse } from 'next/server' +import { getFeaturedPosts } from '@/lib/blog' export async function GET() { try { - const posts = await getFeaturedPosts(); - return NextResponse.json(posts); + const posts = await getFeaturedPosts() + return NextResponse.json(posts) } catch (error) { - console.error("Error fetching featured posts:", error); - return NextResponse.json( - { error: "Failed to fetch featured posts" }, - { status: 500 }, - ); + console.error('Error fetching featured posts:', error) + return NextResponse.json({ error: 'Failed to fetch featured posts' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/build-info/route.ts b/apps/web/src/app/api/build-info/route.ts index fdaddcf3..e3a22f7b 100644 --- a/apps/web/src/app/api/build-info/route.ts +++ b/apps/web/src/app/api/build-info/route.ts @@ -1,6 +1,6 @@ -import { NextResponse } from "next/server"; -import buildInfo from "@/generated/build-info.json"; +import { NextResponse } from 'next/server' +import buildInfo from '@/generated/build-info.json' export async function GET() { - return NextResponse.json(buildInfo); + return NextResponse.json(buildInfo) } diff --git a/apps/web/src/app/api/create/calendar/generate/route.ts b/apps/web/src/app/api/create/calendar/generate/route.ts index fc9003c9..9b692fab 100644 --- a/apps/web/src/app/api/create/calendar/generate/route.ts +++ b/apps/web/src/app/api/create/calendar/generate/route.ts @@ -1,62 +1,55 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { writeFileSync, mkdirSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { execSync } from "child_process"; -import { - generateMonthlyTypst, - generateDailyTypst, - getDaysInMonth, -} from "../utils/typstGenerator"; -import type { AbacusConfig } from "@soroban/abacus-react"; -import { generateCalendarComposite } from "@/utils/calendar/generateCalendarComposite"; -import { generateAbacusElement } from "@/utils/calendar/generateCalendarAbacus"; +import { type NextRequest, NextResponse } from 'next/server' +import { writeFileSync, mkdirSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { execSync } from 'child_process' +import { generateMonthlyTypst, generateDailyTypst, getDaysInMonth } from '../utils/typstGenerator' +import type { AbacusConfig } from '@soroban/abacus-react' +import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite' +import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus' interface CalendarRequest { - month: number; - year: number; - format: "monthly" | "daily"; - paperSize: "us-letter" | "a4" | "a3" | "tabloid"; - abacusConfig?: AbacusConfig; + month: number + year: number + format: 'monthly' | 'daily' + paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid' + abacusConfig?: AbacusConfig } export async function POST(request: NextRequest) { - let tempDir: string | null = null; + let tempDir: string | null = null try { // Dynamic import to avoid Next.js bundler issues with react-dom/server - const { renderToStaticMarkup } = await import("react-dom/server"); + const { renderToStaticMarkup } = await import('react-dom/server') - const body: CalendarRequest = await request.json(); - const { month, year, format, paperSize, abacusConfig } = body; + const body: CalendarRequest = await request.json() + const { month, year, format, paperSize, abacusConfig } = body // Validate inputs if (!month || month < 1 || month > 12 || !year || year < 1 || year > 9999) { - return NextResponse.json( - { error: "Invalid month or year" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 }) } // Create temp directory for SVG files - tempDir = join(tmpdir(), `calendar-${Date.now()}-${Math.random()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = join(tmpdir(), `calendar-${Date.now()}-${Math.random()}`) + mkdirSync(tempDir, { recursive: true }) // Generate and write SVG files - const daysInMonth = getDaysInMonth(year, month); - let typstContent: string; + const daysInMonth = getDaysInMonth(year, month) + let typstContent: string - if (format === "monthly") { + if (format === 'monthly') { // Generate single composite SVG for monthly calendar const calendarSvg = generateCalendarComposite({ month, year, renderToString: renderToStaticMarkup, - }); + }) if (!calendarSvg || calendarSvg.trim().length === 0) { - throw new Error("Generated empty composite calendar SVG"); + throw new Error('Generated empty composite calendar SVG') } - writeFileSync(join(tempDir, "calendar.svg"), calendarSvg); + writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg) // Generate Typst document typstContent = generateMonthlyTypst({ @@ -64,26 +57,24 @@ export async function POST(request: NextRequest) { year, paperSize, daysInMonth, - }); + }) } else { // Daily format: generate individual SVGs for each day for (let day = 1; day <= daysInMonth; day++) { - const svg = renderToStaticMarkup(generateAbacusElement(day, 2)); + const svg = renderToStaticMarkup(generateAbacusElement(day, 2)) if (!svg || svg.trim().length === 0) { - throw new Error(`Generated empty SVG for day ${day}`); + throw new Error(`Generated empty SVG for day ${day}`) } - writeFileSync(join(tempDir, `day-${day}.svg`), svg); + writeFileSync(join(tempDir, `day-${day}.svg`), svg) } // Generate year SVG - const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))); - const yearSvg = renderToStaticMarkup( - generateAbacusElement(year, yearColumns), - ); + const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))) + const yearSvg = renderToStaticMarkup(generateAbacusElement(year, yearColumns)) if (!yearSvg || yearSvg.trim().length === 0) { - throw new Error(`Generated empty SVG for year ${year}`); + throw new Error(`Generated empty SVG for year ${year}`) } - writeFileSync(join(tempDir, "year.svg"), yearSvg); + writeFileSync(join(tempDir, 'year.svg'), yearSvg) // Generate Typst document typstContent = generateDailyTypst({ @@ -91,57 +82,57 @@ export async function POST(request: NextRequest) { year, paperSize, daysInMonth, - }); + }) } // Compile with Typst: stdin for .typ content, stdout for PDF output - let pdfBuffer: Buffer; + let pdfBuffer: Buffer try { - pdfBuffer = execSync("typst compile --format pdf - -", { + pdfBuffer = execSync('typst compile --format pdf - -', { input: typstContent, cwd: tempDir, // Run in temp dir so relative paths work maxBuffer: 50 * 1024 * 1024, // 50MB limit for large calendars - }); + }) } catch (error) { - console.error("Typst compilation error:", error); + console.error('Typst compilation error:', error) return NextResponse.json( - { error: "Failed to compile PDF. Is Typst installed?" }, - { status: 500 }, - ); + { error: 'Failed to compile PDF. Is Typst installed?' }, + { status: 500 } + ) } // Clean up temp directory - rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null // Return JSON with PDF return NextResponse.json({ - pdf: pdfBuffer.toString("base64"), - filename: `calendar-${year}-${String(month).padStart(2, "0")}.pdf`, - }); + pdf: pdfBuffer.toString('base64'), + filename: `calendar-${year}-${String(month).padStart(2, '0')}.pdf`, + }) } catch (error) { - console.error("Error generating calendar:", error); + console.error('Error generating calendar:', error) // Clean up temp directory if it exists if (tempDir) { try { - rmSync(tempDir, { recursive: true, force: true }); + rmSync(tempDir, { recursive: true, force: true }) } catch (cleanupError) { - console.error("Failed to clean up temp directory:", cleanupError); + console.error('Failed to clean up temp directory:', cleanupError) } } // Surface the actual error for debugging - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : undefined; + const errorMessage = error instanceof Error ? error.message : String(error) + const errorStack = error instanceof Error ? error.stack : undefined return NextResponse.json( { - error: "Failed to generate calendar", + error: 'Failed to generate calendar', message: errorMessage, - ...(process.env.NODE_ENV === "development" && { stack: errorStack }), + ...(process.env.NODE_ENV === 'development' && { stack: errorStack }), }, - { status: 500 }, - ); + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/create/calendar/preview/route.ts b/apps/web/src/app/api/create/calendar/preview/route.ts index 20ced4d5..997cb7fd 100644 --- a/apps/web/src/app/api/create/calendar/preview/route.ts +++ b/apps/web/src/app/api/create/calendar/preview/route.ts @@ -1,115 +1,103 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { writeFileSync, mkdirSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { execSync } from "child_process"; -import { generateMonthlyTypst, getDaysInMonth } from "../utils/typstGenerator"; -import { generateCalendarComposite } from "@/utils/calendar/generateCalendarComposite"; -import { generateAbacusElement } from "@/utils/calendar/generateCalendarAbacus"; +import { type NextRequest, NextResponse } from 'next/server' +import { writeFileSync, mkdirSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { execSync } from 'child_process' +import { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator' +import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite' +import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus' interface PreviewRequest { - month: number; - year: number; - format: "monthly" | "daily"; + month: number + year: number + format: 'monthly' | 'daily' } -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic' export async function POST(request: NextRequest) { - let tempDir: string | null = null; + let tempDir: string | null = null try { - const body: PreviewRequest = await request.json(); - const { month, year, format } = body; + const body: PreviewRequest = await request.json() + const { month, year, format } = body // Validate inputs if (!month || month < 1 || month > 12 || !year || year < 1 || year > 9999) { - return NextResponse.json( - { error: "Invalid month or year" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 }) } // Dynamic import to avoid Next.js bundler issues - const { renderToStaticMarkup } = await import("react-dom/server"); + const { renderToStaticMarkup } = await import('react-dom/server') // Create temp directory for SVG file(s) - tempDir = join(tmpdir(), `calendar-preview-${Date.now()}-${Math.random()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = join(tmpdir(), `calendar-preview-${Date.now()}-${Math.random()}`) + mkdirSync(tempDir, { recursive: true }) // Generate Typst document content - const daysInMonth = getDaysInMonth(year, month); - let typstContent: string; + const daysInMonth = getDaysInMonth(year, month) + let typstContent: string - if (format === "monthly") { + if (format === 'monthly') { // Generate and write composite SVG const calendarSvg = generateCalendarComposite({ month, year, renderToString: renderToStaticMarkup, - }); - writeFileSync(join(tempDir, "calendar.svg"), calendarSvg); + }) + writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg) typstContent = generateMonthlyTypst({ month, year, - paperSize: "us-letter", + paperSize: 'us-letter', daysInMonth, - }); + }) } else { // Daily format: Create a SINGLE composite SVG (like monthly) to avoid multi-image export issue // Generate individual abacus SVGs - const daySvg = renderToStaticMarkup(generateAbacusElement(1, 2)); + const daySvg = renderToStaticMarkup(generateAbacusElement(1, 2)) if (!daySvg || daySvg.trim().length === 0) { - throw new Error("Generated empty SVG for day 1"); + throw new Error('Generated empty SVG for day 1') } - const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))); - const yearSvg = renderToStaticMarkup( - generateAbacusElement(year, yearColumns), - ); + const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))) + const yearSvg = renderToStaticMarkup(generateAbacusElement(year, yearColumns)) if (!yearSvg || yearSvg.trim().length === 0) { - throw new Error(`Generated empty SVG for year ${year}`); + throw new Error(`Generated empty SVG for year ${year}`) } // Create composite SVG with both year and day abacus const monthName = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ][month - 1]; - const dayOfWeek = new Date(year, month - 1, 1).toLocaleDateString( - "en-US", - { - weekday: "long", - }, - ); + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ][month - 1] + const dayOfWeek = new Date(year, month - 1, 1).toLocaleDateString('en-US', { + weekday: 'long', + }) // Extract SVG content (remove outer tags) - const yearSvgContent = yearSvg - .replace(/]*>/, "") - .replace(/<\/svg>$/, ""); - const daySvgContent = daySvg - .replace(/]*>/, "") - .replace(/<\/svg>$/, ""); + const yearSvgContent = yearSvg.replace(/]*>/, '').replace(/<\/svg>$/, '') + const daySvgContent = daySvg.replace(/]*>/, '').replace(/<\/svg>$/, '') // Create composite SVG (850x1100 = US Letter aspect ratio) - const compositeWidth = 850; - const compositeHeight = 1100; - const yearAbacusWidth = 120; // Natural width at scale 1 - const yearAbacusHeight = 230; - const dayAbacusWidth = 120; - const dayAbacusHeight = 230; + const compositeWidth = 850 + const compositeHeight = 1100 + const yearAbacusWidth = 120 // Natural width at scale 1 + const yearAbacusHeight = 230 + const dayAbacusWidth = 120 + const dayAbacusHeight = 230 const compositeSvg = ` @@ -155,9 +143,9 @@ export async function POST(request: NextRequest) { -`; +` - writeFileSync(join(tempDir, "daily-preview.svg"), compositeSvg); + writeFileSync(join(tempDir, 'daily-preview.svg'), compositeSvg) // Use single composite image (like monthly) typstContent = `#set page( @@ -168,46 +156,46 @@ export async function POST(request: NextRequest) { #align(center + horizon)[ #image("daily-preview.svg", width: 100%, fit: "contain") ] -`; +` } // Compile with Typst: stdin for .typ content, stdout for SVG output - let svg: string; + let svg: string try { - svg = execSync("typst compile --format svg - -", { + svg = execSync('typst compile --format svg - -', { input: typstContent, - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, // Run in temp dir so relative paths work - }); + }) } catch (error) { - console.error("Typst compilation error:", error); + console.error('Typst compilation error:', error) return NextResponse.json( - { error: "Failed to compile preview. Is Typst installed?" }, - { status: 500 }, - ); + { error: 'Failed to compile preview. Is Typst installed?' }, + { status: 500 } + ) } // Clean up temp directory - rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null - return NextResponse.json({ svg }); + return NextResponse.json({ svg }) } catch (error) { - console.error("Error generating preview:", error); + console.error('Error generating preview:', error) // Clean up temp directory if it exists if (tempDir) { try { - rmSync(tempDir, { recursive: true, force: true }); + rmSync(tempDir, { recursive: true, force: true }) } catch (cleanupError) { - console.error("Failed to clean up temp directory:", cleanupError); + console.error('Failed to clean up temp directory:', cleanupError) } } - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error) return NextResponse.json( - { error: "Failed to generate preview", message: errorMessage }, - { status: 500 }, - ); + { error: 'Failed to generate preview', message: errorMessage }, + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts b/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts index 3fdf25f7..98ee086c 100644 --- a/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts +++ b/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts @@ -1,70 +1,70 @@ interface TypstMonthlyConfig { - month: number; - year: number; - paperSize: "us-letter" | "a4" | "a3" | "tabloid"; - daysInMonth: number; + month: number + year: number + paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid' + daysInMonth: number } interface TypstDailyConfig { - month: number; - year: number; - paperSize: "us-letter" | "a4" | "a3" | "tabloid"; - daysInMonth: number; + month: number + year: number + paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid' + daysInMonth: number } const MONTH_NAMES = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] export function getDaysInMonth(year: number, month: number): number { - return new Date(year, month, 0).getDate(); + return new Date(year, month, 0).getDate() } function getFirstDayOfWeek(year: number, month: number): number { - return new Date(year, month - 1, 1).getDay(); // 0 = Sunday + return new Date(year, month - 1, 1).getDay() // 0 = Sunday } function getDayOfWeek(year: number, month: number, day: number): string { - const date = new Date(year, month - 1, day); - return date.toLocaleDateString("en-US", { weekday: "long" }); + const date = new Date(year, month - 1, day) + return date.toLocaleDateString('en-US', { weekday: 'long' }) } -type PaperSize = "us-letter" | "a4" | "a3" | "tabloid"; +type PaperSize = 'us-letter' | 'a4' | 'a3' | 'tabloid' interface PaperConfig { - typstName: string; - marginX: string; - marginY: string; + typstName: string + marginX: string + marginY: string } function getPaperConfig(size: string): PaperConfig { const configs: Record = { // Tight margins to maximize space for calendar grid - "us-letter": { typstName: "us-letter", marginX: "0.5in", marginY: "0.5in" }, + 'us-letter': { typstName: 'us-letter', marginX: '0.5in', marginY: '0.5in' }, // A4 is slightly taller/narrower than US Letter - adjust margins proportionally - a4: { typstName: "a4", marginX: "1.3cm", marginY: "1.3cm" }, + a4: { typstName: 'a4', marginX: '1.3cm', marginY: '1.3cm' }, // A3 is 2x area of A4 - can use same margins but will scale content larger - a3: { typstName: "a3", marginX: "1.5cm", marginY: "1.5cm" }, + a3: { typstName: 'a3', marginX: '1.5cm', marginY: '1.5cm' }, // Tabloid (11" × 17") is larger - can use more margin - tabloid: { typstName: "us-tabloid", marginX: "0.75in", marginY: "0.75in" }, - }; - return configs[size as PaperSize] || configs["us-letter"]; + tabloid: { typstName: 'us-tabloid', marginX: '0.75in', marginY: '0.75in' }, + } + return configs[size as PaperSize] || configs['us-letter'] } export function generateMonthlyTypst(config: TypstMonthlyConfig): string { - const { paperSize } = config; - const paperConfig = getPaperConfig(paperSize); + const { paperSize } = config + const paperConfig = getPaperConfig(paperSize) // Single-page design: use one composite SVG that scales to fit // This prevents overflow - Typst will scale the image to fit available space @@ -77,18 +77,18 @@ export function generateMonthlyTypst(config: TypstMonthlyConfig): string { #align(center + horizon)[ #image("calendar.svg", width: 100%, fit: "contain") ] -`; +` } export function generateDailyTypst(config: TypstDailyConfig): string { - const { month, year, paperSize, daysInMonth } = config; - const paperConfig = getPaperConfig(paperSize); - const monthName = MONTH_NAMES[month - 1]; + const { month, year, paperSize, daysInMonth } = config + const paperConfig = getPaperConfig(paperSize) + const monthName = MONTH_NAMES[month - 1] - let pages = ""; + let pages = '' for (let day = 1; day <= daysInMonth; day++) { - const dayOfWeek = getDayOfWeek(year, month, day); + const dayOfWeek = getDayOfWeek(year, month, day) pages += ` #page( @@ -184,12 +184,12 @@ export function generateDailyTypst(config: TypstDailyConfig): string { ] ] -${day < daysInMonth ? "" : ""}`; +${day < daysInMonth ? '' : ''}` if (day < daysInMonth) { - pages += "\n"; + pages += '\n' } } - return pages; + return pages } diff --git a/apps/web/src/app/api/create/flashcards/preview/route.ts b/apps/web/src/app/api/create/flashcards/preview/route.ts index b6ee2e47..21aafeec 100644 --- a/apps/web/src/app/api/create/flashcards/preview/route.ts +++ b/apps/web/src/app/api/create/flashcards/preview/route.ts @@ -1,80 +1,70 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { writeFileSync, mkdirSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { execSync } from "child_process"; -import type { FlashcardFormState } from "@/app/create/flashcards/page"; +import { type NextRequest, NextResponse } from 'next/server' +import { writeFileSync, mkdirSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { execSync } from 'child_process' +import type { FlashcardFormState } from '@/app/create/flashcards/page' import { generateFlashcardFront, generateFlashcardBack, -} from "@/utils/flashcards/generateFlashcardSvgs"; +} from '@/utils/flashcards/generateFlashcardSvgs' -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic' /** * Parse range string to get numbers for preview (first page only) */ -function parseRangeForPreview( - range: string, - step: number, - cardsPerPage: number, -): number[] { - const numbers: number[] = []; +function parseRangeForPreview(range: string, step: number, cardsPerPage: number): number[] { + const numbers: number[] = [] - if (range.includes("-")) { - const [start, end] = range.split("-").map((n) => parseInt(n, 10)); + if (range.includes('-')) { + const [start, end] = range.split('-').map((n) => parseInt(n, 10)) for (let i = start; i <= end && numbers.length < cardsPerPage; i += step) { - numbers.push(i); + numbers.push(i) } - } else if (range.includes(",")) { - const parts = range.split(",").map((n) => parseInt(n.trim(), 10)); - numbers.push(...parts.slice(0, cardsPerPage)); + } else if (range.includes(',')) { + const parts = range.split(',').map((n) => parseInt(n.trim(), 10)) + numbers.push(...parts.slice(0, cardsPerPage)) } else { - numbers.push(parseInt(range, 10)); + numbers.push(parseInt(range, 10)) } - return numbers.slice(0, cardsPerPage); + return numbers.slice(0, cardsPerPage) } export async function POST(request: NextRequest) { - let tempDir: string | null = null; + let tempDir: string | null = null try { - const body: FlashcardFormState = await request.json(); + const body: FlashcardFormState = await request.json() const { - range = "0-99", + range = '0-99', step = 1, cardsPerPage = 6, - paperSize = "us-letter", - orientation = "portrait", - beadShape = "diamond", - colorScheme = "place-value", - colorPalette = "default", + paperSize = 'us-letter', + orientation = 'portrait', + beadShape = 'diamond', + colorScheme = 'place-value', + colorPalette = 'default', hideInactiveBeads = false, showEmptyColumns = false, - columns = "auto", + columns = 'auto', scaleFactor = 0.9, coloredNumerals = false, - } = body; + } = body // Dynamic import to avoid Next.js bundler issues - const { renderToStaticMarkup } = await import("react-dom/server"); + const { renderToStaticMarkup } = await import('react-dom/server') // Create temp directory for SVG files - tempDir = join( - tmpdir(), - `flashcards-preview-${Date.now()}-${Math.random()}`, - ); - mkdirSync(tempDir, { recursive: true }); + tempDir = join(tmpdir(), `flashcards-preview-${Date.now()}-${Math.random()}`) + mkdirSync(tempDir, { recursive: true }) // Get numbers for first page only - const numbers = parseRangeForPreview(range, step, cardsPerPage); + const numbers = parseRangeForPreview(range, step, cardsPerPage) if (numbers.length === 0) { - return NextResponse.json( - { error: "No valid numbers in range" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'No valid numbers in range' }, { status: 400 }) } // Generate SVG files for each card (front and back) @@ -84,58 +74,54 @@ export async function POST(request: NextRequest) { colorPalette, hideInactiveBeads, showEmptyColumns, - columns: (columns === "auto" ? "auto" : Number(columns)) as - | number - | "auto", + columns: (columns === 'auto' ? 'auto' : Number(columns)) as number | 'auto', scaleFactor, coloredNumerals, - }; + } for (let i = 0; i < numbers.length; i++) { - const num = numbers[i]; + const num = numbers[i] // Generate front (abacus) - const frontElement = generateFlashcardFront(num, config); - const frontSvg = renderToStaticMarkup(frontElement); - writeFileSync(join(tempDir, `card_${i}_front.svg`), frontSvg); + const frontElement = generateFlashcardFront(num, config) + const frontSvg = renderToStaticMarkup(frontElement) + writeFileSync(join(tempDir, `card_${i}_front.svg`), frontSvg) // Generate back (numeral) - const backElement = generateFlashcardBack(num, config); - const backSvg = renderToStaticMarkup(backElement); - writeFileSync(join(tempDir, `card_${i}_back.svg`), backSvg); + const backElement = generateFlashcardBack(num, config) + const backSvg = renderToStaticMarkup(backElement) + writeFileSync(join(tempDir, `card_${i}_back.svg`), backSvg) } // Calculate card dimensions based on paper size and orientation const paperDimensions = { - "us-letter": { width: 8.5, height: 11 }, + 'us-letter': { width: 8.5, height: 11 }, a4: { width: 8.27, height: 11.69 }, a3: { width: 11.69, height: 16.54 }, a5: { width: 5.83, height: 8.27 }, - }; + } - const paper = paperDimensions[paperSize] || paperDimensions["us-letter"]; + const paper = paperDimensions[paperSize] || paperDimensions['us-letter'] const [pageWidth, pageHeight] = - orientation === "landscape" - ? [paper.height, paper.width] - : [paper.width, paper.height]; + orientation === 'landscape' ? [paper.height, paper.width] : [paper.width, paper.height] // Calculate grid layout (2 columns × 3 rows for 6 cards per page typically) - const cols = 2; - const rows = Math.ceil(cardsPerPage / cols); - const margin = 0.5; // inches - const gutter = 0.2; // inches between cards + const cols = 2 + const rows = Math.ceil(cardsPerPage / cols) + const margin = 0.5 // inches + const gutter = 0.2 // inches between cards - const availableWidth = pageWidth - 2 * margin - gutter * (cols - 1); - const availableHeight = pageHeight - 2 * margin - gutter * (rows - 1); - const cardWidth = availableWidth / cols; - const cardHeight = availableHeight / rows; + const availableWidth = pageWidth - 2 * margin - gutter * (cols - 1) + const availableHeight = pageHeight - 2 * margin - gutter * (rows - 1) + const cardWidth = availableWidth / cols + const cardHeight = availableHeight / rows // Generate Typst document with card grid const typstContent = ` #set page( paper: "${paperSize}", margin: (x: ${margin}in, y: ${margin}in), - flipped: ${orientation === "landscape"}, + flipped: ${orientation === 'landscape'}, ) // Grid layout for flashcards preview (first page only) @@ -146,9 +132,9 @@ export async function POST(request: NextRequest) { row-gutter: ${gutter}in, ${numbers .map((_, i) => { - return ` image("card_${i}_front.svg", width: ${cardWidth}in, height: ${cardHeight}in, fit: "contain"),`; + return ` image("card_${i}_front.svg", width: ${cardWidth}in, height: ${cardHeight}in, fit: "contain"),` }) - .join("\n")} + .join('\n')} ) // Add preview label @@ -158,45 +144,45 @@ export async function POST(request: NextRequest) { dy: 0.25in, text(10pt, fill: gray)[Preview (first ${numbers.length} cards)] ) -`; +` // Compile with Typst: stdin for .typ content, stdout for SVG output - let svg: string; + let svg: string try { - svg = execSync("typst compile --format svg - -", { + svg = execSync('typst compile --format svg - -', { input: typstContent, - encoding: "utf8", + encoding: 'utf8', cwd: tempDir, // Run in temp dir so relative paths work - }); + }) } catch (error) { - console.error("Typst compilation error:", error); + console.error('Typst compilation error:', error) return NextResponse.json( - { error: "Failed to compile preview. Is Typst installed?" }, - { status: 500 }, - ); + { error: 'Failed to compile preview. Is Typst installed?' }, + { status: 500 } + ) } // Clean up temp directory - rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null - return NextResponse.json({ svg }); + return NextResponse.json({ svg }) } catch (error) { - console.error("Error generating preview:", error); + console.error('Error generating preview:', error) // Clean up temp directory if it exists if (tempDir) { try { - rmSync(tempDir, { recursive: true, force: true }); + rmSync(tempDir, { recursive: true, force: true }) } catch (cleanupError) { - console.error("Failed to clean up temp directory:", cleanupError); + console.error('Failed to clean up temp directory:', cleanupError) } } - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error) return NextResponse.json( - { error: "Failed to generate preview", message: errorMessage }, - { status: 500 }, - ); + { error: 'Failed to generate preview', message: errorMessage }, + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/create/worksheets/addition/example/route.ts b/apps/web/src/app/api/create/worksheets/addition/example/route.ts index 1ae56b06..1e0bfb16 100644 --- a/apps/web/src/app/api/create/worksheets/addition/example/route.ts +++ b/apps/web/src/app/api/create/worksheets/addition/example/route.ts @@ -8,40 +8,40 @@ // This ensures blog post examples use the EXACT same rendering as the live UI preview, // maintaining consistency between what users see in documentation vs. the actual tool. -import { execSync } from "child_process"; -import { type NextRequest, NextResponse } from "next/server"; +import { execSync } from 'child_process' +import { type NextRequest, NextResponse } from 'next/server' import { generateProblems, generateSubtractionProblems, -} from "@/app/create/worksheets/addition/problemGenerator"; -import type { WorksheetOperator } from "@/app/create/worksheets/addition/types"; +} from '@/app/create/worksheets/addition/problemGenerator' +import type { WorksheetOperator } from '@/app/create/worksheets/addition/types' import { generateProblemStackFunction, generateSubtractionProblemStackFunction, generateTypstHelpers, generatePlaceValueColors, -} from "@/app/create/worksheets/addition/typstHelpers"; +} from '@/app/create/worksheets/addition/typstHelpers' -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic' interface ExampleRequest { - showCarryBoxes?: boolean; - showAnswerBoxes?: boolean; - showPlaceValueColors?: boolean; - showProblemNumbers?: boolean; - showCellBorder?: boolean; - showTenFrames?: boolean; - showTenFramesForAll?: boolean; - showBorrowNotation?: boolean; - showBorrowingHints?: boolean; - fontSize?: number; - operator?: WorksheetOperator; + showCarryBoxes?: boolean + showAnswerBoxes?: boolean + showPlaceValueColors?: boolean + showProblemNumbers?: boolean + showCellBorder?: boolean + showTenFrames?: boolean + showTenFramesForAll?: boolean + showBorrowNotation?: boolean + showBorrowingHints?: boolean + fontSize?: number + operator?: WorksheetOperator // For addition - addend1?: number; - addend2?: number; + addend1?: number + addend2?: number // For subtraction - minuend?: number; - subtrahend?: number; + minuend?: number + subtrahend?: number } /** @@ -49,34 +49,34 @@ interface ExampleRequest { * Uses the EXACT same Typst structure as the full worksheet generator */ function generateExampleTypst(config: ExampleRequest): string { - const operator = config.operator ?? "addition"; - const fontSize = config.fontSize || 14; - const cellSize = 0.35; // Compact cell size for examples + const operator = config.operator ?? 'addition' + const fontSize = config.fontSize || 14 + const cellSize = 0.35 // Compact cell size for examples // Boolean flags matching worksheet generator - const showCarries = config.showCarryBoxes ?? false; - const showAnswers = config.showAnswerBoxes ?? false; - const showColors = config.showPlaceValueColors ?? false; - const showNumbers = config.showProblemNumbers ?? false; - const showTenFrames = config.showTenFrames ?? false; - const showTenFramesForAll = config.showTenFramesForAll ?? false; - const showBorrowNotation = config.showBorrowNotation ?? true; - const showBorrowingHints = config.showBorrowingHints ?? false; + const showCarries = config.showCarryBoxes ?? false + const showAnswers = config.showAnswerBoxes ?? false + const showColors = config.showPlaceValueColors ?? false + const showNumbers = config.showProblemNumbers ?? false + const showTenFrames = config.showTenFrames ?? false + const showTenFramesForAll = config.showTenFramesForAll ?? false + const showBorrowNotation = config.showBorrowNotation ?? true + const showBorrowingHints = config.showBorrowingHints ?? false - if (operator === "addition") { + if (operator === 'addition') { // Use custom addends if provided, otherwise generate a problem - let a: number; - let b: number; + let a: number + let b: number if (config.addend1 !== undefined && config.addend2 !== undefined) { - a = config.addend1; - b = config.addend2; + a = config.addend1 + b = config.addend2 } else { // Generate a simple 2-digit + 2-digit problem with carries - const problems = generateProblems(1, 0.8, 0.5, false, 12345); - const problem = problems[0]; - a = problem.a; - b = problem.b; + const problems = generateProblems(1, 0.8, 0.5, false, 12345) + const problem = problems[0] + a = problem.a + b = problem.b } return String.raw` @@ -84,12 +84,12 @@ function generateExampleTypst(config: ExampleRequest): string { #set text(size: ${fontSize}pt, font: "New Computer Modern Math") #let heavy-stroke = 0.8pt -#let show-carries = ${showCarries ? "true" : "false"} -#let show-answers = ${showAnswers ? "true" : "false"} -#let show-colors = ${showColors ? "true" : "false"} -#let show-numbers = ${showNumbers ? "true" : "false"} -#let show-ten-frames = ${showTenFrames ? "true" : "false"} -#let show-ten-frames-for-all = ${showTenFramesForAll ? "true" : "false"} +#let show-carries = ${showCarries ? 'true' : 'false'} +#let show-answers = ${showAnswers ? 'true' : 'false'} +#let show-colors = ${showColors ? 'true' : 'false'} +#let show-numbers = ${showNumbers ? 'true' : 'false'} +#let show-ten-frames = ${showTenFrames ? 'true' : 'false'} +#let show-ten-frames-for-all = ${showTenFramesForAll ? 'true' : 'false'} ${generatePlaceValueColors()} @@ -103,29 +103,22 @@ ${generateProblemStackFunction(cellSize, 3)} #align(center + horizon)[ #problem-stack(a, b, if show-numbers { 0 } else { none }, show-carries, show-answers, show-colors, show-ten-frames, show-numbers) ] -`; +` } else { // Subtraction - let minuend: number; - let subtrahend: number; + let minuend: number + let subtrahend: number if (config.minuend !== undefined && config.subtrahend !== undefined) { - minuend = config.minuend; - subtrahend = config.subtrahend; + minuend = config.minuend + subtrahend = config.subtrahend } else { // Generate a simple 2-digit - 2-digit problem with borrows - const digitRange = { min: 2, max: 2 }; - const problems = generateSubtractionProblems( - 1, - digitRange, - 0.8, - 0.5, - false, - 12345, - ); - const problem = problems[0]; - minuend = problem.minuend; - subtrahend = problem.subtrahend; + const digitRange = { min: 2, max: 2 } + const problems = generateSubtractionProblems(1, digitRange, 0.8, 0.5, false, 12345) + const problem = problems[0] + minuend = problem.minuend + subtrahend = problem.subtrahend } return String.raw` @@ -133,14 +126,14 @@ ${generateProblemStackFunction(cellSize, 3)} #set text(size: ${fontSize}pt, font: "New Computer Modern Math") #let heavy-stroke = 0.8pt -#let show-borrows = ${showCarries ? "true" : "false"} -#let show-answers = ${showAnswers ? "true" : "false"} -#let show-colors = ${showColors ? "true" : "false"} -#let show-numbers = ${showNumbers ? "true" : "false"} -#let show-ten-frames = ${showTenFrames ? "true" : "false"} -#let show-ten-frames-for-all = ${showTenFramesForAll ? "true" : "false"} -#let show-borrow-notation = ${showBorrowNotation ? "true" : "false"} -#let show-borrowing-hints = ${showBorrowingHints ? "true" : "false"} +#let show-borrows = ${showCarries ? 'true' : 'false'} +#let show-answers = ${showAnswers ? 'true' : 'false'} +#let show-colors = ${showColors ? 'true' : 'false'} +#let show-numbers = ${showNumbers ? 'true' : 'false'} +#let show-ten-frames = ${showTenFrames ? 'true' : 'false'} +#let show-ten-frames-for-all = ${showTenFramesForAll ? 'true' : 'false'} +#let show-borrow-notation = ${showBorrowNotation ? 'true' : 'false'} +#let show-borrowing-hints = ${showBorrowingHints ? 'true' : 'false'} ${generatePlaceValueColors()} @@ -154,36 +147,36 @@ ${generateSubtractionProblemStackFunction(cellSize, 3)} #align(center + horizon)[ #subtraction-problem-stack(minuend, subtrahend, if show-numbers { 0 } else { none }, show-borrows, show-answers, show-colors, show-ten-frames, show-numbers, show-borrow-notation, show-borrowing-hints) ] -`; +` } } export async function POST(request: NextRequest) { try { - const body: ExampleRequest = await request.json(); + const body: ExampleRequest = await request.json() // Generate Typst source with all display options - const typstSource = generateExampleTypst(body); + const typstSource = generateExampleTypst(body) // Compile to SVG - const svg = execSync("typst compile --format svg - -", { + const svg = execSync('typst compile --format svg - -', { input: typstSource, - encoding: "utf8", + encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, - }); + }) - return NextResponse.json({ svg }); + return NextResponse.json({ svg }) } catch (error) { - console.error("Error generating example:", error); + console.error('Error generating example:', error) - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error) return NextResponse.json( { - error: "Failed to generate example", + error: 'Failed to generate example', message: errorMessage, }, - { status: 500 }, - ); + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/create/worksheets/addition/preview/route.ts b/apps/web/src/app/api/create/worksheets/addition/preview/route.ts index 871d8450..2363dd1e 100644 --- a/apps/web/src/app/api/create/worksheets/addition/preview/route.ts +++ b/apps/web/src/app/api/create/worksheets/addition/preview/route.ts @@ -1,17 +1,17 @@ // API route for generating addition worksheet previews (SVG) -import { type NextRequest, NextResponse } from "next/server"; -import { generateWorksheetPreview } from "@/app/create/worksheets/addition/generatePreview"; -import type { WorksheetFormState } from "@/app/create/worksheets/addition/types"; +import { type NextRequest, NextResponse } from 'next/server' +import { generateWorksheetPreview } from '@/app/create/worksheets/addition/generatePreview' +import type { WorksheetFormState } from '@/app/create/worksheets/addition/types' -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic' export async function POST(request: NextRequest) { try { - const body: WorksheetFormState = await request.json(); + const body: WorksheetFormState = await request.json() // Generate preview using shared logic - const result = generateWorksheetPreview(body); + const result = generateWorksheetPreview(body) if (!result.success) { return NextResponse.json( @@ -19,23 +19,23 @@ export async function POST(request: NextRequest) { error: result.error, details: result.details, }, - { status: 400 }, - ); + { status: 400 } + ) } // Return pages as JSON - return NextResponse.json({ pages: result.pages }); + return NextResponse.json({ pages: result.pages }) } catch (error) { - console.error("Error generating preview:", error); + console.error('Error generating preview:', error) - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error) return NextResponse.json( { - error: "Failed to generate preview", + error: 'Failed to generate preview', message: errorMessage, }, - { status: 500 }, - ); + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/create/worksheets/addition/route.ts b/apps/web/src/app/api/create/worksheets/addition/route.ts index 23685eaf..f802ff14 100644 --- a/apps/web/src/app/api/create/worksheets/addition/route.ts +++ b/apps/web/src/app/api/create/worksheets/addition/route.ts @@ -1,54 +1,51 @@ // API route for generating addition worksheets -import { type NextRequest, NextResponse } from "next/server"; -import { execSync } from "child_process"; -import { validateWorksheetConfig } from "@/app/create/worksheets/addition/validation"; +import { type NextRequest, NextResponse } from 'next/server' +import { execSync } from 'child_process' +import { validateWorksheetConfig } from '@/app/create/worksheets/addition/validation' import { generateProblems, generateSubtractionProblems, generateMixedProblems, -} from "@/app/create/worksheets/addition/problemGenerator"; -import { generateTypstSource } from "@/app/create/worksheets/addition/typstGenerator"; -import type { - WorksheetFormState, - WorksheetProblem, -} from "@/app/create/worksheets/addition/types"; +} from '@/app/create/worksheets/addition/problemGenerator' +import { generateTypstSource } from '@/app/create/worksheets/addition/typstGenerator' +import type { WorksheetFormState, WorksheetProblem } from '@/app/create/worksheets/addition/types' export async function POST(request: NextRequest) { try { - const body: WorksheetFormState = await request.json(); + const body: WorksheetFormState = await request.json() // Validate configuration - const validation = validateWorksheetConfig(body); + const validation = validateWorksheetConfig(body) if (!validation.isValid || !validation.config) { return NextResponse.json( - { error: "Invalid configuration", errors: validation.errors }, - { status: 400 }, - ); + { error: 'Invalid configuration', errors: validation.errors }, + { status: 400 } + ) } - const config = validation.config; + const config = validation.config // Generate problems based on operator type - let problems: WorksheetProblem[]; - if (config.operator === "addition") { + let problems: WorksheetProblem[] + if (config.operator === 'addition') { problems = generateProblems( config.total, config.pAnyStart, config.pAllStart, config.interpolate, config.seed, - config.digitRange, - ); - } else if (config.operator === "subtraction") { + config.digitRange + ) + } else if (config.operator === 'subtraction') { problems = generateSubtractionProblems( config.total, config.digitRange, config.pAnyStart, config.pAllStart, config.interpolate, - config.seed, - ); + config.seed + ) } else { // mixed problems = generateMixedProblems( @@ -57,65 +54,64 @@ export async function POST(request: NextRequest) { config.pAnyStart, config.pAllStart, config.interpolate, - config.seed, - ); + config.seed + ) } // Generate Typst sources (one per page) - const typstSources = generateTypstSource(config, problems); + const typstSources = generateTypstSource(config, problems) // Join pages with pagebreak for PDF - const typstSource = typstSources.join("\n\n#pagebreak()\n\n"); + const typstSource = typstSources.join('\n\n#pagebreak()\n\n') // Compile with Typst: stdin → stdout - let pdfBuffer: Buffer; + let pdfBuffer: Buffer try { - pdfBuffer = execSync("typst compile --format pdf - -", { + pdfBuffer = execSync('typst compile --format pdf - -', { input: typstSource, maxBuffer: 10 * 1024 * 1024, // 10MB limit - }); + }) } catch (error) { - console.error("Typst compilation error:", error); + console.error('Typst compilation error:', error) // Extract the actual Typst error message const stderr = - error instanceof Error && "stderr" in error + error instanceof Error && 'stderr' in error ? String((error as any).stderr) - : "Unknown compilation error"; + : 'Unknown compilation error' return NextResponse.json( { - error: "Failed to compile worksheet PDF", + error: 'Failed to compile worksheet PDF', details: stderr, - ...(process.env.NODE_ENV === "development" && { - typstSource: - typstSource.split("\n").slice(0, 20).join("\n") + "\n...", + ...(process.env.NODE_ENV === 'development' && { + typstSource: typstSource.split('\n').slice(0, 20).join('\n') + '\n...', }), }, - { status: 500 }, - ); + { status: 500 } + ) } // Return binary PDF directly return new Response(pdfBuffer as unknown as BodyInit, { headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="addition-worksheet-${Date.now()}.pdf"`, + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="addition-worksheet-${Date.now()}.pdf"`, }, - }); + }) } catch (error) { - console.error("Error generating worksheet:", error); + console.error('Error generating worksheet:', error) - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : undefined; + const errorMessage = error instanceof Error ? error.message : String(error) + const errorStack = error instanceof Error ? error.stack : undefined return NextResponse.json( { - error: "Failed to generate worksheet", + error: 'Failed to generate worksheet', message: errorMessage, - ...(process.env.NODE_ENV === "development" && { stack: errorStack }), + ...(process.env.NODE_ENV === 'development' && { stack: errorStack }), }, - { status: 500 }, - ); + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/debug/active-players/route.ts b/apps/web/src/app/api/debug/active-players/route.ts index 937d74f0..04dd3a9b 100644 --- a/apps/web/src/app/api/debug/active-players/route.ts +++ b/apps/web/src/app/api/debug/active-players/route.ts @@ -1,11 +1,11 @@ -import { NextResponse } from "next/server"; -import { getViewerId } from "@/lib/viewer"; -import { getActivePlayers } from "@/lib/arcade/player-manager"; -import { db, schema } from "@/db"; -import { eq } from "drizzle-orm"; +import { NextResponse } from 'next/server' +import { getViewerId } from '@/lib/viewer' +import { getActivePlayers } from '@/lib/arcade/player-manager' +import { db, schema } from '@/db' +import { eq } from 'drizzle-orm' // Force dynamic rendering - this route uses headers() -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic' /** * GET /api/debug/active-players @@ -13,27 +13,24 @@ export const dynamic = "force-dynamic"; */ export async function GET() { try { - const viewerId = await getViewerId(); + const viewerId = await getViewerId() // Get user record const user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) if (!user) { - return NextResponse.json( - { error: "User not found", viewerId }, - { status: 404 }, - ); + return NextResponse.json({ error: 'User not found', viewerId }, { status: 404 }) } // Get ALL players for this user const allPlayers = await db.query.players.findMany({ where: eq(schema.players.userId, user.id), - }); + }) // Get active players using the helper - const activePlayers = await getActivePlayers(viewerId); + const activePlayers = await getActivePlayers(viewerId) return NextResponse.json({ viewerId, @@ -52,12 +49,12 @@ export async function GET() { })), activeCount: activePlayers.length, totalCount: allPlayers.length, - }); + }) } catch (error) { - console.error("Failed to fetch active players:", error); + console.error('Failed to fetch active players:', error) return NextResponse.json( - { error: "Failed to fetch active players", details: String(error) }, - { status: 500 }, - ); + { error: 'Failed to fetch active players', details: String(error) }, + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/download/[id]/route.ts b/apps/web/src/app/api/download/[id]/route.ts index 8578b587..98b95e6e 100644 --- a/apps/web/src/app/api/download/[id]/route.ts +++ b/apps/web/src/app/api/download/[id]/route.ts @@ -1,49 +1,46 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { assetStore } from "@/lib/asset-store"; +import { type NextRequest, NextResponse } from 'next/server' +import { assetStore } from '@/lib/asset-store' -export async function GET( - _request: NextRequest, - { params }: { params: { id: string } }, -) { +export async function GET(_request: NextRequest, { params }: { params: { id: string } }) { try { - const { id } = params; + const { id } = params - console.log("🔍 Looking for asset:", id); - console.log("📦 Available assets:", await assetStore.keys()); + console.log('🔍 Looking for asset:', id) + console.log('📦 Available assets:', await assetStore.keys()) // Get asset from store - const asset = await assetStore.get(id); + const asset = await assetStore.get(id) if (!asset) { - console.log("❌ Asset not found in store"); + console.log('❌ Asset not found in store') return NextResponse.json( { - error: "Asset not found or expired", + error: 'Asset not found or expired', }, - { status: 404 }, - ); + { status: 404 } + ) } - console.log("✅ Asset found, serving download"); + console.log('✅ Asset found, serving download') // Return file with appropriate headers return new NextResponse(new Uint8Array(asset.data), { status: 200, headers: { - "Content-Type": asset.mimeType, - "Content-Disposition": `attachment; filename="${asset.filename}"`, - "Content-Length": asset.data.length.toString(), - "Cache-Control": "private, no-cache, no-store, must-revalidate", - Expires: "0", - Pragma: "no-cache", + 'Content-Type': asset.mimeType, + 'Content-Disposition': `attachment; filename="${asset.filename}"`, + 'Content-Length': asset.data.length.toString(), + 'Cache-Control': 'private, no-cache, no-store, must-revalidate', + Expires: '0', + Pragma: 'no-cache', }, - }); + }) } catch (error) { - console.error("❌ Download failed:", error); + console.error('❌ Download failed:', error) return NextResponse.json( { - error: "Failed to download file", + error: 'Failed to download file', }, - { status: 500 }, - ); + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/download/assets/[id]/route.ts b/apps/web/src/app/api/download/assets/[id]/route.ts index 38c2e7e5..9fc2a8fd 100644 --- a/apps/web/src/app/api/download/assets/[id]/route.ts +++ b/apps/web/src/app/api/download/assets/[id]/route.ts @@ -1,37 +1,28 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { assetStore } from "@/lib/asset-store"; +import { type NextRequest, NextResponse } from 'next/server' +import { assetStore } from '@/lib/asset-store' -export async function GET( - _request: NextRequest, - { params }: { params: { id: string } }, -) { +export async function GET(_request: NextRequest, { params }: { params: { id: string } }) { try { - const { id } = params; + const { id } = params - const asset = await assetStore.get(id); + const asset = await assetStore.get(id) if (!asset) { - return NextResponse.json({ error: "Asset not found" }, { status: 404 }); + return NextResponse.json({ error: 'Asset not found' }, { status: 404 }) } // Set appropriate headers for download - const headers = new Headers(); - headers.set("Content-Type", asset.mimeType); - headers.set( - "Content-Disposition", - `attachment; filename="${asset.filename}"`, - ); - headers.set("Content-Length", asset.data.length.toString()); - headers.set("Cache-Control", "no-cache, no-store, must-revalidate"); + const headers = new Headers() + headers.set('Content-Type', asset.mimeType) + headers.set('Content-Disposition', `attachment; filename="${asset.filename}"`) + headers.set('Content-Length', asset.data.length.toString()) + headers.set('Cache-Control', 'no-cache, no-store, must-revalidate') return new NextResponse(new Uint8Array(asset.data), { status: 200, headers, - }); + }) } catch (error) { - console.error("Asset download error:", error); - return NextResponse.json( - { error: "Failed to download asset" }, - { status: 500 }, - ); + console.error('Asset download error:', error) + return NextResponse.json({ error: 'Failed to download asset' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/generate/route.ts b/apps/web/src/app/api/generate/route.ts index 5f37eb8b..b79e1f7e 100644 --- a/apps/web/src/app/api/generate/route.ts +++ b/apps/web/src/app/api/generate/route.ts @@ -1,50 +1,50 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { writeFileSync, mkdirSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { execSync } from "child_process"; -import type { FlashcardConfig } from "@/app/create/flashcards/page"; +import { type NextRequest, NextResponse } from 'next/server' +import { writeFileSync, mkdirSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { execSync } from 'child_process' +import type { FlashcardConfig } from '@/app/create/flashcards/page' import { generateFlashcardFront, generateFlashcardBack, -} from "@/utils/flashcards/generateFlashcardSvgs"; +} from '@/utils/flashcards/generateFlashcardSvgs' -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic' /** * Parse range string to get all numbers */ function parseRange(range: string, step: number): number[] { - const numbers: number[] = []; + const numbers: number[] = [] - if (range.includes("-")) { - const [start, end] = range.split("-").map((n) => parseInt(n, 10)); + if (range.includes('-')) { + const [start, end] = range.split('-').map((n) => parseInt(n, 10)) for (let i = start; i <= end; i += step) { - numbers.push(i); + numbers.push(i) } - } else if (range.includes(",")) { - const parts = range.split(",").map((n) => parseInt(n.trim(), 10)); - numbers.push(...parts); + } else if (range.includes(',')) { + const parts = range.split(',').map((n) => parseInt(n.trim(), 10)) + numbers.push(...parts) } else { - numbers.push(parseInt(range, 10)); + numbers.push(parseInt(range, 10)) } - return numbers; + return numbers } /** * Shuffle array with seed for reproducibility */ function shuffleWithSeed(array: T[], seed?: number): T[] { - const shuffled = [...array]; - const rng = seed !== undefined ? seededRandom(seed) : Math.random; + const shuffled = [...array] + const rng = seed !== undefined ? seededRandom(seed) : Math.random for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + const j = Math.floor(rng() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] } - return shuffled; + return shuffled } /** @@ -52,61 +52,58 @@ function shuffleWithSeed(array: T[], seed?: number): T[] { */ function seededRandom(seed: number) { return () => { - seed = (seed + 0x6d2b79f5) | 0; - let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } } export async function POST(request: NextRequest) { - let tempDir: string | null = null; + let tempDir: string | null = null try { - const config: FlashcardConfig = await request.json(); + const config: FlashcardConfig = await request.json() const { - range = "0-99", + range = '0-99', step = 1, cardsPerPage = 6, - paperSize = "us-letter", - orientation = "portrait", + paperSize = 'us-letter', + orientation = 'portrait', margins, - gutter = "5mm", + gutter = '5mm', shuffle = false, seed, showCutMarks = false, showRegistration = false, - beadShape = "diamond", - colorScheme = "place-value", - colorPalette = "default", + beadShape = 'diamond', + colorScheme = 'place-value', + colorPalette = 'default', hideInactiveBeads = false, showEmptyColumns = false, - columns = "auto", + columns = 'auto', scaleFactor = 0.9, coloredNumerals = false, - format = "pdf", - } = config; + format = 'pdf', + } = config // Dynamic import to avoid Next.js bundler issues - const { renderToStaticMarkup } = await import("react-dom/server"); + const { renderToStaticMarkup } = await import('react-dom/server') // Create temp directory for SVG files - tempDir = join(tmpdir(), `flashcards-${Date.now()}-${Math.random()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = join(tmpdir(), `flashcards-${Date.now()}-${Math.random()}`) + mkdirSync(tempDir, { recursive: true }) // Get all numbers - let numbers = parseRange(range, step); + let numbers = parseRange(range, step) // Apply shuffle if requested if (shuffle) { - numbers = shuffleWithSeed(numbers, seed); + numbers = shuffleWithSeed(numbers, seed) } if (numbers.length === 0) { - return NextResponse.json( - { error: "No valid numbers in range" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'No valid numbers in range' }, { status: 400 }) } // Generate SVG files for each card (front and back) @@ -116,84 +113,78 @@ export async function POST(request: NextRequest) { colorPalette, hideInactiveBeads, showEmptyColumns, - columns: (columns === "auto" ? "auto" : Number(columns)) as - | number - | "auto", + columns: (columns === 'auto' ? 'auto' : Number(columns)) as number | 'auto', scaleFactor, coloredNumerals, - }; + } for (let i = 0; i < numbers.length; i++) { - const num = numbers[i]; + const num = numbers[i] // Generate front (abacus) - const frontElement = generateFlashcardFront(num, svgConfig); - const frontSvg = renderToStaticMarkup(frontElement); - writeFileSync(join(tempDir, `card_${i}_front.svg`), frontSvg); + const frontElement = generateFlashcardFront(num, svgConfig) + const frontSvg = renderToStaticMarkup(frontElement) + writeFileSync(join(tempDir, `card_${i}_front.svg`), frontSvg) // Generate back (numeral) - const backElement = generateFlashcardBack(num, svgConfig); - const backSvg = renderToStaticMarkup(backElement); - writeFileSync(join(tempDir, `card_${i}_back.svg`), backSvg); + const backElement = generateFlashcardBack(num, svgConfig) + const backSvg = renderToStaticMarkup(backElement) + writeFileSync(join(tempDir, `card_${i}_back.svg`), backSvg) } // Calculate paper dimensions and layout const paperDimensions = { - "us-letter": { width: 8.5, height: 11 }, + 'us-letter': { width: 8.5, height: 11 }, a4: { width: 8.27, height: 11.69 }, a3: { width: 11.69, height: 16.54 }, a5: { width: 5.83, height: 8.27 }, - }; + } - const paper = paperDimensions[paperSize] || paperDimensions["us-letter"]; + const paper = paperDimensions[paperSize] || paperDimensions['us-letter'] const [pageWidth, pageHeight] = - orientation === "landscape" - ? [paper.height, paper.width] - : [paper.width, paper.height]; + orientation === 'landscape' ? [paper.height, paper.width] : [paper.width, paper.height] // Calculate grid layout (typically 2 columns × 3 rows for 6 cards) - const cols = 2; - const rows = Math.ceil(cardsPerPage / cols); + const cols = 2 + const rows = Math.ceil(cardsPerPage / cols) // Use provided margins or defaults const margin = { - top: margins?.top || "0.5in", - bottom: margins?.bottom || "0.5in", - left: margins?.left || "0.5in", - right: margins?.right || "0.5in", - }; + top: margins?.top || '0.5in', + bottom: margins?.bottom || '0.5in', + left: margins?.left || '0.5in', + right: margins?.right || '0.5in', + } // Parse gutter (convert from string like "5mm" to inches for calculation) - const gutterInches = parseFloat(gutter) / 25.4; // Rough mm to inch conversion + const gutterInches = parseFloat(gutter) / 25.4 // Rough mm to inch conversion // Calculate available space (approximate, Typst will handle exact layout) - const marginInches = 0.5; // Simplified for now - const availableWidth = - pageWidth - 2 * marginInches - gutterInches * (cols - 1); - const availableHeight = - pageHeight - 2 * marginInches - gutterInches * (rows - 1); - const cardWidth = availableWidth / cols; - const cardHeight = availableHeight / rows; + const marginInches = 0.5 // Simplified for now + const availableWidth = pageWidth - 2 * marginInches - gutterInches * (cols - 1) + const availableHeight = pageHeight - 2 * marginInches - gutterInches * (rows - 1) + const cardWidth = availableWidth / cols + const cardHeight = availableHeight / rows // Generate pages - const totalPages = Math.ceil(numbers.length / cardsPerPage); - const pages: string[] = []; + const totalPages = Math.ceil(numbers.length / cardsPerPage) + const pages: string[] = [] for (let pageNum = 0; pageNum < totalPages; pageNum++) { - const startIdx = pageNum * cardsPerPage; - const endIdx = Math.min(startIdx + cardsPerPage, numbers.length); - const pageCards = []; + const startIdx = pageNum * cardsPerPage + const endIdx = Math.min(startIdx + cardsPerPage, numbers.length) + const pageCards = [] for (let i = startIdx; i < endIdx; i++) { pageCards.push( - ` image("card_${i}_front.svg", width: ${cardWidth}in, height: ${cardHeight}in, fit: "contain")`, - ); + ` image("card_${i}_front.svg", width: ${cardWidth}in, height: ${cardHeight}in, fit: "contain")` + ) } // Fill remaining slots with empty cells if needed - const remaining = cardsPerPage - pageCards.length; + const remaining = cardsPerPage - pageCards.length for (let i = 0; i < remaining; i++) { - pageCards.push(` []`); // Empty cell + pageCards.push(` []`) // Empty cell } pages.push(`#grid( @@ -201,8 +192,8 @@ export async function POST(request: NextRequest) { rows: ${rows}, column-gutter: ${gutter}, row-gutter: ${gutter}, -${pageCards.join(",\n")} -)`); +${pageCards.join(',\n')} +)`) } // Generate Typst document @@ -210,60 +201,60 @@ ${pageCards.join(",\n")} #set page( paper: "${paperSize}", margin: (x: ${margin.left}, y: ${margin.top}), - flipped: ${orientation === "landscape"}, + flipped: ${orientation === 'landscape'}, ) -${pages.join("\n\n#pagebreak()\n\n")} -`; +${pages.join('\n\n#pagebreak()\n\n')} +` // Compile with Typst - let pdfBuffer: Buffer; + let pdfBuffer: Buffer try { - pdfBuffer = execSync("typst compile --format pdf - -", { + pdfBuffer = execSync('typst compile --format pdf - -', { input: typstContent, cwd: tempDir, // Run in temp dir so relative paths work maxBuffer: 100 * 1024 * 1024, // 100MB limit for large sets - }); + }) } catch (error) { - console.error("Typst compilation error:", error); + console.error('Typst compilation error:', error) return NextResponse.json( - { error: "Failed to compile PDF. Is Typst installed?" }, - { status: 500 }, - ); + { error: 'Failed to compile PDF. Is Typst installed?' }, + { status: 500 } + ) } // Clean up temp directory - rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null // Create filename for download - const filename = `soroban-flashcards-${range}.pdf`; + const filename = `soroban-flashcards-${range}.pdf` // Return PDF directly as download return new NextResponse(new Uint8Array(pdfBuffer), { headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="${filename}"`, - "Content-Length": pdfBuffer.length.toString(), + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': pdfBuffer.length.toString(), }, - }); + }) } catch (error) { - console.error("Error generating flashcards:", error); + console.error('Error generating flashcards:', error) // Clean up temp directory if it exists if (tempDir) { try { - rmSync(tempDir, { recursive: true, force: true }); + rmSync(tempDir, { recursive: true, force: true }) } catch (cleanupError) { - console.error("Failed to clean up temp directory:", cleanupError); + console.error('Failed to clean up temp directory:', cleanupError) } } - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error) return NextResponse.json( - { error: "Failed to generate flashcards", message: errorMessage }, - { status: 500 }, - ); + { error: 'Failed to generate flashcards', message: errorMessage }, + { status: 500 } + ) } } @@ -271,24 +262,24 @@ ${pages.join("\n\n#pagebreak()\n\n")} export async function GET() { try { // Check if Typst is available - execSync("typst --version", { encoding: "utf8" }); + execSync('typst --version', { encoding: 'utf8' }) return NextResponse.json({ - status: "healthy", - generator: "typescript-typst", + status: 'healthy', + generator: 'typescript-typst', dependencies: { typst: true, python: false, // No longer needed! }, - }); + }) } catch (error) { return NextResponse.json( { - status: "unhealthy", - error: "Typst not available", - message: error instanceof Error ? error.message : "Unknown error", + status: 'unhealthy', + error: 'Typst not available', + message: error instanceof Error ? error.message : 'Unknown error', }, - { status: 500 }, - ); + { status: 500 } + ) } } diff --git a/apps/web/src/app/api/player-stats/[playerId]/route.ts b/apps/web/src/app/api/player-stats/[playerId]/route.ts index 1948164d..c20df36e 100644 --- a/apps/web/src/app/api/player-stats/[playerId]/route.ts +++ b/apps/web/src/app/api/player-stats/[playerId]/route.ts @@ -1,31 +1,25 @@ -import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { db } from "@/db"; -import type { GameStatsBreakdown } from "@/db/schema/player-stats"; -import { playerStats } from "@/db/schema/player-stats"; -import { players } from "@/db/schema/players"; -import type { - GetPlayerStatsResponse, - PlayerStatsData, -} from "@/lib/arcade/stats/types"; -import { getViewerId } from "@/lib/viewer"; +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { db } from '@/db' +import type { GameStatsBreakdown } from '@/db/schema/player-stats' +import { playerStats } from '@/db/schema/player-stats' +import { players } from '@/db/schema/players' +import type { GetPlayerStatsResponse, PlayerStatsData } from '@/lib/arcade/stats/types' +import { getViewerId } from '@/lib/viewer' /** * GET /api/player-stats/[playerId] * * Fetches stats for a specific player (must be owned by current user). */ -export async function GET( - _request: Request, - { params }: { params: { playerId: string } }, -) { +export async function GET(_request: Request, { params }: { params: { playerId: string } }) { try { - const { playerId } = params; + const { playerId } = params // 1. Authenticate user - const viewerId = await getViewerId(); + const viewerId = await getViewerId() if (!viewerId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } // 2. Verify player belongs to user @@ -34,17 +28,17 @@ export async function GET( .from(players) .where(eq(players.id, playerId)) .limit(1) - .then((rows) => rows[0]); + .then((rows) => rows[0]) if (!player) { - return NextResponse.json({ error: "Player not found" }, { status: 404 }); + return NextResponse.json({ error: 'Player not found' }, { status: 404 }) } if (player.userId !== viewerId) { return NextResponse.json( - { error: "Forbidden: player belongs to another user" }, - { status: 403 }, - ); + { error: 'Forbidden: player belongs to another user' }, + { status: 403 } + ) } // 3. Fetch player stats @@ -53,36 +47,34 @@ export async function GET( .from(playerStats) .where(eq(playerStats.playerId, playerId)) .limit(1) - .then((rows) => rows[0]); + .then((rows) => rows[0]) const playerStatsData: PlayerStatsData = stats ? convertToPlayerStatsData(stats) - : createDefaultPlayerStats(playerId); + : createDefaultPlayerStats(playerId) // 4. Return response const response: GetPlayerStatsResponse = { stats: playerStatsData, - }; + } - return NextResponse.json(response); + return NextResponse.json(response) } catch (error) { - console.error("❌ Failed to fetch player stats:", error); + console.error('❌ Failed to fetch player stats:', error) return NextResponse.json( { - error: "Failed to fetch player stats", + error: 'Failed to fetch player stats', details: error instanceof Error ? error.message : String(error), }, - { status: 500 }, - ); + { status: 500 } + ) } } /** * Convert DB record to PlayerStatsData */ -function convertToPlayerStatsData( - dbStats: typeof playerStats.$inferSelect, -): PlayerStatsData { +function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData { return { playerId: dbStats.playerId, gamesPlayed: dbStats.gamesPlayed, @@ -95,14 +87,14 @@ function convertToPlayerStatsData( lastPlayedAt: dbStats.lastPlayedAt, createdAt: dbStats.createdAt, updatedAt: dbStats.updatedAt, - }; + } } /** * Create default player stats for new player */ function createDefaultPlayerStats(playerId: string): PlayerStatsData { - const now = new Date(); + const now = new Date() return { playerId, gamesPlayed: 0, @@ -115,5 +107,5 @@ function createDefaultPlayerStats(playerId: string): PlayerStatsData { lastPlayedAt: null, createdAt: now, updatedAt: now, - }; + } } diff --git a/apps/web/src/app/api/player-stats/record-game/route.ts b/apps/web/src/app/api/player-stats/record-game/route.ts index 1bb4f297..c51dbf2b 100644 --- a/apps/web/src/app/api/player-stats/record-game/route.ts +++ b/apps/web/src/app/api/player-stats/record-game/route.ts @@ -1,8 +1,8 @@ -import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { db } from "@/db"; -import type { GameStatsBreakdown } from "@/db/schema/player-stats"; -import { playerStats } from "@/db/schema/player-stats"; +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { db } from '@/db' +import type { GameStatsBreakdown } from '@/db/schema/player-stats' +import { playerStats } from '@/db/schema/player-stats' import type { GameResult, PlayerGameResult, @@ -10,8 +10,8 @@ import type { RecordGameRequest, RecordGameResponse, StatsUpdate, -} from "@/lib/arcade/stats/types"; -import { getViewerId } from "@/lib/viewer"; +} from '@/lib/arcade/stats/types' +import { getViewerId } from '@/lib/viewer' /** * POST /api/player-stats/record-game @@ -22,57 +22,50 @@ import { getViewerId } from "@/lib/viewer"; export async function POST(request: Request) { try { // 1. Authenticate user - const viewerId = await getViewerId(); + const viewerId = await getViewerId() if (!viewerId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } // 2. Parse and validate request - const body: RecordGameRequest = await request.json(); - const { gameResult } = body; + const body: RecordGameRequest = await request.json() + const { gameResult } = body - if ( - !gameResult || - !gameResult.playerResults || - gameResult.playerResults.length === 0 - ) { + if (!gameResult || !gameResult.playerResults || gameResult.playerResults.length === 0) { return NextResponse.json( - { error: "Invalid game result: playerResults required" }, - { status: 400 }, - ); + { error: 'Invalid game result: playerResults required' }, + { status: 400 } + ) } if (!gameResult.gameType) { - return NextResponse.json( - { error: "Invalid game result: gameType required" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Invalid game result: gameType required' }, { status: 400 }) } // 3. Process each player's result - const updates: StatsUpdate[] = []; + const updates: StatsUpdate[] = [] for (const playerResult of gameResult.playerResults) { - const update = await recordPlayerResult(gameResult, playerResult); - updates.push(update); + const update = await recordPlayerResult(gameResult, playerResult) + updates.push(update) } // 4. Return success response const response: RecordGameResponse = { success: true, updates, - }; + } - return NextResponse.json(response); + return NextResponse.json(response) } catch (error) { - console.error("❌ Failed to record game result:", error); + console.error('❌ Failed to record game result:', error) return NextResponse.json( { - error: "Failed to record game result", + error: 'Failed to record game result', details: error instanceof Error ? error.message : String(error), }, - { status: 500 }, - ); + { status: 500 } + ) } } @@ -81,9 +74,9 @@ export async function POST(request: Request) { */ async function recordPlayerResult( gameResult: GameResult, - playerResult: PlayerGameResult, + playerResult: PlayerGameResult ): Promise { - const { playerId } = playerResult; + const { playerId } = playerResult // 1. Fetch or create player stats const existingStats = await db @@ -91,52 +84,49 @@ async function recordPlayerResult( .from(playerStats) .where(eq(playerStats.playerId, playerId)) .limit(1) - .then((rows) => rows[0]); + .then((rows) => rows[0]) const previousStats: PlayerStatsData = existingStats ? convertToPlayerStatsData(existingStats) - : createDefaultPlayerStats(playerId); + : createDefaultPlayerStats(playerId) // 2. Calculate new stats - const newStats: PlayerStatsData = { ...previousStats }; + const newStats: PlayerStatsData = { ...previousStats } // Always increment games played - newStats.gamesPlayed++; + newStats.gamesPlayed++ // Handle wins/losses (cooperative vs competitive) if (gameResult.metadata?.isTeamVictory !== undefined) { // Cooperative game: all players share outcome if (playerResult.won) { - newStats.totalWins++; + newStats.totalWins++ } else { - newStats.totalLosses++; + newStats.totalLosses++ } } else { // Competitive/Solo: individual outcome if (playerResult.won) { - newStats.totalWins++; + newStats.totalWins++ } else { - newStats.totalLosses++; + newStats.totalLosses++ } } // Update best time (if provided and improved) if (playerResult.completionTime) { if (!newStats.bestTime || playerResult.completionTime < newStats.bestTime) { - newStats.bestTime = playerResult.completionTime; + newStats.bestTime = playerResult.completionTime } } // Update highest accuracy (if provided and improved) - if ( - playerResult.accuracy !== undefined && - playerResult.accuracy > newStats.highestAccuracy - ) { - newStats.highestAccuracy = playerResult.accuracy; + if (playerResult.accuracy !== undefined && playerResult.accuracy > newStats.highestAccuracy) { + newStats.highestAccuracy = playerResult.accuracy } // Update per-game stats (JSON) - const gameType = gameResult.gameType; + const gameType = gameResult.gameType const currentGameStats: GameStatsBreakdown = newStats.gameStats[gameType] || { gamesPlayed: 0, wins: 0, @@ -145,22 +135,19 @@ async function recordPlayerResult( highestAccuracy: 0, averageScore: 0, lastPlayed: 0, - }; + } - currentGameStats.gamesPlayed++; + currentGameStats.gamesPlayed++ if (playerResult.won) { - currentGameStats.wins++; + currentGameStats.wins++ } else { - currentGameStats.losses++; + currentGameStats.losses++ } // Update game-specific best time if (playerResult.completionTime) { - if ( - !currentGameStats.bestTime || - playerResult.completionTime < currentGameStats.bestTime - ) { - currentGameStats.bestTime = playerResult.completionTime; + if (!currentGameStats.bestTime || playerResult.completionTime < currentGameStats.bestTime) { + currentGameStats.bestTime = playerResult.completionTime } } @@ -169,27 +156,26 @@ async function recordPlayerResult( playerResult.accuracy !== undefined && playerResult.accuracy > currentGameStats.highestAccuracy ) { - currentGameStats.highestAccuracy = playerResult.accuracy; + currentGameStats.highestAccuracy = playerResult.accuracy } // Update average score if (playerResult.score !== undefined) { - const previousTotal = - currentGameStats.averageScore * (currentGameStats.gamesPlayed - 1); + const previousTotal = currentGameStats.averageScore * (currentGameStats.gamesPlayed - 1) currentGameStats.averageScore = - (previousTotal + playerResult.score) / currentGameStats.gamesPlayed; + (previousTotal + playerResult.score) / currentGameStats.gamesPlayed } - currentGameStats.lastPlayed = gameResult.completedAt; + currentGameStats.lastPlayed = gameResult.completedAt - newStats.gameStats[gameType] = currentGameStats; + newStats.gameStats[gameType] = currentGameStats // Update favorite game type (most played) - newStats.favoriteGameType = getMostPlayedGame(newStats.gameStats); + newStats.favoriteGameType = getMostPlayedGame(newStats.gameStats) // Update timestamps - newStats.lastPlayedAt = new Date(gameResult.completedAt); - newStats.updatedAt = new Date(); + newStats.lastPlayedAt = new Date(gameResult.completedAt) + newStats.updatedAt = new Date() // 3. Save to database if (existingStats) { @@ -207,7 +193,7 @@ async function recordPlayerResult( lastPlayedAt: newStats.lastPlayedAt, updatedAt: newStats.updatedAt, }) - .where(eq(playerStats.playerId, playerId)); + .where(eq(playerStats.playerId, playerId)) } else { // Insert new record await db.insert(playerStats).values({ @@ -222,7 +208,7 @@ async function recordPlayerResult( lastPlayedAt: newStats.lastPlayedAt, createdAt: newStats.createdAt, updatedAt: newStats.updatedAt, - }); + }) } // 4. Return update summary @@ -235,15 +221,13 @@ async function recordPlayerResult( wins: newStats.totalWins - previousStats.totalWins, losses: newStats.totalLosses - previousStats.totalLosses, }, - }; + } } /** * Convert DB record to PlayerStatsData */ -function convertToPlayerStatsData( - dbStats: typeof playerStats.$inferSelect, -): PlayerStatsData { +function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData { return { playerId: dbStats.playerId, gamesPlayed: dbStats.gamesPlayed, @@ -256,14 +240,14 @@ function convertToPlayerStatsData( lastPlayedAt: dbStats.lastPlayedAt, createdAt: dbStats.createdAt, updatedAt: dbStats.updatedAt, - }; + } } /** * Create default player stats for new player */ function createDefaultPlayerStats(playerId: string): PlayerStatsData { - const now = new Date(); + const now = new Date() return { playerId, gamesPlayed: 0, @@ -276,22 +260,18 @@ function createDefaultPlayerStats(playerId: string): PlayerStatsData { lastPlayedAt: null, createdAt: now, updatedAt: now, - }; + } } /** * Determine most-played game from game stats */ -function getMostPlayedGame( - gameStats: Record, -): string | null { - const games = Object.entries(gameStats); - if (games.length === 0) return null; +function getMostPlayedGame(gameStats: Record): string | null { + const games = Object.entries(gameStats) + if (games.length === 0) return null return games.reduce((mostPlayed, [gameType, stats]) => { - const mostPlayedStats = gameStats[mostPlayed]; - return stats.gamesPlayed > (mostPlayedStats?.gamesPlayed || 0) - ? gameType - : mostPlayed; - }, games[0][0]); + const mostPlayedStats = gameStats[mostPlayed] + return stats.gamesPlayed > (mostPlayedStats?.gamesPlayed || 0) ? gameType : mostPlayed + }, games[0][0]) } diff --git a/apps/web/src/app/api/player-stats/route.ts b/apps/web/src/app/api/player-stats/route.ts index 31d1018f..4008e583 100644 --- a/apps/web/src/app/api/player-stats/route.ts +++ b/apps/web/src/app/api/player-stats/route.ts @@ -1,17 +1,14 @@ -import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { db } from "@/db"; -import type { GameStatsBreakdown } from "@/db/schema/player-stats"; -import { playerStats } from "@/db/schema/player-stats"; -import { players } from "@/db/schema/players"; -import type { - GetAllPlayerStatsResponse, - PlayerStatsData, -} from "@/lib/arcade/stats/types"; -import { getViewerId } from "@/lib/viewer"; +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { db } from '@/db' +import type { GameStatsBreakdown } from '@/db/schema/player-stats' +import { playerStats } from '@/db/schema/player-stats' +import { players } from '@/db/schema/players' +import type { GetAllPlayerStatsResponse, PlayerStatsData } from '@/lib/arcade/stats/types' +import { getViewerId } from '@/lib/viewer' // Force dynamic rendering - this route uses headers() -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic' /** * GET /api/player-stats @@ -21,21 +18,18 @@ export const dynamic = "force-dynamic"; export async function GET() { try { // 1. Authenticate user - const viewerId = await getViewerId(); + const viewerId = await getViewerId() if (!viewerId) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } // 2. Fetch all user's players - const userPlayers = await db - .select() - .from(players) - .where(eq(players.userId, viewerId)); + const userPlayers = await db.select().from(players).where(eq(players.userId, viewerId)) - const playerIds = userPlayers.map((p) => p.id); + const playerIds = userPlayers.map((p) => p.id) // 3. Fetch stats for all players - const allStats: PlayerStatsData[] = []; + const allStats: PlayerStatsData[] = [] for (const playerId of playerIds) { const stats = await db @@ -43,40 +37,38 @@ export async function GET() { .from(playerStats) .where(eq(playerStats.playerId, playerId)) .limit(1) - .then((rows) => rows[0]); + .then((rows) => rows[0]) if (stats) { - allStats.push(convertToPlayerStatsData(stats)); + allStats.push(convertToPlayerStatsData(stats)) } else { // Player exists but has no stats yet - allStats.push(createDefaultPlayerStats(playerId)); + allStats.push(createDefaultPlayerStats(playerId)) } } // 4. Return response const response: GetAllPlayerStatsResponse = { playerStats: allStats, - }; + } - return NextResponse.json(response); + return NextResponse.json(response) } catch (error) { - console.error("❌ Failed to fetch player stats:", error); + console.error('❌ Failed to fetch player stats:', error) return NextResponse.json( { - error: "Failed to fetch player stats", + error: 'Failed to fetch player stats', details: error instanceof Error ? error.message : String(error), }, - { status: 500 }, - ); + { status: 500 } + ) } } /** * Convert DB record to PlayerStatsData */ -function convertToPlayerStatsData( - dbStats: typeof playerStats.$inferSelect, -): PlayerStatsData { +function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData { return { playerId: dbStats.playerId, gamesPlayed: dbStats.gamesPlayed, @@ -89,14 +81,14 @@ function convertToPlayerStatsData( lastPlayedAt: dbStats.lastPlayedAt, createdAt: dbStats.createdAt, updatedAt: dbStats.updatedAt, - }; + } } /** * Create default player stats for new player */ function createDefaultPlayerStats(playerId: string): PlayerStatsData { - const now = new Date(); + const now = new Date() return { playerId, gamesPlayed: 0, @@ -109,5 +101,5 @@ function createDefaultPlayerStats(playerId: string): PlayerStatsData { lastPlayedAt: null, createdAt: now, updatedAt: now, - }; + } } diff --git a/apps/web/src/app/api/players/[id]/route.ts b/apps/web/src/app/api/players/[id]/route.ts index 1df05ca7..475abd19 100644 --- a/apps/web/src/app/api/players/[id]/route.ts +++ b/apps/web/src/app/api/players/[id]/route.ts @@ -1,27 +1,24 @@ -import { and, eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getViewerId } from "@/lib/viewer"; +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getViewerId } from '@/lib/viewer' /** * PATCH /api/players/[id] * Update a player (only if it belongs to the current viewer) */ -export async function PATCH( - req: NextRequest, - { params }: { params: { id: string } }, -) { +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { try { - const viewerId = await getViewerId(); - const body = await req.json(); + const viewerId = await getViewerId() + const body = await req.json() // Get user record (must exist if player exists) const user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); + return NextResponse.json({ error: 'User not found' }, { status: 404 }) } // Check if user has an active arcade session @@ -29,17 +26,17 @@ export async function PATCH( if (body.isActive !== undefined) { const activeSession = await db.query.arcadeSessions.findFirst({ where: eq(schema.arcadeSessions.userId, viewerId), - }); + }) if (activeSession) { return NextResponse.json( { - error: "Cannot modify active players during an active game session", + error: 'Cannot modify active players during an active game session', activeGame: activeSession.currentGame, gameUrl: activeSession.gameUrl, }, - { status: 403 }, - ); + { status: 403 } + ) } } @@ -54,28 +51,17 @@ export async function PATCH( ...(body.isActive !== undefined && { isActive: body.isActive }), // userId is explicitly NOT included - it comes from session }) - .where( - and( - eq(schema.players.id, params.id), - eq(schema.players.userId, user.id), - ), - ) - .returning(); + .where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id))) + .returning() if (!updatedPlayer) { - return NextResponse.json( - { error: "Player not found or unauthorized" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 }) } - return NextResponse.json({ player: updatedPlayer }); + return NextResponse.json({ player: updatedPlayer }) } catch (error) { - console.error("Failed to update player:", error); - return NextResponse.json( - { error: "Failed to update player" }, - { status: 500 }, - ); + console.error('Failed to update player:', error) + return NextResponse.json({ error: 'Failed to update player' }, { status: 500 }) } } @@ -83,46 +69,32 @@ export async function PATCH( * DELETE /api/players/[id] * Delete a player (only if it belongs to the current viewer) */ -export async function DELETE( - _req: NextRequest, - { params }: { params: { id: string } }, -) { +export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) { try { - const viewerId = await getViewerId(); + const viewerId = await getViewerId() // Get user record (must exist if player exists) const user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); + return NextResponse.json({ error: 'User not found' }, { status: 404 }) } // Delete player (only if it belongs to this user) const [deletedPlayer] = await db .delete(schema.players) - .where( - and( - eq(schema.players.id, params.id), - eq(schema.players.userId, user.id), - ), - ) - .returning(); + .where(and(eq(schema.players.id, params.id), eq(schema.players.userId, user.id))) + .returning() if (!deletedPlayer) { - return NextResponse.json( - { error: "Player not found or unauthorized" }, - { status: 404 }, - ); + return NextResponse.json({ error: 'Player not found or unauthorized' }, { status: 404 }) } - return NextResponse.json({ success: true, player: deletedPlayer }); + return NextResponse.json({ success: true, player: deletedPlayer }) } catch (error) { - console.error("Failed to delete player:", error); - return NextResponse.json( - { error: "Failed to delete player" }, - { status: 500 }, - ); + console.error('Failed to delete player:', error) + return NextResponse.json({ error: 'Failed to delete player' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/players/__tests__/arcade-session-validation.e2e.test.ts b/apps/web/src/app/api/players/__tests__/arcade-session-validation.e2e.test.ts index 3735735f..16e94fe0 100644 --- a/apps/web/src/app/api/players/__tests__/arcade-session-validation.e2e.test.ts +++ b/apps/web/src/app/api/players/__tests__/arcade-session-validation.e2e.test.ts @@ -2,11 +2,11 @@ * @vitest-environment node */ -import { eq } from "drizzle-orm"; -import { NextRequest } from "next/server"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { db, schema } from "../../../../db"; -import { PATCH } from "../[id]/route"; +import { eq } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { db, schema } from '../../../../db' +import { PATCH } from '../[id]/route' /** * Arcade Session Validation E2E Tests @@ -15,336 +15,309 @@ import { PATCH } from "../[id]/route"; * correctly prevents isActive changes when user has an active arcade session. */ -describe("PATCH /api/players/[id] - Arcade Session Validation", () => { - let testUserId: string; - let testGuestId: string; - let testPlayerId: string; +describe('PATCH /api/players/[id] - Arcade Session Validation', () => { + let testUserId: string + let testGuestId: string + let testPlayerId: string beforeEach(async () => { // Create a test user with unique guest ID - testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const [user] = await db - .insert(schema.users) - .values({ guestId: testGuestId }) - .returning(); - testUserId = user.id; + testGuestId = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` + const [user] = await db.insert(schema.users).values({ guestId: testGuestId }).returning() + testUserId = user.id // Create a test player const [player] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Test Player", - emoji: "😀", - color: "#3b82f6", + name: 'Test Player', + emoji: '😀', + color: '#3b82f6', isActive: false, }) - .returning(); - testPlayerId = player.id; - }); + .returning() + testPlayerId = player.id + }) afterEach(async () => { // Clean up: delete test arcade session (if exists) - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.userId, testGuestId)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, testGuestId)) // Clean up: delete test user (cascade deletes players) - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) + }) - it("should return 403 when trying to change isActive with active arcade session", async () => { + it('should return 403 when trying to change isActive with active arcade session', async () => { // Create an arcade room first const [room] = await db .insert(schema.arcadeRooms) .values({ - code: "TEST01", + code: 'TEST01', createdBy: testGuestId, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: JSON.stringify({ difficulty: 6, - gameType: "abacus-numeral", + gameType: 'abacus-numeral', turnTimer: 30, }), }) - .returning(); + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ roomId: room.id, userId: testGuestId, - currentGame: "matching", - gameUrl: "/arcade/matching", + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([testPlayerId]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) // Mock request to change isActive - const mockRequest = new NextRequest( - `http://localhost:3000/api/players/${testPlayerId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `guest_id=${testGuestId}`, - }, - body: JSON.stringify({ isActive: true }), + const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Cookie: `guest_id=${testGuestId}`, }, - ); + body: JSON.stringify({ isActive: true }), + }) // Mock getViewerId by setting cookie - const response = await PATCH(mockRequest, { params: { id: testPlayerId } }); - const data = await response.json(); + const response = await PATCH(mockRequest, { params: { id: testPlayerId } }) + const data = await response.json() // Should be rejected with 403 - expect(response.status).toBe(403); - expect(data.error).toContain( - "Cannot modify active players during an active game session", - ); - expect(data.activeGame).toBe("matching"); - expect(data.gameUrl).toBe("/arcade/matching"); + expect(response.status).toBe(403) + expect(data.error).toContain('Cannot modify active players during an active game session') + expect(data.activeGame).toBe('matching') + expect(data.gameUrl).toBe('/arcade/matching') // Verify player isActive was NOT changed const player = await db.query.players.findFirst({ where: eq(schema.players.id, testPlayerId), - }); - expect(player?.isActive).toBe(false); // Still false - }); + }) + expect(player?.isActive).toBe(false) // Still false + }) - it("should allow isActive change when no active arcade session", async () => { + it('should allow isActive change when no active arcade session', async () => { // No arcade session created // Mock request to change isActive - const mockRequest = new NextRequest( - `http://localhost:3000/api/players/${testPlayerId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `guest_id=${testGuestId}`, - }, - body: JSON.stringify({ isActive: true }), + const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Cookie: `guest_id=${testGuestId}`, }, - ); + body: JSON.stringify({ isActive: true }), + }) - const response = await PATCH(mockRequest, { params: { id: testPlayerId } }); - const data = await response.json(); + const response = await PATCH(mockRequest, { params: { id: testPlayerId } }) + const data = await response.json() // Should succeed - expect(response.status).toBe(200); - expect(data.player.isActive).toBe(true); + expect(response.status).toBe(200) + expect(data.player.isActive).toBe(true) // Verify player isActive was changed const player = await db.query.players.findFirst({ where: eq(schema.players.id, testPlayerId), - }); - expect(player?.isActive).toBe(true); - }); + }) + expect(player?.isActive).toBe(true) + }) - it("should allow non-isActive changes even with active arcade session", async () => { + it('should allow non-isActive changes even with active arcade session', async () => { // Create an arcade room first const [room] = await db .insert(schema.arcadeRooms) .values({ - code: "TEST02", + code: 'TEST02', createdBy: testGuestId, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: JSON.stringify({ difficulty: 6, - gameType: "abacus-numeral", + gameType: 'abacus-numeral', turnTimer: 30, }), }) - .returning(); + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ roomId: room.id, userId: testGuestId, - currentGame: "matching", - gameUrl: "/arcade/matching", + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([testPlayerId]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) // Mock request to change name/emoji/color (NOT isActive) - const mockRequest = new NextRequest( - `http://localhost:3000/api/players/${testPlayerId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `guest_id=${testGuestId}`, - }, - body: JSON.stringify({ - name: "Updated Name", - emoji: "🎉", - color: "#ff0000", - }), + const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Cookie: `guest_id=${testGuestId}`, }, - ); + body: JSON.stringify({ + name: 'Updated Name', + emoji: '🎉', + color: '#ff0000', + }), + }) - const response = await PATCH(mockRequest, { params: { id: testPlayerId } }); - const data = await response.json(); + const response = await PATCH(mockRequest, { params: { id: testPlayerId } }) + const data = await response.json() // Should succeed - expect(response.status).toBe(200); - expect(data.player.name).toBe("Updated Name"); - expect(data.player.emoji).toBe("🎉"); - expect(data.player.color).toBe("#ff0000"); + expect(response.status).toBe(200) + expect(data.player.name).toBe('Updated Name') + expect(data.player.emoji).toBe('🎉') + expect(data.player.color).toBe('#ff0000') // Verify changes were applied const player = await db.query.players.findFirst({ where: eq(schema.players.id, testPlayerId), - }); - expect(player?.name).toBe("Updated Name"); - expect(player?.emoji).toBe("🎉"); - expect(player?.color).toBe("#ff0000"); - }); + }) + expect(player?.name).toBe('Updated Name') + expect(player?.emoji).toBe('🎉') + expect(player?.color).toBe('#ff0000') + }) - it("should allow isActive change after arcade session ends", async () => { + it('should allow isActive change after arcade session ends', async () => { // Create an arcade room first const [room] = await db .insert(schema.arcadeRooms) .values({ - code: "TEST03", + code: 'TEST03', createdBy: testGuestId, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: JSON.stringify({ difficulty: 6, - gameType: "abacus-numeral", + gameType: 'abacus-numeral', turnTimer: 30, }), }) - .returning(); + .returning() // Create an active arcade session - const now = new Date(); + const now = new Date() await db.insert(schema.arcadeSessions).values({ roomId: room.id, userId: testGuestId, - currentGame: "matching", - gameUrl: "/arcade/matching", + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([testPlayerId]), startedAt: now, lastActivityAt: now, expiresAt: new Date(now.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) // End the session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.roomId, room.id)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, room.id)) // Mock request to change isActive - const mockRequest = new NextRequest( - `http://localhost:3000/api/players/${testPlayerId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `guest_id=${testGuestId}`, - }, - body: JSON.stringify({ isActive: true }), + const mockRequest = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Cookie: `guest_id=${testGuestId}`, }, - ); + body: JSON.stringify({ isActive: true }), + }) - const response = await PATCH(mockRequest, { params: { id: testPlayerId } }); - const data = await response.json(); + const response = await PATCH(mockRequest, { params: { id: testPlayerId } }) + const data = await response.json() // Should succeed - expect(response.status).toBe(200); - expect(data.player.isActive).toBe(true); - }); + expect(response.status).toBe(200) + expect(data.player.isActive).toBe(true) + }) - it("should handle multiple players with different isActive states", async () => { + it('should handle multiple players with different isActive states', async () => { // Create additional players const [player2] = await db .insert(schema.players) .values({ userId: testUserId, - name: "Player 2", - emoji: "😎", - color: "#8b5cf6", + name: 'Player 2', + emoji: '😎', + color: '#8b5cf6', isActive: true, }) - .returning(); + .returning() // Create an arcade room first const [room] = await db .insert(schema.arcadeRooms) .values({ - code: "TEST04", + code: 'TEST04', createdBy: testGuestId, - creatorName: "Test User", - gameName: "matching", + creatorName: 'Test User', + gameName: 'matching', gameConfig: JSON.stringify({ difficulty: 6, - gameType: "abacus-numeral", + gameType: 'abacus-numeral', turnTimer: 30, }), }) - .returning(); + .returning() // Create arcade session - const now2 = new Date(); + const now2 = new Date() await db.insert(schema.arcadeSessions).values({ roomId: room.id, userId: testGuestId, - currentGame: "matching", - gameUrl: "/arcade/matching", + currentGame: 'matching', + gameUrl: '/arcade/matching', gameState: JSON.stringify({}), activePlayers: JSON.stringify([testPlayerId, player2.id]), startedAt: now2, lastActivityAt: now2, expiresAt: new Date(now2.getTime() + 3600000), // 1 hour from now version: 1, - }); + }) // Try to toggle player1 (inactive -> active) - should fail - const request1 = new NextRequest( - `http://localhost:3000/api/players/${testPlayerId}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `guest_id=${testGuestId}`, - }, - body: JSON.stringify({ isActive: true }), + const request1 = new NextRequest(`http://localhost:3000/api/players/${testPlayerId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Cookie: `guest_id=${testGuestId}`, }, - ); + body: JSON.stringify({ isActive: true }), + }) - const response1 = await PATCH(request1, { params: { id: testPlayerId } }); - expect(response1.status).toBe(403); + const response1 = await PATCH(request1, { params: { id: testPlayerId } }) + expect(response1.status).toBe(403) // Try to toggle player2 (active -> inactive) - should also fail - const request2 = new NextRequest( - `http://localhost:3000/api/players/${player2.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `guest_id=${testGuestId}`, - }, - body: JSON.stringify({ isActive: false }), + const request2 = new NextRequest(`http://localhost:3000/api/players/${player2.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Cookie: `guest_id=${testGuestId}`, }, - ); + body: JSON.stringify({ isActive: false }), + }) - const response2 = await PATCH(request2, { params: { id: player2.id } }); - expect(response2.status).toBe(403); - }); -}); + const response2 = await PATCH(request2, { params: { id: player2.id } }) + expect(response2.status).toBe(403) + }) +}) diff --git a/apps/web/src/app/api/players/route.ts b/apps/web/src/app/api/players/route.ts index ffdb777b..6a3aa98b 100644 --- a/apps/web/src/app/api/players/route.ts +++ b/apps/web/src/app/api/players/route.ts @@ -1,7 +1,7 @@ -import { eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getViewerId } from "@/lib/viewer"; +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getViewerId } from '@/lib/viewer' /** * GET /api/players @@ -9,24 +9,21 @@ import { getViewerId } from "@/lib/viewer"; */ export async function GET() { try { - const viewerId = await getViewerId(); + const viewerId = await getViewerId() // Get or create user record - const user = await getOrCreateUser(viewerId); + const user = await getOrCreateUser(viewerId) // Get all players for this user const players = await db.query.players.findMany({ where: eq(schema.players.userId, user.id), orderBy: (players, { desc }) => [desc(players.createdAt)], - }); + }) - return NextResponse.json({ players }); + return NextResponse.json({ players }) } catch (error) { - console.error("Failed to fetch players:", error); - return NextResponse.json( - { error: "Failed to fetch players" }, - { status: 500 }, - ); + console.error('Failed to fetch players:', error) + return NextResponse.json({ error: 'Failed to fetch players' }, { status: 500 }) } } @@ -36,19 +33,19 @@ export async function GET() { */ export async function POST(req: NextRequest) { try { - const viewerId = await getViewerId(); - const body = await req.json(); + const viewerId = await getViewerId() + const body = await req.json() // Validate required fields if (!body.name || !body.emoji || !body.color) { return NextResponse.json( - { error: "Missing required fields: name, emoji, color" }, - { status: 400 }, - ); + { error: 'Missing required fields: name, emoji, color' }, + { status: 400 } + ) } // Get or create user record - const user = await getOrCreateUser(viewerId); + const user = await getOrCreateUser(viewerId) // Create player const [player] = await db @@ -60,15 +57,12 @@ export async function POST(req: NextRequest) { color: body.color, isActive: body.isActive ?? false, }) - .returning(); + .returning() - return NextResponse.json({ player }, { status: 201 }); + return NextResponse.json({ player }, { status: 201 }) } catch (error) { - console.error("Failed to create player:", error); - return NextResponse.json( - { error: "Failed to create player" }, - { status: 500 }, - ); + console.error('Failed to create player:', error) + return NextResponse.json({ error: 'Failed to create player' }, { status: 500 }) } } @@ -79,7 +73,7 @@ async function getOrCreateUser(viewerId: string) { // Try to find existing user by guest ID let user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) // If no user exists, create one if (!user) { @@ -88,10 +82,10 @@ async function getOrCreateUser(viewerId: string) { .values({ guestId: viewerId, }) - .returning(); + .returning() - user = newUser; + user = newUser } - return user; + return user } diff --git a/apps/web/src/app/api/user-stats/route.ts b/apps/web/src/app/api/user-stats/route.ts index 5ef6b5d1..be5be675 100644 --- a/apps/web/src/app/api/user-stats/route.ts +++ b/apps/web/src/app/api/user-stats/route.ts @@ -1,7 +1,7 @@ -import { eq } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getViewerId } from "@/lib/viewer"; +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getViewerId } from '@/lib/viewer' /** * GET /api/user-stats @@ -9,12 +9,12 @@ import { getViewerId } from "@/lib/viewer"; */ export async function GET() { try { - const viewerId = await getViewerId(); + const viewerId = await getViewerId() // Get user record const user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) if (!user) { // No user yet, return default stats @@ -26,13 +26,13 @@ export async function GET() { bestTime: null, highestAccuracy: 0, }, - }); + }) } // Get stats record let stats = await db.query.userStats.findFirst({ where: eq(schema.userStats.userId, user.id), - }); + }) // If no stats record exists, create one with defaults if (!stats) { @@ -41,18 +41,15 @@ export async function GET() { .values({ userId: user.id, }) - .returning(); + .returning() - stats = newStats; + stats = newStats } - return NextResponse.json({ stats }); + return NextResponse.json({ stats }) } catch (error) { - console.error("Failed to fetch user stats:", error); - return NextResponse.json( - { error: "Failed to fetch user stats" }, - { status: 500 }, - ); + console.error('Failed to fetch user stats:', error) + return NextResponse.json({ error: 'Failed to fetch user stats' }, { status: 500 }) } } @@ -62,13 +59,13 @@ export async function GET() { */ export async function PATCH(req: NextRequest) { try { - const viewerId = await getViewerId(); - const body = await req.json(); + const viewerId = await getViewerId() + const body = await req.json() // Get or create user record let user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) if (!user) { // Create user if it doesn't exist @@ -77,25 +74,23 @@ export async function PATCH(req: NextRequest) { .values({ guestId: viewerId, }) - .returning(); + .returning() - user = newUser; + user = newUser } // Get existing stats const stats = await db.query.userStats.findFirst({ where: eq(schema.userStats.userId, user.id), - }); + }) // Prepare update values - const updates: any = {}; - if (body.gamesPlayed !== undefined) updates.gamesPlayed = body.gamesPlayed; - if (body.totalWins !== undefined) updates.totalWins = body.totalWins; - if (body.favoriteGameType !== undefined) - updates.favoriteGameType = body.favoriteGameType; - if (body.bestTime !== undefined) updates.bestTime = body.bestTime; - if (body.highestAccuracy !== undefined) - updates.highestAccuracy = body.highestAccuracy; + const updates: any = {} + if (body.gamesPlayed !== undefined) updates.gamesPlayed = body.gamesPlayed + if (body.totalWins !== undefined) updates.totalWins = body.totalWins + if (body.favoriteGameType !== undefined) updates.favoriteGameType = body.favoriteGameType + if (body.bestTime !== undefined) updates.bestTime = body.bestTime + if (body.highestAccuracy !== undefined) updates.highestAccuracy = body.highestAccuracy if (stats) { // Update existing stats @@ -103,9 +98,9 @@ export async function PATCH(req: NextRequest) { .update(schema.userStats) .set(updates) .where(eq(schema.userStats.userId, user.id)) - .returning(); + .returning() - return NextResponse.json({ stats: updatedStats }); + return NextResponse.json({ stats: updatedStats }) } else { // Create new stats record const [newStats] = await db @@ -114,15 +109,12 @@ export async function PATCH(req: NextRequest) { userId: user.id, ...updates, }) - .returning(); + .returning() - return NextResponse.json({ stats: newStats }, { status: 201 }); + return NextResponse.json({ stats: newStats }, { status: 201 }) } } catch (error) { - console.error("Failed to update user stats:", error); - return NextResponse.json( - { error: "Failed to update user stats" }, - { status: 500 }, - ); + console.error('Failed to update user stats:', error) + return NextResponse.json({ error: 'Failed to update user stats' }, { status: 500 }) } } diff --git a/apps/web/src/app/api/viewer/route.ts b/apps/web/src/app/api/viewer/route.ts index 136e8dc8..e6ab8eb3 100644 --- a/apps/web/src/app/api/viewer/route.ts +++ b/apps/web/src/app/api/viewer/route.ts @@ -1,5 +1,5 @@ -import { NextResponse } from "next/server"; -import { getViewerId } from "@/lib/viewer"; +import { NextResponse } from 'next/server' +import { getViewerId } from '@/lib/viewer' /** * GET /api/viewer @@ -8,12 +8,9 @@ import { getViewerId } from "@/lib/viewer"; */ export async function GET() { try { - const viewerId = await getViewerId(); - return NextResponse.json({ viewerId }); + const viewerId = await getViewerId() + return NextResponse.json({ viewerId }) } catch (_error) { - return NextResponse.json( - { error: "No valid viewer session found" }, - { status: 401 }, - ); + return NextResponse.json({ error: 'No valid viewer session found' }, { status: 401 }) } } diff --git a/apps/web/src/app/api/worksheets/settings/route.ts b/apps/web/src/app/api/worksheets/settings/route.ts index da158efe..47167326 100644 --- a/apps/web/src/app/api/worksheets/settings/route.ts +++ b/apps/web/src/app/api/worksheets/settings/route.ts @@ -1,12 +1,12 @@ -import { eq, and } from "drizzle-orm"; -import { type NextRequest, NextResponse } from "next/server"; -import { db, schema } from "@/db"; -import { getViewerId } from "@/lib/viewer"; +import { eq, and } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { db, schema } from '@/db' +import { getViewerId } from '@/lib/viewer' import { parseAdditionConfig, serializeAdditionConfig, defaultAdditionConfig, -} from "@/app/create/worksheets/config-schemas"; +} from '@/app/create/worksheets/config-schemas' /** * GET /api/worksheets/settings?type=addition @@ -21,23 +21,20 @@ import { */ export async function GET(req: NextRequest) { try { - const viewerId = await getViewerId(); - const { searchParams } = new URL(req.url); - const worksheetType = searchParams.get("type"); + const viewerId = await getViewerId() + const { searchParams } = new URL(req.url) + const worksheetType = searchParams.get('type') if (!worksheetType) { - return NextResponse.json( - { error: "Missing type parameter" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Missing type parameter' }, { status: 400 }) } // Only 'addition' is supported for now - if (worksheetType !== "addition") { + if (worksheetType !== 'addition') { return NextResponse.json( { error: `Unsupported worksheet type: ${worksheetType}` }, - { status: 400 }, - ); + { status: 400 } + ) } // Look up user's saved settings @@ -47,32 +44,29 @@ export async function GET(req: NextRequest) { .where( and( eq(schema.worksheetSettings.userId, viewerId), - eq(schema.worksheetSettings.worksheetType, worksheetType), - ), + eq(schema.worksheetSettings.worksheetType, worksheetType) + ) ) - .limit(1); + .limit(1) if (!row) { // No saved settings, return defaults return NextResponse.json({ config: defaultAdditionConfig, exists: false, - }); + }) } // Parse and validate config (auto-migrates to latest version) - const config = parseAdditionConfig(row.config); + const config = parseAdditionConfig(row.config) return NextResponse.json({ config, exists: true, - }); + }) } catch (error: any) { - console.error("Failed to load worksheet settings:", error); - return NextResponse.json( - { error: "Failed to load worksheet settings" }, - { status: 500 }, - ); + console.error('Failed to load worksheet settings:', error) + return NextResponse.json({ error: 'Failed to load worksheet settings' }, { status: 500 }) } } @@ -90,35 +84,29 @@ export async function GET(req: NextRequest) { */ export async function POST(req: NextRequest) { try { - const viewerId = await getViewerId(); - const body = await req.json(); + const viewerId = await getViewerId() + const body = await req.json() - const { type: worksheetType, config } = body; + const { type: worksheetType, config } = body if (!worksheetType) { - return NextResponse.json( - { error: "Missing type field" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Missing type field' }, { status: 400 }) } if (!config) { - return NextResponse.json( - { error: "Missing config field" }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Missing config field' }, { status: 400 }) } // Only 'addition' is supported for now - if (worksheetType !== "addition") { + if (worksheetType !== 'addition') { return NextResponse.json( { error: `Unsupported worksheet type: ${worksheetType}` }, - { status: 400 }, - ); + { status: 400 } + ) } // Serialize config (adds version automatically) - const configJson = serializeAdditionConfig(config); + const configJson = serializeAdditionConfig(config) // Check if user already has settings for this type const [existing] = await db @@ -127,12 +115,12 @@ export async function POST(req: NextRequest) { .where( and( eq(schema.worksheetSettings.userId, viewerId), - eq(schema.worksheetSettings.worksheetType, worksheetType), - ), + eq(schema.worksheetSettings.worksheetType, worksheetType) + ) ) - .limit(1); + .limit(1) - const now = new Date(); + const now = new Date() if (existing) { // Update existing row @@ -142,15 +130,15 @@ export async function POST(req: NextRequest) { config: configJson, updatedAt: now, }) - .where(eq(schema.worksheetSettings.id, existing.id)); + .where(eq(schema.worksheetSettings.id, existing.id)) return NextResponse.json({ success: true, id: existing.id, - }); + }) } else { // Insert new row - const id = crypto.randomUUID(); + const id = crypto.randomUUID() await db.insert(schema.worksheetSettings).values({ id, userId: viewerId, @@ -158,18 +146,15 @@ export async function POST(req: NextRequest) { config: configJson, createdAt: now, updatedAt: now, - }); + }) return NextResponse.json({ success: true, id, - }); + }) } } catch (error: any) { - console.error("Failed to save worksheet settings:", error); - return NextResponse.json( - { error: "Failed to save worksheet settings" }, - { status: 500 }, - ); + console.error('Failed to save worksheet settings:', error) + return NextResponse.json({ error: 'Failed to save worksheet settings' }, { status: 500 }) } } diff --git a/apps/web/src/app/arcade-rooms/[roomId]/page.tsx b/apps/web/src/app/arcade-rooms/[roomId]/page.tsx index 88b8a972..cfb2b7bf 100644 --- a/apps/web/src/app/arcade-rooms/[roomId]/page.tsx +++ b/apps/web/src/app/arcade-rooms/[roomId]/page.tsx @@ -1,253 +1,242 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { io, type Socket } from "socket.io-client"; -import { css } from "../../../../styled-system/css"; -import { useToast } from "@/components/common/ToastContext"; -import { PageWithNav } from "@/components/PageWithNav"; -import { useViewerId } from "@/hooks/useViewerId"; -import { getRoomDisplayWithEmoji } from "@/utils/room-display"; +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { io, type Socket } from 'socket.io-client' +import { css } from '../../../../styled-system/css' +import { useToast } from '@/components/common/ToastContext' +import { PageWithNav } from '@/components/PageWithNav' +import { useViewerId } from '@/hooks/useViewerId' +import { getRoomDisplayWithEmoji } from '@/utils/room-display' interface Room { - id: string; - code: string; - name: string | null; - gameName: string; - status: "lobby" | "playing" | "finished"; - createdBy: string; - creatorName: string; - isLocked: boolean; + id: string + code: string + name: string | null + gameName: string + status: 'lobby' | 'playing' | 'finished' + createdBy: string + creatorName: string + isLocked: boolean } interface Member { - id: string; - userId: string; - displayName: string; - isCreator: boolean; - isOnline: boolean; - joinedAt: Date; + id: string + userId: string + displayName: string + isCreator: boolean + isOnline: boolean + joinedAt: Date } interface Player { - id: string; - userId: string; - name: string; - emoji: string; - color: string; - isActive: boolean; + id: string + userId: string + name: string + emoji: string + color: string + isActive: boolean } export default function RoomDetailPage() { - const params = useParams(); - const router = useRouter(); - const { showError } = useToast(); - const roomId = params.roomId as string; - const { data: guestId } = useViewerId(); + const params = useParams() + const router = useRouter() + const { showError } = useToast() + const roomId = params.roomId as string + const { data: guestId } = useViewerId() - const [room, setRoom] = useState(null); - const [members, setMembers] = useState([]); - const [memberPlayers, setMemberPlayers] = useState>( - {}, - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [socket, setSocket] = useState(null); - const [isConnected, setIsConnected] = useState(false); + const [room, setRoom] = useState(null) + const [members, setMembers] = useState([]) + const [memberPlayers, setMemberPlayers] = useState>({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [socket, setSocket] = useState(null) + const [isConnected, setIsConnected] = useState(false) useEffect(() => { - fetchRoom(); - }, [roomId]); + fetchRoom() + }, [roomId]) useEffect(() => { - if (!guestId || !roomId) return; + if (!guestId || !roomId) return // Connect to socket - const sock = io({ path: "/api/socket" }); - setSocket(sock); + const sock = io({ path: '/api/socket' }) + setSocket(sock) - sock.on("connect", () => { - setIsConnected(true); + sock.on('connect', () => { + setIsConnected(true) // Join the room - sock.emit("join-room", { roomId, userId: guestId }); - }); + sock.emit('join-room', { roomId, userId: guestId }) + }) - sock.on("disconnect", () => { - setIsConnected(false); - }); + sock.on('disconnect', () => { + setIsConnected(false) + }) - sock.on("room-joined", (data) => { - console.log("Joined room:", data); + sock.on('room-joined', (data) => { + console.log('Joined room:', data) if (data.members) { - setMembers(data.members); + setMembers(data.members) } if (data.memberPlayers) { - setMemberPlayers(data.memberPlayers); + setMemberPlayers(data.memberPlayers) } - }); + }) - sock.on("member-joined", (data) => { - console.log("Member joined:", data); + sock.on('member-joined', (data) => { + console.log('Member joined:', data) if (data.members) { - setMembers(data.members); + setMembers(data.members) } if (data.memberPlayers) { - setMemberPlayers(data.memberPlayers); + setMemberPlayers(data.memberPlayers) } - }); + }) - sock.on("member-left", (data) => { - console.log("Member left:", data); + sock.on('member-left', (data) => { + console.log('Member left:', data) if (data.members) { - setMembers(data.members); + setMembers(data.members) } if (data.memberPlayers) { - setMemberPlayers(data.memberPlayers); + setMemberPlayers(data.memberPlayers) } - }); + }) - sock.on("room-error", (error) => { - console.error("Room error:", error); - setError(error.error); - }); + sock.on('room-error', (error) => { + console.error('Room error:', error) + setError(error.error) + }) - sock.on("room-players-updated", (data) => { - console.log("Room players updated:", data); + sock.on('room-players-updated', (data) => { + console.log('Room players updated:', data) if (data.memberPlayers) { - setMemberPlayers(data.memberPlayers); + setMemberPlayers(data.memberPlayers) } - }); + }) return () => { - sock.emit("leave-room", { roomId, userId: guestId }); - sock.disconnect(); - }; - }, [roomId, guestId]); + sock.emit('leave-room', { roomId, userId: guestId }) + sock.disconnect() + } + }, [roomId, guestId]) // Notify room when window regains focus (user might have changed players in another tab) useEffect(() => { - if (!socket || !guestId || !roomId) return; + if (!socket || !guestId || !roomId) return const handleFocus = () => { - console.log("Window focused, notifying room of potential player changes"); - socket.emit("players-updated", { roomId, userId: guestId }); - }; + console.log('Window focused, notifying room of potential player changes') + socket.emit('players-updated', { roomId, userId: guestId }) + } - window.addEventListener("focus", handleFocus); - return () => window.removeEventListener("focus", handleFocus); - }, [socket, roomId, guestId]); + window.addEventListener('focus', handleFocus) + return () => window.removeEventListener('focus', handleFocus) + }, [socket, roomId, guestId]) const fetchRoom = async () => { try { - setLoading(true); - const response = await fetch(`/api/arcade/rooms/${roomId}`); + setLoading(true) + const response = await fetch(`/api/arcade/rooms/${roomId}`) if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + throw new Error(`HTTP ${response.status}`) } - const data = await response.json(); - setRoom(data.room); - setMembers(data.members || []); - setMemberPlayers(data.memberPlayers || {}); - setError(null); + const data = await response.json() + setRoom(data.room) + setMembers(data.members || []) + setMemberPlayers(data.memberPlayers || {}) + setError(null) } catch (err) { - console.error("Failed to fetch room:", err); - setError("Failed to load room"); + console.error('Failed to fetch room:', err) + setError('Failed to load room') } finally { - setLoading(false); + setLoading(false) } - }; + } const startGame = () => { - if (!room) return; + if (!room) return // Navigate to the room game page - router.push("/arcade"); - }; + router.push('/arcade') + } const joinRoom = async () => { try { const response = await fetch(`/api/arcade/rooms/${roomId}/join`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ displayName: "Player" }), - }); + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName: 'Player' }), + }) if (!response.ok) { - const errorData = await response.json(); + const errorData = await response.json() // Handle specific room membership conflict - if (errorData.code === "ROOM_MEMBERSHIP_CONFLICT") { - showError( - "Already in Another Room", - errorData.userMessage || errorData.message, - ); + if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') { + showError('Already in Another Room', errorData.userMessage || errorData.message) // Refresh the page to update room state - await fetchRoom(); - return; + await fetchRoom() + return } - throw new Error(errorData.error || `HTTP ${response.status}`); + throw new Error(errorData.error || `HTTP ${response.status}`) } - const data = await response.json(); + const data = await response.json() // Show notification if user was auto-removed from other rooms if (data.autoLeave) { - console.log(`[Room Join] ${data.autoLeave.message}`); + console.log(`[Room Join] ${data.autoLeave.message}`) // Could show a toast notification here in the future } // Refresh room data to update membership UI - await fetchRoom(); + await fetchRoom() } catch (err) { - console.error("Failed to join room:", err); - showError( - "Failed to join room", - err instanceof Error ? err.message : undefined, - ); + console.error('Failed to join room:', err) + showError('Failed to join room', err instanceof Error ? err.message : undefined) } - }; + } const leaveRoom = async () => { try { const response = await fetch(`/api/arcade/rooms/${roomId}/leave`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `HTTP ${response.status}`); + const errorData = await response.json() + throw new Error(errorData.error || `HTTP ${response.status}`) } // Navigate to arcade home after successfully leaving - router.push("/arcade"); + router.push('/arcade') } catch (err) { - console.error("Failed to leave room:", err); - showError( - "Failed to leave room", - err instanceof Error ? err.message : undefined, - ); + console.error('Failed to leave room:', err) + showError('Failed to leave room', err instanceof Error ? err.message : undefined) } - }; + } if (loading) { return (
Loading room...
- ); + ) } if (error || !room) { @@ -255,39 +244,39 @@ export default function RoomDetailPage() {
-

- {error || "Room not found"} +

+ {error || 'Room not found'}

- ); + ) } - const onlineMembers = members.filter((m) => m.isOnline); + const onlineMembers = members.filter((m) => m.isOnline) // Check if current user is a member - const isMember = members.some((m) => m.userId === guestId); + const isMember = members.some((m) => m.userId === guestId) // Calculate union of all active players in the room - const allPlayers: Player[] = []; - const playerIds = new Set(); + const allPlayers: Player[] = [] + const playerIds = new Set() for (const userId in memberPlayers) { for (const player of memberPlayers[userId]) { if (!playerIds.has(player.id)) { - playerIds.add(player.id); - allPlayers.push(player); + playerIds.add(player.id) + allPlayers.push(player) } } } @@ -320,35 +309,35 @@ export default function RoomDetailPage() {
-
+
{/* Header */}
-
+

{getRoomDisplayWithEmoji({ @@ -379,23 +368,23 @@ export default function RoomDetailPage() {

🎮 {room.gameName} 👤 Host: {room.creatorName} Code: {room.code} @@ -404,40 +393,38 @@ export default function RoomDetailPage() {
- {isConnected ? "Connected" : "Disconnected"} + {isConnected ? 'Connected' : 'Disconnected'}
@@ -447,50 +434,46 @@ export default function RoomDetailPage() { {/* Game Players - Union of all active players */}

🎯 Game Players ({allPlayers.length})

-

+

These players will participate when the game starts

{allPlayers.length > 0 ? ( -
+
{allPlayers.map((player) => (
- - {player.emoji} - + {player.emoji} {player.name}
))} @@ -498,10 +481,10 @@ export default function RoomDetailPage() { ) : (
No active players yet. Members need to set up their players. @@ -512,107 +495,103 @@ export default function RoomDetailPage() { {/* Members List */}

👥 Room Members ({onlineMembers.length}/{members.length})

-

+

Users in this room and their active players

-
+
{members.map((member) => { - const players = memberPlayers[member.userId] || []; + const players = memberPlayers[member.userId] || [] return (
- + {member.displayName} {member.isCreator && ( HOST )}
- - {member.isOnline ? "🟢 Online" : "⚫ Offline"} + + {member.isOnline ? '🟢 Online' : '⚫ Offline'}
{players.length > 0 && (
Players: @@ -621,14 +600,14 @@ export default function RoomDetailPage() { {player.emoji} {player.name} @@ -639,37 +618,37 @@ export default function RoomDetailPage() { {players.length === 0 && (
No active players
)}
- ); + ) })}
{/* Actions */} -
+
{isMember ? ( <> ) : ( <> )} @@ -739,5 +718,5 @@ export default function RoomDetailPage() {
- ); + ) } diff --git a/apps/web/src/app/arcade-rooms/page.tsx b/apps/web/src/app/arcade-rooms/page.tsx index 3cb70328..b1dde4f9 100644 --- a/apps/web/src/app/arcade-rooms/page.tsx +++ b/apps/web/src/app/arcade-rooms/page.tsx @@ -1,212 +1,195 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { css } from "../../../styled-system/css"; -import { useToast } from "@/components/common/ToastContext"; -import { PageWithNav } from "@/components/PageWithNav"; -import { getRoomDisplayWithEmoji } from "@/utils/room-display"; +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { css } from '../../../styled-system/css' +import { useToast } from '@/components/common/ToastContext' +import { PageWithNav } from '@/components/PageWithNav' +import { getRoomDisplayWithEmoji } from '@/utils/room-display' interface Room { - id: string; - code: string; - name: string | null; - gameName: string; - status: "lobby" | "playing" | "finished"; - createdAt: Date; - creatorName: string; - isLocked: boolean; - accessMode: - | "open" - | "password" - | "approval-only" - | "restricted" - | "locked" - | "retired"; - memberCount?: number; - playerCount?: number; - isMember?: boolean; + id: string + code: string + name: string | null + gameName: string + status: 'lobby' | 'playing' | 'finished' + createdAt: Date + creatorName: string + isLocked: boolean + accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired' + memberCount?: number + playerCount?: number + isMember?: boolean } export default function RoomBrowserPage() { - const router = useRouter(); - const { showError, showInfo } = useToast(); - const [rooms, setRooms] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); + const router = useRouter() + const { showError, showInfo } = useToast() + const [rooms, setRooms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showCreateModal, setShowCreateModal] = useState(false) useEffect(() => { - fetchRooms(); - }, []); + fetchRooms() + }, []) const fetchRooms = async () => { try { - setLoading(true); - const response = await fetch("/api/arcade/rooms"); + setLoading(true) + const response = await fetch('/api/arcade/rooms') if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + throw new Error(`HTTP ${response.status}`) } - const data = await response.json(); - setRooms(data.rooms); - setError(null); + const data = await response.json() + setRooms(data.rooms) + setError(null) } catch (err) { - console.error("Failed to fetch rooms:", err); - setError("Failed to load rooms"); + console.error('Failed to fetch rooms:', err) + setError('Failed to load rooms') } finally { - setLoading(false); + setLoading(false) } - }; + } const createRoom = async (name: string | null, gameName: string) => { try { - const response = await fetch("/api/arcade/rooms", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/arcade/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, gameName, - creatorName: "Player", + creatorName: 'Player', gameConfig: { difficulty: 6 }, }), - }); + }) if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + throw new Error(`HTTP ${response.status}`) } - const data = await response.json(); - router.push(`/join/${data.room.code}`); + const data = await response.json() + router.push(`/join/${data.room.code}`) } catch (err) { - console.error("Failed to create room:", err); - showError( - "Failed to create room", - err instanceof Error ? err.message : undefined, - ); + console.error('Failed to create room:', err) + showError('Failed to create room', err instanceof Error ? err.message : undefined) } - }; + } const joinRoom = async (room: Room) => { try { // Check access mode - if (room.accessMode === "password") { - const password = prompt( - `Enter password for ${room.name || `Room ${room.code}`}:`, - ); - if (!password) return; // User cancelled + if (room.accessMode === 'password') { + const password = prompt(`Enter password for ${room.name || `Room ${room.code}`}:`) + if (!password) return // User cancelled const response = await fetch(`/api/arcade/rooms/${room.id}/join`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ displayName: "Player", password }), - }); + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName: 'Player', password }), + }) if (!response.ok) { - const errorData = await response.json(); - showError("Failed to join room", errorData.error); - return; + const errorData = await response.json() + showError('Failed to join room', errorData.error) + return } - router.push(`/arcade-rooms/${room.id}`); - return; + router.push(`/arcade-rooms/${room.id}`) + return } - if (room.accessMode === "approval-only") { + if (room.accessMode === 'approval-only') { showInfo( - "Approval Required", - "This room requires host approval. Please use the Join Room modal to request access.", - ); - return; + 'Approval Required', + 'This room requires host approval. Please use the Join Room modal to request access.' + ) + return } - if (room.accessMode === "restricted") { + if (room.accessMode === 'restricted') { showInfo( - "Invitation Only", - "This room is invitation-only. Please ask the host for an invitation.", - ); - return; + 'Invitation Only', + 'This room is invitation-only. Please ask the host for an invitation.' + ) + return } // For open rooms const response = await fetch(`/api/arcade/rooms/${room.id}/join`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ displayName: "Player" }), - }); + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName: 'Player' }), + }) if (!response.ok) { - const errorData = await response.json(); + const errorData = await response.json() // Handle specific room membership conflict - if (errorData.code === "ROOM_MEMBERSHIP_CONFLICT") { - showError( - "Already in Another Room", - errorData.userMessage || errorData.message, - ); + if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') { + showError('Already in Another Room', errorData.userMessage || errorData.message) // Refresh the page to update room list state - await fetchRooms(); - return; + await fetchRooms() + return } - throw new Error(errorData.error || `HTTP ${response.status}`); + throw new Error(errorData.error || `HTTP ${response.status}`) } - const data = await response.json(); + const data = await response.json() // Show notification if user was auto-removed from other rooms if (data.autoLeave) { - console.log(`[Room Join] ${data.autoLeave.message}`); + console.log(`[Room Join] ${data.autoLeave.message}`) // Could show a toast notification here in the future } - router.push(`/arcade-rooms/${room.id}`); + router.push(`/arcade-rooms/${room.id}`) } catch (err) { - console.error("Failed to join room:", err); - showError( - "Failed to join room", - err instanceof Error ? err.message : undefined, - ); + console.error('Failed to join room:', err) + showError('Failed to join room', err instanceof Error ? err.message : undefined) } - }; + } return (
-
+
{/* Header */} -
+

🎮 Multiplayer Rooms

-

+

Join a room or create your own to play with friends

)}
@@ -455,88 +429,84 @@ export default function RoomBrowserPage() { {showCreateModal && (
setShowCreateModal(false)} >
e.stopPropagation()} >

Create New Room

{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const nameValue = formData.get("name") as string; - const gameName = formData.get("gameName") as string; + e.preventDefault() + const formData = new FormData(e.currentTarget) + const nameValue = formData.get('name') as string + const gameName = formData.get('gameName') as string // Treat empty name as null - const name = nameValue?.trim() || null; + const name = nameValue?.trim() || null if (gameName) { - createRoom(name, gameName); + createRoom(name, gameName) } }} > -
+
-
+
-
+
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/AISystem/SpeechBubble.tsx b/apps/web/src/app/arcade/complement-race/components/AISystem/SpeechBubble.tsx index 905bbd76..1c61a87b 100644 --- a/apps/web/src/app/arcade/complement-race/components/AISystem/SpeechBubble.tsx +++ b/apps/web/src/app/arcade/complement-race/components/AISystem/SpeechBubble.tsx @@ -1,62 +1,62 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; +import { useEffect, useState } from 'react' interface SpeechBubbleProps { - message: string; - onHide: () => void; + message: string + onHide: () => void } export function SpeechBubble({ message, onHide }: SpeechBubbleProps) { - const [isVisible, setIsVisible] = useState(true); + const [isVisible, setIsVisible] = useState(true) useEffect(() => { // Auto-hide after 3.5s (line 11749-11752) const timer = setTimeout(() => { - setIsVisible(false); - setTimeout(onHide, 300); // Wait for fade-out animation - }, 3500); + setIsVisible(false) + setTimeout(onHide, 300) // Wait for fade-out animation + }, 3500) - return () => clearTimeout(timer); - }, [onHide]); + return () => clearTimeout(timer) + }, [onHide]) return (
{message} {/* Tail pointing down */}
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/AISystem/aiCommentary.ts b/apps/web/src/app/arcade/complement-race/components/AISystem/aiCommentary.ts index 55b6bcf0..04f140ee 100644 --- a/apps/web/src/app/arcade/complement-race/components/AISystem/aiCommentary.ts +++ b/apps/web/src/app/arcade/complement-race/components/AISystem/aiCommentary.ts @@ -1,156 +1,154 @@ -import type { AIRacer } from "../../lib/gameTypes"; +import type { AIRacer } from '../../lib/gameTypes' export type CommentaryContext = - | "ahead" - | "behind" - | "adaptive_struggle" - | "adaptive_mastery" - | "player_passed" - | "ai_passed" - | "lapped" - | "desperate_catchup"; + | 'ahead' + | 'behind' + | 'adaptive_struggle' + | 'adaptive_mastery' + | 'player_passed' + | 'ai_passed' + | 'lapped' + | 'desperate_catchup' // Swift AI - Competitive personality (lines 11768-11834) export const swiftAICommentary: Record = { ahead: [ - "💨 Eat my dust!", - "🔥 Too slow for me!", + '💨 Eat my dust!', + '🔥 Too slow for me!', "⚡ You can't catch me!", "🚀 I'm built for speed!", - "🏃‍♂️ This is way too easy!", + '🏃‍♂️ This is way too easy!', ], behind: [ - "😤 Not over yet!", + '😤 Not over yet!', "💪 I'm just getting started!", - "🔥 Watch me catch up to you!", + '🔥 Watch me catch up to you!', "⚡ I'm coming for you!", - "🏃‍♂️ This is my comeback!", + '🏃‍♂️ This is my comeback!', ], adaptive_struggle: [ - "😏 You struggling much?", - "🤖 Math is easy for me!", - "⚡ You need to think faster!", - "🔥 Need me to slow down?", + '😏 You struggling much?', + '🤖 Math is easy for me!', + '⚡ You need to think faster!', + '🔥 Need me to slow down?', ], adaptive_mastery: [ "😮 You're actually impressive!", "🤔 You're getting faster...", - "😤 Time for me to step it up!", - "⚡ Not bad for a human!", + '😤 Time for me to step it up!', + '⚡ Not bad for a human!', ], player_passed: [ - "😠 No way you just passed me!", + '😠 No way you just passed me!', "🔥 This isn't over!", "💨 I'm just getting warmed up!", "😤 Your lucky streak won't last!", "⚡ I'll be back in front of you soon!", ], ai_passed: [ - "💨 See ya later, slowpoke!", - "😎 Thanks for the warm-up!", + '💨 See ya later, slowpoke!', + '😎 Thanks for the warm-up!', "🔥 This is how it's done!", "⚡ I'll see you at the finish line!", - "💪 Try to keep up with me!", + '💪 Try to keep up with me!', ], lapped: [ - "😡 You just lapped me?! No way!", - "🤬 This is embarrassing for me!", + '😡 You just lapped me?! No way!', + '🤬 This is embarrassing for me!', "😤 I'm not going down without a fight!", - "💢 How did you get so far ahead?!", - "🔥 Time to show you my real speed!", + '💢 How did you get so far ahead?!', + '🔥 Time to show you my real speed!', "😠 You won't stay ahead for long!", ], desperate_catchup: [ "🚨 TURBO MODE ACTIVATED! I'm coming for you!", - "💥 You forced me to unleash my true power!", - "🔥 NO MORE MR. NICE AI! Time to go all out!", + '💥 You forced me to unleash my true power!', + '🔥 NO MORE MR. NICE AI! Time to go all out!', "⚡ I'm switching to MAXIMUM OVERDRIVE!", "😤 You made me angry - now you'll see what I can do!", "🚀 AFTERBURNERS ENGAGED! This isn't over!", ], -}; +} // Math Bot - Analytical personality (lines 11835-11901) export const mathBotCommentary: Record = { ahead: [ - "📊 My performance is optimal!", - "🤖 My logic beats your speed!", - "📈 I have 87% win probability!", + '📊 My performance is optimal!', + '🤖 My logic beats your speed!', + '📈 I have 87% win probability!', "⚙️ I'm perfectly calibrated!", - "🔬 Science prevails over you!", + '🔬 Science prevails over you!', ], behind: [ - "🤔 Recalculating my strategy...", + '🤔 Recalculating my strategy...', "📊 You're exceeding my projections!", - "⚙️ Adjusting my parameters!", + '⚙️ Adjusting my parameters!', "🔬 I'm analyzing your technique!", "📈 You're a statistical anomaly!", ], adaptive_struggle: [ - "📊 I detect inefficiencies in you!", - "🔬 You should focus on patterns!", - "⚙️ Use that extra time wisely!", - "📈 You have room for improvement!", + '📊 I detect inefficiencies in you!', + '🔬 You should focus on patterns!', + '⚙️ Use that extra time wisely!', + '📈 You have room for improvement!', ], adaptive_mastery: [ - "🤖 Your optimization is excellent!", - "📊 Your metrics are impressive!", + '🤖 Your optimization is excellent!', + '📊 Your metrics are impressive!', "⚙️ I'm updating my models because of you!", - "🔬 You have near-AI efficiency!", + '🔬 You have near-AI efficiency!', ], player_passed: [ - "🤖 Your strategy is fascinating!", + '🤖 Your strategy is fascinating!', "📊 You're an unexpected variable!", "⚙️ I'm adjusting my algorithms...", - "🔬 Your execution is impressive!", + '🔬 Your execution is impressive!', "📈 I'm recalculating the odds!", ], ai_passed: [ - "🤖 My efficiency is optimized!", - "📊 Just as I calculated!", - "⚙️ All my systems nominal!", - "🔬 My logic prevails over you!", + '🤖 My efficiency is optimized!', + '📊 Just as I calculated!', + '⚙️ All my systems nominal!', + '🔬 My logic prevails over you!', "📈 I'm at 96% confidence level!", ], lapped: [ - "🤖 Error: You have exceeded my projections!", - "📊 This outcome has 0.3% probability!", - "⚙️ I need to recalibrate my systems!", - "🔬 Your performance is... statistically improbable!", - "📈 My confidence level just dropped to 12%!", - "🤔 I must analyze your methodology!", + '🤖 Error: You have exceeded my projections!', + '📊 This outcome has 0.3% probability!', + '⚙️ I need to recalibrate my systems!', + '🔬 Your performance is... statistically improbable!', + '📈 My confidence level just dropped to 12%!', + '🤔 I must analyze your methodology!', ], desperate_catchup: [ - "🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!", - "🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!", - "⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!", - "📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!", + '🤖 EMERGENCY PROTOCOL ACTIVATED! Initiating maximum speed!', + '🚨 CRITICAL GAP DETECTED! Engaging catchup algorithms!', + '⚙️ OVERCLOCKING MY PROCESSORS! Prepare for rapid acceleration!', + '📊 PROBABILITY OF FAILURE: UNACCEPTABLE! Switching to turbo mode!', "🔬 HYPOTHESIS: You're about to see my true potential!", - "📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!", + '📈 CONFIDENCE LEVEL: RISING! My comeback protocol is online!', ], -}; +} // Get AI commentary message (lines 11636-11657) export function getAICommentary( racer: AIRacer, context: CommentaryContext, _playerProgress: number, - _aiProgress: number, + _aiProgress: number ): string | null { // Check cooldown (line 11759-11761) - const now = Date.now(); + const now = Date.now() if (now - racer.lastComment < racer.commentCooldown) { - return null; + return null } // Select message set based on personality and context const messages = - racer.personality === "competitive" - ? swiftAICommentary[context] - : mathBotCommentary[context]; + racer.personality === 'competitive' ? swiftAICommentary[context] : mathBotCommentary[context] - if (!messages || messages.length === 0) return null; + if (!messages || messages.length === 0) return null // Return random message - return messages[Math.floor(Math.random() * messages.length)]; + return messages[Math.floor(Math.random() * messages.length)] } diff --git a/apps/web/src/app/arcade/complement-race/components/AbacusTarget.tsx b/apps/web/src/app/arcade/complement-race/components/AbacusTarget.tsx index 2dc9c2d1..c8ab7d46 100644 --- a/apps/web/src/app/arcade/complement-race/components/AbacusTarget.tsx +++ b/apps/web/src/app/arcade/complement-race/components/AbacusTarget.tsx @@ -1,9 +1,9 @@ -"use client"; +'use client' -import { AbacusReact } from "@soroban/abacus-react"; +import { AbacusReact } from '@soroban/abacus-react' interface AbacusTargetProps { - number: number; // The complement number to display + number: number // The complement number to display } /** @@ -14,9 +14,9 @@ export function AbacusTarget({ number }: AbacusTargetProps) { return (
@@ -30,5 +30,5 @@ export function AbacusTarget({ number }: AbacusTargetProps) { scaleFactor={0.72} />
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/ComplementRaceGame.tsx b/apps/web/src/app/arcade/complement-race/components/ComplementRaceGame.tsx index fb95e5df..1eebfdda 100644 --- a/apps/web/src/app/arcade/complement-race/components/ComplementRaceGame.tsx +++ b/apps/web/src/app/arcade/complement-race/components/ComplementRaceGame.tsx @@ -1,42 +1,42 @@ -"use client"; +'use client' // Use modular game provider for multiplayer support -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import { GameControls } from "./GameControls"; -import { GameCountdown } from "./GameCountdown"; -import { GameDisplay } from "./GameDisplay"; -import { GameIntro } from "./GameIntro"; -import { GameResults } from "./GameResults"; +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import { GameControls } from './GameControls' +import { GameCountdown } from './GameCountdown' +import { GameDisplay } from './GameDisplay' +import { GameIntro } from './GameIntro' +import { GameResults } from './GameResults' export function ComplementRaceGame() { - const { state } = useComplementRace(); + const { state } = useComplementRace() return (
{/* Background pattern - subtle grass texture */} - {state.style !== "sprint" && ( + {state.style !== 'sprint' && (
- + {/* Top-left tree cluster */}
{/* Top-right tree cluster */}
{/* Bottom-left tree cluster */}
{/* Bottom-right tree cluster */}
{/* Additional smaller clusters for depth */}
)} {/* Flying bird shadows - very subtle from aerial view */} - {state.style !== "sprint" && ( + {state.style !== 'sprint' && (
)} {/* Subtle cloud shadows moving across field */} - {state.style !== "sprint" && ( + {state.style !== 'sprint' && (
@@ -370,21 +354,21 @@ export function ComplementRaceGame() {
- {state.gamePhase === "intro" && } - {state.gamePhase === "controls" && } - {state.gamePhase === "countdown" && } - {state.gamePhase === "playing" && } - {state.gamePhase === "results" && } + {state.gamePhase === 'intro' && } + {state.gamePhase === 'controls' && } + {state.gamePhase === 'countdown' && } + {state.gamePhase === 'playing' && } + {state.gamePhase === 'results' && }
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/GameControls.tsx b/apps/web/src/app/arcade/complement-race/components/GameControls.tsx index a4bf27be..08f0a6a7 100644 --- a/apps/web/src/app/arcade/complement-race/components/GameControls.tsx +++ b/apps/web/src/app/arcade/complement-race/components/GameControls.tsx @@ -1,80 +1,74 @@ -"use client"; +'use client' -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import type { - ComplementDisplay, - GameMode, - GameStyle, - TimeoutSetting, -} from "../lib/gameTypes"; -import { AbacusTarget } from "./AbacusTarget"; +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import type { ComplementDisplay, GameMode, GameStyle, TimeoutSetting } from '../lib/gameTypes' +import { AbacusTarget } from './AbacusTarget' export function GameControls() { - const { state, dispatch } = useComplementRace(); + const { state, dispatch } = useComplementRace() const handleModeSelect = (mode: GameMode) => { - dispatch({ type: "SET_MODE", mode }); - }; + dispatch({ type: 'SET_MODE', mode }) + } const handleStyleSelect = (style: GameStyle) => { - dispatch({ type: "SET_STYLE", style }); + dispatch({ type: 'SET_STYLE', style }) // Start the game immediately - no navigation needed - if (style === "sprint") { - dispatch({ type: "BEGIN_GAME" }); + if (style === 'sprint') { + dispatch({ type: 'BEGIN_GAME' }) } else { - dispatch({ type: "START_COUNTDOWN" }); + dispatch({ type: 'START_COUNTDOWN' }) } - }; + } const handleTimeoutSelect = (timeout: TimeoutSetting) => { - dispatch({ type: "SET_TIMEOUT", timeout }); - }; + dispatch({ type: 'SET_TIMEOUT', timeout }) + } return (
{/* Animated background pattern */}
{/* Header */}

Complement Race @@ -84,73 +78,73 @@ export function GameControls() { {/* Settings Bar */}
{/* Number Mode & Display */}
{/* Number Mode Pills */}
Mode: {[ - { mode: "friends5" as GameMode, label: "5" }, - { mode: "friends10" as GameMode, label: "10" }, - { mode: "mixed" as GameMode, label: "Mix" }, + { mode: 'friends5' as GameMode, label: '5' }, + { mode: 'friends10' as GameMode, label: '10' }, + { mode: 'mixed' as GameMode, label: 'Mix' }, ].map(({ mode, label }) => ( - ), - )} + {(['number', 'abacus', 'random'] as ComplementDisplay[]).map((displayMode) => ( + + ))}
{/* Speed Pills */}
Speed: {( [ - "preschool", - "kindergarten", - "relaxed", - "slow", - "normal", - "fast", - "expert", + 'preschool', + 'kindergarten', + 'relaxed', + 'slow', + 'normal', + 'fast', + 'expert', ] as TimeoutSetting[] ).map((timeout) => ( ))} @@ -281,59 +264,51 @@ export function GameControls() { {/* Preview - compact */}
- - Preview: - + Preview:
?
- + - {state.complementDisplay === "number" ? ( + + + {state.complementDisplay === 'number' ? ( 3 - ) : state.complementDisplay === "abacus" ? ( -
+ ) : state.complementDisplay === 'abacus' ? ( +
) : ( - 🎲 + 🎲 )} - = - - {state.mode === "friends5" - ? "5" - : state.mode === "friends10" - ? "10" - : "?"} + = + + {state.mode === 'friends5' ? '5' : state.mode === 'friends10' ? '10' : '?'}
@@ -345,173 +320,161 @@ export function GameControls() { data-component="race-cards-container" style={{ flex: 1, - padding: "0 20px 20px", - display: "flex", - flexDirection: "column", - gap: "16px", - position: "relative", + padding: '0 20px 20px', + display: 'flex', + flexDirection: 'column', + gap: '16px', + position: 'relative', zIndex: 1, - overflow: "auto", + overflow: 'auto', }} > {[ { - style: "practice" as GameStyle, - emoji: "🏁", - title: "Practice Race", - desc: "Race against AI to 20 correct answers", - gradient: "linear-gradient(135deg, #10b981 0%, #059669 100%)", - shadowColor: "rgba(16, 185, 129, 0.5)", - accentColor: "#34d399", + style: 'practice' as GameStyle, + emoji: '🏁', + title: 'Practice Race', + desc: 'Race against AI to 20 correct answers', + gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', + shadowColor: 'rgba(16, 185, 129, 0.5)', + accentColor: '#34d399', }, { - style: "sprint" as GameStyle, - emoji: "🚂", - title: "Steam Sprint", - desc: "High-speed 60-second train journey", - gradient: "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)", - shadowColor: "rgba(245, 158, 11, 0.5)", - accentColor: "#fbbf24", + style: 'sprint' as GameStyle, + emoji: '🚂', + title: 'Steam Sprint', + desc: 'High-speed 60-second train journey', + gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', + shadowColor: 'rgba(245, 158, 11, 0.5)', + accentColor: '#fbbf24', }, { - style: "survival" as GameStyle, - emoji: "🔄", - title: "Survival Circuit", - desc: "Endless laps - beat your best time", - gradient: "linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)", - shadowColor: "rgba(139, 92, 246, 0.5)", - accentColor: "#a78bfa", + style: 'survival' as GameStyle, + emoji: '🔄', + title: 'Survival Circuit', + desc: 'Endless laps - beat your best time', + gradient: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)', + shadowColor: 'rgba(139, 92, 246, 0.5)', + accentColor: '#a78bfa', }, - ].map( - ({ - style, - emoji, - title, - desc, - gradient, - shadowColor, - accentColor, - }) => ( - - ), - )} + + {/* PLAY NOW button */} +
+ PLAY + +
+
+ + ))}
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/GameCountdown.tsx b/apps/web/src/app/arcade/complement-race/components/GameCountdown.tsx index fde98797..f38f7aba 100644 --- a/apps/web/src/app/arcade/complement-race/components/GameCountdown.tsx +++ b/apps/web/src/app/arcade/complement-race/components/GameCountdown.tsx @@ -1,83 +1,83 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import { useSoundEffects } from "../hooks/useSoundEffects"; +import { useEffect, useState } from 'react' +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import { useSoundEffects } from '../hooks/useSoundEffects' export function GameCountdown() { - const { dispatch } = useComplementRace(); - const { playSound } = useSoundEffects(); - const [count, setCount] = useState(3); - const [showGo, setShowGo] = useState(false); + const { dispatch } = useComplementRace() + const { playSound } = useSoundEffects() + const [count, setCount] = useState(3) + const [showGo, setShowGo] = useState(false) useEffect(() => { const countdownInterval = setInterval(() => { setCount((prevCount) => { if (prevCount > 1) { // Play countdown beep (volume 0.4) - playSound("countdown", 0.4); - return prevCount - 1; + playSound('countdown', 0.4) + return prevCount - 1 } else if (prevCount === 1) { // Show GO! - setShowGo(true); + setShowGo(true) // Play race start fanfare (volume 0.6) - playSound("race_start", 0.6); - return 0; + playSound('race_start', 0.6) + return 0 } - return prevCount; - }); - }, 1000); + return prevCount + }) + }, 1000) - return () => clearInterval(countdownInterval); - }, [playSound]); + return () => clearInterval(countdownInterval) + }, [playSound]) useEffect(() => { if (showGo) { // Hide countdown and start game after GO animation const timer = setTimeout(() => { - dispatch({ type: "BEGIN_GAME" }); - }, 1000); + dispatch({ type: 'BEGIN_GAME' }) + }, 1000) - return () => clearTimeout(timer); + return () => clearTimeout(timer) } - }, [showGo, dispatch]); + }, [showGo, dispatch]) return (
- {showGo ? "GO!" : count} + {showGo ? 'GO!' : count}
{!showGo && (
Get Ready! @@ -100,5 +100,5 @@ export function GameCountdown() { }} />
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/GameDisplay.tsx b/apps/web/src/app/arcade/complement-race/components/GameDisplay.tsx index f51a761f..090b2c41 100644 --- a/apps/web/src/app/arcade/complement-race/components/GameDisplay.tsx +++ b/apps/web/src/app/arcade/complement-race/components/GameDisplay.tsx @@ -1,72 +1,63 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import { useAdaptiveDifficulty } from "../hooks/useAdaptiveDifficulty"; -import { useAIRacers } from "../hooks/useAIRacers"; -import { useSoundEffects } from "../hooks/useSoundEffects"; -import { generatePassengers } from "../lib/passengerGenerator"; -import { AbacusTarget } from "./AbacusTarget"; -import { CircularTrack } from "./RaceTrack/CircularTrack"; -import { LinearTrack } from "./RaceTrack/LinearTrack"; -import { SteamTrainJourney } from "./RaceTrack/SteamTrainJourney"; -import { RouteCelebration } from "./RouteCelebration"; +import { useEffect, useState } from 'react' +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import { useAdaptiveDifficulty } from '../hooks/useAdaptiveDifficulty' +import { useAIRacers } from '../hooks/useAIRacers' +import { useSoundEffects } from '../hooks/useSoundEffects' +import { generatePassengers } from '../lib/passengerGenerator' +import { AbacusTarget } from './AbacusTarget' +import { CircularTrack } from './RaceTrack/CircularTrack' +import { LinearTrack } from './RaceTrack/LinearTrack' +import { SteamTrainJourney } from './RaceTrack/SteamTrainJourney' +import { RouteCelebration } from './RouteCelebration' -type FeedbackAnimation = "correct" | "incorrect" | null; +type FeedbackAnimation = 'correct' | 'incorrect' | null export function GameDisplay() { - const { state, dispatch, boostMomentum } = useComplementRace(); - useAIRacers(); // Activate AI racer updates (not used in sprint mode) - const { trackPerformance, getAdaptiveFeedbackMessage } = - useAdaptiveDifficulty(); - const { playSound } = useSoundEffects(); - const [feedbackAnimation, setFeedbackAnimation] = - useState(null); + const { state, dispatch, boostMomentum } = useComplementRace() + useAIRacers() // Activate AI racer updates (not used in sprint mode) + const { trackPerformance, getAdaptiveFeedbackMessage } = useAdaptiveDifficulty() + const { playSound } = useSoundEffects() + const [feedbackAnimation, setFeedbackAnimation] = useState(null) // Clear feedback animation after it plays (line 1996, 2001) useEffect(() => { if (feedbackAnimation) { const timer = setTimeout(() => { - setFeedbackAnimation(null); - }, 500); // Match animation duration - return () => clearTimeout(timer); + setFeedbackAnimation(null) + }, 500) // Match animation duration + return () => clearTimeout(timer) } - }, [feedbackAnimation]); + }, [feedbackAnimation]) // Show adaptive feedback with auto-hide useEffect(() => { if (state.adaptiveFeedback) { const timer = setTimeout(() => { - dispatch({ type: "CLEAR_ADAPTIVE_FEEDBACK" }); - }, 3000); - return () => clearTimeout(timer); + dispatch({ type: 'CLEAR_ADAPTIVE_FEEDBACK' }) + }, 3000) + return () => clearTimeout(timer) } - }, [state.adaptiveFeedback, dispatch]); + }, [state.adaptiveFeedback, dispatch]) // Check for finish line (player reaches race goal) - only for practice mode useEffect(() => { if ( state.correctAnswers >= state.raceGoal && state.isGameActive && - state.style === "practice" + state.style === 'practice' ) { // Play celebration sound (line 14182) - playSound("celebration"); + playSound('celebration') // End the game - dispatch({ type: "END_RACE" }); + dispatch({ type: 'END_RACE' }) // Show results after a short delay setTimeout(() => { - dispatch({ type: "SHOW_RESULTS" }); - }, 1500); + dispatch({ type: 'SHOW_RESULTS' }) + }, 1500) } - }, [ - state.correctAnswers, - state.raceGoal, - state.isGameActive, - state.style, - dispatch, - playSound, - ]); + }, [state.correctAnswers, state.raceGoal, state.isGameActive, state.style, dispatch, playSound]) // For survival mode (endless circuit), track laps but never end // For sprint mode (steam sprint), end after 60 seconds (will implement later) @@ -76,114 +67,106 @@ export function GameDisplay() { const handleKeyPress = (e: KeyboardEvent) => { // Only process number keys if (/^[0-9]$/.test(e.key)) { - const newInput = state.currentInput + e.key; - dispatch({ type: "UPDATE_INPUT", input: newInput }); + const newInput = state.currentInput + e.key + dispatch({ type: 'UPDATE_INPUT', input: newInput }) // Check if answer is complete if (state.currentQuestion) { - const answer = parseInt(newInput, 10); - const correctAnswer = state.currentQuestion.correctAnswer; + const answer = parseInt(newInput, 10) + const correctAnswer = state.currentQuestion.correctAnswer // If we have enough digits to match the answer, submit if (newInput.length >= correctAnswer.toString().length) { - const responseTime = Date.now() - state.questionStartTime; - const isCorrect = answer === correctAnswer; - const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`; + const responseTime = Date.now() - state.questionStartTime + const isCorrect = answer === correctAnswer + const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}` if (isCorrect) { // Correct answer - dispatch({ type: "SUBMIT_ANSWER", answer }); - trackPerformance(true, responseTime); + dispatch({ type: 'SUBMIT_ANSWER', answer }) + trackPerformance(true, responseTime) // Trigger correct answer animation (line 1996) - setFeedbackAnimation("correct"); + setFeedbackAnimation('correct') // Play appropriate sound based on performance (from web_generator.py lines 11530-11542) - const newStreak = state.streak + 1; + const newStreak = state.streak + 1 if (newStreak > 0 && newStreak % 5 === 0) { // Epic streak sound for every 5th correct answer - playSound("streak"); + playSound('streak') } else if (responseTime < 800) { // Whoosh sound for very fast responses (under 800ms) - playSound("whoosh"); + playSound('whoosh') } else if (responseTime < 1200 && state.streak >= 3) { // Combo sound for rapid answers while on a streak - playSound("combo"); + playSound('combo') } else { // Regular correct sound - playSound("correct"); + playSound('correct') } // Boost momentum for sprint mode - if (state.style === "sprint") { - boostMomentum(true); + if (state.style === 'sprint') { + boostMomentum(true) // Play train whistle for milestones in sprint mode (line 13222-13235) if (newStreak >= 5 && newStreak % 3 === 0) { // Major milestone - play train whistle setTimeout(() => { - playSound("train_whistle", 0.4); - }, 200); + playSound('train_whistle', 0.4) + }, 200) } else if (state.momentum >= 90) { // High momentum celebration - occasional whistle if (Math.random() < 0.3) { setTimeout(() => { - playSound("train_whistle", 0.25); - }, 150); + playSound('train_whistle', 0.25) + }, 150) } } } // Show adaptive feedback - const feedback = getAdaptiveFeedbackMessage( - pairKey, - true, - responseTime, - ); + const feedback = getAdaptiveFeedbackMessage(pairKey, true, responseTime) if (feedback) { - dispatch({ type: "SHOW_ADAPTIVE_FEEDBACK", feedback }); + dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback }) } - dispatch({ type: "NEXT_QUESTION" }); + dispatch({ type: 'NEXT_QUESTION' }) } else { // Incorrect answer - trackPerformance(false, responseTime); + trackPerformance(false, responseTime) // Trigger incorrect answer animation (line 2001) - setFeedbackAnimation("incorrect"); + setFeedbackAnimation('incorrect') // Play incorrect sound (from web_generator.py line 11589) - playSound("incorrect"); + playSound('incorrect') // Reduce momentum for sprint mode - if (state.style === "sprint") { - boostMomentum(false); + if (state.style === 'sprint') { + boostMomentum(false) } // Show adaptive feedback - const feedback = getAdaptiveFeedbackMessage( - pairKey, - false, - responseTime, - ); + const feedback = getAdaptiveFeedbackMessage(pairKey, false, responseTime) if (feedback) { - dispatch({ type: "SHOW_ADAPTIVE_FEEDBACK", feedback }); + dispatch({ type: 'SHOW_ADAPTIVE_FEEDBACK', feedback }) } - dispatch({ type: "UPDATE_INPUT", input: "" }); + dispatch({ type: 'UPDATE_INPUT', input: '' }) } } } - } else if (e.key === "Backspace") { + } else if (e.key === 'Backspace') { dispatch({ - type: "UPDATE_INPUT", + type: 'UPDATE_INPUT', input: state.currentInput.slice(0, -1), - }); + }) } - }; + } - window.addEventListener("keydown", handleKeyPress); - return () => window.removeEventListener("keydown", handleKeyPress); + window.addEventListener('keydown', handleKeyPress) + return () => window.removeEventListener('keydown', handleKeyPress) }, [ state.currentInput, state.currentQuestion, @@ -196,34 +179,34 @@ export function GameDisplay() { boostMomentum, playSound, state.momentum, - ]); + ]) // Handle route celebration continue const handleContinueToNextRoute = () => { - const nextRoute = state.currentRoute + 1; + const nextRoute = state.currentRoute + 1 // Start new route (this also hides celebration) dispatch({ - type: "START_NEW_ROUTE", + type: 'START_NEW_ROUTE', routeNumber: nextRoute, stations: state.stations, // Keep same stations for now - }); + }) // Generate new passengers - const newPassengers = generatePassengers(state.stations); - dispatch({ type: "GENERATE_PASSENGERS", passengers: newPassengers }); - }; + const newPassengers = generatePassengers(state.stations) + dispatch({ type: 'GENERATE_PASSENGERS', passengers: newPassengers }) + } - if (!state.currentQuestion) return null; + if (!state.currentQuestion) return null return (
{/* Adaptive Feedback */} @@ -231,21 +214,21 @@ export function GameDisplay() {
{state.adaptiveFeedback.message} @@ -253,84 +236,84 @@ export function GameDisplay() { )} {/* Stats Header - constrained width, hidden for sprint mode */} - {state.style !== "sprint" && ( + {state.style !== 'sprint' && (
-
+
Score
{state.score}
-
+
Streak
{state.streak} 🔥
-
+
Progress
{state.correctAnswers}/{state.raceGoal} @@ -344,28 +327,28 @@ export function GameDisplay() {
- {state.style === "survival" ? ( + {state.style === 'survival' ? ( - ) : state.style === "sprint" ? ( + ) : state.style === 'sprint' ? ( {/* Question Display - only for non-sprint modes */} - {state.style !== "sprint" && ( + {state.style !== 'sprint' && (
{/* Complement equation as main focus */}
- {state.currentInput || "?"} + {state.currentInput || '?'} - + + + {state.currentQuestion.showAsAbacus ? (
@@ -453,17 +435,15 @@ export function GameDisplay() { ) : ( {state.currentQuestion.number} )} - = - - {state.currentQuestion.targetSum} - + = + {state.currentQuestion.targetSum}
)} {/* Route Celebration Modal */} - {state.showRouteCelebration && state.style === "sprint" && ( + {state.showRouteCelebration && state.style === 'sprint' && ( )}
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/GameIntro.tsx b/apps/web/src/app/arcade/complement-race/components/GameIntro.tsx index 34cb378a..847ae931 100644 --- a/apps/web/src/app/arcade/complement-race/components/GameIntro.tsx +++ b/apps/web/src/app/arcade/complement-race/components/GameIntro.tsx @@ -1,32 +1,32 @@ -"use client"; +'use client' -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; +import { useComplementRace } from '@/arcade-games/complement-race/Provider' export function GameIntro() { - const { dispatch } = useComplementRace(); + const { dispatch } = useComplementRace() const handleStartClick = () => { - dispatch({ type: "SHOW_CONTROLS" }); - }; + dispatch({ type: 'SHOW_CONTROLS' }) + } return (

Speed Complement Race @@ -34,32 +34,32 @@ export function GameIntro() {

- Race against AI opponents while solving complement problems! Find the - missing number to complete the equation. + Race against AI opponents while solving complement problems! Find the missing number to + complete the equation.

How to Play @@ -67,35 +67,35 @@ export function GameIntro() {
    -
  • - 🎯 - +
  • + 🎯 + Find the complement number to reach the target sum
  • -
  • - - +
  • + + Type your answer quickly to move forward in the race
  • -
  • - 🤖 - +
  • + 🤖 + Compete against Swift AI and Math Bot with unique personalities
  • -
  • - 🏆 - +
  • + 🏆 + Earn points for correct answers and build up your streak
  • @@ -105,30 +105,28 @@ export function GameIntro() {

- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/GameResults.tsx b/apps/web/src/app/arcade/complement-race/components/GameResults.tsx index e5cbd978..798b7753 100644 --- a/apps/web/src/app/arcade/complement-race/components/GameResults.tsx +++ b/apps/web/src/app/arcade/complement-race/components/GameResults.tsx @@ -1,182 +1,161 @@ -"use client"; +'use client' -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; +import { useComplementRace } from '@/arcade-games/complement-race/Provider' export function GameResults() { - const { state, dispatch } = useComplementRace(); + const { state, dispatch } = useComplementRace() // Determine race outcome - const playerWon = state.aiRacers.every( - (racer) => state.correctAnswers > racer.position, - ); + const playerWon = state.aiRacers.every((racer) => state.correctAnswers > racer.position) const playerPosition = - state.aiRacers.filter((racer) => racer.position >= state.correctAnswers) - .length + 1; + state.aiRacers.filter((racer) => racer.position >= state.correctAnswers).length + 1 return (
{/* Result Header */}
- {playerWon - ? "🏆" - : playerPosition === 2 - ? "🥈" - : playerPosition === 3 - ? "🥉" - : "🎯"} + {playerWon ? '🏆' : playerPosition === 2 ? '🥈' : playerPosition === 3 ? '🥉' : '🎯'}

- {playerWon - ? "Victory!" - : `${playerPosition}${getOrdinalSuffix(playerPosition)} Place`} + {playerWon ? 'Victory!' : `${playerPosition}${getOrdinalSuffix(playerPosition)} Place`}

- {playerWon ? "You beat all the AI racers!" : `You finished the race!`} + {playerWon ? 'You beat all the AI racers!' : `You finished the race!`}

{/* Stats */}
Final Score
-
+
{state.score}
Best Streak
-
+
{state.bestStreak} 🔥
Total Questions
-
+
{state.totalQuestions}
Accuracy
-
+
{state.totalQuestions > 0 - ? Math.round( - (state.correctAnswers / state.totalQuestions) * 100, - ) + ? Math.round((state.correctAnswers / state.totalQuestions) * 100) : 0} %
@@ -186,23 +165,23 @@ export function GameResults() { {/* Final Standings */}

Final Standings

{[ - { name: "You", position: state.correctAnswers, icon: "👤" }, + { name: 'You', position: state.correctAnswers, icon: '👤' }, ...state.aiRacers.map((racer) => ({ name: racer.name, position: racer.position, @@ -214,33 +193,31 @@ export function GameResults() {
-
+
#{index + 1}
-
{racer.icon}
+
{racer.icon}
{racer.name} @@ -248,9 +225,9 @@ export function GameResults() {
{Math.floor(racer.position)} @@ -262,30 +239,30 @@ export function GameResults() { {/* Buttons */}
- ); + ) } function getOrdinalSuffix(num: number): string { - if (num === 1) return "st"; - if (num === 2) return "nd"; - if (num === 3) return "rd"; - return "th"; + if (num === 1) return 'st' + if (num === 2) return 'nd' + if (num === 3) return 'rd' + return 'th' } diff --git a/apps/web/src/app/arcade/complement-race/components/PassengerCard.tsx b/apps/web/src/app/arcade/complement-race/components/PassengerCard.tsx index 63ceeadf..8c554ca9 100644 --- a/apps/web/src/app/arcade/complement-race/components/PassengerCard.tsx +++ b/apps/web/src/app/arcade/complement-race/components/PassengerCard.tsx @@ -1,12 +1,12 @@ -"use client"; +'use client' -import { memo } from "react"; -import type { Passenger, Station } from "@/arcade-games/complement-race/types"; +import { memo } from 'react' +import type { Passenger, Station } from '@/arcade-games/complement-race/types' interface PassengerCardProps { - passenger: Passenger; - originStation: Station | undefined; - destinationStation: Station | undefined; + passenger: Passenger + originStation: Station | undefined + destinationStation: Station | undefined } export const PassengerCard = memo(function PassengerCard({ @@ -14,84 +14,83 @@ export const PassengerCard = memo(function PassengerCard({ originStation, destinationStation, }: PassengerCardProps) { - if (!destinationStation || !originStation) return null; + if (!destinationStation || !originStation) return null // Vintage train station colors // Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered - const isBoarded = passenger.claimedBy !== null; - const isDelivered = passenger.deliveredBy !== null; + const isBoarded = passenger.claimedBy !== null + const isDelivered = passenger.deliveredBy !== null const bgColor = isDelivered - ? "#1a3a1a" // Dark green for delivered + ? '#1a3a1a' // Dark green for delivered : !isBoarded - ? "#2a2419" // Dark brown/sepia for waiting + ? '#2a2419' // Dark brown/sepia for waiting : passenger.isUrgent - ? "#3a2419" // Dark red-brown for urgent - : "#1a2a3a"; // Dark blue for aboard + ? '#3a2419' // Dark red-brown for urgent + : '#1a2a3a' // Dark blue for aboard const accentColor = isDelivered - ? "#4ade80" // Green + ? '#4ade80' // Green : !isBoarded - ? "#d4af37" // Gold for waiting + ? '#d4af37' // Gold for waiting : passenger.isUrgent - ? "#ff6b35" // Orange-red for urgent - : "#60a5fa"; // Blue for aboard + ? '#ff6b35' // Orange-red for urgent + : '#60a5fa' // Blue for aboard - const borderColor = - passenger.isUrgent && isBoarded && !isDelivered ? "#ff6b35" : "#d4af37"; + const borderColor = passenger.isUrgent && isBoarded && !isDelivered ? '#ff6b35' : '#d4af37' return (
{/* Top row: Passenger info and status */}
-
- {isDelivered ? "✅" : passenger.avatar} +
+ {isDelivered ? '✅' : passenger.avatar}
{passenger.name} @@ -101,59 +100,57 @@ export const PassengerCard = memo(function PassengerCard({ {/* Status indicator */}
- {isDelivered ? "DLVRD" : isBoarded ? "BOARD" : "WAIT"} + {isDelivered ? 'DLVRD' : isBoarded ? 'BOARD' : 'WAIT'}
{/* Route information */}
{/* From station */}
FROM: - - {originStation.icon} - + {originStation.icon} {originStation.name} @@ -163,30 +160,28 @@ export const PassengerCard = memo(function PassengerCard({ {/* To station */}
TO: - - {destinationStation.icon} - + {destinationStation.icon} {destinationStation.name} @@ -198,20 +193,20 @@ export const PassengerCard = memo(function PassengerCard({ {!isDelivered && (
- {passenger.isUrgent ? "+20" : "+10"} + {passenger.isUrgent ? '+20' : '+10'}
)} @@ -219,12 +214,12 @@ export const PassengerCard = memo(function PassengerCard({ {passenger.isUrgent && !isDelivered && isBoarded && (
⚠️ @@ -253,5 +248,5 @@ export const PassengerCard = memo(function PassengerCard({ } `}
- ); -}); + ) +}) diff --git a/apps/web/src/app/arcade/complement-race/components/PressureGauge.tsx b/apps/web/src/app/arcade/complement-race/components/PressureGauge.tsx index e01d0885..a50d4ff3 100644 --- a/apps/web/src/app/arcade/complement-race/components/PressureGauge.tsx +++ b/apps/web/src/app/arcade/complement-race/components/PressureGauge.tsx @@ -1,18 +1,18 @@ -"use client"; +'use client' -import { animated, useSpring } from "@react-spring/web"; -import { AbacusReact } from "@soroban/abacus-react"; -import { useAbacusSettings } from "@/hooks/useAbacusSettings"; +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 + 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; + const { data: abacusSettings } = useAbacusSettings() + const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false + const maxPressure = 150 // Animate pressure value smoothly with spring physics const spring = useSpring({ @@ -22,38 +22,38 @@ export function PressureGauge({ pressure }: PressureGaugeProps) { friction: 14, clamp: false, }, - }); + }) // Calculate needle angle - sweeps 180° from left to right // 0 PSI = 180° (pointing left), 150 PSI = 0° (pointing right) - const angle = spring.pressure.to((p) => 180 - (p / maxPressure) * 180); + const angle = spring.pressure.to((p) => 180 - (p / maxPressure) * 180) // Get pressure color (animated) const color = spring.pressure.to((p) => { - if (p < 50) return "#ef4444"; // Red (low) - if (p < 100) return "#f59e0b"; // Orange (medium) - return "#10b981"; // Green (high) - }); + if (p < 50) return '#ef4444' // Red (low) + if (p < 100) return '#f59e0b' // Orange (medium) + return '#10b981' // Green (high) + }) return (
{/* Title */}
PRESSURE @@ -63,9 +63,9 @@ export function PressureGauge({ pressure }: PressureGaugeProps) { {/* Background arc - semicircle from left to right (bottom half) */} @@ -80,16 +80,16 @@ export function PressureGauge({ pressure }: PressureGaugeProps) { {/* Tick marks */} {[0, 50, 100, 150].map((psi, index) => { // Angle from 180° (left) to 0° (right) - const tickAngle = 180 - (psi / maxPressure) * 180; - const tickRad = (tickAngle * Math.PI) / 180; - const x1 = 100 + Math.cos(tickRad) * 70; - const y1 = 100 - Math.sin(tickRad) * 70; // Subtract for SVG coords - const x2 = 100 + Math.cos(tickRad) * 80; - const y2 = 100 - Math.sin(tickRad) * 80; // Subtract for SVG coords + const tickAngle = 180 - (psi / maxPressure) * 180 + const tickRad = (tickAngle * Math.PI) / 180 + const x1 = 100 + Math.cos(tickRad) * 70 + const y1 = 100 - Math.sin(tickRad) * 70 // Subtract for SVG coords + const x2 = 100 + Math.cos(tickRad) * 80 + const y2 = 100 - Math.sin(tickRad) * 80 // Subtract for SVG coords // Position for abacus label - const labelX = 100 + Math.cos(tickRad) * 112; - const labelY = 100 - Math.sin(tickRad) * 112; + const labelX = 100 + Math.cos(tickRad) * 112 + const labelY = 100 - Math.sin(tickRad) * 112 return ( @@ -102,17 +102,12 @@ export function PressureGauge({ pressure }: PressureGaugeProps) { strokeWidth="2" strokeLinecap="round" /> - +
@@ -131,9 +126,9 @@ export function PressureGauge({ pressure }: PressureGaugeProps) { ) : (
{psi} @@ -142,7 +137,7 @@ export function PressureGauge({ pressure }: PressureGaugeProps) {
- ); + ) })} {/* Center pivot */} @@ -166,21 +161,21 @@ export function PressureGauge({ pressure }: PressureGaugeProps) { {/* Pressure readout */}
{useNativeAbacusNumbers ? ( <>
@@ -196,21 +191,14 @@ export function PressureGauge({ pressure }: PressureGaugeProps) { }} />
- - PSI - + PSI ) : ( -
- {Math.round(pressure)}{" "} - PSI +
+ {Math.round(pressure)} PSI
)}
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx index f8731ac2..ec5cc864 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/CircularTrack.tsx @@ -1,206 +1,192 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; -import { useGameMode } from "@/contexts/GameModeContext"; -import { useUserProfile } from "@/contexts/UserProfileContext"; -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import { useSoundEffects } from "../../hooks/useSoundEffects"; -import type { AIRacer } from "../../lib/gameTypes"; -import { SpeechBubble } from "../AISystem/SpeechBubble"; +import { useEffect, useState } from 'react' +import { useGameMode } from '@/contexts/GameModeContext' +import { useUserProfile } from '@/contexts/UserProfileContext' +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import { useSoundEffects } from '../../hooks/useSoundEffects' +import type { AIRacer } from '../../lib/gameTypes' +import { SpeechBubble } from '../AISystem/SpeechBubble' interface CircularTrackProps { - playerProgress: number; - playerLap: number; - aiRacers: AIRacer[]; - aiLaps: Map; + playerProgress: number + playerLap: number + aiRacers: AIRacer[] + aiLaps: Map } -export function CircularTrack({ - playerProgress, - playerLap, - aiRacers, - aiLaps, -}: CircularTrackProps) { - const { state, dispatch } = useComplementRace(); - const { players, activePlayers } = useGameMode(); - const { profile: _profile } = useUserProfile(); - const { playSound } = useSoundEffects(); - const [celebrationCooldown, setCelebrationCooldown] = useState>( - new Set(), - ); +export function CircularTrack({ playerProgress, playerLap, aiRacers, aiLaps }: CircularTrackProps) { + const { state, dispatch } = useComplementRace() + const { players, activePlayers } = useGameMode() + const { profile: _profile } = useUserProfile() + const { playSound } = useSoundEffects() + const [celebrationCooldown, setCelebrationCooldown] = useState>(new Set()) // Get the current user's active local players (consistent with navbar pattern) const activeLocalPlayers = Array.from(activePlayers) .map((id) => players.get(id)) - .filter( - (p): p is NonNullable => p !== undefined && p.isLocal !== false, - ); - const playerEmoji = activeLocalPlayers[0]?.emoji ?? "👤"; - const [dimensions, setDimensions] = useState({ width: 600, height: 400 }); + .filter((p): p is NonNullable => p !== undefined && p.isLocal !== false) + const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤' + const [dimensions, setDimensions] = useState({ width: 600, height: 400 }) // Update dimensions on mount and resize useEffect(() => { const updateDimensions = () => { - const vw = window.innerWidth; - const vh = window.innerHeight; - const isLandscape = vw > vh; + const vw = window.innerWidth + const vh = window.innerHeight + const isLandscape = vw > vh if (isLandscape) { // Landscape: wider track (emphasize horizontal straights) - const width = Math.min(vw * 0.75, 800); - const height = Math.min(vh * 0.5, 350); - setDimensions({ width, height }); + const width = Math.min(vw * 0.75, 800) + const height = Math.min(vh * 0.5, 350) + setDimensions({ width, height }) } else { // Portrait: taller track (emphasize vertical straights) - const width = Math.min(vw * 0.85, 350); - const height = Math.min(vh * 0.5, 550); - setDimensions({ width, height }); - } - }; - - updateDimensions(); - window.addEventListener("resize", updateDimensions); - return () => window.removeEventListener("resize", updateDimensions); - }, []); - - const padding = 40; - const trackWidth = dimensions.width - padding * 2; - const trackHeight = dimensions.height - padding * 2; - - // For a rounded rectangle track, we have straight sections and curved ends - const straightLength = - Math.max(trackWidth, trackHeight) - Math.min(trackWidth, trackHeight); - const radius = Math.min(trackWidth, trackHeight) / 2; - const isHorizontal = trackWidth > trackHeight; - - // Calculate position on rounded rectangle track - const getCircularPosition = (progress: number) => { - const progressPerLap = 50; - const normalizedProgress = (progress % progressPerLap) / progressPerLap; - - // Track perimeter consists of: 2 straights + 2 semicircles - const straightPerim = straightLength; - const curvePerim = Math.PI * radius; - const totalPerim = 2 * straightPerim + 2 * curvePerim; - - const distanceAlongTrack = normalizedProgress * totalPerim; - - const centerX = dimensions.width / 2; - const centerY = dimensions.height / 2; - - let x: number, y: number, angle: number; - - if (isHorizontal) { - // Horizontal track: straight sections on top/bottom, curves on left/right - const topStraightEnd = straightPerim; - const rightCurveEnd = topStraightEnd + curvePerim; - const bottomStraightEnd = rightCurveEnd + straightPerim; - const _leftCurveEnd = bottomStraightEnd + curvePerim; - - if (distanceAlongTrack < topStraightEnd) { - // Top straight (moving right) - const t = distanceAlongTrack / straightPerim; - x = centerX - straightLength / 2 + t * straightLength; - y = centerY - radius; - angle = 90; - } else if (distanceAlongTrack < rightCurveEnd) { - // Right curve - const curveProgress = - (distanceAlongTrack - topStraightEnd) / curvePerim; - const curveAngle = curveProgress * Math.PI - Math.PI / 2; - x = centerX + straightLength / 2 + radius * Math.cos(curveAngle); - y = centerY + radius * Math.sin(curveAngle); - angle = curveProgress * 180 + 90; - } else if (distanceAlongTrack < bottomStraightEnd) { - // Bottom straight (moving left) - const t = (distanceAlongTrack - rightCurveEnd) / straightPerim; - x = centerX + straightLength / 2 - t * straightLength; - y = centerY + radius; - angle = 270; - } else { - // Left curve - const curveProgress = - (distanceAlongTrack - bottomStraightEnd) / curvePerim; - const curveAngle = curveProgress * Math.PI + Math.PI / 2; - x = centerX - straightLength / 2 + radius * Math.cos(curveAngle); - y = centerY + radius * Math.sin(curveAngle); - angle = curveProgress * 180 + 270; - } - } else { - // Vertical track: straight sections on left/right, curves on top/bottom - const leftStraightEnd = straightPerim; - const bottomCurveEnd = leftStraightEnd + curvePerim; - const rightStraightEnd = bottomCurveEnd + straightPerim; - const _topCurveEnd = rightStraightEnd + curvePerim; - - if (distanceAlongTrack < leftStraightEnd) { - // Left straight (moving down) - const t = distanceAlongTrack / straightPerim; - x = centerX - radius; - y = centerY - straightLength / 2 + t * straightLength; - angle = 180; - } else if (distanceAlongTrack < bottomCurveEnd) { - // Bottom curve - const curveProgress = - (distanceAlongTrack - leftStraightEnd) / curvePerim; - const curveAngle = curveProgress * Math.PI; - x = centerX + radius * Math.cos(curveAngle); - y = centerY + straightLength / 2 + radius * Math.sin(curveAngle); - angle = curveProgress * 180 + 180; - } else if (distanceAlongTrack < rightStraightEnd) { - // Right straight (moving up) - const t = (distanceAlongTrack - bottomCurveEnd) / straightPerim; - x = centerX + radius; - y = centerY + straightLength / 2 - t * straightLength; - angle = 0; - } else { - // Top curve - const curveProgress = - (distanceAlongTrack - rightStraightEnd) / curvePerim; - const curveAngle = curveProgress * Math.PI + Math.PI; - x = centerX + radius * Math.cos(curveAngle); - y = centerY - straightLength / 2 + radius * Math.sin(curveAngle); - angle = curveProgress * 180; + const width = Math.min(vw * 0.85, 350) + const height = Math.min(vh * 0.5, 550) + setDimensions({ width, height }) } } - return { x, y, angle }; - }; + updateDimensions() + window.addEventListener('resize', updateDimensions) + return () => window.removeEventListener('resize', updateDimensions) + }, []) + + const padding = 40 + const trackWidth = dimensions.width - padding * 2 + const trackHeight = dimensions.height - padding * 2 + + // For a rounded rectangle track, we have straight sections and curved ends + const straightLength = Math.max(trackWidth, trackHeight) - Math.min(trackWidth, trackHeight) + const radius = Math.min(trackWidth, trackHeight) / 2 + const isHorizontal = trackWidth > trackHeight + + // Calculate position on rounded rectangle track + const getCircularPosition = (progress: number) => { + const progressPerLap = 50 + const normalizedProgress = (progress % progressPerLap) / progressPerLap + + // Track perimeter consists of: 2 straights + 2 semicircles + const straightPerim = straightLength + const curvePerim = Math.PI * radius + const totalPerim = 2 * straightPerim + 2 * curvePerim + + const distanceAlongTrack = normalizedProgress * totalPerim + + const centerX = dimensions.width / 2 + const centerY = dimensions.height / 2 + + let x: number, y: number, angle: number + + if (isHorizontal) { + // Horizontal track: straight sections on top/bottom, curves on left/right + const topStraightEnd = straightPerim + const rightCurveEnd = topStraightEnd + curvePerim + const bottomStraightEnd = rightCurveEnd + straightPerim + const _leftCurveEnd = bottomStraightEnd + curvePerim + + if (distanceAlongTrack < topStraightEnd) { + // Top straight (moving right) + const t = distanceAlongTrack / straightPerim + x = centerX - straightLength / 2 + t * straightLength + y = centerY - radius + angle = 90 + } else if (distanceAlongTrack < rightCurveEnd) { + // Right curve + const curveProgress = (distanceAlongTrack - topStraightEnd) / curvePerim + const curveAngle = curveProgress * Math.PI - Math.PI / 2 + x = centerX + straightLength / 2 + radius * Math.cos(curveAngle) + y = centerY + radius * Math.sin(curveAngle) + angle = curveProgress * 180 + 90 + } else if (distanceAlongTrack < bottomStraightEnd) { + // Bottom straight (moving left) + const t = (distanceAlongTrack - rightCurveEnd) / straightPerim + x = centerX + straightLength / 2 - t * straightLength + y = centerY + radius + angle = 270 + } else { + // Left curve + const curveProgress = (distanceAlongTrack - bottomStraightEnd) / curvePerim + const curveAngle = curveProgress * Math.PI + Math.PI / 2 + x = centerX - straightLength / 2 + radius * Math.cos(curveAngle) + y = centerY + radius * Math.sin(curveAngle) + angle = curveProgress * 180 + 270 + } + } else { + // Vertical track: straight sections on left/right, curves on top/bottom + const leftStraightEnd = straightPerim + const bottomCurveEnd = leftStraightEnd + curvePerim + const rightStraightEnd = bottomCurveEnd + straightPerim + const _topCurveEnd = rightStraightEnd + curvePerim + + if (distanceAlongTrack < leftStraightEnd) { + // Left straight (moving down) + const t = distanceAlongTrack / straightPerim + x = centerX - radius + y = centerY - straightLength / 2 + t * straightLength + angle = 180 + } else if (distanceAlongTrack < bottomCurveEnd) { + // Bottom curve + const curveProgress = (distanceAlongTrack - leftStraightEnd) / curvePerim + const curveAngle = curveProgress * Math.PI + x = centerX + radius * Math.cos(curveAngle) + y = centerY + straightLength / 2 + radius * Math.sin(curveAngle) + angle = curveProgress * 180 + 180 + } else if (distanceAlongTrack < rightStraightEnd) { + // Right straight (moving up) + const t = (distanceAlongTrack - bottomCurveEnd) / straightPerim + x = centerX + radius + y = centerY + straightLength / 2 - t * straightLength + angle = 0 + } else { + // Top curve + const curveProgress = (distanceAlongTrack - rightStraightEnd) / curvePerim + const curveAngle = curveProgress * Math.PI + Math.PI + x = centerX + radius * Math.cos(curveAngle) + y = centerY - straightLength / 2 + radius * Math.sin(curveAngle) + angle = curveProgress * 180 + } + } + + return { x, y, angle } + } // Check for lap completions and show celebrations useEffect(() => { // Check player lap - const playerCurrentLap = Math.floor(playerProgress / 50); - if (playerCurrentLap > playerLap && !celebrationCooldown.has("player")) { - dispatch({ type: "COMPLETE_LAP", racerId: "player" }); + const playerCurrentLap = Math.floor(playerProgress / 50) + if (playerCurrentLap > playerLap && !celebrationCooldown.has('player')) { + dispatch({ type: 'COMPLETE_LAP', racerId: 'player' }) // Play celebration sound (line 12801) - playSound("lap_celebration", 0.6); - setCelebrationCooldown((prev) => new Set(prev).add("player")); + playSound('lap_celebration', 0.6) + setCelebrationCooldown((prev) => new Set(prev).add('player')) setTimeout(() => { setCelebrationCooldown((prev) => { - const next = new Set(prev); - next.delete("player"); - return next; - }); - }, 2000); + const next = new Set(prev) + next.delete('player') + return next + }) + }, 2000) } // Check AI laps aiRacers.forEach((racer) => { - const aiCurrentLap = Math.floor(racer.position / 50); - const aiPreviousLap = aiLaps.get(racer.id) || 0; + const aiCurrentLap = Math.floor(racer.position / 50) + const aiPreviousLap = aiLaps.get(racer.id) || 0 if (aiCurrentLap > aiPreviousLap && !celebrationCooldown.has(racer.id)) { - dispatch({ type: "COMPLETE_LAP", racerId: racer.id }); - setCelebrationCooldown((prev) => new Set(prev).add(racer.id)); + dispatch({ type: 'COMPLETE_LAP', racerId: racer.id }) + setCelebrationCooldown((prev) => new Set(prev).add(racer.id)) setTimeout(() => { setCelebrationCooldown((prev) => { - const next = new Set(prev); - next.delete(racer.id); - return next; - }); - }, 2000); + const next = new Set(prev) + next.delete(racer.id) + return next + }) + }, 2000) } - }); + }) }, [ playerProgress, playerLap, @@ -209,28 +195,25 @@ export function CircularTrack({ celebrationCooldown, dispatch, // Play celebration sound (line 12801) playSound, - ]); + ]) - const playerPos = getCircularPosition(playerProgress); + const playerPos = getCircularPosition(playerProgress) // Create rounded rectangle path with wider curves (banking effect) - const createRoundedRectPath = ( - radiusOffset: number, - isOuter: boolean = false, - ) => { - const centerX = dimensions.width / 2; - const centerY = dimensions.height / 2; + const createRoundedRectPath = (radiusOffset: number, isOuter: boolean = false) => { + const centerX = dimensions.width / 2 + const centerY = dimensions.height / 2 // Make curves wider by increasing radius more on outer edges - const curveWidthBonus = isOuter ? radiusOffset * 0.15 : radiusOffset * -0.1; - const r = radius + radiusOffset + curveWidthBonus; + const curveWidthBonus = isOuter ? radiusOffset * 0.15 : radiusOffset * -0.1 + const r = radius + radiusOffset + curveWidthBonus if (isHorizontal) { // Horizontal track - curved ends on left/right - const leftCenterX = centerX - straightLength / 2; - const rightCenterX = centerX + straightLength / 2; - const curveTopY = centerY - r; - const curveBottomY = centerY + r; + const leftCenterX = centerX - straightLength / 2 + const rightCenterX = centerX + straightLength / 2 + const curveTopY = centerY - r + const curveBottomY = centerY + r return ` M ${leftCenterX} ${curveTopY} @@ -239,13 +222,13 @@ export function CircularTrack({ L ${leftCenterX} ${curveBottomY} A ${r} ${r} 0 0 1 ${leftCenterX} ${curveTopY} Z - `; + ` } else { // Vertical track - curved ends on top/bottom - const topCenterY = centerY - straightLength / 2; - const bottomCenterY = centerY + straightLength / 2; - const curveLeftX = centerX - r; - const curveRightX = centerX + r; + const topCenterY = centerY - straightLength / 2 + const bottomCenterY = centerY + straightLength / 2 + const curveLeftX = centerX - r + const curveRightX = centerX + r return ` M ${curveLeftX} ${topCenterY} @@ -254,18 +237,18 @@ export function CircularTrack({ L ${curveRightX} ${topCenterY} A ${r} ${r} 0 0 0 ${curveLeftX} ${topCenterY} Z - `; + ` } - }; + } return (
{/* SVG Track */} @@ -274,40 +257,22 @@ export function CircularTrack({ width={dimensions.width} height={dimensions.height} style={{ - position: "absolute", + position: 'absolute', top: 0, left: 0, }} > {/* Infield grass */} - + {/* Track background - reddish clay color */} - + {/* Track outer edge - white boundary */} - + {/* Track inner edge - white boundary */} - + {/* Lane markers - dashed white lines */} {[-5, 0, 5].map((offset) => ( @@ -324,16 +289,16 @@ export function CircularTrack({ {/* Start/Finish line - checkered flag pattern */} {(() => { - const centerX = dimensions.width / 2; - const centerY = dimensions.height / 2; - const trackThickness = 35; // Track width from inner to outer edge + const centerX = dimensions.width / 2 + const centerY = dimensions.height / 2 + const trackThickness = 35 // Track width from inner to outer edge if (isHorizontal) { // Horizontal track: vertical finish line crossing the top straight - const x = centerX; - const yStart = centerY - radius - 18; // Outer edge - const squareSize = trackThickness / 6; - const lineWidth = 12; + const x = centerX + const yStart = centerY - radius - 18 // Outer edge + const squareSize = trackThickness / 6 + const lineWidth = 12 return ( {/* Checkered pattern - vertical line */} @@ -344,17 +309,17 @@ export function CircularTrack({ y={yStart + squareSize * i} width={lineWidth} height={squareSize} - fill={i % 2 === 0 ? "black" : "white"} + fill={i % 2 === 0 ? 'black' : 'white'} /> ))} - ); + ) } else { // Vertical track: horizontal finish line crossing the left straight - const xStart = centerX - radius - 18; // Outer edge - const y = centerY; - const squareSize = trackThickness / 6; - const lineWidth = 12; + const xStart = centerX - radius - 18 // Outer edge + const y = centerY + const squareSize = trackThickness / 6 + const lineWidth = 12 return ( {/* Checkered pattern - horizontal line */} @@ -365,23 +330,23 @@ export function CircularTrack({ y={y - lineWidth / 2} width={squareSize} height={lineWidth} - fill={i % 2 === 0 ? "black" : "white"} + fill={i % 2 === 0 ? 'black' : 'white'} /> ))} - ); + ) } })()} {/* Distance markers (quarter points) */} {[0.25, 0.5, 0.75].map((fraction) => { - const pos = getCircularPosition(fraction * 50); - const markerLength = 12; - const perpAngle = (pos.angle + 90) * (Math.PI / 180); - const x1 = pos.x - markerLength * Math.cos(perpAngle); - const y1 = pos.y - markerLength * Math.sin(perpAngle); - const x2 = pos.x + markerLength * Math.cos(perpAngle); - const y2 = pos.y + markerLength * Math.sin(perpAngle); + const pos = getCircularPosition(fraction * 50) + const markerLength = 12 + const perpAngle = (pos.angle + 90) * (Math.PI / 180) + const x1 = pos.x - markerLength * Math.cos(perpAngle) + const y1 = pos.y - markerLength * Math.sin(perpAngle) + const x2 = pos.x + markerLength * Math.cos(perpAngle) + const y2 = pos.y + markerLength * Math.sin(perpAngle) return ( - ); + ) })} {/* Player racer */}
{playerEmoji} @@ -415,89 +380,87 @@ export function CircularTrack({ {/* AI racers */} {aiRacers.map((racer, _index) => { - const aiPos = getCircularPosition(racer.position); - const activeBubble = state.activeSpeechBubbles.get(racer.id); + const aiPos = getCircularPosition(racer.position) + const activeBubble = state.activeSpeechBubbles.get(racer.id) return (
{racer.icon} {activeBubble && (
- dispatch({ type: "CLEAR_AI_COMMENT", racerId: racer.id }) - } + onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })} />
)}
- ); + ) })} {/* Lap counter */}
Lap
{playerLap + 1}
{Math.floor(((playerProgress % 50) / 50) * 100)}% @@ -505,21 +468,21 @@ export function CircularTrack({
{/* Lap celebration */} - {celebrationCooldown.has("player") && ( + {celebrationCooldown.has('player') && (
@@ -527,5 +490,5 @@ export function CircularTrack({
)}
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/GameHUD.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/GameHUD.tsx index b5d76914..40f64900 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/GameHUD.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/GameHUD.tsx @@ -1,27 +1,27 @@ -"use client"; +'use client' -import { memo } from "react"; -import type { ComplementQuestion } from "../../lib/gameTypes"; -import type { Passenger, Station } from "@/arcade-games/complement-race/types"; -import { AbacusTarget } from "../AbacusTarget"; -import { PassengerCard } from "../PassengerCard"; -import { PressureGauge } from "../PressureGauge"; +import { memo } from 'react' +import type { ComplementQuestion } from '../../lib/gameTypes' +import type { Passenger, Station } from '@/arcade-games/complement-race/types' +import { AbacusTarget } from '../AbacusTarget' +import { PassengerCard } from '../PassengerCard' +import { PressureGauge } from '../PressureGauge' interface RouteTheme { - emoji: string; - name: string; + emoji: string + name: string } interface GameHUDProps { - routeTheme: RouteTheme; - currentRoute: number; - periodName: string; - timeRemaining: number; - pressure: number; - nonDeliveredPassengers: Passenger[]; - stations: Station[]; - currentQuestion: ComplementQuestion | null; - currentInput: string; + routeTheme: RouteTheme + currentRoute: number + periodName: string + timeRemaining: number + pressure: number + nonDeliveredPassengers: Passenger[] + stations: Station[] + currentQuestion: ComplementQuestion | null + currentInput: string } export const GameHUD = memo( @@ -42,51 +42,47 @@ export const GameHUD = memo(
{/* Current Route */}
- {routeTheme.emoji} + {routeTheme.emoji}
-
- Route {currentRoute} -
-
- {routeTheme.name} -
+
Route {currentRoute}
+
{routeTheme.name}
{/* Time of Day */}
{periodName} @@ -97,16 +93,16 @@ export const GameHUD = memo(
@@ -117,11 +113,11 @@ export const GameHUD = memo(
@@ -132,27 +128,23 @@ export const GameHUD = memo(
{nonDeliveredPassengers.map((passenger) => ( s.id === passenger.originStationId, - )} - destinationStation={stations.find( - (s) => s.id === passenger.destinationStationId, - )} + originStation={stations.find((s) => s.id === passenger.originStationId)} + destinationStation={stations.find((s) => s.id === passenger.destinationStationId)} /> ))}
@@ -163,17 +155,16 @@ export const GameHUD = memo(
@@ -181,38 +172,38 @@ export const GameHUD = memo(
- {currentInput || "?"} + {currentInput || '?'} - + + + {currentQuestion.showAsAbacus ? (
@@ -220,16 +211,14 @@ export const GameHUD = memo( ) : ( {currentQuestion.number} )} - = - - {currentQuestion.targetSum} - + = + {currentQuestion.targetSum}
)} - ); - }, -); + ) + } +) -GameHUD.displayName = "GameHUD"; +GameHUD.displayName = 'GameHUD' diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx index 93d8e9fe..6633b8b7 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx @@ -1,49 +1,44 @@ -"use client"; +'use client' -import { useSpring, useSprings, animated, to } from "@react-spring/web"; -import { useMemo, useRef } from "react"; -import type { PlayerState } from "@/arcade-games/complement-race/types"; -import type { RailroadTrackGenerator } from "../../lib/RailroadTrackGenerator"; +import { useSpring, useSprings, animated, to } from '@react-spring/web' +import { useMemo, useRef } from 'react' +import type { PlayerState } from '@/arcade-games/complement-race/types' +import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' // Overlap threshold: if ghost car is within this distance of any local car, make it ghostly -const OVERLAP_THRESHOLD = 20; // % of track length -const GHOST_OPACITY = 0.35; // Opacity when overlapping -const SOLID_OPACITY = 1.0; // Opacity when separated +const OVERLAP_THRESHOLD = 20 // % of track length +const GHOST_OPACITY = 0.35 // Opacity when overlapping +const SOLID_OPACITY = 1.0 // Opacity when separated interface GhostTrainProps { - player: PlayerState; - trainPosition: number; - localTrainCarPositions: number[]; // [locomotive, car1, car2, car3] - maxCars: number; - carSpacing: number; - trackGenerator: RailroadTrackGenerator; - pathRef: React.RefObject; + player: PlayerState + trainPosition: number + localTrainCarPositions: number[] // [locomotive, car1, car2, car3] + maxCars: number + carSpacing: number + trackGenerator: RailroadTrackGenerator + pathRef: React.RefObject } interface CarTransform { - x: number; - y: number; - rotation: number; - opacity: number; - position: number; + x: number + y: number + rotation: number + opacity: number + position: number } /** * Calculate opacity for a ghost car based on distance to nearest local car */ -function calculateCarOpacity( - ghostCarPosition: number, - localCarPositions: number[], -): number { +function calculateCarOpacity(ghostCarPosition: number, localCarPositions: number[]): number { // Find minimum distance to any local car const minDistance = Math.min( - ...localCarPositions.map((localPos) => - Math.abs(ghostCarPosition - localPos), - ), - ); + ...localCarPositions.map((localPos) => Math.abs(ghostCarPosition - localPos)) + ) // If within threshold, use ghost opacity; otherwise solid - return minDistance < OVERLAP_THRESHOLD ? GHOST_OPACITY : SOLID_OPACITY; + return minDistance < OVERLAP_THRESHOLD ? GHOST_OPACITY : SOLID_OPACITY } /** @@ -60,25 +55,24 @@ export function GhostTrain({ trackGenerator, pathRef, }: GhostTrainProps) { - const ghostRef = useRef(null); + const ghostRef = useRef(null) // Calculate target transform for locomotive (used by spring animation) const locomotiveTarget = useMemo(() => { if (!pathRef.current) { - return null; + return null } - const pathLength = pathRef.current.getTotalLength(); - const targetDistance = (trainPosition / 100) * pathLength; - const point = pathRef.current.getPointAtLength(targetDistance); + const pathLength = pathRef.current.getTotalLength() + const targetDistance = (trainPosition / 100) * pathLength + const point = pathRef.current.getPointAtLength(targetDistance) // Calculate tangent for rotation - const tangentDelta = 1; - const tangentDistance = Math.min(targetDistance + tangentDelta, pathLength); - const tangentPoint = pathRef.current.getPointAtLength(tangentDistance); + const tangentDelta = 1 + const tangentDistance = Math.min(targetDistance + tangentDelta, pathLength) + const tangentPoint = pathRef.current.getPointAtLength(tangentDistance) const rotation = - (Math.atan2(tangentPoint.y - point.y, tangentPoint.x - point.x) * 180) / - Math.PI; + (Math.atan2(tangentPoint.y - point.y, tangentPoint.x - point.x) * 180) / Math.PI return { x: point.x, @@ -86,8 +80,8 @@ export function GhostTrain({ rotation, position: trainPosition, opacity: calculateCarOpacity(trainPosition, localTrainCarPositions), - }; - }, [trainPosition, localTrainCarPositions, pathRef]); + } + }, [trainPosition, localTrainCarPositions, pathRef]) // Animated spring for smooth locomotive movement const locomotiveSpring = useSpring({ @@ -96,32 +90,28 @@ export function GhostTrain({ rotation: locomotiveTarget?.rotation ?? 0, opacity: locomotiveTarget?.opacity ?? 1, config: { tension: 280, friction: 60 }, // Smooth but responsive - }); + }) // Calculate target transforms for cars (used by spring animations) const carTargets = useMemo(() => { if (!pathRef.current) { - return []; + return [] } - const pathLength = pathRef.current.getTotalLength(); - const cars: CarTransform[] = []; + const pathLength = pathRef.current.getTotalLength() + const cars: CarTransform[] = [] for (let i = 0; i < maxCars; i++) { - const carPosition = Math.max(0, trainPosition - (i + 1) * carSpacing); - const targetDistance = (carPosition / 100) * pathLength; - const point = pathRef.current.getPointAtLength(targetDistance); + const carPosition = Math.max(0, trainPosition - (i + 1) * carSpacing) + const targetDistance = (carPosition / 100) * pathLength + const point = pathRef.current.getPointAtLength(targetDistance) // Calculate tangent for rotation - const tangentDelta = 1; - const tangentDistance = Math.min( - targetDistance + tangentDelta, - pathLength, - ); - const tangentPoint = pathRef.current.getPointAtLength(tangentDistance); + const tangentDelta = 1 + const tangentDistance = Math.min(targetDistance + tangentDelta, pathLength) + const tangentPoint = pathRef.current.getPointAtLength(tangentDistance) const rotation = - (Math.atan2(tangentPoint.y - point.y, tangentPoint.x - point.x) * 180) / - Math.PI; + (Math.atan2(tangentPoint.y - point.y, tangentPoint.x - point.x) * 180) / Math.PI cars.push({ x: point.x, @@ -129,11 +119,11 @@ export function GhostTrain({ rotation, position: carPosition, opacity: calculateCarOpacity(carPosition, localTrainCarPositions), - }); + }) } - return cars; - }, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef]); + return cars + }, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef]) // Animated springs for smooth car movement (useSprings for multiple cars) const carSprings = useSprings( @@ -144,12 +134,12 @@ export function GhostTrain({ rotation: target.rotation, opacity: target.opacity, config: { tension: 280, friction: 60 }, - })), - ); + })) + ) // Don't render if position data isn't ready if (!locomotiveTarget) { - return null; + return null } return ( @@ -158,7 +148,7 @@ export function GhostTrain({ `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`, + (x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)` )} opacity={locomotiveSpring.opacity} > @@ -168,9 +158,9 @@ export function GhostTrain({ y={0} textAnchor="middle" style={{ - fontSize: "100px", - filter: `drop-shadow(0 2px 8px ${player.color || "rgba(100, 100, 255, 0.6)"})`, - pointerEvents: "none", + fontSize: '100px', + filter: `drop-shadow(0 2px 8px ${player.color || 'rgba(100, 100, 255, 0.6)'})`, + pointerEvents: 'none', }} > 🚂 @@ -183,12 +173,12 @@ export function GhostTrain({ y={-60} textAnchor="middle" style={{ - fontSize: "18px", - fontWeight: "bold", - fill: player.color || "#6366f1", - filter: "drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))", - pointerEvents: "none", - transform: "scaleX(-1)", // Counter the parent's scaleX(-1) + fontSize: '18px', + fontWeight: 'bold', + fill: player.color || '#6366f1', + filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))', + pointerEvents: 'none', + transform: 'scaleX(-1)', // Counter the parent's scaleX(-1) }} > {player.name || `Player ${player.id.slice(0, 4)}`} @@ -201,12 +191,12 @@ export function GhostTrain({ y={50} textAnchor="middle" style={{ - fontSize: "14px", - fontWeight: "bold", - fill: "rgba(255, 255, 255, 0.9)", - filter: "drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5))", - pointerEvents: "none", - transform: "scaleX(-1)", // Counter the parent's scaleX(-1) + fontSize: '14px', + fontWeight: 'bold', + fill: 'rgba(255, 255, 255, 0.9)', + filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5))', + pointerEvents: 'none', + transform: 'scaleX(-1)', // Counter the parent's scaleX(-1) }} > {player.score} @@ -219,7 +209,7 @@ export function GhostTrain({ key={`car-${index}`} transform={to( [spring.x, spring.y, spring.rotation], - (x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`, + (x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)` )} opacity={spring.opacity} > @@ -229,9 +219,9 @@ export function GhostTrain({ y={0} textAnchor="middle" style={{ - fontSize: "85px", - filter: `drop-shadow(0 2px 6px ${player.color || "rgba(100, 100, 255, 0.4)"})`, - pointerEvents: "none", + fontSize: '85px', + filter: `drop-shadow(0 2px 6px ${player.color || 'rgba(100, 100, 255, 0.4)'})`, + pointerEvents: 'none', }} > 🚃 @@ -239,5 +229,5 @@ export function GhostTrain({ ))} - ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx index a2d93010..1ac2862c 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/LinearTrack.tsx @@ -1,16 +1,16 @@ -"use client"; +'use client' -import { useGameMode } from "@/contexts/GameModeContext"; -import { useUserProfile } from "@/contexts/UserProfileContext"; -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import type { AIRacer } from "../../lib/gameTypes"; -import { SpeechBubble } from "../AISystem/SpeechBubble"; +import { useGameMode } from '@/contexts/GameModeContext' +import { useUserProfile } from '@/contexts/UserProfileContext' +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import type { AIRacer } from '../../lib/gameTypes' +import { SpeechBubble } from '../AISystem/SpeechBubble' interface LinearTrackProps { - playerProgress: number; - aiRacers: AIRacer[]; - raceGoal: number; - showFinishLine?: boolean; + playerProgress: number + aiRacers: AIRacer[] + raceGoal: number + showFinishLine?: boolean } export function LinearTrack({ @@ -19,75 +19,73 @@ export function LinearTrack({ raceGoal, showFinishLine = true, }: LinearTrackProps) { - const { state, dispatch } = useComplementRace(); - const { players, activePlayers } = useGameMode(); - const { profile: _profile } = useUserProfile(); + const { state, dispatch } = useComplementRace() + const { players, activePlayers } = useGameMode() + const { profile: _profile } = useUserProfile() // Get the current user's active local players (consistent with navbar pattern) const activeLocalPlayers = Array.from(activePlayers) .map((id) => players.get(id)) - .filter( - (p): p is NonNullable => p !== undefined && p.isLocal !== false, - ); - const playerEmoji = activeLocalPlayers[0]?.emoji ?? "👤"; + .filter((p): p is NonNullable => p !== undefined && p.isLocal !== false) + const playerEmoji = activeLocalPlayers[0]?.emoji ?? '👤' // Position calculation: leftPercent = Math.min(98, (progress / raceGoal) * 96 + 2) // 2% minimum (start), 98% maximum (near finish), 96% range for race const getPosition = (progress: number) => { - return Math.min(98, (progress / raceGoal) * 96 + 2); - }; + return Math.min(98, (progress / raceGoal) * 96 + 2) + } - const playerPosition = getPosition(playerProgress); + const playerPosition = getPosition(playerProgress) return (
{/* Track lines */}
@@ -95,14 +93,14 @@ export function LinearTrack({ {showFinishLine && (
)} @@ -110,13 +108,13 @@ export function LinearTrack({ {/* Player racer */}
@@ -125,20 +123,20 @@ export function LinearTrack({ {/* AI racers */} {aiRacers.map((racer, index) => { - const aiPosition = getPosition(racer.position); - const activeBubble = state.activeSpeechBubbles.get(racer.id); + const aiPosition = getPosition(racer.position) + const activeBubble = state.activeSpeechBubbles.get(racer.id) return (
@@ -146,42 +144,40 @@ export function LinearTrack({ {activeBubble && (
- dispatch({ type: "CLEAR_AI_COMMENT", racerId: racer.id }) - } + onHide={() => dispatch({ type: 'CLEAR_AI_COMMENT', racerId: racer.id })} />
)}
- ); + ) })} {/* Progress indicator */}
{playerProgress} / {raceGoal}
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/RailroadTrackPath.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/RailroadTrackPath.tsx index a74265df..5ed7e147 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/RailroadTrackPath.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/RailroadTrackPath.tsx @@ -1,24 +1,24 @@ -"use client"; +'use client' -import { memo } from "react"; -import type { Passenger, Station } from "@/arcade-games/complement-race/types"; -import type { Landmark } from "../../lib/landmarks"; +import { memo } from 'react' +import type { Passenger, Station } from '@/arcade-games/complement-race/types' +import type { Landmark } from '../../lib/landmarks' interface RailroadTrackPathProps { tiesAndRails: { - ties: Array<{ x1: number; y1: number; x2: number; y2: number }>; - leftRailPath: string; - rightRailPath: string; - } | null; - referencePath: string; - pathRef: React.RefObject; - landmarkPositions: Array<{ x: number; y: number }>; - landmarks: Landmark[]; - stationPositions: Array<{ x: number; y: number }>; - stations: Station[]; - passengers: Passenger[]; - boardingAnimations: Map; - disembarkingAnimations: Map; + ties: Array<{ x1: number; y1: number; x2: number; y2: number }> + leftRailPath: string + rightRailPath: string + } | null + referencePath: string + pathRef: React.RefObject + landmarkPositions: Array<{ x: number; y: number }> + landmarks: Landmark[] + stationPositions: Array<{ x: number; y: number }> + stations: Station[] + passengers: Passenger[] + boardingAnimations: Map + disembarkingAnimations: Map } export const RailroadTrackPath = memo( @@ -76,13 +76,7 @@ export const RailroadTrackPath = memo( )} {/* Reference path (invisible, used for positioning) */} - + {/* Landmarks - background scenery */} {landmarkPositions.map((pos, index) => ( @@ -93,9 +87,9 @@ export const RailroadTrackPath = memo( textAnchor="middle" style={{ fontSize: `${(landmarks[index]?.size || 24) * 2.0}px`, - pointerEvents: "none", + pointerEvents: 'none', opacity: 0.7, - filter: "drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2))", + filter: 'drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2))', }} > {landmarks[index]?.emoji} @@ -104,7 +98,7 @@ export const RailroadTrackPath = memo( {/* Station markers */} {stationPositions.map((pos, index) => { - const station = stations[index]; + const station = stations[index] // Find passengers waiting at this station (exclude currently boarding) // Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered const waitingPassengers = passengers.filter( @@ -112,15 +106,15 @@ export const RailroadTrackPath = memo( p.originStationId === station?.id && p.claimedBy === null && p.deliveredBy === null && - !boardingAnimations.has(p.id), - ); + !boardingAnimations.has(p.id) + ) // Find passengers delivered at this station (exclude currently disembarking) const deliveredPassengers = passengers.filter( (p) => p.destinationStationId === station?.id && p.deliveredBy !== null && - !disembarkingAnimations.has(p.id), - ); + !disembarkingAnimations.has(p.id) + ) return ( @@ -139,7 +133,7 @@ export const RailroadTrackPath = memo( y={pos.y - 40} textAnchor="middle" fontSize="48" - style={{ pointerEvents: "none" }} + style={{ pointerEvents: 'none' }} > {station?.icon} @@ -154,12 +148,11 @@ export const RailroadTrackPath = memo( strokeWidth="0.5" style={{ fontWeight: 900, - pointerEvents: "none", - fontFamily: - '"Comic Sans MS", "Chalkboard SE", "Bradley Hand", cursive', - textShadow: "0 2px 4px rgba(0, 0, 0, 0.2)", - letterSpacing: "0.5px", - paintOrder: "stroke fill", + pointerEvents: 'none', + fontFamily: '"Comic Sans MS", "Chalkboard SE", "Bradley Hand", cursive', + textShadow: '0 2px 4px rgba(0, 0, 0, 0.2)', + letterSpacing: '0.5px', + paintOrder: 'stroke fill', }} > {station?.name} @@ -173,11 +166,11 @@ export const RailroadTrackPath = memo( y={pos.y - 30} textAnchor="middle" style={{ - fontSize: "55px", - pointerEvents: "none", + fontSize: '55px', + pointerEvents: 'none', filter: passenger.isUrgent - ? "drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))" - : "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))", + ? 'drop-shadow(0 0 8px rgba(245, 158, 11, 0.8))' + : 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))', }} > {passenger.avatar} @@ -188,27 +181,25 @@ export const RailroadTrackPath = memo( {deliveredPassengers.map((passenger, pIndex) => ( {passenger.avatar} ))} - ); + ) })} - ); - }, -); + ) + } +) -RailroadTrackPath.displayName = "RailroadTrackPath"; +RailroadTrackPath.displayName = 'RailroadTrackPath' diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx index 708526c0..f448ba1e 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx @@ -1,55 +1,53 @@ -"use client"; +'use client' -import { animated, useSpring } from "@react-spring/web"; -import { memo, useMemo, useRef, useState } from "react"; -import { useGameMode } from "@/contexts/GameModeContext"; -import { useUserProfile } from "@/contexts/UserProfileContext"; -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; +import { animated, useSpring } from '@react-spring/web' +import { memo, useMemo, useRef, useState } from 'react' +import { useGameMode } from '@/contexts/GameModeContext' +import { useUserProfile } from '@/contexts/UserProfileContext' +import { useComplementRace } from '@/arcade-games/complement-race/Provider' import { type BoardingAnimation, type DisembarkingAnimation, usePassengerAnimations, -} from "../../hooks/usePassengerAnimations"; -import type { ComplementQuestion } from "../../lib/gameTypes"; -import { useSteamJourney } from "../../hooks/useSteamJourney"; -import { useTrackManagement } from "../../hooks/useTrackManagement"; -import { useTrainTransforms } from "../../hooks/useTrainTransforms"; -import { RailroadTrackGenerator } from "../../lib/RailroadTrackGenerator"; -import { getRouteTheme } from "../../lib/routeThemes"; -import { GameHUD } from "./GameHUD"; -import { RailroadTrackPath } from "./RailroadTrackPath"; -import { TrainAndCars } from "./TrainAndCars"; -import { TrainTerrainBackground } from "./TrainTerrainBackground"; -import { GhostTrain } from "./GhostTrain"; +} from '../../hooks/usePassengerAnimations' +import type { ComplementQuestion } from '../../lib/gameTypes' +import { useSteamJourney } from '../../hooks/useSteamJourney' +import { useTrackManagement } from '../../hooks/useTrackManagement' +import { useTrainTransforms } from '../../hooks/useTrainTransforms' +import { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' +import { getRouteTheme } from '../../lib/routeThemes' +import { GameHUD } from './GameHUD' +import { RailroadTrackPath } from './RailroadTrackPath' +import { TrainAndCars } from './TrainAndCars' +import { TrainTerrainBackground } from './TrainTerrainBackground' +import { GhostTrain } from './GhostTrain' -const BoardingPassengerAnimation = memo( - ({ animation }: { animation: BoardingAnimation }) => { - const spring = useSpring({ - from: { x: animation.fromX, y: animation.fromY, opacity: 1 }, - to: { x: animation.toX, y: animation.toY, opacity: 1 }, - config: { tension: 120, friction: 14 }, - }); +const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => { + const spring = useSpring({ + from: { x: animation.fromX, y: animation.fromY, opacity: 1 }, + to: { x: animation.toX, y: animation.toY, opacity: 1 }, + config: { tension: 120, friction: 14 }, + }) - return ( - - {animation.passenger.avatar} - - ); - }, -); -BoardingPassengerAnimation.displayName = "BoardingPassengerAnimation"; + return ( + + {animation.passenger.avatar} + + ) +}) +BoardingPassengerAnimation.displayName = 'BoardingPassengerAnimation' const DisembarkingPassengerAnimation = memo( ({ animation }: { animation: DisembarkingAnimation }) => { @@ -57,7 +55,7 @@ const DisembarkingPassengerAnimation = memo( from: { x: animation.fromX, y: animation.fromY, opacity: 1 }, to: { x: animation.toX, y: animation.toY, opacity: 1 }, config: { tension: 120, friction: 14 }, - }); + }) return ( {animation.passenger.avatar} - ); - }, -); -DisembarkingPassengerAnimation.displayName = "DisembarkingPassengerAnimation"; + ) + } +) +DisembarkingPassengerAnimation.displayName = 'DisembarkingPassengerAnimation' interface SteamTrainJourneyProps { - momentum: number; - trainPosition: number; - pressure: number; - elapsedTime: number; - currentQuestion: ComplementQuestion | null; - currentInput: string; + momentum: number + trainPosition: number + pressure: number + elapsedTime: number + currentQuestion: ComplementQuestion | null + currentInput: string } export function SteamTrainJourney({ @@ -95,28 +93,28 @@ export function SteamTrainJourney({ currentQuestion, currentInput, }: SteamTrainJourneyProps) { - const { state, multiplayerState, localPlayerId } = useComplementRace(); + const { state, multiplayerState, localPlayerId } = useComplementRace() - const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney(); - const _skyGradient = getSkyGradient(); - const period = getTimeOfDayPeriod(); - const { players } = useGameMode(); - const { profile: _profile } = useUserProfile(); + const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney() + const _skyGradient = getSkyGradient() + const period = getTimeOfDayPeriod() + const { players } = useGameMode() + const { profile: _profile } = useUserProfile() // Get the LOCAL player's emoji (not just the first player!) - const activePlayers = Array.from(players.values()).filter((p) => p.isActive); - const localPlayer = activePlayers.find((p) => p.isLocal); - const playerEmoji = localPlayer?.emoji ?? "👤"; + const activePlayers = Array.from(players.values()).filter((p) => p.isActive) + const localPlayer = activePlayers.find((p) => p.isLocal) + const playerEmoji = localPlayer?.emoji ?? '👤' - const svgRef = useRef(null); - const pathRef = useRef(null); - const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600)); + const svgRef = useRef(null) + const pathRef = useRef(null) + const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600)) // Use server's authoritative maxConcurrentPassengers calculation // This ensures visual display matches game logic and console logs - const maxCars = Math.max(1, state.maxConcurrentPassengers || 3); + const maxCars = Math.max(1, state.maxConcurrentPassengers || 3) - const carSpacing = 7; // Distance between cars (in % of track) + const carSpacing = 7 // Distance between cars (in % of track) // Train transforms (extracted to hook) const { trainTransform, trainCars, locomotiveOpacity } = useTrainTransforms({ @@ -125,7 +123,7 @@ export function SteamTrainJourney({ pathRef, maxCars, carSpacing, - }); + }) // Track management (extracted to hook) const { @@ -144,35 +142,26 @@ export function SteamTrainJourney({ passengers: state.passengers, maxCars, carSpacing, - }); + }) // Passenger animations (extracted to hook) - const { boardingAnimations, disembarkingAnimations } = usePassengerAnimations( - { - passengers: state.passengers, - stations: state.stations, - stationPositions, - trainPosition, - trackGenerator, - pathRef, - }, - ); + const { boardingAnimations, disembarkingAnimations } = usePassengerAnimations({ + passengers: state.passengers, + stations: state.stations, + stationPositions, + trainPosition, + trackGenerator, + pathRef, + }) // Time remaining (60 seconds total) - const timeRemaining = Math.max(0, 60 - Math.floor(elapsedTime / 1000)); + const timeRemaining = Math.max(0, 60 - Math.floor(elapsedTime / 1000)) // Period names for display - const periodNames = [ - "Dawn", - "Morning", - "Midday", - "Afternoon", - "Dusk", - "Night", - ]; + const periodNames = ['Dawn', 'Morning', 'Midday', 'Afternoon', 'Dusk', 'Night'] // Get current route theme - const routeTheme = getRouteTheme(state.currentRoute); + const routeTheme = getRouteTheme(state.currentRoute) // Memoize filtered passenger lists to avoid recalculating on every render // Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered @@ -180,18 +169,15 @@ export function SteamTrainJourney({ const boardedPassengers = useMemo( () => displayPassengers.filter( - (p) => - p.claimedBy === localPlayer?.id && - p.claimedBy !== null && - p.deliveredBy === null, + (p) => p.claimedBy === localPlayer?.id && p.claimedBy !== null && p.deliveredBy === null ), - [displayPassengers, localPlayer?.id], - ); + [displayPassengers, localPlayer?.id] + ) const nonDeliveredPassengers = useMemo( () => displayPassengers.filter((p) => p.deliveredBy === null), - [displayPassengers], - ); + [displayPassengers] + ) // Memoize ground texture circles to avoid recreating on every render const groundTextureCircles = useMemo( @@ -202,48 +188,46 @@ export function SteamTrainJourney({ cy: 140 + (i % 5) * 60, r: 2 + (i % 3), })), - [], - ); + [] + ) // Calculate local train car positions for ghost train overlap detection // Array includes locomotive + all cars: [locomotive, car1, car2, car3] const localTrainCarPositions = useMemo(() => { - const positions = [trainPosition]; // Locomotive at front + const positions = [trainPosition] // Locomotive at front for (let i = 0; i < maxCars; i++) { - positions.push(Math.max(0, trainPosition - (i + 1) * carSpacing)); + positions.push(Math.max(0, trainPosition - (i + 1) * carSpacing)) } - return positions; - }, [trainPosition, maxCars, carSpacing]); + return positions + }, [trainPosition, maxCars, carSpacing]) // Get other players for ghost trains (filter out local player) const otherPlayers = useMemo(() => { if (!multiplayerState?.players || !localPlayerId) { - return []; + return [] } const filtered = Object.entries(multiplayerState.players) - .filter( - ([playerId, player]) => playerId !== localPlayerId && player.isActive, - ) - .map(([_, player]) => player); + .filter(([playerId, player]) => playerId !== localPlayerId && player.isActive) + .map(([_, player]) => player) - return filtered; - }, [multiplayerState?.players, localPlayerId]); + return filtered + }, [multiplayerState?.players, localPlayerId]) - if (!trackData) return null; + if (!trackData) return null return (
{/* Game HUD - overlays and UI elements */} @@ -265,10 +249,10 @@ export function SteamTrainJourney({ ref={svgRef} viewBox="-50 -50 900 700" style={{ - width: "100%", - height: "auto", - aspectRatio: "800 / 600", - overflow: "visible", + width: '100%', + height: 'auto', + aspectRatio: '800 / 600', + overflow: 'visible', }} > {/* Terrain background - ground, mountains, and tunnels */} @@ -370,5 +354,5 @@ export function SteamTrainJourney({ } `}
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainAndCars.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainAndCars.tsx index 05f93a8b..58c22a95 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainAndCars.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainAndCars.tsx @@ -1,41 +1,38 @@ -"use client"; +'use client' -import { memo } from "react"; -import type { - BoardingAnimation, - DisembarkingAnimation, -} from "../../hooks/usePassengerAnimations"; -import type { Passenger } from "@/arcade-games/complement-race/types"; +import { memo } from 'react' +import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations' +import type { Passenger } from '@/arcade-games/complement-race/types' interface TrainCarTransform { - x: number; - y: number; - rotation: number; - position: number; - opacity: number; + x: number + y: number + rotation: number + position: number + opacity: number } interface TrainTransform { - x: number; - y: number; - rotation: number; + x: number + y: number + rotation: number } interface TrainAndCarsProps { - boardingAnimations: Map; - disembarkingAnimations: Map; + boardingAnimations: Map + disembarkingAnimations: Map BoardingPassengerAnimation: React.ComponentType<{ - animation: BoardingAnimation; - }>; + animation: BoardingAnimation + }> DisembarkingPassengerAnimation: React.ComponentType<{ - animation: DisembarkingAnimation; - }>; - trainCars: TrainCarTransform[]; - boardedPassengers: Passenger[]; - trainTransform: TrainTransform; - locomotiveOpacity: number; - playerEmoji: string; - momentum: number; + animation: DisembarkingAnimation + }> + trainCars: TrainCarTransform[] + boardedPassengers: Passenger[] + trainTransform: TrainTransform + locomotiveOpacity: number + playerEmoji: string + momentum: number } export const TrainAndCars = memo( @@ -72,7 +69,7 @@ export const TrainAndCars = memo( {/* Train cars - render in reverse order so locomotive appears on top */} {trainCars.map((carTransform, carIndex) => { // Assign passenger to this car (if one exists for this car index) - const passenger = boardedPassengers[carIndex]; + const passenger = boardedPassengers[carIndex] return ( {/* Train car */} @@ -91,9 +88,9 @@ export const TrainAndCars = memo( y={0} textAnchor="middle" style={{ - fontSize: "65px", - filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))", - pointerEvents: "none", + fontSize: '65px', + filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))', + pointerEvents: 'none', }} > 🚃 @@ -107,18 +104,18 @@ export const TrainAndCars = memo( y={0} textAnchor="middle" style={{ - fontSize: "42px", + fontSize: '42px', filter: passenger.isUrgent - ? "drop-shadow(0 0 6px rgba(245, 158, 11, 0.8))" - : "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))", - pointerEvents: "none", + ? 'drop-shadow(0 0 6px rgba(245, 158, 11, 0.8))' + : 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))', + pointerEvents: 'none', }} > {passenger.avatar} )} - ); + ) })} {/* Locomotive - rendered last so it appears on top */} @@ -127,7 +124,7 @@ export const TrainAndCars = memo( transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`} opacity={locomotiveOpacity} style={{ - transition: "opacity 0.5s ease-in", + transition: 'opacity 0.5s ease-in', }} > {/* Train locomotive */} @@ -137,9 +134,9 @@ export const TrainAndCars = memo( y={0} textAnchor="middle" style={{ - fontSize: "100px", - filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))", - pointerEvents: "none", + fontSize: '100px', + filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))', + pointerEvents: 'none', }} > 🚂 @@ -152,9 +149,9 @@ export const TrainAndCars = memo( y={0} textAnchor="middle" style={{ - fontSize: "70px", - filter: "drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))", - pointerEvents: "none", + fontSize: '70px', + filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))', + pointerEvents: 'none', }} > {playerEmoji} @@ -170,10 +167,10 @@ export const TrainAndCars = memo( r="10" fill="rgba(255, 255, 255, 0.6)" style={{ - filter: "blur(4px)", + filter: 'blur(4px)', animation: `steamPuffSVG 2s ease-out infinite`, animationDelay: `${delay}s`, - pointerEvents: "none", + pointerEvents: 'none', }} /> ))} @@ -188,16 +185,16 @@ export const TrainAndCars = memo( r="3" fill="#2c2c2c" style={{ - animation: "coalFallingSVG 1.2s ease-out infinite", + animation: 'coalFallingSVG 1.2s ease-out infinite', animationDelay: `${delay}s`, - pointerEvents: "none", + pointerEvents: 'none', }} /> ))} - ); - }, -); + ) + } +) -TrainAndCars.displayName = "TrainAndCars"; +TrainAndCars.displayName = 'TrainAndCars' diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainTerrainBackground.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainTerrainBackground.tsx index abfd3262..53ae8baf 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainTerrainBackground.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/TrainTerrainBackground.tsx @@ -1,15 +1,15 @@ -"use client"; +'use client' -import { memo } from "react"; +import { memo } from 'react' interface TrainTerrainBackgroundProps { - ballastPath: string; + ballastPath: string groundTextureCircles: Array<{ - key: string; - cx: number; - cy: number; - r: number; - }>; + key: string + cx: number + cy: number + r: number + }> } export const TrainTerrainBackground = memo( @@ -18,55 +18,19 @@ export const TrainTerrainBackground = memo( <> {/* Gradient definitions for mountain shading and ground */} - - - - + + + + - - - - + + + + - - + + @@ -74,13 +38,7 @@ export const TrainTerrainBackground = memo( {/* Ground surface gradient for depth */} - + {/* Ground texture - scattered rocks/pebbles */} {groundTextureCircles.map((circle) => ( @@ -95,13 +53,7 @@ export const TrainTerrainBackground = memo( ))} {/* Railroad ballast (gravel bed) */} - + {/* Left mountain and tunnel */} @@ -112,10 +64,7 @@ export const TrainTerrainBackground = memo( {/* Mountain ridge shading */} - + {/* Tunnel depth/interior (dark entrance) */} @@ -156,10 +105,7 @@ export const TrainTerrainBackground = memo( {/* Mountain ridge shading */} - + {/* Tunnel depth/interior (dark entrance) */} @@ -191,8 +137,8 @@ export const TrainTerrainBackground = memo( /> - ); - }, -); + ) + } +) -TrainTerrainBackground.displayName = "TrainTerrainBackground"; +TrainTerrainBackground.displayName = 'TrainTerrainBackground' diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/__tests__/GameHUD.test.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/__tests__/GameHUD.test.tsx index 83161bd9..64bf78a9 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/__tests__/GameHUD.test.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/__tests__/GameHUD.test.tsx @@ -1,61 +1,61 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, test, vi } from "vitest"; -import type { Passenger, Station } from "@/arcade-games/complement-race/types"; -import { GameHUD } from "../GameHUD"; +import { render, screen } from '@testing-library/react' +import { describe, expect, test, vi } from 'vitest' +import type { Passenger, Station } from '@/arcade-games/complement-race/types' +import { GameHUD } from '../GameHUD' // Mock child components -vi.mock("../../PassengerCard", () => ({ +vi.mock('../../PassengerCard', () => ({ PassengerCard: ({ passenger }: { passenger: Passenger }) => (
{passenger.avatar}
), -})); +})) -vi.mock("../../PressureGauge", () => ({ +vi.mock('../../PressureGauge', () => ({ PressureGauge: ({ pressure }: { pressure: number }) => (
{pressure}
), -})); +})) -describe("GameHUD", () => { +describe('GameHUD', () => { const mockRouteTheme = { - emoji: "🚂", - name: "Mountain Pass", - }; + emoji: '🚂', + name: 'Mountain Pass', + } const mockStations: Station[] = [ { - id: "station-1", - name: "Station 1", + id: 'station-1', + name: 'Station 1', position: 20, - icon: "🏭", - emoji: "🏭", + icon: '🏭', + emoji: '🏭', }, { - id: "station-2", - name: "Station 2", + id: 'station-2', + name: 'Station 2', position: 60, - icon: "🏛️", - emoji: "🏛️", + icon: '🏛️', + emoji: '🏛️', }, - ]; + ] const mockPassenger: Passenger = { - id: "passenger-1", - name: "Test Passenger", - avatar: "👨", - originStationId: "station-1", - destinationStationId: "station-2", + id: 'passenger-1', + name: 'Test Passenger', + avatar: '👨', + originStationId: 'station-1', + destinationStationId: 'station-2', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), - }; + } const defaultProps = { routeTheme: mockRouteTheme, currentRoute: 1, - periodName: "🌅 Dawn", + periodName: '🌅 Dawn', timeRemaining: 45, pressure: 75, nonDeliveredPassengers: [], @@ -66,118 +66,116 @@ describe("GameHUD", () => { correctAnswer: 7, showAsAbacus: false, }, - currentInput: "7", - }; + currentInput: '7', + } - test("renders route information", () => { - render(); + test('renders route information', () => { + render() - expect(screen.getByText("Route 1")).toBeInTheDocument(); - expect(screen.getByText("Mountain Pass")).toBeInTheDocument(); - expect(screen.getByText("🚂")).toBeInTheDocument(); - }); + expect(screen.getByText('Route 1')).toBeInTheDocument() + expect(screen.getByText('Mountain Pass')).toBeInTheDocument() + expect(screen.getByText('🚂')).toBeInTheDocument() + }) - test("renders time of day period", () => { - render(); + test('renders time of day period', () => { + render() - expect(screen.getByText("🌅 Dawn")).toBeInTheDocument(); - }); + expect(screen.getByText('🌅 Dawn')).toBeInTheDocument() + }) - test("renders time remaining", () => { - render(); + test('renders time remaining', () => { + render() - expect(screen.getByText(/45s/)).toBeInTheDocument(); - }); + expect(screen.getByText(/45s/)).toBeInTheDocument() + }) - test("renders pressure gauge", () => { - render(); + test('renders pressure gauge', () => { + render() - expect(screen.getByTestId("pressure-gauge")).toBeInTheDocument(); - expect(screen.getByText("75")).toBeInTheDocument(); - }); + expect(screen.getByTestId('pressure-gauge')).toBeInTheDocument() + expect(screen.getByText('75')).toBeInTheDocument() + }) - test("renders passenger list when passengers exist", () => { - render( - , - ); + test('renders passenger list when passengers exist', () => { + render() - expect(screen.getByTestId("passenger-card")).toBeInTheDocument(); - expect(screen.getByText("👨")).toBeInTheDocument(); - }); + expect(screen.getByTestId('passenger-card')).toBeInTheDocument() + expect(screen.getByText('👨')).toBeInTheDocument() + }) - test("does not render passenger list when empty", () => { - render(); + test('does not render passenger list when empty', () => { + render() - expect(screen.queryByTestId("passenger-card")).not.toBeInTheDocument(); - }); + expect(screen.queryByTestId('passenger-card')).not.toBeInTheDocument() + }) - test("renders current question when provided", () => { - render(); + test('renders current question when provided', () => { + render() - expect(screen.getByText("7")).toBeInTheDocument(); // currentInput - expect(screen.getByText("3")).toBeInTheDocument(); // question.number - expect(screen.getByText("10")).toBeInTheDocument(); // targetSum - expect(screen.getByText("+")).toBeInTheDocument(); - expect(screen.getByText("=")).toBeInTheDocument(); - }); + expect(screen.getByText('7')).toBeInTheDocument() // currentInput + expect(screen.getByText('3')).toBeInTheDocument() // question.number + expect(screen.getByText('10')).toBeInTheDocument() // targetSum + expect(screen.getByText('+')).toBeInTheDocument() + expect(screen.getByText('=')).toBeInTheDocument() + }) - test("shows question mark when no input", () => { - render(); + test('shows question mark when no input', () => { + render() - expect(screen.getByText("?")).toBeInTheDocument(); - }); + expect(screen.getByText('?')).toBeInTheDocument() + }) - test("does not render question display when currentQuestion is null", () => { - render(); + test('does not render question display when currentQuestion is null', () => { + render() - expect(screen.queryByText("+")).not.toBeInTheDocument(); - expect(screen.queryByText("=")).not.toBeInTheDocument(); - }); + expect(screen.queryByText('+')).not.toBeInTheDocument() + expect(screen.queryByText('=')).not.toBeInTheDocument() + }) - test("renders multiple passengers", () => { + test('renders multiple passengers', () => { const passengers = [ mockPassenger, - { ...mockPassenger, id: "passenger-2", avatar: "👩" }, - { ...mockPassenger, id: "passenger-3", avatar: "👧" }, - ]; + { ...mockPassenger, id: 'passenger-2', avatar: '👩' }, + { ...mockPassenger, id: 'passenger-3', avatar: '👧' }, + ] - render(); + render() - expect(screen.getAllByTestId("passenger-card")).toHaveLength(3); - expect(screen.getByText("👨")).toBeInTheDocument(); - expect(screen.getByText("👩")).toBeInTheDocument(); - expect(screen.getByText("👧")).toBeInTheDocument(); - }); + expect(screen.getAllByTestId('passenger-card')).toHaveLength(3) + expect(screen.getByText('👨')).toBeInTheDocument() + expect(screen.getByText('👩')).toBeInTheDocument() + expect(screen.getByText('👧')).toBeInTheDocument() + }) - test("updates when route changes", () => { - const { rerender } = render(); + test('updates when route changes', () => { + const { rerender } = render() - expect(screen.getByText("Route 1")).toBeInTheDocument(); + expect(screen.getByText('Route 1')).toBeInTheDocument() - rerender(); + rerender() - expect(screen.getByText("Route 2")).toBeInTheDocument(); - }); + expect(screen.getByText('Route 2')).toBeInTheDocument() + }) - test("updates when time remaining changes", () => { - const { rerender } = render(); + test('updates when time remaining changes', () => { + const { rerender } = render() - expect(screen.getByText(/45s/)).toBeInTheDocument(); + expect(screen.getByText(/45s/)).toBeInTheDocument() - rerender(); + rerender() - expect(screen.getByText(/30s/)).toBeInTheDocument(); - }); + expect(screen.getByText(/30s/)).toBeInTheDocument() + }) - test("memoization: same props do not cause re-render", () => { - const { rerender, container } = render(); + test('memoization: same props do not cause re-render', () => { + const { rerender, container } = render() - const initialHTML = container.innerHTML; + const initialHTML = container.innerHTML // Rerender with same props - rerender(); + rerender() // Should be memoized (same HTML) - expect(container.innerHTML).toBe(initialHTML); - }); -}); + expect(container.innerHTML).toBe(initialHTML) + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/__tests__/TrainTerrainBackground.test.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/__tests__/TrainTerrainBackground.test.tsx index 6ab4bfdb..4b7426e6 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/__tests__/TrainTerrainBackground.test.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/__tests__/TrainTerrainBackground.test.tsx @@ -1,189 +1,179 @@ -import { render } from "@testing-library/react"; -import { describe, expect, test } from "vitest"; -import { TrainTerrainBackground } from "../TrainTerrainBackground"; +import { render } from '@testing-library/react' +import { describe, expect, test } from 'vitest' +import { TrainTerrainBackground } from '../TrainTerrainBackground' -describe("TrainTerrainBackground", () => { +describe('TrainTerrainBackground', () => { const mockGroundCircles = [ - { key: "ground-1", cx: 10, cy: 150, r: 2 }, - { key: "ground-2", cx: 40, cy: 180, r: 3 }, - ]; + { key: 'ground-1', cx: 10, cy: 150, r: 2 }, + { key: 'ground-2', cx: 40, cy: 180, r: 3 }, + ] - test("renders without crashing", () => { + test('renders without crashing', () => { const { container } = render( - , - ); + + ) - expect(container).toBeTruthy(); - }); + expect(container).toBeTruthy() + }) - test("renders gradient definitions", () => { + test('renders gradient definitions', () => { const { container } = render( - , - ); + + ) - const defs = container.querySelector("defs"); - expect(defs).toBeTruthy(); + const defs = container.querySelector('defs') + expect(defs).toBeTruthy() // Check for gradient IDs - expect(container.querySelector("#mountainGradientLeft")).toBeTruthy(); - expect(container.querySelector("#mountainGradientRight")).toBeTruthy(); - expect(container.querySelector("#groundGradient")).toBeTruthy(); - }); + expect(container.querySelector('#mountainGradientLeft')).toBeTruthy() + expect(container.querySelector('#mountainGradientRight')).toBeTruthy() + expect(container.querySelector('#groundGradient')).toBeTruthy() + }) - test("renders ground layer rects", () => { + test('renders ground layer rects', () => { const { container } = render( - , - ); + + ) - const rects = container.querySelectorAll("rect"); - expect(rects.length).toBeGreaterThan(0); + const rects = container.querySelectorAll('rect') + expect(rects.length).toBeGreaterThan(0) // Check for ground base layer const groundRect = Array.from(rects).find( - (rect) => - rect.getAttribute("fill") === "#8B7355" && - rect.getAttribute("width") === "900", - ); - expect(groundRect).toBeTruthy(); - }); + (rect) => rect.getAttribute('fill') === '#8B7355' && rect.getAttribute('width') === '900' + ) + expect(groundRect).toBeTruthy() + }) - test("renders ground texture circles", () => { + test('renders ground texture circles', () => { const { container } = render( - , - ); + + ) - const circles = container.querySelectorAll("circle"); - expect(circles.length).toBeGreaterThanOrEqual(2); + const circles = container.querySelectorAll('circle') + expect(circles.length).toBeGreaterThanOrEqual(2) // Verify circle attributes - const firstCircle = circles[0]; - expect(firstCircle.getAttribute("cx")).toBe("10"); - expect(firstCircle.getAttribute("cy")).toBe("150"); - expect(firstCircle.getAttribute("r")).toBe("2"); - }); + const firstCircle = circles[0] + expect(firstCircle.getAttribute('cx')).toBe('10') + expect(firstCircle.getAttribute('cy')).toBe('150') + expect(firstCircle.getAttribute('r')).toBe('2') + }) - test("renders ballast path with correct attributes", () => { + test('renders ballast path with correct attributes', () => { const { container } = render( - , - ); + + ) - const ballastPath = Array.from(container.querySelectorAll("path")).find( + const ballastPath = Array.from(container.querySelectorAll('path')).find( (path) => - path.getAttribute("d") === "M 0 300 L 800 300" && - path.getAttribute("stroke") === "#8B7355", - ); - expect(ballastPath).toBeTruthy(); - expect(ballastPath?.getAttribute("stroke-width")).toBe("40"); - }); + path.getAttribute('d') === 'M 0 300 L 800 300' && path.getAttribute('stroke') === '#8B7355' + ) + expect(ballastPath).toBeTruthy() + expect(ballastPath?.getAttribute('stroke-width')).toBe('40') + }) - test("renders left tunnel structure", () => { + test('renders left tunnel structure', () => { const { container } = render( - , - ); + + ) - const leftTunnel = container.querySelector('[data-element="left-tunnel"]'); - expect(leftTunnel).toBeTruthy(); + const leftTunnel = container.querySelector('[data-element="left-tunnel"]') + expect(leftTunnel).toBeTruthy() // Check for tunnel elements - const ellipses = leftTunnel?.querySelectorAll("ellipse"); - expect(ellipses?.length).toBeGreaterThan(0); - }); + const ellipses = leftTunnel?.querySelectorAll('ellipse') + expect(ellipses?.length).toBeGreaterThan(0) + }) - test("renders right tunnel structure", () => { + test('renders right tunnel structure', () => { const { container } = render( - , - ); + + ) - const rightTunnel = container.querySelector( - '[data-element="right-tunnel"]', - ); - expect(rightTunnel).toBeTruthy(); + const rightTunnel = container.querySelector('[data-element="right-tunnel"]') + expect(rightTunnel).toBeTruthy() // Check for tunnel elements - const ellipses = rightTunnel?.querySelectorAll("ellipse"); - expect(ellipses?.length).toBeGreaterThan(0); - }); + const ellipses = rightTunnel?.querySelectorAll('ellipse') + expect(ellipses?.length).toBeGreaterThan(0) + }) - test("renders mountains with gradient fills", () => { + test('renders mountains with gradient fills', () => { const { container } = render( - , - ); + + ) // Check for paths with gradient fills - const gradientPaths = Array.from(container.querySelectorAll("path")).filter( - (path) => path.getAttribute("fill")?.includes("url(#mountainGradient"), - ); - expect(gradientPaths.length).toBeGreaterThanOrEqual(2); - }); + const gradientPaths = Array.from(container.querySelectorAll('path')).filter((path) => + path.getAttribute('fill')?.includes('url(#mountainGradient') + ) + expect(gradientPaths.length).toBeGreaterThanOrEqual(2) + }) - test("handles empty groundTextureCircles array", () => { + test('handles empty groundTextureCircles array', () => { const { container } = render( - - , - ); + + + ) // Should still render other elements - expect(container.querySelector("defs")).toBeTruthy(); - expect( - container.querySelector('[data-element="left-tunnel"]'), - ).toBeTruthy(); - }); + expect(container.querySelector('defs')).toBeTruthy() + expect(container.querySelector('[data-element="left-tunnel"]')).toBeTruthy() + }) - test("memoization: does not re-render with same props", () => { + test('memoization: does not re-render with same props', () => { const { rerender, container } = render( - , - ); + + ) - const initialHTML = container.innerHTML; + const initialHTML = container.innerHTML // Rerender with same props rerender( @@ -192,10 +182,10 @@ describe("TrainTerrainBackground", () => { ballastPath="M 0 300 L 800 300" groundTextureCircles={mockGroundCircles} /> - , - ); + + ) // HTML should be identical (component memoized) - expect(container.innerHTML).toBe(initialHTML); - }); -}); + expect(container.innerHTML).toBe(initialHTML) + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/components/RouteCelebration.tsx b/apps/web/src/app/arcade/complement-race/components/RouteCelebration.tsx index 6902d9c9..965e3b75 100644 --- a/apps/web/src/app/arcade/complement-race/components/RouteCelebration.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RouteCelebration.tsx @@ -1,11 +1,11 @@ -"use client"; +'use client' -import { getRouteTheme } from "../lib/routeThemes"; +import { getRouteTheme } from '../lib/routeThemes' interface RouteCelebrationProps { - completedRouteNumber: number; - nextRouteNumber: number; - onContinue: () => void; + completedRouteNumber: number + nextRouteNumber: number + onContinue: () => void } export function RouteCelebration({ @@ -13,43 +13,43 @@ export function RouteCelebration({ nextRouteNumber, onContinue, }: RouteCelebrationProps) { - const completedTheme = getRouteTheme(completedRouteNumber); - const nextTheme = getRouteTheme(nextRouteNumber); + const completedTheme = getRouteTheme(completedRouteNumber) + const nextTheme = getRouteTheme(nextRouteNumber) return (
{/* Celebration header */}
🎉 @@ -57,10 +57,10 @@ export function RouteCelebration({

Route Complete! @@ -69,19 +69,15 @@ export function RouteCelebration({ {/* Completed route info */}
-
- {completedTheme.emoji} -
-
- {completedTheme.name} -
-
+
{completedTheme.emoji}
+
{completedTheme.name}
+
Route {completedRouteNumber}
@@ -89,29 +85,25 @@ export function RouteCelebration({ {/* Next route preview */}
Next destination:
-
- {nextTheme.emoji} -
-
- {nextTheme.name} -
-
+
{nextTheme.emoji}
+
{nextTheme.name}
+
Route {nextRouteNumber}
@@ -120,24 +112,24 @@ export function RouteCelebration({
- ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/context/ComplementRaceContext.tsx b/apps/web/src/app/arcade/complement-race/context/ComplementRaceContext.tsx index 4242bc1e..fe4603eb 100644 --- a/apps/web/src/app/arcade/complement-race/context/ComplementRaceContext.tsx +++ b/apps/web/src/app/arcade/complement-race/context/ComplementRaceContext.tsx @@ -1,14 +1,8 @@ -"use client"; +'use client' -import type React from "react"; -import { createContext, type ReactNode, useContext, useReducer } from "react"; -import type { - AIRacer, - DifficultyTracker, - GameAction, - GameState, - Station, -} from "../lib/gameTypes"; +import type React from 'react' +import { createContext, type ReactNode, useContext, useReducer } from 'react' +import type { AIRacer, DifficultyTracker, GameAction, GameState, Station } from '../lib/gameTypes' const initialDifficultyTracker: DifficultyTracker = { pairPerformance: new Map(), @@ -19,60 +13,60 @@ const initialDifficultyTracker: DifficultyTracker = { consecutiveIncorrect: 0, learningMode: true, adaptationRate: 0.1, -}; +} const initialAIRacers: AIRacer[] = [ { - id: "ai-racer-1", + id: 'ai-racer-1', position: 0, speed: 0.32, // Balanced speed for good challenge - name: "Swift AI", - personality: "competitive", - icon: "🏃‍♂️", + name: 'Swift AI', + personality: 'competitive', + icon: '🏃‍♂️', lastComment: 0, commentCooldown: 0, previousPosition: 0, }, { - id: "ai-racer-2", + id: 'ai-racer-2', position: 0, speed: 0.2, // Balanced speed for good challenge - name: "Math Bot", - personality: "analytical", - icon: "🏃", + name: 'Math Bot', + personality: 'analytical', + icon: '🏃', lastComment: 0, commentCooldown: 0, previousPosition: 0, }, -]; +] const initialStations: Station[] = [ - { id: "station-0", name: "Depot", position: 0, icon: "🏭", emoji: "🏭" }, - { id: "station-1", name: "Riverside", position: 20, icon: "🌊", emoji: "🌊" }, - { id: "station-2", name: "Hillside", position: 40, icon: "⛰️", emoji: "⛰️" }, + { id: 'station-0', name: 'Depot', position: 0, icon: '🏭', emoji: '🏭' }, + { id: 'station-1', name: 'Riverside', position: 20, icon: '🌊', emoji: '🌊' }, + { id: 'station-2', name: 'Hillside', position: 40, icon: '⛰️', emoji: '⛰️' }, { - id: "station-3", - name: "Canyon View", + id: 'station-3', + name: 'Canyon View', position: 60, - icon: "🏜️", - emoji: "🏜️", + icon: '🏜️', + emoji: '🏜️', }, - { id: "station-4", name: "Meadows", position: 80, icon: "🌾", emoji: "🌾" }, + { id: 'station-4', name: 'Meadows', position: 80, icon: '🌾', emoji: '🌾' }, { - id: "station-5", - name: "Grand Central", + id: 'station-5', + name: 'Grand Central', position: 100, - icon: "🏛️", - emoji: "🏛️", + icon: '🏛️', + emoji: '🏛️', }, -]; +] const initialState: GameState = { // Game configuration - mode: "friends5", - style: "practice", - timeoutSetting: "normal", - complementDisplay: "abacus", // Default to showing abacus + mode: 'friends5', + style: 'practice', + timeoutSetting: 'normal', + complementDisplay: 'abacus', // Default to showing abacus // Current question currentQuestion: null, @@ -88,7 +82,7 @@ const initialState: GameState = { // Game status isGameActive: false, isPaused: false, - gamePhase: "controls", + gamePhase: 'controls', // Timing gameStartTime: null, @@ -122,142 +116,140 @@ const initialState: GameState = { showRouteCelebration: false, // Input - currentInput: "", + currentInput: '', // UI state showScoreModal: false, activeSpeechBubbles: new Map(), adaptiveFeedback: null, -}; +} function gameReducer(state: GameState, action: GameAction): GameState { switch (action.type) { - case "SET_MODE": - return { ...state, mode: action.mode }; + case 'SET_MODE': + return { ...state, mode: action.mode } - case "SET_STYLE": - return { ...state, style: action.style }; + case 'SET_STYLE': + return { ...state, style: action.style } - case "SET_TIMEOUT": - return { ...state, timeoutSetting: action.timeout }; + case 'SET_TIMEOUT': + return { ...state, timeoutSetting: action.timeout } - case "SET_COMPLEMENT_DISPLAY": - return { ...state, complementDisplay: action.display }; + case 'SET_COMPLEMENT_DISPLAY': + return { ...state, complementDisplay: action.display } - case "SHOW_CONTROLS": - return { ...state, gamePhase: "controls" }; + case 'SHOW_CONTROLS': + return { ...state, gamePhase: 'controls' } - case "START_COUNTDOWN": - return { ...state, gamePhase: "countdown" }; + case 'START_COUNTDOWN': + return { ...state, gamePhase: 'countdown' } - case "BEGIN_GAME": { + case 'BEGIN_GAME': { // Generate first question when game starts const generateFirstQuestion = () => { - let targetSum: number; - if (state.mode === "friends5") { - targetSum = 5; - } else if (state.mode === "friends10") { - targetSum = 10; + let targetSum: number + if (state.mode === 'friends5') { + targetSum = 5 + } else if (state.mode === 'friends10') { + targetSum = 10 } else { - targetSum = Math.random() > 0.5 ? 5 : 10; + targetSum = Math.random() > 0.5 ? 5 : 10 } const newNumber = - targetSum === 5 - ? Math.floor(Math.random() * 5) - : Math.floor(Math.random() * 10); + targetSum === 5 ? Math.floor(Math.random() * 5) : Math.floor(Math.random() * 10) // Decide once whether to show as abacus const showAsAbacus = - state.complementDisplay === "abacus" || - (state.complementDisplay === "random" && Math.random() < 0.5); + state.complementDisplay === 'abacus' || + (state.complementDisplay === 'random' && Math.random() < 0.5) return { number: newNumber, targetSum, correctAnswer: targetSum - newNumber, showAsAbacus, - }; - }; + } + } return { ...state, - gamePhase: "playing", + gamePhase: 'playing', isGameActive: true, gameStartTime: Date.now(), questionStartTime: Date.now(), currentQuestion: generateFirstQuestion(), - }; + } } - case "NEXT_QUESTION": { + case 'NEXT_QUESTION': { // Generate new question based on mode const generateQuestion = () => { - let targetSum: number; - if (state.mode === "friends5") { - targetSum = 5; - } else if (state.mode === "friends10") { - targetSum = 10; + let targetSum: number + if (state.mode === 'friends5') { + targetSum = 5 + } else if (state.mode === 'friends10') { + targetSum = 10 } else { - targetSum = Math.random() > 0.5 ? 5 : 10; + targetSum = Math.random() > 0.5 ? 5 : 10 } - let newNumber: number; - let attempts = 0; + let newNumber: number + let attempts = 0 do { if (targetSum === 5) { - newNumber = Math.floor(Math.random() * 5); + newNumber = Math.floor(Math.random() * 5) } else { - newNumber = Math.floor(Math.random() * 10); + newNumber = Math.floor(Math.random() * 10) } - attempts++; + attempts++ } while ( state.currentQuestion && state.currentQuestion.number === newNumber && state.currentQuestion.targetSum === targetSum && attempts < 10 - ); + ) // Decide once whether to show as abacus const showAsAbacus = - state.complementDisplay === "abacus" || - (state.complementDisplay === "random" && Math.random() < 0.5); + state.complementDisplay === 'abacus' || + (state.complementDisplay === 'random' && Math.random() < 0.5) return { number: newNumber, targetSum, correctAnswer: targetSum - newNumber, showAsAbacus, - }; - }; + } + } return { ...state, previousQuestion: state.currentQuestion, currentQuestion: generateQuestion(), questionStartTime: Date.now(), - currentInput: "", - }; + currentInput: '', + } } - case "UPDATE_INPUT": - return { ...state, currentInput: action.input }; + case 'UPDATE_INPUT': + return { ...state, currentInput: action.input } - case "SUBMIT_ANSWER": { - if (!state.currentQuestion) return state; + case 'SUBMIT_ANSWER': { + if (!state.currentQuestion) return state - const isCorrect = action.answer === state.currentQuestion.correctAnswer; - const responseTime = Date.now() - state.questionStartTime; + const isCorrect = action.answer === state.currentQuestion.correctAnswer + const responseTime = Date.now() - state.questionStartTime if (isCorrect) { // Calculate speed bonus: max(0, 300 - (avgTime * 10)) - const speedBonus = Math.max(0, 300 - responseTime / 100); + const speedBonus = Math.max(0, 300 - responseTime / 100) // Update score: correctAnswers * 100 + streak * 50 + speedBonus - const newStreak = state.streak + 1; - const newCorrectAnswers = state.correctAnswers + 1; - const newScore = state.score + 100 + newStreak * 50 + speedBonus; + const newStreak = state.streak + 1 + const newCorrectAnswers = state.correctAnswers + 1 + const newScore = state.score + 100 + newStreak * 50 + speedBonus return { ...state, @@ -266,69 +258,69 @@ function gameReducer(state: GameState, action: GameAction): GameState { bestStreak: Math.max(state.bestStreak, newStreak), score: Math.round(newScore), totalQuestions: state.totalQuestions + 1, - }; + } } else { // Incorrect answer - reset streak but keep score return { ...state, streak: 0, totalQuestions: state.totalQuestions + 1, - }; + } } } - case "UPDATE_AI_POSITIONS": + case 'UPDATE_AI_POSITIONS': return { ...state, aiRacers: state.aiRacers.map((racer) => { - const update = action.positions.find((p) => p.id === racer.id); + const update = action.positions.find((p) => p.id === racer.id) return update ? { ...racer, previousPosition: racer.position, position: update.position, } - : racer; + : racer }), - }; + } - case "UPDATE_MOMENTUM": - return { ...state, momentum: action.momentum }; + case 'UPDATE_MOMENTUM': + return { ...state, momentum: action.momentum } - case "UPDATE_TRAIN_POSITION": - return { ...state, trainPosition: action.position }; + case 'UPDATE_TRAIN_POSITION': + return { ...state, trainPosition: action.position } - case "UPDATE_STEAM_JOURNEY": + case 'UPDATE_STEAM_JOURNEY': return { ...state, momentum: action.momentum, trainPosition: action.trainPosition, pressure: action.pressure, elapsedTime: action.elapsedTime, - }; - - case "COMPLETE_LAP": - if (action.racerId === "player") { - return { ...state, playerLap: state.playerLap + 1 }; - } else { - const newAILaps = new Map(state.aiLaps); - newAILaps.set(action.racerId, (newAILaps.get(action.racerId) || 0) + 1); - return { ...state, aiLaps: newAILaps }; } - case "PAUSE_RACE": - return { ...state, isPaused: true }; + case 'COMPLETE_LAP': + if (action.racerId === 'player') { + return { ...state, playerLap: state.playerLap + 1 } + } else { + const newAILaps = new Map(state.aiLaps) + newAILaps.set(action.racerId, (newAILaps.get(action.racerId) || 0) + 1) + return { ...state, aiLaps: newAILaps } + } - case "RESUME_RACE": - return { ...state, isPaused: false }; + case 'PAUSE_RACE': + return { ...state, isPaused: true } - case "END_RACE": - return { ...state, isGameActive: false }; + case 'RESUME_RACE': + return { ...state, isPaused: false } - case "SHOW_RESULTS": - return { ...state, gamePhase: "results", showScoreModal: true }; + case 'END_RACE': + return { ...state, isGameActive: false } - case "RESET_GAME": + case 'SHOW_RESULTS': + return { ...state, gamePhase: 'results', showScoreModal: true } + + case 'RESET_GAME': return { ...initialState, // Preserve configuration settings @@ -336,12 +328,12 @@ function gameReducer(state: GameState, action: GameAction): GameState { style: state.style, timeoutSetting: state.timeoutSetting, complementDisplay: state.complementDisplay, - gamePhase: "controls", - }; + gamePhase: 'controls', + } - case "TRIGGER_AI_COMMENTARY": { - const newBubbles = new Map(state.activeSpeechBubbles); - newBubbles.set(action.racerId, action.message); + case 'TRIGGER_AI_COMMENTARY': { + const newBubbles = new Map(state.activeSpeechBubbles) + newBubbles.set(action.racerId, action.message) return { ...state, activeSpeechBubbles: newBubbles, @@ -353,69 +345,69 @@ function gameReducer(state: GameState, action: GameAction): GameState { lastComment: Date.now(), commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds } - : racer, + : racer ), - }; + } } - case "CLEAR_AI_COMMENT": { - const clearedBubbles = new Map(state.activeSpeechBubbles); - clearedBubbles.delete(action.racerId); + case 'CLEAR_AI_COMMENT': { + const clearedBubbles = new Map(state.activeSpeechBubbles) + clearedBubbles.delete(action.racerId) return { ...state, activeSpeechBubbles: clearedBubbles, - }; + } } - case "UPDATE_DIFFICULTY_TRACKER": + case 'UPDATE_DIFFICULTY_TRACKER': return { ...state, difficultyTracker: action.tracker, - }; + } - case "UPDATE_AI_SPEEDS": + case 'UPDATE_AI_SPEEDS': return { ...state, aiRacers: action.racers, - }; + } - case "SHOW_ADAPTIVE_FEEDBACK": + case 'SHOW_ADAPTIVE_FEEDBACK': return { ...state, adaptiveFeedback: action.feedback, - }; + } - case "CLEAR_ADAPTIVE_FEEDBACK": + case 'CLEAR_ADAPTIVE_FEEDBACK': return { ...state, adaptiveFeedback: null, - }; + } - case "GENERATE_PASSENGERS": + case 'GENERATE_PASSENGERS': return { ...state, passengers: action.passengers, - }; + } - case "BOARD_PASSENGER": + case 'BOARD_PASSENGER': return { ...state, passengers: state.passengers.map((p) => - p.id === action.passengerId ? { ...p, isBoarded: true } : p, + p.id === action.passengerId ? { ...p, isBoarded: true } : p ), - }; + } - case "DELIVER_PASSENGER": + case 'DELIVER_PASSENGER': return { ...state, passengers: state.passengers.map((p) => - p.id === action.passengerId ? { ...p, isDelivered: true } : p, + p.id === action.passengerId ? { ...p, isDelivered: true } : p ), deliveredPassengers: state.deliveredPassengers + 1, score: state.score + action.points, - }; + } - case "START_NEW_ROUTE": + case 'START_NEW_ROUTE': return { ...state, currentRoute: action.routeNumber, @@ -425,64 +417,57 @@ function gameReducer(state: GameState, action: GameAction): GameState { showRouteCelebration: false, momentum: 50, // Give some starting momentum for the new route pressure: 50, - }; + } - case "COMPLETE_ROUTE": + case 'COMPLETE_ROUTE': return { ...state, cumulativeDistance: state.cumulativeDistance + 100, showRouteCelebration: true, - }; + } - case "HIDE_ROUTE_CELEBRATION": + case 'HIDE_ROUTE_CELEBRATION': return { ...state, showRouteCelebration: false, - }; + } default: - return state; + return state } } interface ComplementRaceContextType { - state: GameState; - dispatch: React.Dispatch; + state: GameState + dispatch: React.Dispatch } -const ComplementRaceContext = createContext< - ComplementRaceContextType | undefined ->(undefined); +const ComplementRaceContext = createContext(undefined) interface ComplementRaceProviderProps { - children: ReactNode; - initialStyle?: "practice" | "sprint" | "survival"; + children: ReactNode + initialStyle?: 'practice' | 'sprint' | 'survival' } -export function ComplementRaceProvider({ - children, - initialStyle, -}: ComplementRaceProviderProps) { +export function ComplementRaceProvider({ children, initialStyle }: ComplementRaceProviderProps) { const [state, dispatch] = useReducer(gameReducer, { ...initialState, style: initialStyle || initialState.style, - }); + }) return ( {children} - ); + ) } export function useComplementRace() { - const context = useContext(ComplementRaceContext); + const context = useContext(ComplementRaceContext) if (context === undefined) { - throw new Error( - "useComplementRace must be used within ComplementRaceProvider", - ); + throw new Error('useComplementRace must be used within ComplementRaceProvider') } - return context; + return context } // Re-export modular game provider for arcade room play @@ -490,4 +475,4 @@ export function useComplementRace() { export { ComplementRaceProvider as RoomComplementRaceProvider, useComplementRace as useRoomComplementRace, -} from "@/arcade-games/complement-race/Provider"; +} from '@/arcade-games/complement-race/Provider' diff --git a/apps/web/src/app/arcade/complement-race/hooks/__tests__/usePassengerAnimations.test.ts b/apps/web/src/app/arcade/complement-race/hooks/__tests__/usePassengerAnimations.test.ts index a14b2336..8eb6b09b 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/__tests__/usePassengerAnimations.test.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/__tests__/usePassengerAnimations.test.ts @@ -1,24 +1,21 @@ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { Passenger, Station } from "../../lib/gameTypes"; -import type { RailroadTrackGenerator } from "../../lib/RailroadTrackGenerator"; -import { usePassengerAnimations } from "../usePassengerAnimations"; +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import type { Passenger, Station } from '../../lib/gameTypes' +import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' +import { usePassengerAnimations } from '../usePassengerAnimations' -describe("usePassengerAnimations", () => { - let mockPathRef: React.RefObject; - let mockTrackGenerator: RailroadTrackGenerator; - let mockStation1: Station; - let mockStation2: Station; - let mockPassenger1: Passenger; - let mockPassenger2: Passenger; +describe('usePassengerAnimations', () => { + let mockPathRef: React.RefObject + let mockTrackGenerator: RailroadTrackGenerator + let mockStation1: Station + let mockStation2: Station + let mockPassenger1: Passenger + let mockPassenger2: Passenger beforeEach(() => { // Create mock path element - const mockPath = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - mockPathRef = { current: mockPath }; + const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') + mockPathRef = { current: mockPath } // Mock track generator mockTrackGenerator = { @@ -27,52 +24,52 @@ describe("usePassengerAnimations", () => { y: 300, rotation: 0, })), - } as unknown as RailroadTrackGenerator; + } as unknown as RailroadTrackGenerator // Create mock stations mockStation1 = { - id: "station-1", - name: "Station 1", + id: 'station-1', + name: 'Station 1', position: 20, - icon: "🏭", - emoji: "🏭", - }; + icon: '🏭', + emoji: '🏭', + } mockStation2 = { - id: "station-2", - name: "Station 2", + id: 'station-2', + name: 'Station 2', position: 60, - icon: "🏛️", - emoji: "🏛️", - }; + icon: '🏛️', + emoji: '🏛️', + } // Create mock passengers mockPassenger1 = { - id: "passenger-1", - name: "Passenger 1", - avatar: "👨", - originStationId: "station-1", - destinationStationId: "station-2", + id: 'passenger-1', + name: 'Passenger 1', + avatar: '👨', + originStationId: 'station-1', + destinationStationId: 'station-2', isBoarded: false, isDelivered: false, isUrgent: false, - }; + } mockPassenger2 = { - id: "passenger-2", - name: "Passenger 2", - avatar: "👩", - originStationId: "station-1", - destinationStationId: "station-2", + id: 'passenger-2', + name: 'Passenger 2', + avatar: '👩', + originStationId: 'station-1', + destinationStationId: 'station-2', isBoarded: false, isDelivered: false, isUrgent: true, - }; + } - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - test("initializes with empty animation maps", () => { + test('initializes with empty animation maps', () => { const { result } = renderHook(() => usePassengerAnimations({ passengers: [], @@ -84,14 +81,14 @@ describe("usePassengerAnimations", () => { trainPosition: 0, trackGenerator: mockTrackGenerator, pathRef: mockPathRef, - }), - ); + }) + ) - expect(result.current.boardingAnimations.size).toBe(0); - expect(result.current.disembarkingAnimations.size).toBe(0); - }); + expect(result.current.boardingAnimations.size).toBe(0) + expect(result.current.disembarkingAnimations.size).toBe(0) + }) - test("creates boarding animation when passenger boards", () => { + test('creates boarding animation when passenger boards', () => { const { result, rerender } = renderHook( ({ passengers }) => usePassengerAnimations({ @@ -109,30 +106,30 @@ describe("usePassengerAnimations", () => { initialProps: { passengers: [mockPassenger1], }, - }, - ); + } + ) // Initially no boarding animations - expect(result.current.boardingAnimations.size).toBe(0); + expect(result.current.boardingAnimations.size).toBe(0) // Passenger boards - const boardedPassenger = { ...mockPassenger1, isBoarded: true }; - rerender({ passengers: [boardedPassenger] }); + const boardedPassenger = { ...mockPassenger1, isBoarded: true } + rerender({ passengers: [boardedPassenger] }) // Should create boarding animation - expect(result.current.boardingAnimations.size).toBe(1); - expect(result.current.boardingAnimations.has("passenger-1")).toBe(true); + expect(result.current.boardingAnimations.size).toBe(1) + expect(result.current.boardingAnimations.has('passenger-1')).toBe(true) - const animation = result.current.boardingAnimations.get("passenger-1"); - expect(animation).toBeDefined(); - expect(animation?.passenger).toEqual(boardedPassenger); - expect(animation?.fromX).toBe(100); // Station position - expect(animation?.fromY).toBe(270); // Station position - 30 - expect(mockTrackGenerator.getTrainTransform).toHaveBeenCalled(); - }); + const animation = result.current.boardingAnimations.get('passenger-1') + expect(animation).toBeDefined() + expect(animation?.passenger).toEqual(boardedPassenger) + expect(animation?.fromX).toBe(100) // Station position + expect(animation?.fromY).toBe(270) // Station position - 30 + expect(mockTrackGenerator.getTrainTransform).toHaveBeenCalled() + }) - test("creates disembarking animation when passenger is delivered", () => { - const boardedPassenger = { ...mockPassenger1, isBoarded: true }; + test('creates disembarking animation when passenger is delivered', () => { + const boardedPassenger = { ...mockPassenger1, isBoarded: true } const { result, rerender } = renderHook( ({ passengers }) => @@ -151,28 +148,28 @@ describe("usePassengerAnimations", () => { initialProps: { passengers: [boardedPassenger], }, - }, - ); + } + ) // Initially no disembarking animations - expect(result.current.disembarkingAnimations.size).toBe(0); + expect(result.current.disembarkingAnimations.size).toBe(0) // Passenger is delivered - const deliveredPassenger = { ...boardedPassenger, isDelivered: true }; - rerender({ passengers: [deliveredPassenger] }); + const deliveredPassenger = { ...boardedPassenger, isDelivered: true } + rerender({ passengers: [deliveredPassenger] }) // Should create disembarking animation - expect(result.current.disembarkingAnimations.size).toBe(1); - expect(result.current.disembarkingAnimations.has("passenger-1")).toBe(true); + expect(result.current.disembarkingAnimations.size).toBe(1) + expect(result.current.disembarkingAnimations.has('passenger-1')).toBe(true) - const animation = result.current.disembarkingAnimations.get("passenger-1"); - expect(animation).toBeDefined(); - expect(animation?.passenger).toEqual(deliveredPassenger); - expect(animation?.toX).toBe(500); // Destination station position - expect(animation?.toY).toBe(270); // Station position - 30 - }); + const animation = result.current.disembarkingAnimations.get('passenger-1') + expect(animation).toBeDefined() + expect(animation?.passenger).toEqual(deliveredPassenger) + expect(animation?.toX).toBe(500) // Destination station position + expect(animation?.toY).toBe(270) // Station position - 30 + }) - test("handles multiple passengers boarding simultaneously", () => { + test('handles multiple passengers boarding simultaneously', () => { const { result, rerender } = renderHook( ({ passengers }) => usePassengerAnimations({ @@ -190,24 +187,24 @@ describe("usePassengerAnimations", () => { initialProps: { passengers: [mockPassenger1, mockPassenger2], }, - }, - ); + } + ) // Both passengers board const boardedPassengers = [ { ...mockPassenger1, isBoarded: true }, { ...mockPassenger2, isBoarded: true }, - ]; - rerender({ passengers: boardedPassengers }); + ] + rerender({ passengers: boardedPassengers }) // Should create boarding animations for both - expect(result.current.boardingAnimations.size).toBe(2); - expect(result.current.boardingAnimations.has("passenger-1")).toBe(true); - expect(result.current.boardingAnimations.has("passenger-2")).toBe(true); - }); + expect(result.current.boardingAnimations.size).toBe(2) + expect(result.current.boardingAnimations.has('passenger-1')).toBe(true) + expect(result.current.boardingAnimations.has('passenger-2')).toBe(true) + }) - test("does not create animation if passenger already boarded in previous state", () => { - const boardedPassenger = { ...mockPassenger1, isBoarded: true }; + test('does not create animation if passenger already boarded in previous state', () => { + const boardedPassenger = { ...mockPassenger1, isBoarded: true } const { result } = renderHook(() => usePassengerAnimations({ @@ -220,15 +217,15 @@ describe("usePassengerAnimations", () => { trainPosition: 20, trackGenerator: mockTrackGenerator, pathRef: mockPathRef, - }), - ); + }) + ) // No animation since passenger was already boarded - expect(result.current.boardingAnimations.size).toBe(0); - }); + expect(result.current.boardingAnimations.size).toBe(0) + }) - test("returns empty animations when pathRef is null", () => { - const nullPathRef: React.RefObject = { current: null }; + test('returns empty animations when pathRef is null', () => { + const nullPathRef: React.RefObject = { current: null } const { result, rerender } = renderHook( ({ passengers }) => @@ -247,18 +244,18 @@ describe("usePassengerAnimations", () => { initialProps: { passengers: [mockPassenger1], }, - }, - ); + } + ) // Passenger boards - const boardedPassenger = { ...mockPassenger1, isBoarded: true }; - rerender({ passengers: [boardedPassenger] }); + const boardedPassenger = { ...mockPassenger1, isBoarded: true } + rerender({ passengers: [boardedPassenger] }) // Should not create animation without path - expect(result.current.boardingAnimations.size).toBe(0); - }); + expect(result.current.boardingAnimations.size).toBe(0) + }) - test("returns empty animations when stationPositions is empty", () => { + test('returns empty animations when stationPositions is empty', () => { const { result, rerender } = renderHook( ({ passengers }) => usePassengerAnimations({ @@ -273,14 +270,14 @@ describe("usePassengerAnimations", () => { initialProps: { passengers: [mockPassenger1], }, - }, - ); + } + ) // Passenger boards - const boardedPassenger = { ...mockPassenger1, isBoarded: true }; - rerender({ passengers: [boardedPassenger] }); + const boardedPassenger = { ...mockPassenger1, isBoarded: true } + rerender({ passengers: [boardedPassenger] }) // Should not create animation without station positions - expect(result.current.boardingAnimations.size).toBe(0); - }); -}); + expect(result.current.boardingAnimations.size).toBe(0) + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.boarding.test.ts b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.boarding.test.ts index 03d3ca39..02d5c76a 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.boarding.test.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.boarding.test.ts @@ -1,11 +1,11 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' // Mock sound effects -vi.mock("../useSoundEffects", () => ({ +vi.mock('../useSoundEffects', () => ({ useSoundEffects: () => ({ playSound: vi.fn(), }), -})); +})) /** * Boarding Logic Tests @@ -15,41 +15,41 @@ vi.mock("../useSoundEffects", () => ({ */ interface Passenger { - id: string; - name: string; - avatar: string; - originStationId: string; - destinationStationId: string; - isBoarded: boolean; - isDelivered: boolean; - isUrgent: boolean; + id: string + name: string + avatar: string + originStationId: string + destinationStationId: string + isBoarded: boolean + isDelivered: boolean + isUrgent: boolean } interface Station { - id: string; - name: string; - icon: string; - position: number; + id: string + name: string + icon: string + position: number } -describe("useSteamJourney - Boarding Logic", () => { - const CAR_SPACING = 7; - let stations: Station[]; - let passengers: Passenger[]; +describe('useSteamJourney - Boarding Logic', () => { + const CAR_SPACING = 7 + let stations: Station[] + let passengers: Passenger[] beforeEach(() => { stations = [ - { id: "s1", name: "Station 1", icon: "🏠", position: 20 }, - { id: "s2", name: "Station 2", icon: "🏢", position: 50 }, - { id: "s3", name: "Station 3", icon: "🏪", position: 80 }, - ]; + { id: 's1', name: 'Station 1', icon: '🏠', position: 20 }, + { id: 's2', name: 'Station 2', icon: '🏢', position: 50 }, + { id: 's3', name: 'Station 3', icon: '🏪', position: 80 }, + ] - vi.useFakeTimers(); - }); + vi.useFakeTimers() + }) afterEach(() => { - vi.useRealTimers(); - }); + vi.useRealTimers() + }) /** * Simulate the boarding logic from useSteamJourney (with fix) @@ -58,305 +58,296 @@ describe("useSteamJourney - Boarding Logic", () => { trainPosition: number, passengers: Passenger[], stations: Station[], - maxCars: number, + maxCars: number ): Passenger[] { - const updatedPassengers = [...passengers]; - const currentBoardedPassengers = updatedPassengers.filter( - (p) => p.isBoarded && !p.isDelivered, - ); + const updatedPassengers = [...passengers] + const currentBoardedPassengers = updatedPassengers.filter((p) => p.isBoarded && !p.isDelivered) // Track which cars are assigned in THIS frame to prevent double-boarding - const carsAssignedThisFrame = new Set(); + const carsAssignedThisFrame = new Set() // Simulate the boarding logic updatedPassengers.forEach((passenger, passengerIndex) => { - if (passenger.isBoarded || passenger.isDelivered) return; + if (passenger.isBoarded || passenger.isDelivered) return - const station = stations.find((s) => s.id === passenger.originStationId); - if (!station) return; + const station = stations.find((s) => s.id === passenger.originStationId) + if (!station) return // Check if any empty car is at this station for (let carIndex = 0; carIndex < maxCars; carIndex++) { // Skip if this car already has a passenger OR was assigned this frame - if ( - currentBoardedPassengers[carIndex] || - carsAssignedThisFrame.has(carIndex) - ) - continue; + if (currentBoardedPassengers[carIndex] || carsAssignedThisFrame.has(carIndex)) continue - const carPosition = Math.max( - 0, - trainPosition - (carIndex + 1) * CAR_SPACING, - ); - const distance = Math.abs(carPosition - station.position); + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING) + const distance = Math.abs(carPosition - station.position) // If car is at station (within 3% tolerance), board this passenger if (distance < 3) { - updatedPassengers[passengerIndex] = { ...passenger, isBoarded: true }; + updatedPassengers[passengerIndex] = { ...passenger, isBoarded: true } // Mark this car as assigned in this frame - carsAssignedThisFrame.add(carIndex); - return; // Board this passenger and move on + carsAssignedThisFrame.add(carIndex) + return // Board this passenger and move on } } - }); + }) - return updatedPassengers; + return updatedPassengers } - test("single passenger at station boards when car arrives", () => { + test('single passenger at station boards when car arrives', () => { passengers = [ { - id: "p1", - name: "Alice", - avatar: "👩", - originStationId: "s1", - destinationStationId: "s2", + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, - ]; + ] // Train at position 27%, first car at position 20% (station 1) - const result = simulateBoardingAtPosition(27, passengers, stations, 1); + const result = simulateBoardingAtPosition(27, passengers, stations, 1) - expect(result[0].isBoarded).toBe(true); - }); + expect(result[0].isBoarded).toBe(true) + }) - test("EDGE CASE: multiple passengers at same station with enough cars", () => { + test('EDGE CASE: multiple passengers at same station with enough cars', () => { passengers = [ { - id: "p1", - name: "Alice", - avatar: "👩", - originStationId: "s1", - destinationStationId: "s2", + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, { - id: "p2", - name: "Bob", - avatar: "👨", - originStationId: "s1", - destinationStationId: "s2", + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, { - id: "p3", - name: "Charlie", - avatar: "👴", - originStationId: "s1", - destinationStationId: "s2", + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, - ]; + ] // Train at position 34%, cars at: 27%, 20%, 13% // Car 1 (27%): 7% away from station (too far) // Car 2 (20%): 0% away from station (at station!) // Car 3 (13%): 7% away from station (too far) - let result = simulateBoardingAtPosition(34, passengers, stations, 3); + let result = simulateBoardingAtPosition(34, passengers, stations, 3) // First iteration: car 2 is at station, should board first passenger - expect(result[0].isBoarded).toBe(true); + expect(result[0].isBoarded).toBe(true) // But what about the other passengers? They should board on subsequent frames // Let's simulate the train advancing slightly - result = simulateBoardingAtPosition(35, result, stations, 3); + result = simulateBoardingAtPosition(35, result, stations, 3) // Now car 1 is at 28% (still too far), car 2 at 21% (still close), car 3 at 14% (too far) // Passenger 2 should still not board yet // Advance more - when does car 1 reach the station? - result = simulateBoardingAtPosition(27, result, stations, 3); + result = simulateBoardingAtPosition(27, result, stations, 3) // Car 1 at 20% (at station!) - expect(result[1].isBoarded).toBe(true); + expect(result[1].isBoarded).toBe(true) // What about passenger 3? Need car 3 to reach station // Car 3 position = trainPosition - (3 * 7) = trainPosition - 21 // For car 3 to be at 20%, need trainPosition = 41 - result = simulateBoardingAtPosition(41, result, stations, 3); + result = simulateBoardingAtPosition(41, result, stations, 3) // Car 3 at 20% (at station!) - expect(result[2].isBoarded).toBe(true); - }); + expect(result[2].isBoarded).toBe(true) + }) - test("EDGE CASE: passengers left behind when train moves too fast", () => { + test('EDGE CASE: passengers left behind when train moves too fast', () => { passengers = [ { - id: "p1", - name: "Alice", - avatar: "👩", - originStationId: "s1", - destinationStationId: "s2", + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, { - id: "p2", - name: "Bob", - avatar: "👨", - originStationId: "s1", - destinationStationId: "s2", + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, - ]; + ] // Simulate train speeding through station // Only 2 cars, but 2 passengers at same station // Frame 1: Train at 27%, car 1 at 20%, car 2 at 13% - let result = simulateBoardingAtPosition(27, passengers, stations, 2); - expect(result[0].isBoarded).toBe(true); - expect(result[1].isBoarded).toBe(false); + let result = simulateBoardingAtPosition(27, passengers, stations, 2) + expect(result[0].isBoarded).toBe(true) + expect(result[1].isBoarded).toBe(false) // Frame 2: Train jumps to 35% (high momentum) // Car 1 at 28%, car 2 at 21% - result = simulateBoardingAtPosition(35, result, stations, 2); + result = simulateBoardingAtPosition(35, result, stations, 2) // Car 2 is at 21%, within 1% of station at 20% - expect(result[1].isBoarded).toBe(true); + expect(result[1].isBoarded).toBe(true) // Frame 3: Train at 45% - both cars past station - result = simulateBoardingAtPosition(45, result, stations, 2); + result = simulateBoardingAtPosition(45, result, stations, 2) // Car 1 at 38%, car 2 at 31% - both way past 20% // All passengers should have boarded - expect(result.every((p) => p.isBoarded)).toBe(true); - }); + expect(result.every((p) => p.isBoarded)).toBe(true) + }) - test("EDGE CASE: passenger left behind when boarding window is missed", () => { + test('EDGE CASE: passenger left behind when boarding window is missed', () => { passengers = [ { - id: "p1", - name: "Alice", - avatar: "👩", - originStationId: "s1", - destinationStationId: "s2", + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, { - id: "p2", - name: "Bob", - avatar: "👨", - originStationId: "s1", - destinationStationId: "s2", + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, - ]; + ] // Only 1 car, 2 passengers // Frame 1: Train at 27%, car at 20% - let result = simulateBoardingAtPosition(27, passengers, stations, 1); - expect(result[0].isBoarded).toBe(true); - expect(result[1].isBoarded).toBe(false); // Second passenger waiting + let result = simulateBoardingAtPosition(27, passengers, stations, 1) + expect(result[0].isBoarded).toBe(true) + expect(result[1].isBoarded).toBe(false) // Second passenger waiting // Frame 2: Train jumps way past (very high momentum) - result = simulateBoardingAtPosition(50, result, stations, 1); + result = simulateBoardingAtPosition(50, result, stations, 1) // Car at 43% - way past station at 20% // Second passenger SHOULD BE LEFT BEHIND! - expect(result[1].isBoarded).toBe(false); - }); + expect(result[1].isBoarded).toBe(false) + }) - test("EDGE CASE: only one passenger boards per car per frame", () => { + test('EDGE CASE: only one passenger boards per car per frame', () => { passengers = [ { - id: "p1", - name: "Alice", - avatar: "👩", - originStationId: "s1", - destinationStationId: "s2", + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, { - id: "p2", - name: "Bob", - avatar: "👨", - originStationId: "s1", - destinationStationId: "s2", + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, - ]; + ] // Only 1 car, both passengers at same station // With the fix, only first passenger should board in this frame - const result = simulateBoardingAtPosition(27, passengers, stations, 1); + const result = simulateBoardingAtPosition(27, passengers, stations, 1) // First passenger boards - expect(result[0].isBoarded).toBe(true); + expect(result[0].isBoarded).toBe(true) // Second passenger does NOT board (car already assigned this frame) - expect(result[1].isBoarded).toBe(false); - }); + expect(result[1].isBoarded).toBe(false) + }) - test("all passengers board before train completely passes station", () => { + test('all passengers board before train completely passes station', () => { passengers = [ { - id: "p1", - name: "Alice", - avatar: "👩", - originStationId: "s1", - destinationStationId: "s2", + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, { - id: "p2", - name: "Bob", - avatar: "👨", - originStationId: "s1", - destinationStationId: "s2", + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, { - id: "p3", - name: "Charlie", - avatar: "👴", - originStationId: "s1", - destinationStationId: "s2", + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 's1', + destinationStationId: 's2', isBoarded: false, isDelivered: false, isUrgent: false, }, - ]; + ] // 3 passengers, 3 cars // Simulate train moving through station frame by frame - let result = passengers; + let result = passengers // Train approaching station for (let pos = 13; pos <= 40; pos += 1) { - result = simulateBoardingAtPosition(pos, result, stations, 3); + result = simulateBoardingAtPosition(pos, result, stations, 3) } // All passengers should have boarded by the time last car passes - const allBoarded = result.every((p) => p.isBoarded); - const leftBehind = result.filter((p) => !p.isBoarded); + const allBoarded = result.every((p) => p.isBoarded) + const leftBehind = result.filter((p) => !p.isBoarded) - expect(allBoarded).toBe(true); + expect(allBoarded).toBe(true) if (!allBoarded) { console.log( - "Passengers left behind:", - leftBehind.map((p) => p.name), - ); + 'Passengers left behind:', + leftBehind.map((p) => p.name) + ) } - }); -}); + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.delivery-thrashing.test.ts b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.delivery-thrashing.test.ts index e12268b7..2834efd1 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.delivery-thrashing.test.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.delivery-thrashing.test.ts @@ -7,27 +7,27 @@ * which rejects all but the first one. */ -import { describe, expect, test } from "vitest"; +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; + 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; + id: string + name: string + emoji: string + position: number } -describe("useSteamJourney - Delivery Thrashing Reproduction", () => { - const CAR_SPACING = 7; +describe('useSteamJourney - Delivery Thrashing Reproduction', () => { + const CAR_SPACING = 7 /** * Simulate the delivery logic from useSteamJourney @@ -37,98 +37,81 @@ describe("useSteamJourney - Delivery Thrashing Reproduction", () => { trainPosition: number, passengers: Passenger[], stations: Station[], - pendingDeliveryRef: Set, + pendingDeliveryRef: Set ): { deliveryAttempts: number; deliveredPassengerIds: string[] } { - let deliveryAttempts = 0; - const deliveredPassengerIds: string[] = []; + let deliveryAttempts = 0 + const deliveredPassengerIds: string[] = [] const currentBoardedPassengers = passengers.filter( - (p) => p.claimedBy !== null && p.deliveredBy === null, - ); + (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; + if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return // Skip if already has a pending delivery request - if (pendingDeliveryRef.has(passenger.id)) return; + if (pendingDeliveryRef.has(passenger.id)) return - const station = stations.find( - (s) => s.id === passenger.destinationStationId, - ); - if (!station) 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); + 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); + pendingDeliveryRef.add(passenger.id) + deliveryAttempts++ + deliveredPassengerIds.push(passenger.id) } - }); + }) - return { deliveryAttempts, deliveredPassengerIds }; + return { deliveryAttempts, deliveredPassengerIds } } - test("WITHOUT fix: multiple frames at same position cause thrashing", () => { + 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 }, - ]; + { 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", + id: 'bob', + name: 'Bob', + claimedBy: 'player1', deliveredBy: null, // Not yet delivered carIndex: 1, // In car 1 (second car) - destinationStationId: "s2", + destinationStationId: 's2', isUrgent: false, }, - ]; + ] // NO pending delivery tracking (simulating the bug) - const noPendingRef = new Set(); + const noPendingRef = new Set() // 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 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 + 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; + 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; + 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 @@ -136,35 +119,35 @@ describe("useSteamJourney - Delivery Thrashing Reproduction", () => { // 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! - }); + console.log(`Total delivery attempts without fix: ${totalAttempts}`) + expect(totalAttempts).toBe(10) // This demonstrates the bug! + }) - test("WITH fix: pendingDeliveryRef prevents thrashing", () => { + 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 }, - ]; + { 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", + id: 'bob', + name: 'Bob', + claimedBy: 'player1', deliveredBy: null, carIndex: 1, - destinationStationId: "s2", + destinationStationId: 's2', isUrgent: false, }, - ]; + ] // WITH pending delivery tracking (the fix) - const pendingDeliveryRef = new Set(); + const pendingDeliveryRef = new Set() - const trainPosition = 53.9; + const trainPosition = 53.9 - let totalAttempts = 0; + let totalAttempts = 0 // Simulate 10 frames at the same position for (let frame = 0; frame < 10; frame++) { @@ -172,62 +155,62 @@ describe("useSteamJourney - Delivery Thrashing Reproduction", () => { trainPosition, passengers, stations, - pendingDeliveryRef, - ); - totalAttempts += result.deliveryAttempts; + 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! ✅ - }); + console.log(`Total delivery attempts with fix: ${totalAttempts}`) + expect(totalAttempts).toBe(1) // Only one attempt! ✅ + }) - test("EDGE CASE: multiple passengers at same station", () => { + 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 }, - ]; + { 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", + id: 'alice', + name: 'Alice', + claimedBy: 'player1', deliveredBy: null, carIndex: 0, // Car 0 - destinationStationId: "s2", + destinationStationId: 's2', isUrgent: false, }, { - id: "bob", - name: "Bob", - claimedBy: "player1", + id: 'bob', + name: 'Bob', + claimedBy: 'player1', deliveredBy: null, carIndex: 1, // Car 1 - destinationStationId: "s2", + destinationStationId: 's2', isUrgent: false, }, - ]; + ] - const pendingDeliveryRef = new Set(); + const pendingDeliveryRef = new Set() // 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; + 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); + 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)})`, - ); + `Train at ${trainPosition}, Car 0 at ${car0Pos} (dist ${Math.abs(car0Pos - 40)}), Car 1 at ${car1Pos} (dist ${Math.abs(car1Pos - 40)})` + ) - let totalAttempts = 0; + let totalAttempts = 0 // Simulate 5 frames for (let frame = 0; frame < 5; frame++) { @@ -235,18 +218,16 @@ describe("useSteamJourney - Delivery Thrashing Reproduction", () => { trainPosition, passengers, stations, - pendingDeliveryRef, - ); - totalAttempts += result.deliveryAttempts; + pendingDeliveryRef + ) + totalAttempts += result.deliveryAttempts if (result.deliveryAttempts > 0) { - console.log( - `Frame ${frame}: Delivered ${result.deliveredPassengerIds.join(", ")}`, - ); + 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 ✅ - }); -}); + console.log(`Total delivery attempts for 2 passengers: ${totalAttempts}`) + expect(totalAttempts).toBe(2) // Alice once, Bob once ✅ + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.passenger.test.tsx b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.passenger.test.tsx index a378d1bf..3f25cf3b 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.passenger.test.tsx +++ b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useSteamJourney.passenger.test.tsx @@ -8,28 +8,23 @@ * 4. Passengers are delivered to the correct destination */ -import { act, renderHook } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { - ComplementRaceProvider, - useComplementRace, -} from "../../context/ComplementRaceContext"; -import type { Passenger, Station } from "../../lib/gameTypes"; -import { useSteamJourney } from "../useSteamJourney"; +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { ComplementRaceProvider, useComplementRace } from '../../context/ComplementRaceContext' +import type { Passenger, Station } from '../../lib/gameTypes' +import { useSteamJourney } from '../useSteamJourney' // Mock sound effects -vi.mock("../useSoundEffects", () => ({ +vi.mock('../useSoundEffects', () => ({ useSoundEffects: () => ({ playSound: vi.fn(), }), -})); +})) // Wrapper component const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -); + {children} +) // Helper to create test passengers const createPassenger = ( @@ -37,277 +32,261 @@ const createPassenger = ( originStationId: string, destinationStationId: string, isBoarded = false, - isDelivered = false, + isDelivered = false ): Passenger => ({ id, name: `Passenger ${id}`, - avatar: "👤", + avatar: '👤', originStationId, destinationStationId, isUrgent: false, isBoarded, isDelivered, -}); +}) // Test stations const _testStations: Station[] = [ - { id: "station-0", name: "Start", position: 0, icon: "🏁", emoji: "🏁" }, - { id: "station-1", name: "Middle", position: 50, icon: "🏢", emoji: "🏢" }, - { id: "station-2", name: "End", position: 100, icon: "🏁", emoji: "🏁" }, -]; + { id: 'station-0', name: 'Start', position: 0, icon: '🏁', emoji: '🏁' }, + { id: 'station-1', name: 'Middle', position: 50, icon: '🏢', emoji: '🏢' }, + { id: 'station-2', name: 'End', position: 100, icon: '🏁', emoji: '🏁' }, +] -describe("useSteamJourney - Passenger Boarding", () => { +describe('useSteamJourney - Passenger Boarding', () => { beforeEach(() => { - vi.useFakeTimers(); - }); + vi.useFakeTimers() + }) afterEach(() => { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - }); + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) - test("passenger boards when train reaches their origin station", () => { + test('passenger boards when train reaches their origin station', () => { const { result } = renderHook( () => { - const journey = useSteamJourney(); - const race = useComplementRace(); - return { journey, race }; + const journey = useSteamJourney() + const race = useComplementRace() + return { journey, race } }, - { wrapper }, - ); + { wrapper } + ) // Setup: Add passenger waiting at station-1 (position 50) - const passenger = createPassenger("p1", "station-1", "station-2"); + const passenger = createPassenger('p1', 'station-1', 'station-2') act(() => { - result.current.race.dispatch({ type: "BEGIN_GAME" }); + result.current.race.dispatch({ type: 'BEGIN_GAME' }) result.current.race.dispatch({ - type: "GENERATE_PASSENGERS", + type: 'GENERATE_PASSENGERS', passengers: [passenger], - }); + }) // Set train position just before station-1 result.current.race.dispatch({ - type: "UPDATE_STEAM_JOURNEY", + type: 'UPDATE_STEAM_JOURNEY', momentum: 50, trainPosition: 40, // First car will be at ~33 (40 - 7) pressure: 75, elapsedTime: 1000, - }); - }); + }) + }) // Verify passenger is waiting - expect(result.current.race.state.passengers[0].isBoarded).toBe(false); + expect(result.current.race.state.passengers[0].isBoarded).toBe(false) // Move train to station-1 position act(() => { result.current.race.dispatch({ - type: "UPDATE_STEAM_JOURNEY", + type: 'UPDATE_STEAM_JOURNEY', momentum: 50, trainPosition: 57, // First car at position 50 (57 - 7) pressure: 75, elapsedTime: 2000, - }); - }); + }) + }) // Advance timers to trigger the interval act(() => { - vi.advanceTimersByTime(100); - }); + vi.advanceTimersByTime(100) + }) // Verify passenger boarded - const boardedPassenger = result.current.race.state.passengers.find( - (p) => p.id === "p1", - ); - expect(boardedPassenger?.isBoarded).toBe(true); - }); + const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1') + expect(boardedPassenger?.isBoarded).toBe(true) + }) - test("multiple passengers can board at the same station on different cars", () => { + test('multiple passengers can board at the same station on different cars', () => { const { result } = renderHook( () => { - const journey = useSteamJourney(); - const race = useComplementRace(); - return { journey, race }; + const journey = useSteamJourney() + const race = useComplementRace() + return { journey, race } }, - { wrapper }, - ); + { wrapper } + ) // Setup: Three passengers waiting at station-1 const passengers = [ - createPassenger("p1", "station-1", "station-2"), - createPassenger("p2", "station-1", "station-2"), - createPassenger("p3", "station-1", "station-2"), - ]; + createPassenger('p1', 'station-1', 'station-2'), + createPassenger('p2', 'station-1', 'station-2'), + createPassenger('p3', 'station-1', 'station-2'), + ] act(() => { - result.current.race.dispatch({ type: "BEGIN_GAME" }); + result.current.race.dispatch({ type: 'BEGIN_GAME' }) result.current.race.dispatch({ - type: "GENERATE_PASSENGERS", + type: 'GENERATE_PASSENGERS', passengers, - }); + }) // Set train with 3 empty cars approaching station-1 (position 50) // Cars at: 50 (57-7), 43 (57-14), 36 (57-21) result.current.race.dispatch({ - type: "UPDATE_STEAM_JOURNEY", + type: 'UPDATE_STEAM_JOURNEY', momentum: 60, trainPosition: 57, pressure: 90, elapsedTime: 1000, - }); - }); + }) + }) // Advance timers act(() => { - vi.advanceTimersByTime(100); - }); + vi.advanceTimersByTime(100) + }) // All three passengers should board (one per car) - const boardedCount = result.current.race.state.passengers.filter( - (p) => p.isBoarded, - ).length; - expect(boardedCount).toBe(3); - }); + const boardedCount = result.current.race.state.passengers.filter((p) => p.isBoarded).length + expect(boardedCount).toBe(3) + }) - test("passenger is not left behind when train passes quickly", () => { + test('passenger is not left behind when train passes quickly', () => { const { result } = renderHook( () => { - const journey = useSteamJourney(); - const race = useComplementRace(); - return { journey, race }; + const journey = useSteamJourney() + const race = useComplementRace() + return { journey, race } }, - { wrapper }, - ); + { wrapper } + ) - const passenger = createPassenger("p1", "station-1", "station-2"); + const passenger = createPassenger('p1', 'station-1', 'station-2') act(() => { - result.current.race.dispatch({ type: "BEGIN_GAME" }); + result.current.race.dispatch({ type: 'BEGIN_GAME' }) result.current.race.dispatch({ - type: "GENERATE_PASSENGERS", + type: 'GENERATE_PASSENGERS', passengers: [passenger], - }); - }); + }) + }) // Simulate train passing through station quickly - const positions = [40, 45, 50, 52, 54, 56, 58, 60, 65, 70]; + const positions = [40, 45, 50, 52, 54, 56, 58, 60, 65, 70] for (const pos of positions) { act(() => { result.current.race.dispatch({ - type: "UPDATE_STEAM_JOURNEY", + type: 'UPDATE_STEAM_JOURNEY', momentum: 80, trainPosition: pos, pressure: 120, elapsedTime: 1000 + pos * 50, - }); - vi.advanceTimersByTime(50); - }); + }) + vi.advanceTimersByTime(50) + }) // Check if passenger boarded - const boardedPassenger = result.current.race.state.passengers.find( - (p) => p.id === "p1", - ); + const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1') if (boardedPassenger?.isBoarded) { // Success! Passenger boarded during the pass - return; + return } } // If we get here, passenger was left behind - const boardedPassenger = result.current.race.state.passengers.find( - (p) => p.id === "p1", - ); - expect(boardedPassenger?.isBoarded).toBe(true); - }); + const boardedPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1') + expect(boardedPassenger?.isBoarded).toBe(true) + }) - test("passenger boards on correct car based on availability", () => { + test('passenger boards on correct car based on availability', () => { const { result } = renderHook( () => { - const journey = useSteamJourney(); - const race = useComplementRace(); - return { journey, race }; + const journey = useSteamJourney() + const race = useComplementRace() + return { journey, race } }, - { wrapper }, - ); + { wrapper } + ) // Setup: One passenger already on car 0, another waiting const passengers = [ - createPassenger("p1", "station-0", "station-2", true, false), // Already boarded on car 0 - createPassenger("p2", "station-1", "station-2"), // Waiting at station-1 - ]; + createPassenger('p1', 'station-0', 'station-2', true, false), // Already boarded on car 0 + createPassenger('p2', 'station-1', 'station-2'), // Waiting at station-1 + ] act(() => { - result.current.race.dispatch({ type: "BEGIN_GAME" }); + result.current.race.dispatch({ type: 'BEGIN_GAME' }) result.current.race.dispatch({ - type: "GENERATE_PASSENGERS", + type: 'GENERATE_PASSENGERS', passengers, - }); + }) // Train at station-1, car 0 occupied, car 1 empty result.current.race.dispatch({ - type: "UPDATE_STEAM_JOURNEY", + type: 'UPDATE_STEAM_JOURNEY', momentum: 50, trainPosition: 57, // Car 0 at 50, Car 1 at 43 pressure: 75, elapsedTime: 2000, - }); - }); + }) + }) act(() => { - vi.advanceTimersByTime(100); - }); + vi.advanceTimersByTime(100) + }) // p2 should board (on car 1 since car 0 is occupied) - const p2 = result.current.race.state.passengers.find((p) => p.id === "p2"); - expect(p2?.isBoarded).toBe(true); + const p2 = result.current.race.state.passengers.find((p) => p.id === 'p2') + expect(p2?.isBoarded).toBe(true) // p1 should still be boarded - const p1 = result.current.race.state.passengers.find((p) => p.id === "p1"); - expect(p1?.isBoarded).toBe(true); - expect(p1?.isDelivered).toBe(false); - }); + const p1 = result.current.race.state.passengers.find((p) => p.id === 'p1') + expect(p1?.isBoarded).toBe(true) + expect(p1?.isDelivered).toBe(false) + }) - test("passenger is delivered when their car reaches destination", () => { + test('passenger is delivered when their car reaches destination', () => { const { result } = renderHook( () => { - const journey = useSteamJourney(); - const race = useComplementRace(); - return { journey, race }; + const journey = useSteamJourney() + const race = useComplementRace() + return { journey, race } }, - { wrapper }, - ); + { wrapper } + ) // Setup: Passenger already boarded, heading to station-2 (position 100) - const passenger = createPassenger( - "p1", - "station-0", - "station-2", - true, - false, - ); + const passenger = createPassenger('p1', 'station-0', 'station-2', true, false) act(() => { - result.current.race.dispatch({ type: "BEGIN_GAME" }); + result.current.race.dispatch({ type: 'BEGIN_GAME' }) result.current.race.dispatch({ - type: "GENERATE_PASSENGERS", + type: 'GENERATE_PASSENGERS', passengers: [passenger], - }); + }) // Move train so car 0 reaches station-2 result.current.race.dispatch({ - type: "UPDATE_STEAM_JOURNEY", + type: 'UPDATE_STEAM_JOURNEY', momentum: 50, trainPosition: 107, // Car 0 at position 100 (107 - 7) pressure: 75, elapsedTime: 5000, - }); - }); + }) + }) act(() => { - vi.advanceTimersByTime(100); - }); + vi.advanceTimersByTime(100) + }) // Passenger should be delivered - const deliveredPassenger = result.current.race.state.passengers.find( - (p) => p.id === "p1", - ); - expect(deliveredPassenger?.isDelivered).toBe(true); - }); -}); + const deliveredPassenger = result.current.race.state.passengers.find((p) => p.id === 'p1') + expect(deliveredPassenger?.isDelivered).toBe(true) + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrackManagement.passenger-display.test.ts b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrackManagement.passenger-display.test.ts index d24b2dcf..59c59336 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrackManagement.passenger-display.test.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrackManagement.passenger-display.test.ts @@ -1,22 +1,19 @@ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { Passenger, Station } from "@/arcade-games/complement-race/types"; -import type { RailroadTrackGenerator } from "../../lib/RailroadTrackGenerator"; -import { useTrackManagement } from "../useTrackManagement"; +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import type { Passenger, Station } from '@/arcade-games/complement-race/types' +import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' +import { useTrackManagement } from '../useTrackManagement' -describe("useTrackManagement - Passenger Display", () => { - let mockPathRef: React.RefObject; - let mockTrackGenerator: RailroadTrackGenerator; - let mockStations: Station[]; - let mockPassengers: Passenger[]; +describe('useTrackManagement - Passenger Display', () => { + let mockPathRef: React.RefObject + let mockTrackGenerator: RailroadTrackGenerator + let mockStations: Station[] + let mockPassengers: Passenger[] beforeEach(() => { // Create mock path element - const mockPath = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - mockPath.getTotalLength = vi.fn(() => 1000); + const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') + mockPath.getTotalLength = vi.fn(() => 1000) mockPath.getPointAtLength = vi.fn((distance: number) => ({ x: distance, y: 300, @@ -24,58 +21,58 @@ describe("useTrackManagement - Passenger Display", () => { z: 0, matrixTransform: () => new DOMPoint(), toJSON: () => ({ x: distance, y: 300, w: 1, z: 0 }), - })) as any; - mockPathRef = { current: mockPath }; + })) as any + mockPathRef = { current: mockPath } // Mock track generator mockTrackGenerator = { generateTrack: vi.fn(() => ({ - ballastPath: "M 0 0", - referencePath: "M 0 0", + ballastPath: 'M 0 0', + referencePath: 'M 0 0', ties: [], - leftRailPath: "M 0 0", - rightRailPath: "M 0 0", + leftRailPath: 'M 0 0', + rightRailPath: 'M 0 0', })), generateTiesAndRails: vi.fn(() => ({ ties: [], - leftRailPath: "M 0 0", - rightRailPath: "M 0 0", + leftRailPath: 'M 0 0', + rightRailPath: 'M 0 0', })), - } as unknown as RailroadTrackGenerator; + } as unknown as RailroadTrackGenerator // Mock stations mockStations = [ { - id: "station1", - name: "Station 1", - icon: "🏠", - emoji: "🏠", + id: 'station1', + name: 'Station 1', + icon: '🏠', + emoji: '🏠', position: 20, }, { - id: "station2", - name: "Station 2", - icon: "🏢", - emoji: "🏢", + id: 'station2', + name: 'Station 2', + icon: '🏢', + emoji: '🏢', position: 50, }, { - id: "station3", - name: "Station 3", - icon: "🏪", - emoji: "🏪", + id: 'station3', + name: 'Station 3', + icon: '🏪', + emoji: '🏪', position: 80, }, - ]; + ] // Mock passengers - initial set (multiplayer format) mockPassengers = [ { - id: "p1", - name: "Alice", - avatar: "👩", - originStationId: "station1", - destinationStationId: "station2", + id: 'p1', + name: 'Alice', + avatar: '👩', + originStationId: 'station1', + destinationStationId: 'station2', isUrgent: false, claimedBy: null, deliveredBy: null, @@ -83,23 +80,23 @@ describe("useTrackManagement - Passenger Display", () => { timestamp: Date.now(), }, { - id: "p2", - name: "Bob", - avatar: "👨", - originStationId: "station2", - destinationStationId: "station3", + id: 'p2', + name: 'Bob', + avatar: '👨', + originStationId: 'station2', + destinationStationId: 'station3', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - test("initial passengers are displayed", () => { + test('initial passengers are displayed', () => { const { result } = renderHook(() => useTrackManagement({ currentRoute: 1, @@ -110,15 +107,15 @@ describe("useTrackManagement - Passenger Display", () => { passengers: mockPassengers, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); - expect(result.current.displayPassengers[1].id).toBe("p2"); - }); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') + expect(result.current.displayPassengers[1].id).toBe('p2') + }) - test("passengers update when boarded (same route gameplay)", () => { + test('passengers update when boarded (same route gameplay)', () => { const { result, rerender } = renderHook( ({ passengers, position }) => useTrackManagement({ @@ -131,26 +128,26 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { passengers: mockPassengers, position: 25 } }, - ); + { initialProps: { passengers: mockPassengers, position: 25 } } + ) // Initially 2 passengers - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].claimedBy).toBe(null); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].claimedBy).toBe(null) // Board first passenger const boardedPassengers = mockPassengers.map((p) => - p.id === "p1" ? { ...p, claimedBy: "player1", carIndex: 0 } : p, - ); + p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p + ) - rerender({ passengers: boardedPassengers, position: 25 }); + rerender({ passengers: boardedPassengers, position: 25 }) // Should show updated passengers - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].claimedBy).toBe("player1"); - }); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].claimedBy).toBe('player1') + }) - test("passengers do NOT update during route transition (train moving)", () => { + test('passengers do NOT update during route transition (train moving)', () => { const { result, rerender } = renderHook( ({ route, passengers, position }) => useTrackManagement({ @@ -163,39 +160,39 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { route: 1, passengers: mockPassengers, position: 50 } }, - ); + { initialProps: { route: 1, passengers: mockPassengers, position: 50 } } + ) // Initially route 1 passengers - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') // Generate new passengers for route 2 const newPassengers: Passenger[] = [ { - id: "p3", - name: "Charlie", - avatar: "👴", - originStationId: "station1", - destinationStationId: "station3", + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 'station1', + destinationStationId: 'station3', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] // Change route but train still moving - rerender({ route: 2, passengers: newPassengers, position: 60 }); + rerender({ route: 2, passengers: newPassengers, position: 60 }) // Should STILL show old passengers (route 1) - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); - expect(result.current.displayPassengers[0].name).toBe("Alice"); - }); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') + expect(result.current.displayPassengers[0].name).toBe('Alice') + }) - test("passengers update when train resets to start (negative position)", () => { + test('passengers update when train resets to start (negative position)', () => { const { result, rerender } = renderHook( ({ route, passengers, position }) => useTrackManagement({ @@ -208,39 +205,39 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { route: 1, passengers: mockPassengers, position: 50 } }, - ); + { initialProps: { route: 1, passengers: mockPassengers, position: 50 } } + ) // Initially route 1 passengers - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') // Generate new passengers for route 2 const newPassengers: Passenger[] = [ { - id: "p3", - name: "Charlie", - avatar: "👴", - originStationId: "station1", - destinationStationId: "station3", + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 'station1', + destinationStationId: 'station3', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] // Change route and train resets - rerender({ route: 2, passengers: newPassengers, position: -5 }); + rerender({ route: 2, passengers: newPassengers, position: -5 }) // Should now show NEW passengers (route 2) - expect(result.current.displayPassengers).toHaveLength(1); - expect(result.current.displayPassengers[0].id).toBe("p3"); - expect(result.current.displayPassengers[0].name).toBe("Charlie"); - }); + expect(result.current.displayPassengers).toHaveLength(1) + expect(result.current.displayPassengers[0].id).toBe('p3') + expect(result.current.displayPassengers[0].name).toBe('Charlie') + }) - test("passengers do NOT flash when transitioning through 100%", () => { + test('passengers do NOT flash when transitioning through 100%', () => { const { result, rerender } = renderHook( ({ route, passengers, position }) => useTrackManagement({ @@ -253,52 +250,52 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { route: 1, passengers: mockPassengers, position: 95 } }, - ); + { initialProps: { route: 1, passengers: mockPassengers, position: 95 } } + ) // At 95% - show route 1 passengers - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') // Generate new passengers for route 2 const newPassengers: Passenger[] = [ { - id: "p3", - name: "Charlie", - avatar: "👴", - originStationId: "station1", - destinationStationId: "station3", + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 'station1', + destinationStationId: 'station3', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] // Train exits (105%) but route hasn't changed yet - rerender({ route: 1, passengers: mockPassengers, position: 105 }); + rerender({ route: 1, passengers: mockPassengers, position: 105 }) // Should STILL show route 1 passengers - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') // Now route changes to 2, but train still at 105% - rerender({ route: 2, passengers: newPassengers, position: 105 }); + rerender({ route: 2, passengers: newPassengers, position: 105 }) // Should STILL show route 1 passengers (old ones) - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') // Train resets to start - rerender({ route: 2, passengers: newPassengers, position: -5 }); + rerender({ route: 2, passengers: newPassengers, position: -5 }) // NOW should show route 2 passengers - expect(result.current.displayPassengers).toHaveLength(1); - expect(result.current.displayPassengers[0].id).toBe("p3"); - }); + expect(result.current.displayPassengers).toHaveLength(1) + expect(result.current.displayPassengers[0].id).toBe('p3') + }) - test("passengers do NOT update when array reference changes but same route", () => { + test('passengers do NOT update when array reference changes but same route', () => { const { result, rerender } = renderHook( ({ passengers, position }) => useTrackManagement({ @@ -311,25 +308,25 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { passengers: mockPassengers, position: 50 } }, - ); + { initialProps: { passengers: mockPassengers, position: 50 } } + ) // Initially route 1 passengers - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') // Create new array with same content (different reference) - const samePassengersNewRef = mockPassengers.map((p) => ({ ...p })); + const samePassengersNewRef = mockPassengers.map((p) => ({ ...p })) // Update with new reference but same content - rerender({ passengers: samePassengersNewRef, position: 50 }); + rerender({ passengers: samePassengersNewRef, position: 50 }) // Display should update because it's the same route (gameplay update) - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].id).toBe("p1"); - }); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].id).toBe('p1') + }) - test("delivered passengers update immediately (same route)", () => { + test('delivered passengers update immediately (same route)', () => { const { result, rerender } = renderHook( ({ passengers, position }) => useTrackManagement({ @@ -342,28 +339,26 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { passengers: mockPassengers, position: 25 } }, - ); + { initialProps: { passengers: mockPassengers, position: 25 } } + ) // Initially 2 passengers, neither delivered - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].deliveredBy).toBe(null); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].deliveredBy).toBe(null) // Deliver first passenger const deliveredPassengers = mockPassengers.map((p) => - p.id === "p1" - ? { ...p, claimedBy: "player1", carIndex: 0, deliveredBy: "player1" } - : p, - ); + p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0, deliveredBy: 'player1' } : p + ) - rerender({ passengers: deliveredPassengers, position: 55 }); + rerender({ passengers: deliveredPassengers, position: 55 }) // Should show updated passengers immediately - expect(result.current.displayPassengers).toHaveLength(2); - expect(result.current.displayPassengers[0].deliveredBy).toBe("player1"); - }); + expect(result.current.displayPassengers).toHaveLength(2) + expect(result.current.displayPassengers[0].deliveredBy).toBe('player1') + }) - test("multiple rapid passenger updates during same route", () => { + test('multiple rapid passenger updates during same route', () => { const { result, rerender } = renderHook( ({ passengers, position }) => useTrackManagement({ @@ -376,41 +371,37 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { passengers: mockPassengers, position: 25 } }, - ); + { initialProps: { passengers: mockPassengers, position: 25 } } + ) // Initially 2 passengers - expect(result.current.displayPassengers).toHaveLength(2); + expect(result.current.displayPassengers).toHaveLength(2) // Board p1 let updated = mockPassengers.map((p) => - p.id === "p1" ? { ...p, claimedBy: "player1", carIndex: 0 } : p, - ); - rerender({ passengers: updated, position: 26 }); - expect(result.current.displayPassengers[0].claimedBy).toBe("player1"); + p.id === 'p1' ? { ...p, claimedBy: 'player1', carIndex: 0 } : p + ) + rerender({ passengers: updated, position: 26 }) + expect(result.current.displayPassengers[0].claimedBy).toBe('player1') // Board p2 - updated = updated.map((p) => - p.id === "p2" ? { ...p, claimedBy: "player1", carIndex: 1 } : p, - ); - rerender({ passengers: updated, position: 52 }); - expect(result.current.displayPassengers[1].claimedBy).toBe("player1"); + updated = updated.map((p) => (p.id === 'p2' ? { ...p, claimedBy: 'player1', carIndex: 1 } : p)) + rerender({ passengers: updated, position: 52 }) + expect(result.current.displayPassengers[1].claimedBy).toBe('player1') // Deliver p1 - updated = updated.map((p) => - p.id === "p1" ? { ...p, deliveredBy: "player1" } : p, - ); - rerender({ passengers: updated, position: 53 }); - expect(result.current.displayPassengers[0].deliveredBy).toBe("player1"); + updated = updated.map((p) => (p.id === 'p1' ? { ...p, deliveredBy: 'player1' } : p)) + rerender({ passengers: updated, position: 53 }) + expect(result.current.displayPassengers[0].deliveredBy).toBe('player1') // All updates should have been reflected - expect(result.current.displayPassengers[0].claimedBy).toBe("player1"); - expect(result.current.displayPassengers[0].deliveredBy).toBe("player1"); - expect(result.current.displayPassengers[1].claimedBy).toBe("player1"); - expect(result.current.displayPassengers[1].deliveredBy).toBe(null); - }); + expect(result.current.displayPassengers[0].claimedBy).toBe('player1') + expect(result.current.displayPassengers[0].deliveredBy).toBe('player1') + expect(result.current.displayPassengers[1].claimedBy).toBe('player1') + expect(result.current.displayPassengers[1].deliveredBy).toBe(null) + }) - test("EDGE CASE: new passengers at position 0 with old route", () => { + test('EDGE CASE: new passengers at position 0 with old route', () => { const { result, rerender } = renderHook( ({ route, passengers, position }) => useTrackManagement({ @@ -423,43 +414,43 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { route: 1, passengers: mockPassengers, position: 95 } }, - ); + { initialProps: { route: 1, passengers: mockPassengers, position: 95 } } + ) // At 95% - route 1 passengers - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers[0].id).toBe('p1') // Train exits tunnel - rerender({ route: 1, passengers: mockPassengers, position: 110 }); - expect(result.current.displayPassengers[0].id).toBe("p1"); + rerender({ route: 1, passengers: mockPassengers, position: 110 }) + expect(result.current.displayPassengers[0].id).toBe('p1') // New passengers generated but route hasn't changed yet, position resets to 0 const newPassengers: Passenger[] = [ { - id: "p3", - name: "Charlie", - avatar: "👴", - originStationId: "station1", - destinationStationId: "station3", + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 'station1', + destinationStationId: 'station3', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] // CRITICAL: New passengers, old route, position = 0 // This could trigger the second useEffect if not handled carefully - rerender({ route: 1, passengers: newPassengers, position: 0 }); + rerender({ route: 1, passengers: newPassengers, position: 0 }) // Should NOT show new passengers yet (route hasn't changed) // But position is 0-100, so second effect might fire - expect(result.current.displayPassengers[0].id).toBe("p1"); - expect(result.current.displayPassengers[0].name).toBe("Alice"); - }); + expect(result.current.displayPassengers[0].id).toBe('p1') + expect(result.current.displayPassengers[0].name).toBe('Alice') + }) - test("EDGE CASE: passengers regenerated at position 5%", () => { + test('EDGE CASE: passengers regenerated at position 5%', () => { const { result, rerender } = renderHook( ({ route, passengers, position }) => useTrackManagement({ @@ -472,36 +463,36 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { route: 1, passengers: mockPassengers, position: 95 } }, - ); + { initialProps: { route: 1, passengers: mockPassengers, position: 95 } } + ) // At 95% - route 1 passengers - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers[0].id).toBe('p1') // New passengers generated while train is at 5% const newPassengers: Passenger[] = [ { - id: "p3", - name: "Charlie", - avatar: "👴", - originStationId: "station1", - destinationStationId: "station3", + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 'station1', + destinationStationId: 'station3', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] // CRITICAL: New passengers array, same route, position within 0-100 - rerender({ route: 1, passengers: newPassengers, position: 5 }); + rerender({ route: 1, passengers: newPassengers, position: 5 }) // Should NOT show new passengers (different array reference, route hasn't changed properly) - expect(result.current.displayPassengers[0].id).toBe("p1"); - }); + expect(result.current.displayPassengers[0].id).toBe('p1') + }) - test("EDGE CASE: rapid route increment with position oscillation", () => { + test('EDGE CASE: rapid route increment with position oscillation', () => { const { result, rerender } = renderHook( ({ route, passengers, position }) => useTrackManagement({ @@ -514,36 +505,36 @@ describe("useTrackManagement - Passenger Display", () => { maxCars: 3, carSpacing: 7, }), - { initialProps: { route: 1, passengers: mockPassengers, position: 50 } }, - ); + { initialProps: { route: 1, passengers: mockPassengers, position: 50 } } + ) - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers[0].id).toBe('p1') const route2Passengers: Passenger[] = [ { - id: "p3", - name: "Charlie", - avatar: "👴", - originStationId: "station1", - destinationStationId: "station3", + id: 'p3', + name: 'Charlie', + avatar: '👴', + originStationId: 'station1', + destinationStationId: 'station3', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] // Route changes, position goes positive briefly before negative - rerender({ route: 2, passengers: route2Passengers, position: 2 }); + rerender({ route: 2, passengers: route2Passengers, position: 2 }) // Should still show old passengers - expect(result.current.displayPassengers[0].id).toBe("p1"); + expect(result.current.displayPassengers[0].id).toBe('p1') // Position goes negative - rerender({ route: 2, passengers: route2Passengers, position: -3 }); + rerender({ route: 2, passengers: route2Passengers, position: -3 }) // NOW should show new passengers - expect(result.current.displayPassengers[0].id).toBe("p3"); - }); -}); + expect(result.current.displayPassengers[0].id).toBe('p3') + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrackManagement.test.ts b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrackManagement.test.ts index 30b04474..8ed3ae81 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrackManagement.test.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrackManagement.test.ts @@ -1,30 +1,27 @@ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { Passenger, Station } from "@/arcade-games/complement-race/types"; -import type { RailroadTrackGenerator } from "../../lib/RailroadTrackGenerator"; -import { useTrackManagement } from "../useTrackManagement"; +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import type { Passenger, Station } from '@/arcade-games/complement-race/types' +import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' +import { useTrackManagement } from '../useTrackManagement' // Mock the landmarks module -vi.mock("../../lib/landmarks", () => ({ +vi.mock('../../lib/landmarks', () => ({ generateLandmarks: vi.fn((_route: number) => [ - { emoji: "🌲", position: 30, offset: { x: 0, y: -50 }, size: 24 }, - { emoji: "🏔️", position: 70, offset: { x: 0, y: -80 }, size: 32 }, + { emoji: '🌲', position: 30, offset: { x: 0, y: -50 }, size: 24 }, + { emoji: '🏔️', position: 70, offset: { x: 0, y: -80 }, size: 32 }, ]), -})); +})) -describe("useTrackManagement", () => { - let mockPathRef: React.RefObject; - let mockTrackGenerator: RailroadTrackGenerator; - let mockStations: Station[]; - let mockPassengers: Passenger[]; +describe('useTrackManagement', () => { + let mockPathRef: React.RefObject + let mockTrackGenerator: RailroadTrackGenerator + let mockStations: Station[] + let mockPassengers: Passenger[] beforeEach(() => { // Create mock path element - const mockPath = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - mockPath.getTotalLength = vi.fn(() => 1000); + const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') + mockPath.getTotalLength = vi.fn(() => 1000) mockPath.getPointAtLength = vi.fn((distance: number) => ({ x: distance, y: 300, @@ -32,8 +29,8 @@ describe("useTrackManagement", () => { z: 0, matrixTransform: () => new DOMPoint(), toJSON: () => ({ x: distance, y: 300, w: 1, z: 0 }), - })) as any; - mockPathRef = { current: mockPath }; + })) as any + mockPathRef = { current: mockPath } // Mock track generator mockTrackGenerator = { @@ -46,47 +43,47 @@ describe("useTrackManagement", () => { { x1: 0, y1: 300, x2: 10, y2: 300 }, { x1: 20, y1: 300, x2: 30, y2: 300 }, ], - leftRailPoints: ["0,295", "100,295"], - rightRailPoints: ["0,305", "100,305"], + leftRailPoints: ['0,295', '100,295'], + rightRailPoints: ['0,305', '100,305'], })), - } as unknown as RailroadTrackGenerator; + } as unknown as RailroadTrackGenerator mockStations = [ { - id: "station-1", - name: "Station 1", + id: 'station-1', + name: 'Station 1', position: 20, - icon: "🏭", - emoji: "🏭", + icon: '🏭', + emoji: '🏭', }, { - id: "station-2", - name: "Station 2", + id: 'station-2', + name: 'Station 2', position: 60, - icon: "🏛️", - emoji: "🏛️", + icon: '🏛️', + emoji: '🏛️', }, - ]; + ] mockPassengers = [ { - id: "passenger-1", - name: "Passenger 1", - avatar: "👨", - originStationId: "station-1", - destinationStationId: "station-2", + id: 'passenger-1', + name: 'Passenger 1', + avatar: '👨', + originStationId: 'station-1', + destinationStationId: 'station-2', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - test("initializes with null trackData", () => { + test('initializes with null trackData', () => { const { result } = renderHook(() => useTrackManagement({ currentRoute: 1, @@ -97,15 +94,15 @@ describe("useTrackManagement", () => { passengers: mockPassengers, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) // Track data should be generated - expect(result.current.trackData).toBeDefined(); - expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(1); - }); + expect(result.current.trackData).toBeDefined() + expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(1) + }) - test("generates landmarks for current route", () => { + test('generates landmarks for current route', () => { const { result } = renderHook(() => useTrackManagement({ currentRoute: 1, @@ -116,15 +113,15 @@ describe("useTrackManagement", () => { passengers: mockPassengers, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.landmarks).toHaveLength(2); - expect(result.current.landmarks[0].emoji).toBe("🌲"); - expect(result.current.landmarks[1].emoji).toBe("🏔️"); - }); + expect(result.current.landmarks).toHaveLength(2) + expect(result.current.landmarks[0].emoji).toBe('🌲') + expect(result.current.landmarks[1].emoji).toBe('🏔️') + }) - test("generates ties and rails when path is ready", () => { + test('generates ties and rails when path is ready', () => { const { result } = renderHook(() => useTrackManagement({ currentRoute: 1, @@ -135,14 +132,14 @@ describe("useTrackManagement", () => { passengers: mockPassengers, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.tiesAndRails).toBeDefined(); - expect(result.current.tiesAndRails?.ties).toHaveLength(2); - }); + expect(result.current.tiesAndRails).toBeDefined() + expect(result.current.tiesAndRails?.ties).toHaveLength(2) + }) - test("calculates station positions along path", () => { + test('calculates station positions along path', () => { const { result } = renderHook(() => useTrackManagement({ currentRoute: 1, @@ -153,17 +150,17 @@ describe("useTrackManagement", () => { passengers: mockPassengers, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.stationPositions).toHaveLength(2); + expect(result.current.stationPositions).toHaveLength(2) // Station 1 at 20% of 1000 = 200 - expect(result.current.stationPositions[0].x).toBe(200); + expect(result.current.stationPositions[0].x).toBe(200) // Station 2 at 60% of 1000 = 600 - expect(result.current.stationPositions[1].x).toBe(600); - }); + expect(result.current.stationPositions[1].x).toBe(600) + }) - test("calculates landmark positions along path", () => { + test('calculates landmark positions along path', () => { const { result } = renderHook(() => useTrackManagement({ currentRoute: 1, @@ -174,16 +171,16 @@ describe("useTrackManagement", () => { passengers: mockPassengers, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.landmarkPositions).toHaveLength(2); + expect(result.current.landmarkPositions).toHaveLength(2) // First landmark at 30% + offset - expect(result.current.landmarkPositions[0].x).toBe(300); // 30% of 1000 - expect(result.current.landmarkPositions[0].y).toBe(250); // 300 + (-50) - }); + expect(result.current.landmarkPositions[0].x).toBe(300) // 30% of 1000 + expect(result.current.landmarkPositions[0].y).toBe(250) // 300 + (-50) + }) - test("delays track update when changing routes mid-journey", () => { + test('delays track update when changing routes mid-journey', () => { const { result, rerender } = renderHook( ({ route, position }) => useTrackManagement({ @@ -198,20 +195,20 @@ describe("useTrackManagement", () => { }), { initialProps: { route: 1, position: 0 }, - }, - ); + } + ) - const initialTrackData = result.current.trackData; + const initialTrackData = result.current.trackData // Change route while train is mid-journey (position > 0) - rerender({ route: 2, position: 50 }); + rerender({ route: 2, position: 50 }) // Track should NOT update yet (pending) - expect(result.current.trackData).toBe(initialTrackData); - expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(2); - }); + expect(result.current.trackData).toBe(initialTrackData) + expect(mockTrackGenerator.generateTrack).toHaveBeenCalledWith(2) + }) - test("applies pending track when train resets to beginning", () => { + test('applies pending track when train resets to beginning', () => { const { result, rerender } = renderHook( ({ route, position }) => useTrackManagement({ @@ -226,21 +223,21 @@ describe("useTrackManagement", () => { }), { initialProps: { route: 1, position: 0 }, - }, - ); + } + ) // Change route while train is mid-journey - rerender({ route: 2, position: 50 }); - const trackDataBeforeReset = result.current.trackData; + rerender({ route: 2, position: 50 }) + const trackDataBeforeReset = result.current.trackData // Train resets to beginning (position < 0) - rerender({ route: 2, position: -5 }); + rerender({ route: 2, position: -5 }) // Track should now update - expect(result.current.trackData).not.toBe(trackDataBeforeReset); - }); + expect(result.current.trackData).not.toBe(trackDataBeforeReset) + }) - test("immediately applies new track when train is at start", () => { + test('immediately applies new track when train is at start', () => { const { result, rerender } = renderHook( ({ route, position }) => useTrackManagement({ @@ -255,33 +252,33 @@ describe("useTrackManagement", () => { }), { initialProps: { route: 1, position: -5 }, - }, - ); + } + ) - const initialTrackData = result.current.trackData; + const initialTrackData = result.current.trackData // Change route while train is at start (position < 0) - rerender({ route: 2, position: -5 }); + rerender({ route: 2, position: -5 }) // Track should update immediately - expect(result.current.trackData).not.toBe(initialTrackData); - }); + expect(result.current.trackData).not.toBe(initialTrackData) + }) - test("delays passenger display update until all cars exit", () => { + test('delays passenger display update until all cars exit', () => { const newPassengers: Passenger[] = [ { - id: "passenger-2", - name: "Passenger 2", - avatar: "👩", - originStationId: "station-1", - destinationStationId: "station-2", + id: 'passenger-2', + name: 'Passenger 2', + avatar: '👩', + originStationId: 'station-1', + destinationStationId: 'station-2', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] const { result, rerender } = renderHook( ({ passengers, position }) => @@ -297,34 +294,34 @@ describe("useTrackManagement", () => { }), { initialProps: { passengers: mockPassengers, position: 50 }, - }, - ); + } + ) - expect(result.current.displayPassengers).toBe(mockPassengers); + expect(result.current.displayPassengers).toBe(mockPassengers) // Change passengers while train is mid-journey // Locomotive at 100%, but last car at 100 - (5*7) = 65% - rerender({ passengers: newPassengers, position: 100 }); + rerender({ passengers: newPassengers, position: 100 }) // Display passengers should NOT update yet (last car hasn't exited) - expect(result.current.displayPassengers).toBe(mockPassengers); - }); + expect(result.current.displayPassengers).toBe(mockPassengers) + }) - test("does not update passenger display until train resets", () => { + test('does not update passenger display until train resets', () => { const newPassengers: Passenger[] = [ { - id: "passenger-2", - name: "Passenger 2", - avatar: "👩", - originStationId: "station-1", - destinationStationId: "station-2", + id: 'passenger-2', + name: 'Passenger 2', + avatar: '👩', + originStationId: 'station-1', + destinationStationId: 'station-2', isUrgent: false, claimedBy: null, deliveredBy: null, carIndex: null, timestamp: Date.now(), }, - ]; + ] const { result, rerender } = renderHook( ({ passengers, position }) => @@ -340,27 +337,27 @@ describe("useTrackManagement", () => { }), { initialProps: { passengers: mockPassengers, position: 50 }, - }, - ); + } + ) // Change passengers, locomotive at position where all cars have exited // Last car exits at position 97%, so locomotive at 132% - rerender({ passengers: newPassengers, position: 132 }); + rerender({ passengers: newPassengers, position: 132 }) // Display passengers should NOT update yet (waiting for train reset) - expect(result.current.displayPassengers).toBe(mockPassengers); + expect(result.current.displayPassengers).toBe(mockPassengers) // Now train resets to beginning - rerender({ passengers: newPassengers, position: -5 }); + rerender({ passengers: newPassengers, position: -5 }) // Display passengers should update now (train reset) - expect(result.current.displayPassengers).toBe(newPassengers); - }); + expect(result.current.displayPassengers).toBe(newPassengers) + }) - test("updates passengers immediately during same route", () => { + test('updates passengers immediately during same route', () => { const updatedPassengers: Passenger[] = [ - { ...mockPassengers[0], claimedBy: "player1", carIndex: 0 }, - ]; + { ...mockPassengers[0], claimedBy: 'player1', carIndex: 0 }, + ] const { result, rerender } = renderHook( ({ passengers, position }) => @@ -376,21 +373,21 @@ describe("useTrackManagement", () => { }), { initialProps: { passengers: mockPassengers, position: 50 }, - }, - ); + } + ) // Update passengers (boarding) during same route - rerender({ passengers: updatedPassengers, position: 55 }); + rerender({ passengers: updatedPassengers, position: 55 }) // Display passengers should update immediately (same route, gameplay update) - expect(result.current.displayPassengers).toBe(updatedPassengers); - }); + expect(result.current.displayPassengers).toBe(updatedPassengers) + }) - test("returns null when no track data", () => { + test('returns null when no track data', () => { // Create a hook where trackGenerator returns null const nullTrackGenerator = { generateTrack: vi.fn(() => null), - } as unknown as RailroadTrackGenerator; + } as unknown as RailroadTrackGenerator const { result } = renderHook(() => useTrackManagement({ @@ -402,9 +399,9 @@ describe("useTrackManagement", () => { passengers: mockPassengers, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.trackData).toBeNull(); - }); -}); + expect(result.current.trackData).toBeNull() + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrainTransforms.test.ts b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrainTransforms.test.ts index dc24d2aa..be108ee2 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrainTransforms.test.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/__tests__/useTrainTransforms.test.ts @@ -1,19 +1,16 @@ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, test, vi } from "vitest"; -import type { RailroadTrackGenerator } from "../../lib/RailroadTrackGenerator"; -import { useTrainTransforms } from "../useTrainTransforms"; +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator' +import { useTrainTransforms } from '../useTrainTransforms' -describe("useTrainTransforms", () => { - let mockPathRef: React.RefObject; - let mockTrackGenerator: RailroadTrackGenerator; +describe('useTrainTransforms', () => { + let mockPathRef: React.RefObject + let mockTrackGenerator: RailroadTrackGenerator beforeEach(() => { // Create mock path element - const mockPath = document.createElementNS( - "http://www.w3.org/2000/svg", - "path", - ); - mockPathRef = { current: mockPath }; + const mockPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') + mockPathRef = { current: mockPath } // Mock track generator mockTrackGenerator = { @@ -22,13 +19,13 @@ describe("useTrainTransforms", () => { y: 300, rotation: position / 10, })), - } as unknown as RailroadTrackGenerator; + } as unknown as RailroadTrackGenerator - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - test("returns default transform when pathRef is null", () => { - const nullPathRef: React.RefObject = { current: null }; + test('returns default transform when pathRef is null', () => { + const nullPathRef: React.RefObject = { current: null } const { result } = renderHook(() => useTrainTransforms({ @@ -37,18 +34,18 @@ describe("useTrainTransforms", () => { pathRef: nullPathRef, maxCars: 5, carSpacing: 7, - }), - ); + }) + ) expect(result.current.trainTransform).toEqual({ x: 50, y: 300, rotation: 0, - }); - expect(result.current.trainCars).toHaveLength(5); - }); + }) + expect(result.current.trainCars).toHaveLength(5) + }) - test("calculates train transform at given position", () => { + test('calculates train transform at given position', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 50, @@ -56,17 +53,17 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); + }) + ) expect(result.current.trainTransform).toEqual({ x: 500, // 50 * 10 y: 300, rotation: 5, // 50 / 10 - }); - }); + }) + }) - test("updates transform when train position changes", () => { + test('updates transform when train position changes', () => { const { result, rerender } = renderHook( ({ position }) => useTrainTransforms({ @@ -76,16 +73,16 @@ describe("useTrainTransforms", () => { maxCars: 5, carSpacing: 7, }), - { initialProps: { position: 20 } }, - ); + { initialProps: { position: 20 } } + ) - expect(result.current.trainTransform.x).toBe(200); + expect(result.current.trainTransform.x).toBe(200) - rerender({ position: 60 }); - expect(result.current.trainTransform.x).toBe(600); - }); + rerender({ position: 60 }) + expect(result.current.trainTransform.x).toBe(600) + }) - test("calculates correct number of train cars", () => { + test('calculates correct number of train cars', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 50, @@ -93,13 +90,13 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.trainCars).toHaveLength(5); - }); + expect(result.current.trainCars).toHaveLength(5) + }) - test("respects custom maxCars parameter", () => { + test('respects custom maxCars parameter', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 50, @@ -107,13 +104,13 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.trainCars).toHaveLength(3); - }); + expect(result.current.trainCars).toHaveLength(3) + }) - test("respects custom carSpacing parameter", () => { + test('respects custom carSpacing parameter', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 50, @@ -121,14 +118,14 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 10, - }), - ); + }) + ) // First car should be at position 50 - 10 = 40 - expect(result.current.trainCars[0].position).toBe(40); - }); + expect(result.current.trainCars[0].position).toBe(40) + }) - test("positions cars behind locomotive with correct spacing", () => { + test('positions cars behind locomotive with correct spacing', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 50, @@ -136,15 +133,15 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 3, carSpacing: 10, - }), - ); + }) + ) - expect(result.current.trainCars[0].position).toBe(40); // 50 - 1*10 - expect(result.current.trainCars[1].position).toBe(30); // 50 - 2*10 - expect(result.current.trainCars[2].position).toBe(20); // 50 - 3*10 - }); + expect(result.current.trainCars[0].position).toBe(40) // 50 - 1*10 + expect(result.current.trainCars[1].position).toBe(30) // 50 - 2*10 + expect(result.current.trainCars[2].position).toBe(20) // 50 - 3*10 + }) - test("calculates locomotive opacity correctly during fade in", () => { + test('calculates locomotive opacity correctly during fade in', () => { // Fade in range: 3-8% const { result: result1 } = renderHook(() => useTrainTransforms({ @@ -153,9 +150,9 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); - expect(result1.current.locomotiveOpacity).toBe(0); + }) + ) + expect(result1.current.locomotiveOpacity).toBe(0) const { result: result2 } = renderHook(() => useTrainTransforms({ @@ -164,9 +161,9 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); - expect(result2.current.locomotiveOpacity).toBe(0.5); + }) + ) + expect(result2.current.locomotiveOpacity).toBe(0.5) const { result: result3 } = renderHook(() => useTrainTransforms({ @@ -175,12 +172,12 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); - expect(result3.current.locomotiveOpacity).toBe(1); - }); + }) + ) + expect(result3.current.locomotiveOpacity).toBe(1) + }) - test("calculates locomotive opacity correctly during fade out", () => { + test('calculates locomotive opacity correctly during fade out', () => { // Fade out range: 92-97% const { result: result1 } = renderHook(() => useTrainTransforms({ @@ -189,9 +186,9 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); - expect(result1.current.locomotiveOpacity).toBe(1); + }) + ) + expect(result1.current.locomotiveOpacity).toBe(1) const { result: result2 } = renderHook(() => useTrainTransforms({ @@ -200,9 +197,9 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); - expect(result2.current.locomotiveOpacity).toBe(0.5); + }) + ) + expect(result2.current.locomotiveOpacity).toBe(0.5) const { result: result3 } = renderHook(() => useTrainTransforms({ @@ -211,12 +208,12 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); - expect(result3.current.locomotiveOpacity).toBe(0); - }); + }) + ) + expect(result3.current.locomotiveOpacity).toBe(0) + }) - test("locomotive is fully visible in middle of track", () => { + test('locomotive is fully visible in middle of track', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 50, @@ -224,13 +221,13 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); + }) + ) - expect(result.current.locomotiveOpacity).toBe(1); - }); + expect(result.current.locomotiveOpacity).toBe(1) + }) - test("calculates car opacity independently for each car", () => { + test('calculates car opacity independently for each car', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 10, // Locomotive at 10%, first car at 3% (fading in) @@ -238,19 +235,19 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 2, carSpacing: 7, - }), - ); + }) + ) // First car at position 3 should be starting to fade in - expect(result.current.trainCars[0].position).toBe(3); - expect(result.current.trainCars[0].opacity).toBe(0); + expect(result.current.trainCars[0].position).toBe(3) + expect(result.current.trainCars[0].opacity).toBe(0) // Second car at position -4 should be invisible (not yet entered) - expect(result.current.trainCars[1].position).toBe(0); // clamped to 0 - expect(result.current.trainCars[1].opacity).toBe(0); - }); + expect(result.current.trainCars[1].position).toBe(0) // clamped to 0 + expect(result.current.trainCars[1].opacity).toBe(0) + }) - test("car positions cannot go below zero", () => { + test('car positions cannot go below zero', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 5, @@ -258,16 +255,16 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 3, carSpacing: 7, - }), - ); + }) + ) // First car at 5 - 7 = -2, should be clamped to 0 - expect(result.current.trainCars[0].position).toBe(0); + expect(result.current.trainCars[0].position).toBe(0) // Second car at 5 - 14 = -9, should be clamped to 0 - expect(result.current.trainCars[1].position).toBe(0); - }); + expect(result.current.trainCars[1].position).toBe(0) + }) - test("cars fade out completely past 97%", () => { + test('cars fade out completely past 97%', () => { const { result } = renderHook(() => useTrainTransforms({ trainPosition: 104, // Last car at 104 - 35 = 69% (5 cars * 7 spacing) @@ -275,15 +272,15 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); + }) + ) - const lastCar = result.current.trainCars[4]; - expect(lastCar.position).toBe(69); - expect(lastCar.opacity).toBe(1); // Still visible, not past 97% - }); + const lastCar = result.current.trainCars[4] + expect(lastCar.position).toBe(69) + expect(lastCar.opacity).toBe(1) // Still visible, not past 97% + }) - test("memoizes car transforms to avoid recalculation on same inputs", () => { + test('memoizes car transforms to avoid recalculation on same inputs', () => { const { result, rerender } = renderHook(() => useTrainTransforms({ trainPosition: 50, @@ -291,15 +288,15 @@ describe("useTrainTransforms", () => { pathRef: mockPathRef, maxCars: 5, carSpacing: 7, - }), - ); + }) + ) - const firstCars = result.current.trainCars; + const firstCars = result.current.trainCars // Rerender with same props - rerender(); + rerender() // Should be the exact same array reference (memoized) - expect(result.current.trainCars).toBe(firstCars); - }); -}); + expect(result.current.trainCars).toBe(firstCars) + }) +}) diff --git a/apps/web/src/app/arcade/complement-race/hooks/useAIRacers.ts b/apps/web/src/app/arcade/complement-race/hooks/useAIRacers.ts index cca4af94..4a821ad5 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useAIRacers.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useAIRacers.ts @@ -1,126 +1,114 @@ -import { useEffect } from "react"; -import { - type CommentaryContext, - getAICommentary, -} from "../components/AISystem/aiCommentary"; -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import { useSoundEffects } from "./useSoundEffects"; +import { useEffect } from 'react' +import { type CommentaryContext, getAICommentary } from '../components/AISystem/aiCommentary' +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import { useSoundEffects } from './useSoundEffects' export function useAIRacers() { - const { state, dispatch } = useComplementRace(); - const { playSound } = useSoundEffects(); + const { state, dispatch } = useComplementRace() + const { playSound } = useSoundEffects() useEffect(() => { - if (!state.isGameActive) return; + if (!state.isGameActive) return // Update AI positions every 200ms (line 11690) const aiUpdateInterval = setInterval(() => { const newPositions = state.aiRacers.map((racer) => { // Base speed with random variance (0.6-1.4 range via Math.random() * 0.8 + 0.6) - const variance = Math.random() * 0.8 + 0.6; - let speed = racer.speed * variance * state.speedMultiplier; + const variance = Math.random() * 0.8 + 0.6 + let speed = racer.speed * variance * state.speedMultiplier // Rubber-banding: AI speeds up 2x when >10 units behind player (line 11697-11699) - const distanceBehind = state.correctAnswers - racer.position; + const distanceBehind = state.correctAnswers - racer.position if (distanceBehind > 10) { - speed *= 2; + speed *= 2 } // Update position - const newPosition = racer.position + speed; + const newPosition = racer.position + speed return { id: racer.id, position: newPosition, - }; - }); + } + }) - dispatch({ type: "UPDATE_AI_POSITIONS", positions: newPositions }); + dispatch({ type: 'UPDATE_AI_POSITIONS', positions: newPositions }) // Check for AI win in practice mode (line 14151) - if (state.style === "practice" && state.isGameActive) { + if (state.style === 'practice' && state.isGameActive) { const winningAI = state.aiRacers.find((racer, index) => { - const updatedPosition = - newPositions[index]?.position || racer.position; - return updatedPosition >= state.raceGoal; - }); + const updatedPosition = newPositions[index]?.position || racer.position + return updatedPosition >= state.raceGoal + }) if (winningAI) { // Play game over sound (line 14193) - playSound("gameOver"); + playSound('gameOver') // End the game - dispatch({ type: "END_RACE" }); + dispatch({ type: 'END_RACE' }) // Show results after a short delay setTimeout(() => { - dispatch({ type: "SHOW_RESULTS" }); - }, 1500); - return; // Exit early to prevent further updates + dispatch({ type: 'SHOW_RESULTS' }) + }, 1500) + return // Exit early to prevent further updates } } // Check for commentary triggers after position updates state.aiRacers.forEach((racer) => { const updatedPosition = - newPositions.find((p) => p.id === racer.id)?.position || - racer.position; - const distanceBehind = state.correctAnswers - updatedPosition; - const distanceAhead = updatedPosition - state.correctAnswers; + newPositions.find((p) => p.id === racer.id)?.position || racer.position + const distanceBehind = state.correctAnswers - updatedPosition + const distanceAhead = updatedPosition - state.correctAnswers // Detect passing events const playerJustPassed = - racer.previousPosition > state.correctAnswers && - updatedPosition < state.correctAnswers; + racer.previousPosition > state.correctAnswers && updatedPosition < state.correctAnswers const aiJustPassed = - racer.previousPosition < state.correctAnswers && - updatedPosition > state.correctAnswers; + racer.previousPosition < state.correctAnswers && updatedPosition > state.correctAnswers // Determine commentary context - let context: CommentaryContext | null = null; + let context: CommentaryContext | null = null if (playerJustPassed) { - context = "player_passed"; + context = 'player_passed' } else if (aiJustPassed) { - context = "ai_passed"; + context = 'ai_passed' } else if (distanceBehind > 20) { // Player has lapped the AI (more than 20 units behind) - context = "lapped"; + context = 'lapped' } else if (distanceBehind > 10) { // AI is desperate to catch up (rubber-banding active) - context = "desperate_catchup"; + context = 'desperate_catchup' } else if (distanceAhead > 5) { // AI is significantly ahead - context = "ahead"; + context = 'ahead' } else if (distanceBehind > 3) { // AI is behind - context = "behind"; + context = 'behind' } // Trigger commentary if context is valid if (context) { - const message = getAICommentary( - racer, - context, - state.correctAnswers, - updatedPosition, - ); + const message = getAICommentary(racer, context, state.correctAnswers, updatedPosition) if (message) { dispatch({ - type: "TRIGGER_AI_COMMENTARY", + type: 'TRIGGER_AI_COMMENTARY', racerId: racer.id, message, context, - }); + }) // Play special turbo sound when AI goes desperate (line 11941) - if (context === "desperate_catchup") { - playSound("ai_turbo", 0.12); + if (context === 'desperate_catchup') { + playSound('ai_turbo', 0.12) } } } - }); - }, 200); + }) + }, 200) - return () => clearInterval(aiUpdateInterval); + return () => clearInterval(aiUpdateInterval) }, [ state.isGameActive, state.aiRacers, @@ -130,9 +118,9 @@ export function useAIRacers() { playSound, state.raceGoal, state.style, - ]); + ]) return { aiRacers: state.aiRacers, - }; + } } diff --git a/apps/web/src/app/arcade/complement-race/hooks/useAdaptiveDifficulty.ts b/apps/web/src/app/arcade/complement-race/hooks/useAdaptiveDifficulty.ts index aabf6348..64fda521 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useAdaptiveDifficulty.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useAdaptiveDifficulty.ts @@ -1,314 +1,297 @@ -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import type { PairPerformance } from "../lib/gameTypes"; +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import type { PairPerformance } from '../lib/gameTypes' export function useAdaptiveDifficulty() { - const { state, dispatch } = useComplementRace(); + const { state, dispatch } = useComplementRace() // Track performance after each answer (lines 14495-14553) const trackPerformance = (isCorrect: boolean, responseTime: number) => { - if (!state.currentQuestion) return; + if (!state.currentQuestion) return - const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`; + const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}` // Get or create performance data for this pair - const pairData: PairPerformance = - state.difficultyTracker.pairPerformance.get(pairKey) || { - attempts: 0, - correct: 0, - avgTime: 0, - difficulty: 1, - }; + const pairData: PairPerformance = state.difficultyTracker.pairPerformance.get(pairKey) || { + attempts: 0, + correct: 0, + avgTime: 0, + difficulty: 1, + } // Update performance data - pairData.attempts++; + pairData.attempts++ if (isCorrect) { - pairData.correct++; + pairData.correct++ } // Update average time (rolling average) - const totalTime = pairData.avgTime * (pairData.attempts - 1) + responseTime; - pairData.avgTime = totalTime / pairData.attempts; + const totalTime = pairData.avgTime * (pairData.attempts - 1) + responseTime + pairData.avgTime = totalTime / pairData.attempts // Calculate pair-specific difficulty (lines 14555-14576) if (pairData.attempts >= 2) { - const accuracyRate = pairData.correct / pairData.attempts; - const avgTime = pairData.avgTime; + const accuracyRate = pairData.correct / pairData.attempts + const avgTime = pairData.avgTime - let difficulty = 1; + let difficulty = 1 if (accuracyRate >= 0.9 && avgTime < 1500) { - difficulty = 1; // Very easy + difficulty = 1 // Very easy } else if (accuracyRate >= 0.8 && avgTime < 2000) { - difficulty = 2; // Easy + difficulty = 2 // Easy } else if (accuracyRate >= 0.7 || avgTime < 2500) { - difficulty = 3; // Medium + difficulty = 3 // Medium } else if (accuracyRate >= 0.5 || avgTime < 3500) { - difficulty = 4; // Hard + difficulty = 4 // Hard } else { - difficulty = 5; // Very hard + difficulty = 5 // Very hard } - pairData.difficulty = difficulty; + pairData.difficulty = difficulty } // Update difficulty tracker in state - const newPairPerformance = new Map(state.difficultyTracker.pairPerformance); - newPairPerformance.set(pairKey, pairData); + const newPairPerformance = new Map(state.difficultyTracker.pairPerformance) + newPairPerformance.set(pairKey, pairData) // Update consecutive counters const newTracker = { ...state.difficultyTracker, pairPerformance: newPairPerformance, - consecutiveCorrect: isCorrect - ? state.difficultyTracker.consecutiveCorrect + 1 - : 0, - consecutiveIncorrect: !isCorrect - ? state.difficultyTracker.consecutiveIncorrect + 1 - : 0, - }; + consecutiveCorrect: isCorrect ? state.difficultyTracker.consecutiveCorrect + 1 : 0, + consecutiveIncorrect: !isCorrect ? state.difficultyTracker.consecutiveIncorrect + 1 : 0, + } // Adapt global difficulty (lines 14578-14605) if (newTracker.consecutiveCorrect >= 3) { // Reduce time limit (increase difficulty) newTracker.currentTimeLimit = Math.max( 1000, - newTracker.currentTimeLimit - - newTracker.currentTimeLimit * newTracker.adaptationRate, - ); + newTracker.currentTimeLimit - newTracker.currentTimeLimit * newTracker.adaptationRate + ) } else if (newTracker.consecutiveIncorrect >= 2) { // Increase time limit (decrease difficulty) newTracker.currentTimeLimit = Math.min( 5000, - newTracker.currentTimeLimit + - newTracker.baseTimeLimit * newTracker.adaptationRate, - ); + newTracker.currentTimeLimit + newTracker.baseTimeLimit * newTracker.adaptationRate + ) } // Update overall difficulty level const avgDifficulty = Array.from(newTracker.pairPerformance.values()).reduce( (sum, data) => sum + data.difficulty, - 0, - ) / Math.max(1, newTracker.pairPerformance.size); + 0 + ) / Math.max(1, newTracker.pairPerformance.size) - newTracker.difficultyLevel = Math.round(avgDifficulty); + newTracker.difficultyLevel = Math.round(avgDifficulty) // Exit learning mode after sufficient data (lines 14548-14552) if ( newTracker.pairPerformance.size >= 5 && - Array.from(newTracker.pairPerformance.values()).some( - (data) => data.attempts >= 3, - ) + Array.from(newTracker.pairPerformance.values()).some((data) => data.attempts >= 3) ) { - newTracker.learningMode = false; + newTracker.learningMode = false } // Dispatch update - dispatch({ type: "UPDATE_DIFFICULTY_TRACKER", tracker: newTracker }); + dispatch({ type: 'UPDATE_DIFFICULTY_TRACKER', tracker: newTracker }) // Adapt AI speeds based on player performance - adaptAISpeeds(newTracker); - }; + adaptAISpeeds(newTracker) + } // Calculate recent success rate (lines 14685-14693) const calculateRecentSuccessRate = (): number => { - const recentQuestions = Math.min(10, state.totalQuestions); - if (recentQuestions === 0) return 0.5; // Default for first question + const recentQuestions = Math.min(10, state.totalQuestions) + if (recentQuestions === 0) return 0.5 // Default for first question // Use global tracking for recent performance const recentCorrect = Math.max( 0, - state.correctAnswers - - Math.max(0, state.totalQuestions - recentQuestions), - ); - return recentCorrect / recentQuestions; - }; + state.correctAnswers - Math.max(0, state.totalQuestions - recentQuestions) + ) + return recentCorrect / recentQuestions + } // Calculate average response time (lines 14695-14705) const calculateAverageResponseTime = (): number => { - const recentPairs = Array.from( - state.difficultyTracker.pairPerformance.values(), - ) + const recentPairs = Array.from(state.difficultyTracker.pairPerformance.values()) .filter((data) => data.attempts >= 1) - .slice(-5); // Last 5 different pairs encountered + .slice(-5) // Last 5 different pairs encountered - if (recentPairs.length === 0) return 3000; // Default for learning mode + if (recentPairs.length === 0) return 3000 // Default for learning mode - const totalTime = recentPairs.reduce((sum, data) => sum + data.avgTime, 0); - return totalTime / recentPairs.length; - }; + const totalTime = recentPairs.reduce((sum, data) => sum + data.avgTime, 0) + return totalTime / recentPairs.length + } // Adapt AI speeds based on performance (lines 14607-14683) const adaptAISpeeds = (tracker: typeof state.difficultyTracker) => { // Don't adapt during learning mode - if (tracker.learningMode) return; + if (tracker.learningMode) return - const playerSuccessRate = calculateRecentSuccessRate(); - const avgResponseTime = calculateAverageResponseTime(); + const playerSuccessRate = calculateRecentSuccessRate() + const avgResponseTime = calculateAverageResponseTime() // Base speed multipliers for each race mode - let baseSpeedMultiplier: number; + let baseSpeedMultiplier: number switch (state.style) { - case "practice": - baseSpeedMultiplier = 0.7; - break; - case "sprint": - baseSpeedMultiplier = 0.9; - break; - case "survival": - baseSpeedMultiplier = state.speedMultiplier * state.survivalMultiplier; - break; + case 'practice': + baseSpeedMultiplier = 0.7 + break + case 'sprint': + baseSpeedMultiplier = 0.9 + break + case 'survival': + baseSpeedMultiplier = state.speedMultiplier * state.survivalMultiplier + break default: - baseSpeedMultiplier = 0.7; + baseSpeedMultiplier = 0.7 } // Calculate adaptive multiplier based on player performance - let adaptiveMultiplier = 1.0; + let adaptiveMultiplier = 1.0 // Success rate factor (0.5x to 1.6x based on success rate) if (playerSuccessRate > 0.85) { - adaptiveMultiplier *= 1.6; // Player doing great - speed up AI significantly + adaptiveMultiplier *= 1.6 // Player doing great - speed up AI significantly } else if (playerSuccessRate > 0.75) { - adaptiveMultiplier *= 1.3; // Player doing well - speed up AI moderately + adaptiveMultiplier *= 1.3 // Player doing well - speed up AI moderately } else if (playerSuccessRate > 0.6) { - adaptiveMultiplier *= 1.0; // Player doing okay - keep AI at base speed + adaptiveMultiplier *= 1.0 // Player doing okay - keep AI at base speed } else if (playerSuccessRate > 0.45) { - adaptiveMultiplier *= 0.75; // Player struggling - slow down AI + adaptiveMultiplier *= 0.75 // Player struggling - slow down AI } else { - adaptiveMultiplier *= 0.5; // Player really struggling - significantly slow AI + adaptiveMultiplier *= 0.5 // Player really struggling - significantly slow AI } // Response time factor - faster players get faster AI if (avgResponseTime < 1500) { - adaptiveMultiplier *= 1.2; // Very fast player + adaptiveMultiplier *= 1.2 // Very fast player } else if (avgResponseTime < 2500) { - adaptiveMultiplier *= 1.1; // Fast player + adaptiveMultiplier *= 1.1 // Fast player } else if (avgResponseTime > 4000) { - adaptiveMultiplier *= 0.9; // Slow player + adaptiveMultiplier *= 0.9 // Slow player } // Streak bonus - players on hot streaks get more challenge if (state.streak >= 8) { - adaptiveMultiplier *= 1.3; + adaptiveMultiplier *= 1.3 } else if (state.streak >= 5) { - adaptiveMultiplier *= 1.15; + adaptiveMultiplier *= 1.15 } // Apply bounds to prevent extreme values - adaptiveMultiplier = Math.max(0.3, Math.min(2.0, adaptiveMultiplier)); + adaptiveMultiplier = Math.max(0.3, Math.min(2.0, adaptiveMultiplier)) // Update AI speeds with adaptive multiplier - const finalSpeedMultiplier = baseSpeedMultiplier * adaptiveMultiplier; + const finalSpeedMultiplier = baseSpeedMultiplier * adaptiveMultiplier // Update AI racer speeds const updatedRacers = state.aiRacers.map((racer, index) => { if (index === 0) { // Swift AI (more aggressive) - return { ...racer, speed: 0.32 * finalSpeedMultiplier }; + return { ...racer, speed: 0.32 * finalSpeedMultiplier } } else { // Math Bot (more consistent) - return { ...racer, speed: 0.2 * finalSpeedMultiplier }; + return { ...racer, speed: 0.2 * finalSpeedMultiplier } } - }); + }) - dispatch({ type: "UPDATE_AI_SPEEDS", racers: updatedRacers }); + dispatch({ type: 'UPDATE_AI_SPEEDS', racers: updatedRacers }) // Debug logging for AI adaptation (every 5 questions) if (state.totalQuestions % 5 === 0) { - console.log("🤖 AI Speed Adaptation:", { + console.log('🤖 AI Speed Adaptation:', { playerSuccessRate: `${Math.round(playerSuccessRate * 100)}%`, avgResponseTime: `${Math.round(avgResponseTime)}ms`, streak: state.streak, adaptiveMultiplier: Math.round(adaptiveMultiplier * 100) / 100, - swiftAISpeed: updatedRacers[0] - ? Math.round(updatedRacers[0].speed * 1000) / 1000 - : 0, - mathBotSpeed: updatedRacers[1] - ? Math.round(updatedRacers[1].speed * 1000) / 1000 - : 0, - }); + swiftAISpeed: updatedRacers[0] ? Math.round(updatedRacers[0].speed * 1000) / 1000 : 0, + mathBotSpeed: updatedRacers[1] ? Math.round(updatedRacers[1].speed * 1000) / 1000 : 0, + }) } - }; + } // Get adaptive time limit for current question (lines 14740-14763) const getAdaptiveTimeLimit = (): number => { - if (!state.currentQuestion) return 3000; + if (!state.currentQuestion) return 3000 - let adaptiveTime: number; + let adaptiveTime: number if (state.difficultyTracker.learningMode) { - adaptiveTime = Math.max(2000, state.difficultyTracker.currentTimeLimit); + adaptiveTime = Math.max(2000, state.difficultyTracker.currentTimeLimit) } else { - const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}`; - const pairData = state.difficultyTracker.pairPerformance.get(pairKey); + const pairKey = `${state.currentQuestion.number}_${state.currentQuestion.correctAnswer}_${state.currentQuestion.targetSum}` + const pairData = state.difficultyTracker.pairPerformance.get(pairKey) if (pairData && pairData.attempts >= 2) { // Use pair-specific difficulty - const baseTime = state.difficultyTracker.baseTimeLimit; - const difficultyMultiplier = (6 - pairData.difficulty) / 5; // Invert: difficulty 1 = more time - adaptiveTime = Math.max(1000, baseTime * difficultyMultiplier); + const baseTime = state.difficultyTracker.baseTimeLimit + const difficultyMultiplier = (6 - pairData.difficulty) / 5 // Invert: difficulty 1 = more time + adaptiveTime = Math.max(1000, baseTime * difficultyMultiplier) } else { // Default for new pairs - adaptiveTime = state.difficultyTracker.currentTimeLimit; + adaptiveTime = state.difficultyTracker.currentTimeLimit } } // Apply user timeout setting override (lines 14765-14785) - return applyTimeoutSetting(adaptiveTime); - }; + return applyTimeoutSetting(adaptiveTime) + } // Apply timeout setting multiplier (lines 14765-14785) const applyTimeoutSetting = (baseTime: number): number => { switch (state.timeoutSetting) { - case "preschool": - return Math.max(baseTime * 4, 20000); // At least 20 seconds - case "kindergarten": - return Math.max(baseTime * 3, 15000); // At least 15 seconds - case "relaxed": - return Math.max(baseTime * 2.4, 12000); // At least 12 seconds - case "slow": - return Math.max(baseTime * 1.6, 8000); // At least 8 seconds - case "normal": - return Math.max(baseTime, 5000); // At least 5 seconds - case "fast": - return Math.max(baseTime * 0.6, 3000); // At least 3 seconds - case "expert": - return Math.max(baseTime * 0.4, 2000); // At least 2 seconds + case 'preschool': + return Math.max(baseTime * 4, 20000) // At least 20 seconds + case 'kindergarten': + return Math.max(baseTime * 3, 15000) // At least 15 seconds + case 'relaxed': + return Math.max(baseTime * 2.4, 12000) // At least 12 seconds + case 'slow': + return Math.max(baseTime * 1.6, 8000) // At least 8 seconds + case 'normal': + return Math.max(baseTime, 5000) // At least 5 seconds + case 'fast': + return Math.max(baseTime * 0.6, 3000) // At least 3 seconds + case 'expert': + return Math.max(baseTime * 0.4, 2000) // At least 2 seconds default: - return baseTime; + return baseTime } - }; + } // Get adaptive feedback message (lines 11655-11721) const getAdaptiveFeedbackMessage = ( pairKey: string, _isCorrect: boolean, - _responseTime: number, + _responseTime: number ): { - message: string; - type: "learning" | "struggling" | "mastered" | "adapted"; + message: string + type: 'learning' | 'struggling' | 'mastered' | 'adapted' } | null => { - const pairData = state.difficultyTracker.pairPerformance.get(pairKey); - const [num1, num2, _sum] = pairKey.split("_").map(Number); + const pairData = state.difficultyTracker.pairPerformance.get(pairKey) + const [num1, num2, _sum] = pairKey.split('_').map(Number) // Learning mode messages if (state.difficultyTracker.learningMode) { const encouragements = [ "🧠 I'm learning your style! Keep going!", - "📊 Building your skill profile...", - "🎯 Every answer helps me understand you better!", - "🚀 Analyzing your complement superpowers!", - ]; + '📊 Building your skill profile...', + '🎯 Every answer helps me understand you better!', + '🚀 Analyzing your complement superpowers!', + ] return { - message: - encouragements[Math.floor(Math.random() * encouragements.length)], - type: "learning", - }; + message: encouragements[Math.floor(Math.random() * encouragements.length)], + type: 'learning', + } } // After learning - provide specific feedback if (pairData && pairData.attempts >= 3) { - const accuracy = pairData.correct / pairData.attempts; - const avgTime = pairData.avgTime; + const accuracy = pairData.correct / pairData.attempts + const avgTime = pairData.avgTime // Struggling pairs (< 60% accuracy) if (accuracy < 0.6) { @@ -317,14 +300,11 @@ export function useAdaptiveDifficulty() { `🎯 Working on ${num1}+${num2} - you've got this!`, `⏰ Taking it slower with ${num1}+${num2} - no rush!`, `🧩 ${num1}+${num2} is getting special attention from me!`, - ]; + ] return { - message: - strugglingMessages[ - Math.floor(Math.random() * strugglingMessages.length) - ], - type: "struggling", - }; + message: strugglingMessages[Math.floor(Math.random() * strugglingMessages.length)], + type: 'struggling', + } } // Mastered pairs (> 85% accuracy and fast) @@ -334,14 +314,11 @@ export function useAdaptiveDifficulty() { `🔥 You've conquered ${num1}+${num2} - speeding it up!`, `🏆 ${num1}+${num2} expert detected! Challenge mode ON!`, `⭐ ${num1}+${num2} is your superpower! Going faster!`, - ]; + ] return { - message: - masteredMessages[ - Math.floor(Math.random() * masteredMessages.length) - ], - type: "mastered", - }; + message: masteredMessages[Math.floor(Math.random() * masteredMessages.length)], + type: 'mastered', + } } } @@ -349,17 +326,17 @@ export function useAdaptiveDifficulty() { if (state.difficultyTracker.consecutiveCorrect >= 3) { return { message: "🚀 You're on fire! Increasing the challenge!", - type: "adapted", - }; + type: 'adapted', + } } else if (state.difficultyTracker.consecutiveIncorrect >= 2) { return { message: "🤗 Let's slow down a bit - I'm here to help!", - type: "adapted", - }; + type: 'adapted', + } } - return null; - }; + return null + } return { trackPerformance, @@ -367,5 +344,5 @@ export function useAdaptiveDifficulty() { calculateRecentSuccessRate, calculateAverageResponseTime, getAdaptiveFeedbackMessage, - }; + } } diff --git a/apps/web/src/app/arcade/complement-race/hooks/useGameLoop.ts b/apps/web/src/app/arcade/complement-race/hooks/useGameLoop.ts index a6cff2b9..43a8cade 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useGameLoop.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useGameLoop.ts @@ -1,69 +1,69 @@ -import { useCallback, useEffect } from "react"; -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; +import { useCallback, useEffect } from 'react' +import { useComplementRace } from '@/arcade-games/complement-race/Provider' export function useGameLoop() { - const { state, dispatch } = useComplementRace(); + const { state, dispatch } = useComplementRace() // Generate first question when game begins useEffect(() => { - if (state.gamePhase === "playing" && !state.currentQuestion) { - dispatch({ type: "NEXT_QUESTION" }); + if (state.gamePhase === 'playing' && !state.currentQuestion) { + dispatch({ type: 'NEXT_QUESTION' }) } - }, [state.gamePhase, state.currentQuestion, dispatch]); + }, [state.gamePhase, state.currentQuestion, dispatch]) const nextQuestion = useCallback(() => { - if (!state.isGameActive) return; - dispatch({ type: "NEXT_QUESTION" }); - }, [state.isGameActive, dispatch]); + if (!state.isGameActive) return + dispatch({ type: 'NEXT_QUESTION' }) + }, [state.isGameActive, dispatch]) const submitAnswer = useCallback( (answer: number) => { - if (!state.currentQuestion) return; + if (!state.currentQuestion) return - const isCorrect = answer === state.currentQuestion.correctAnswer; + const isCorrect = answer === state.currentQuestion.correctAnswer if (isCorrect) { // Update score, streak, progress // TODO: Will implement full scoring in next step - dispatch({ type: "SUBMIT_ANSWER", answer }); + dispatch({ type: 'SUBMIT_ANSWER', answer }) // Move to next question - dispatch({ type: "NEXT_QUESTION" }); + dispatch({ type: 'NEXT_QUESTION' }) } else { // Reset streak // TODO: Will implement incorrect answer handling - dispatch({ type: "SUBMIT_ANSWER", answer }); + dispatch({ type: 'SUBMIT_ANSWER', answer }) } }, - [state.currentQuestion, dispatch], - ); + [state.currentQuestion, dispatch] + ) const startCountdown = useCallback(() => { // Trigger countdown phase - dispatch({ type: "START_COUNTDOWN" }); + dispatch({ type: 'START_COUNTDOWN' }) // Start 3-2-1-GO countdown (lines 11163-11211) - let count = 3; + let count = 3 const countdownInterval = setInterval(() => { if (count > 0) { // TODO: Play countdown sound - count--; + count-- } else { // GO! // TODO: Play start sound - clearInterval(countdownInterval); + clearInterval(countdownInterval) // Start the actual game after GO animation (1 second delay) setTimeout(() => { - dispatch({ type: "BEGIN_GAME" }); - }, 1000); + dispatch({ type: 'BEGIN_GAME' }) + }, 1000) } - }, 1000); - }, [dispatch]); + }, 1000) + }, [dispatch]) return { nextQuestion, submitAnswer, startCountdown, - }; + } } diff --git a/apps/web/src/app/arcade/complement-race/hooks/usePassengerAnimations.ts b/apps/web/src/app/arcade/complement-race/hooks/usePassengerAnimations.ts index 2bc69f0a..d49b8a0d 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/usePassengerAnimations.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/usePassengerAnimations.ts @@ -1,33 +1,33 @@ -import { useEffect, useRef, useState } from "react"; -import type { Passenger, Station } from "../lib/gameTypes"; -import type { RailroadTrackGenerator } from "../lib/RailroadTrackGenerator"; +import { useEffect, useRef, useState } from 'react' +import type { Passenger, Station } from '../lib/gameTypes' +import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator' export interface BoardingAnimation { - passenger: Passenger; - fromX: number; - fromY: number; - toX: number; - toY: number; - carIndex: number; - startTime: number; + passenger: Passenger + fromX: number + fromY: number + toX: number + toY: number + carIndex: number + startTime: number } export interface DisembarkingAnimation { - passenger: Passenger; - fromX: number; - fromY: number; - toX: number; - toY: number; - startTime: number; + passenger: Passenger + fromX: number + fromY: number + toX: number + toY: number + startTime: number } interface UsePassengerAnimationsParams { - passengers: Passenger[]; - stations: Station[]; - stationPositions: Array<{ x: number; y: number }>; - trainPosition: number; - trackGenerator: RailroadTrackGenerator; - pathRef: React.RefObject; + passengers: Passenger[] + stations: Station[] + stationPositions: Array<{ x: number; y: number }> + trainPosition: number + trackGenerator: RailroadTrackGenerator + pathRef: React.RefObject } export function usePassengerAnimations({ @@ -38,57 +38,50 @@ export function usePassengerAnimations({ trackGenerator, pathRef, }: UsePassengerAnimationsParams) { - const [boardingAnimations, setBoardingAnimations] = useState< - Map - >(new Map()); + const [boardingAnimations, setBoardingAnimations] = useState>( + new Map() + ) const [disembarkingAnimations, setDisembarkingAnimations] = useState< Map - >(new Map()); - const previousPassengersRef = useRef(passengers); + >(new Map()) + const previousPassengersRef = useRef(passengers) // Detect passengers boarding/disembarking and start animations useEffect(() => { - if (!pathRef.current || stationPositions.length === 0) return; + if (!pathRef.current || stationPositions.length === 0) return - const previousPassengers = previousPassengersRef.current; - const currentPassengers = passengers; + const previousPassengers = previousPassengersRef.current + const currentPassengers = passengers // Find newly boarded passengers const newlyBoarded = currentPassengers.filter((curr) => { - const prev = previousPassengers.find((p) => p.id === curr.id); - return curr.isBoarded && prev && !prev.isBoarded; - }); + const prev = previousPassengers.find((p) => p.id === curr.id) + return curr.isBoarded && prev && !prev.isBoarded + }) // Find newly delivered passengers const newlyDelivered = currentPassengers.filter((curr) => { - const prev = previousPassengers.find((p) => p.id === curr.id); - return curr.isDelivered && prev && !prev.isDelivered; - }); + const prev = previousPassengers.find((p) => p.id === curr.id) + return curr.isDelivered && prev && !prev.isDelivered + }) // Start animation for each newly boarded passenger newlyBoarded.forEach((passenger) => { // Find origin station - const originStation = stations.find( - (s) => s.id === passenger.originStationId, - ); - if (!originStation) return; + const originStation = stations.find((s) => s.id === passenger.originStationId) + if (!originStation) return - const stationIndex = stations.indexOf(originStation); - const stationPos = stationPositions[stationIndex]; - if (!stationPos) return; + const stationIndex = stations.indexOf(originStation) + const stationPos = stationPositions[stationIndex] + if (!stationPos) return // Find which car this passenger will be in - const boardedPassengers = currentPassengers.filter( - (p) => p.isBoarded && !p.isDelivered, - ); - const carIndex = boardedPassengers.indexOf(passenger); + const boardedPassengers = currentPassengers.filter((p) => p.isBoarded && !p.isDelivered) + const carIndex = boardedPassengers.indexOf(passenger) // Calculate train car position - const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7); // 7% spacing - const carTransform = trackGenerator.getTrainTransform( - pathRef.current!, - carPosition, - ); + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing + const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition) // Create boarding animation const animation: BoardingAnimation = { @@ -99,51 +92,42 @@ export function usePassengerAnimations({ toY: carTransform.y, carIndex, startTime: Date.now(), - }; + } setBoardingAnimations((prev) => { - const next = new Map(prev); - next.set(passenger.id, animation); - return next; - }); + const next = new Map(prev) + next.set(passenger.id, animation) + return next + }) // Remove animation after 800ms setTimeout(() => { setBoardingAnimations((prev) => { - const next = new Map(prev); - next.delete(passenger.id); - return next; - }); - }, 800); - }); + const next = new Map(prev) + next.delete(passenger.id) + return next + }) + }, 800) + }) // Start animation for each newly delivered passenger newlyDelivered.forEach((passenger) => { // Find destination station - const destinationStation = stations.find( - (s) => s.id === passenger.destinationStationId, - ); - if (!destinationStation) return; + const destinationStation = stations.find((s) => s.id === passenger.destinationStationId) + if (!destinationStation) return - const stationIndex = stations.indexOf(destinationStation); - const stationPos = stationPositions[stationIndex]; - if (!stationPos) return; + const stationIndex = stations.indexOf(destinationStation) + const stationPos = stationPositions[stationIndex] + if (!stationPos) return // Find which car this passenger was in (before delivery) - const prevBoardedPassengers = previousPassengers.filter( - (p) => p.isBoarded && !p.isDelivered, - ); - const carIndex = prevBoardedPassengers.findIndex( - (p) => p.id === passenger.id, - ); - if (carIndex === -1) return; + const prevBoardedPassengers = previousPassengers.filter((p) => p.isBoarded && !p.isDelivered) + const carIndex = prevBoardedPassengers.findIndex((p) => p.id === passenger.id) + if (carIndex === -1) return // Calculate train car position at time of disembarking - const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7); // 7% spacing - const carTransform = trackGenerator.getTrainTransform( - pathRef.current!, - carPosition, - ); + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * 7) // 7% spacing + const carTransform = trackGenerator.getTrainTransform(pathRef.current!, carPosition) // Create disembarking animation (from car to station) const animation: DisembarkingAnimation = { @@ -153,37 +137,30 @@ export function usePassengerAnimations({ toX: stationPos.x, toY: stationPos.y - 30, startTime: Date.now(), - }; + } setDisembarkingAnimations((prev) => { - const next = new Map(prev); - next.set(passenger.id, animation); - return next; - }); + const next = new Map(prev) + next.set(passenger.id, animation) + return next + }) // Remove animation after 800ms setTimeout(() => { setDisembarkingAnimations((prev) => { - const next = new Map(prev); - next.delete(passenger.id); - return next; - }); - }, 800); - }); + const next = new Map(prev) + next.delete(passenger.id) + return next + }) + }, 800) + }) // Update ref - previousPassengersRef.current = currentPassengers; - }, [ - passengers, - stations, - stationPositions, - trainPosition, - trackGenerator, - pathRef, - ]); + previousPassengersRef.current = currentPassengers + }, [passengers, stations, stationPositions, trainPosition, trackGenerator, pathRef]) return { boardingAnimations, disembarkingAnimations, - }; + } } diff --git a/apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts b/apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts index ae429e9b..3ccf7cc6 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts @@ -1,5 +1,5 @@ -import { useCallback, useContext, useRef } from "react"; -import { PreviewModeContext } from "@/components/GamePreview"; +import { useCallback, useContext, useRef } from 'react' +import { PreviewModeContext } from '@/components/GamePreview' /** * Web Audio API sound effects system @@ -9,14 +9,14 @@ import { PreviewModeContext } from "@/components/GamePreview"; */ interface Note { - freq: number; - time: number; - duration: number; + freq: number + time: number + duration: number } export function useSoundEffects() { - const audioContextsRef = useRef([]); - const previewMode = useContext(PreviewModeContext); + const audioContextsRef = useRef([]) + const previewMode = useContext(PreviewModeContext) /** * Helper function to play multi-note 90s arcade sounds @@ -26,67 +26,61 @@ export function useSoundEffects() { audioContext: AudioContext, notes: Note[], volume: number = 0.15, - waveType: OscillatorType = "sine", + waveType: OscillatorType = 'sine' ) => { notes.forEach((note) => { - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - const filterNode = audioContext.createBiquadFilter(); + const oscillator = audioContext.createOscillator() + const gainNode = audioContext.createGain() + const filterNode = audioContext.createBiquadFilter() // Create that classic 90s arcade sound chain - oscillator.connect(filterNode); - filterNode.connect(gainNode); - gainNode.connect(audioContext.destination); + oscillator.connect(filterNode) + filterNode.connect(gainNode) + gainNode.connect(audioContext.destination) // Set wave type for that retro flavor - oscillator.type = waveType; + oscillator.type = waveType // Add some 90s-style filtering - filterNode.type = "lowpass"; - filterNode.frequency.setValueAtTime( - 2000, - audioContext.currentTime + note.time, - ); - filterNode.Q.setValueAtTime(1, audioContext.currentTime + note.time); + filterNode.type = 'lowpass' + filterNode.frequency.setValueAtTime(2000, audioContext.currentTime + note.time) + filterNode.Q.setValueAtTime(1, audioContext.currentTime + note.time) // Set frequency and add vibrato for that classic arcade wobble - oscillator.frequency.setValueAtTime( - note.freq, - audioContext.currentTime + note.time, - ); - if (waveType === "sawtooth" || waveType === "square") { + oscillator.frequency.setValueAtTime(note.freq, audioContext.currentTime + note.time) + if (waveType === 'sawtooth' || waveType === 'square') { // Add slight vibrato for extra 90s flavor oscillator.frequency.exponentialRampToValueAtTime( note.freq * 1.02, - audioContext.currentTime + note.time + note.duration * 0.5, - ); + audioContext.currentTime + note.time + note.duration * 0.5 + ) oscillator.frequency.exponentialRampToValueAtTime( note.freq, - audioContext.currentTime + note.time + note.duration, - ); + audioContext.currentTime + note.time + note.duration + ) } // Classic arcade envelope - quick attack, moderate decay - gainNode.gain.setValueAtTime(0, audioContext.currentTime + note.time); + gainNode.gain.setValueAtTime(0, audioContext.currentTime + note.time) gainNode.gain.exponentialRampToValueAtTime( volume, - audioContext.currentTime + note.time + 0.01, - ); + audioContext.currentTime + note.time + 0.01 + ) gainNode.gain.exponentialRampToValueAtTime( volume * 0.7, - audioContext.currentTime + note.time + note.duration * 0.7, - ); + audioContext.currentTime + note.time + note.duration * 0.7 + ) gainNode.gain.exponentialRampToValueAtTime( 0.001, - audioContext.currentTime + note.time + note.duration, - ); + audioContext.currentTime + note.time + note.duration + ) - oscillator.start(audioContext.currentTime + note.time); - oscillator.stop(audioContext.currentTime + note.time + note.duration); - }); + oscillator.start(audioContext.currentTime + note.time) + oscillator.stop(audioContext.currentTime + note.time + note.duration) + }) }, - [], - ); + [] + ) /** * Play a sound effect @@ -96,39 +90,38 @@ export function useSoundEffects() { const playSound = useCallback( ( type: - | "correct" - | "incorrect" - | "timeout" - | "countdown" - | "race_start" - | "celebration" - | "lap_celebration" - | "gameOver" - | "ai_turbo" - | "milestone" - | "streak" - | "combo" - | "whoosh" - | "train_chuff" - | "train_whistle" - | "coal_spill" - | "steam_hiss", - volume: number = 0.15, + | 'correct' + | 'incorrect' + | 'timeout' + | 'countdown' + | 'race_start' + | 'celebration' + | 'lap_celebration' + | 'gameOver' + | 'ai_turbo' + | 'milestone' + | 'streak' + | 'combo' + | 'whoosh' + | 'train_chuff' + | 'train_whistle' + | 'coal_spill' + | 'steam_hiss', + volume: number = 0.15 ) => { // Disable all audio in preview mode if (previewMode?.isPreview) { - return; + return } try { - const audioContext = new (window.AudioContext || - (window as any).webkitAudioContext)(); + const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() // Track audio contexts for cleanup - audioContextsRef.current.push(audioContext); + audioContextsRef.current.push(audioContext) switch (type) { - case "correct": + case 'correct': // Classic 90s "power-up" sound - ascending beeps play90sSound( audioContext, @@ -138,11 +131,11 @@ export function useSoundEffects() { { freq: 784, time: 0.16, duration: 0.12 }, // G5 ], volume, - "sawtooth", - ); - break; + 'sawtooth' + ) + break - case "incorrect": + case 'incorrect': // Classic arcade "error" sound - descending buzz play90sSound( audioContext, @@ -152,11 +145,11 @@ export function useSoundEffects() { { freq: 200, time: 0.1, duration: 0.2 }, ], volume * 0.8, - "square", - ); - break; + 'square' + ) + break - case "timeout": + case 'timeout': // Classic "time's up" alarm play90sSound( audioContext, @@ -167,21 +160,21 @@ export function useSoundEffects() { { freq: 600, time: 0.3, duration: 0.15 }, ], volume, - "square", - ); - break; + 'square' + ) + break - case "countdown": + case 'countdown': // Classic arcade countdown beep play90sSound( audioContext, [{ freq: 800, time: 0, duration: 0.15 }], volume * 0.6, - "sine", - ); - break; + 'sine' + ) + break - case "race_start": + case 'race_start': // Epic race start fanfare play90sSound( audioContext, @@ -192,11 +185,11 @@ export function useSoundEffects() { { freq: 1046, time: 0.3, duration: 0.3 }, // C6 - triumphant! ], volume * 1.2, - "sawtooth", - ); - break; + 'sawtooth' + ) + break - case "celebration": + case 'celebration': // Classic victory fanfare - like completing a level play90sSound( audioContext, @@ -208,11 +201,11 @@ export function useSoundEffects() { { freq: 1318, time: 0.6, duration: 0.3 }, // E6 - epic finish! ], volume * 1.5, - "sawtooth", - ); - break; + 'sawtooth' + ) + break - case "lap_celebration": + case 'lap_celebration': // Radical "bonus achieved" sound play90sSound( audioContext, @@ -223,11 +216,11 @@ export function useSoundEffects() { { freq: 2093, time: 0.24, duration: 0.15 }, // C7 - totally rad! ], volume * 1.3, - "sawtooth", - ); - break; + 'sawtooth' + ) + break - case "gameOver": + case 'gameOver': // Classic "game over" descending tones play90sSound( audioContext, @@ -239,11 +232,11 @@ export function useSoundEffects() { { freq: 200, time: 0.9, duration: 0.4 }, ], volume, - "triangle", - ); - break; + 'triangle' + ) + break - case "ai_turbo": + case 'ai_turbo': // Sound when AI goes into turbo mode play90sSound( audioContext, @@ -254,11 +247,11 @@ export function useSoundEffects() { { freq: 800, time: 0.15, duration: 0.1 }, ], volume * 0.7, - "sawtooth", - ); - break; + 'sawtooth' + ) + break - case "milestone": + case 'milestone': // Rad milestone sound - like collecting a power-up play90sSound( audioContext, @@ -269,11 +262,11 @@ export function useSoundEffects() { { freq: 1046, time: 0.3, duration: 0.15 }, // C6 - awesome! ], volume * 1.1, - "sawtooth", - ); - break; + 'sawtooth' + ) + break - case "streak": + case 'streak': // Epic streak sound - getting hot! play90sSound( audioContext, @@ -284,11 +277,11 @@ export function useSoundEffects() { { freq: 1760, time: 0.2, duration: 0.1 }, // A6 - on fire! ], volume * 1.2, - "sawtooth", - ); - break; + 'sawtooth' + ) + break - case "combo": + case 'combo': // Gnarly combo sound - for rapid correct answers play90sSound( audioContext, @@ -299,97 +292,73 @@ export function useSoundEffects() { { freq: 1480, time: 0.12, duration: 0.06 }, // F#6 ], volume * 0.9, - "square", - ); - break; + 'square' + ) + break - case "whoosh": { + case 'whoosh': { // Cool whoosh sound for fast responses - const whooshOsc = audioContext.createOscillator(); - const whooshGain = audioContext.createGain(); - const whooshFilter = audioContext.createBiquadFilter(); + const whooshOsc = audioContext.createOscillator() + const whooshGain = audioContext.createGain() + const whooshFilter = audioContext.createBiquadFilter() - whooshOsc.connect(whooshFilter); - whooshFilter.connect(whooshGain); - whooshGain.connect(audioContext.destination); + whooshOsc.connect(whooshFilter) + whooshFilter.connect(whooshGain) + whooshGain.connect(audioContext.destination) - whooshOsc.type = "sawtooth"; - whooshFilter.type = "highpass"; - whooshFilter.frequency.setValueAtTime( - 1000, - audioContext.currentTime, - ); - whooshFilter.frequency.exponentialRampToValueAtTime( - 100, - audioContext.currentTime + 0.3, - ); + whooshOsc.type = 'sawtooth' + whooshFilter.type = 'highpass' + whooshFilter.frequency.setValueAtTime(1000, audioContext.currentTime) + whooshFilter.frequency.exponentialRampToValueAtTime(100, audioContext.currentTime + 0.3) - whooshOsc.frequency.setValueAtTime(400, audioContext.currentTime); - whooshOsc.frequency.exponentialRampToValueAtTime( - 800, - audioContext.currentTime + 0.15, - ); - whooshOsc.frequency.exponentialRampToValueAtTime( - 200, - audioContext.currentTime + 0.3, - ); + whooshOsc.frequency.setValueAtTime(400, audioContext.currentTime) + whooshOsc.frequency.exponentialRampToValueAtTime(800, audioContext.currentTime + 0.15) + whooshOsc.frequency.exponentialRampToValueAtTime(200, audioContext.currentTime + 0.3) - whooshGain.gain.setValueAtTime(0, audioContext.currentTime); + whooshGain.gain.setValueAtTime(0, audioContext.currentTime) whooshGain.gain.exponentialRampToValueAtTime( volume * 0.6, - audioContext.currentTime + 0.02, - ); - whooshGain.gain.exponentialRampToValueAtTime( - 0.001, - audioContext.currentTime + 0.3, - ); + audioContext.currentTime + 0.02 + ) + whooshGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.3) - whooshOsc.start(audioContext.currentTime); - whooshOsc.stop(audioContext.currentTime + 0.3); - break; + whooshOsc.start(audioContext.currentTime) + whooshOsc.stop(audioContext.currentTime + 0.3) + break } - case "train_chuff": { + case 'train_chuff': { // Realistic steam train chuffing sound - const chuffOsc = audioContext.createOscillator(); - const chuffGain = audioContext.createGain(); - const chuffFilter = audioContext.createBiquadFilter(); + const chuffOsc = audioContext.createOscillator() + const chuffGain = audioContext.createGain() + const chuffFilter = audioContext.createBiquadFilter() - chuffOsc.connect(chuffFilter); - chuffFilter.connect(chuffGain); - chuffGain.connect(audioContext.destination); + chuffOsc.connect(chuffFilter) + chuffFilter.connect(chuffGain) + chuffGain.connect(audioContext.destination) - chuffOsc.type = "sawtooth"; - chuffFilter.type = "bandpass"; - chuffFilter.frequency.setValueAtTime(150, audioContext.currentTime); - chuffFilter.Q.setValueAtTime(5, audioContext.currentTime); + chuffOsc.type = 'sawtooth' + chuffFilter.type = 'bandpass' + chuffFilter.frequency.setValueAtTime(150, audioContext.currentTime) + chuffFilter.Q.setValueAtTime(5, audioContext.currentTime) - chuffOsc.frequency.setValueAtTime(80, audioContext.currentTime); - chuffOsc.frequency.exponentialRampToValueAtTime( - 120, - audioContext.currentTime + 0.05, - ); - chuffOsc.frequency.exponentialRampToValueAtTime( - 60, - audioContext.currentTime + 0.2, - ); + chuffOsc.frequency.setValueAtTime(80, audioContext.currentTime) + chuffOsc.frequency.exponentialRampToValueAtTime(120, audioContext.currentTime + 0.05) + chuffOsc.frequency.exponentialRampToValueAtTime(60, audioContext.currentTime + 0.2) - chuffGain.gain.setValueAtTime(0, audioContext.currentTime); + chuffGain.gain.setValueAtTime(0, audioContext.currentTime) chuffGain.gain.exponentialRampToValueAtTime( volume * 0.8, - audioContext.currentTime + 0.01, - ); - chuffGain.gain.exponentialRampToValueAtTime( - 0.001, - audioContext.currentTime + 0.2, - ); + audioContext.currentTime + 0.01 + ) + chuffGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.2) - chuffOsc.start(audioContext.currentTime); - chuffOsc.stop(audioContext.currentTime + 0.2); - break; + chuffOsc.start(audioContext.currentTime) + chuffOsc.stop(audioContext.currentTime + 0.2) + break } - case "train_whistle": + case 'train_whistle': // Classic steam train whistle play90sSound( audioContext, @@ -399,102 +368,85 @@ export function useSoundEffects() { { freq: 523, time: 0.3, duration: 0.2 }, // C5 - fade out ], volume * 1.2, - "sine", - ); - break; + 'sine' + ) + break - case "coal_spill": { + case 'coal_spill': { // Coal chunks spilling sound effect - const coalOsc = audioContext.createOscillator(); - const coalGain = audioContext.createGain(); - const coalFilter = audioContext.createBiquadFilter(); + const coalOsc = audioContext.createOscillator() + const coalGain = audioContext.createGain() + const coalFilter = audioContext.createBiquadFilter() - coalOsc.connect(coalFilter); - coalFilter.connect(coalGain); - coalGain.connect(audioContext.destination); + coalOsc.connect(coalFilter) + coalFilter.connect(coalGain) + coalGain.connect(audioContext.destination) - coalOsc.type = "square"; - coalFilter.type = "lowpass"; - coalFilter.frequency.setValueAtTime(300, audioContext.currentTime); + coalOsc.type = 'square' + coalFilter.type = 'lowpass' + coalFilter.frequency.setValueAtTime(300, audioContext.currentTime) // Simulate coal chunks falling with random frequency bursts - coalOsc.frequency.setValueAtTime( - 200 + Math.random() * 100, - audioContext.currentTime, - ); + coalOsc.frequency.setValueAtTime(200 + Math.random() * 100, audioContext.currentTime) coalOsc.frequency.exponentialRampToValueAtTime( 100 + Math.random() * 50, - audioContext.currentTime + 0.1, - ); + audioContext.currentTime + 0.1 + ) coalOsc.frequency.exponentialRampToValueAtTime( 80 + Math.random() * 40, - audioContext.currentTime + 0.3, - ); + audioContext.currentTime + 0.3 + ) - coalGain.gain.setValueAtTime(0, audioContext.currentTime); + coalGain.gain.setValueAtTime(0, audioContext.currentTime) coalGain.gain.exponentialRampToValueAtTime( volume * 0.6, - audioContext.currentTime + 0.01, - ); + audioContext.currentTime + 0.01 + ) coalGain.gain.exponentialRampToValueAtTime( volume * 0.3, - audioContext.currentTime + 0.15, - ); - coalGain.gain.exponentialRampToValueAtTime( - 0.001, - audioContext.currentTime + 0.4, - ); + audioContext.currentTime + 0.15 + ) + coalGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.4) - coalOsc.start(audioContext.currentTime); - coalOsc.stop(audioContext.currentTime + 0.4); - break; + coalOsc.start(audioContext.currentTime) + coalOsc.stop(audioContext.currentTime + 0.4) + break } - case "steam_hiss": { + case 'steam_hiss': { // Steam hissing sound for locomotive - const steamOsc = audioContext.createOscillator(); - const steamGain = audioContext.createGain(); - const steamFilter = audioContext.createBiquadFilter(); + const steamOsc = audioContext.createOscillator() + const steamGain = audioContext.createGain() + const steamFilter = audioContext.createBiquadFilter() - steamOsc.connect(steamFilter); - steamFilter.connect(steamGain); - steamGain.connect(audioContext.destination); + steamOsc.connect(steamFilter) + steamFilter.connect(steamGain) + steamGain.connect(audioContext.destination) - steamOsc.type = "triangle"; - steamFilter.type = "highpass"; - steamFilter.frequency.setValueAtTime( - 2000, - audioContext.currentTime, - ); + steamOsc.type = 'triangle' + steamFilter.type = 'highpass' + steamFilter.frequency.setValueAtTime(2000, audioContext.currentTime) - steamOsc.frequency.setValueAtTime( - 4000 + Math.random() * 1000, - audioContext.currentTime, - ); + steamOsc.frequency.setValueAtTime(4000 + Math.random() * 1000, audioContext.currentTime) - steamGain.gain.setValueAtTime(0, audioContext.currentTime); + steamGain.gain.setValueAtTime(0, audioContext.currentTime) steamGain.gain.exponentialRampToValueAtTime( volume * 0.4, - audioContext.currentTime + 0.02, - ); - steamGain.gain.exponentialRampToValueAtTime( - 0.001, - audioContext.currentTime + 0.6, - ); + audioContext.currentTime + 0.02 + ) + steamGain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.6) - steamOsc.start(audioContext.currentTime); - steamOsc.stop(audioContext.currentTime + 0.6); - break; + steamOsc.start(audioContext.currentTime) + steamOsc.stop(audioContext.currentTime + 0.6) + break } } } catch (_e) { - console.log( - "🎵 Web Audio not supported - missing out on rad 90s sounds!", - ); + console.log('🎵 Web Audio not supported - missing out on rad 90s sounds!') } }, - [play90sSound, previewMode], - ); + [play90sSound, previewMode] + ) /** * Stop all currently playing sounds @@ -504,20 +456,20 @@ export function useSoundEffects() { if (audioContextsRef.current.length > 0) { audioContextsRef.current.forEach((context) => { try { - context.close(); + context.close() } catch (_e) { // Ignore errors } - }); - audioContextsRef.current = []; + }) + audioContextsRef.current = [] } } catch (e) { - console.log("🔇 Sound cleanup error:", e); + console.log('🔇 Sound cleanup error:', e) } - }, []); + }, []) return { playSound, stopAllSounds, - }; + } } diff --git a/apps/web/src/app/arcade/complement-race/hooks/useSteamJourney.ts b/apps/web/src/app/arcade/complement-race/hooks/useSteamJourney.ts index 106221c9..bd875381 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useSteamJourney.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useSteamJourney.ts @@ -1,6 +1,6 @@ -import { useEffect, useRef } from "react"; -import { useComplementRace } from "@/arcade-games/complement-race/Provider"; -import { useSoundEffects } from "./useSoundEffects"; +import { useEffect, useRef } from 'react' +import { useComplementRace } from '@/arcade-games/complement-race/Provider' +import { useSoundEffects } from './useSoundEffects' /** * Steam Sprint momentum system (Infinite Mode) @@ -30,50 +30,41 @@ const MOMENTUM_DECAY_RATES = { normal: 9.0, fast: 11.0, expert: 13.0, -}; +} -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 GAME_DURATION = 60000; // 60 seconds in milliseconds +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 GAME_DURATION = 60000 // 60 seconds in milliseconds export function useSteamJourney() { - const { state, dispatch } = useComplementRace(); - const { playSound } = useSoundEffects(); - const gameStartTimeRef = useRef(0); - const lastUpdateRef = useRef(0); - const routeExitThresholdRef = useRef(107); // Default for 1 car: 100 + 7 - const missedPassengersRef = useRef>(new Set()); // Track which passengers have been logged as missed - const pendingBoardingRef = useRef>(new Set()); // Track passengers with pending boarding requests across frames - const pendingDeliveryRef = useRef>(new Set()); // Track passengers with pending delivery requests across frames - const previousTrainPositionRef = useRef(0); // Track previous position to detect threshold crossings + const { state, dispatch } = useComplementRace() + const { playSound } = useSoundEffects() + const gameStartTimeRef = useRef(0) + const lastUpdateRef = useRef(0) + const routeExitThresholdRef = useRef(107) // Default for 1 car: 100 + 7 + const missedPassengersRef = useRef>(new Set()) // Track which passengers have been logged as missed + const pendingBoardingRef = useRef>(new Set()) // Track passengers with pending boarding requests across frames + const pendingDeliveryRef = useRef>(new Set()) // Track passengers with pending delivery requests across frames + const previousTrainPositionRef = useRef(0) // Track previous position to detect threshold crossings // Initialize game start time useEffect(() => { - if ( - state.isGameActive && - state.style === "sprint" && - gameStartTimeRef.current === 0 - ) { - gameStartTimeRef.current = Date.now(); - lastUpdateRef.current = Date.now(); + if (state.isGameActive && state.style === 'sprint' && gameStartTimeRef.current === 0) { + gameStartTimeRef.current = Date.now() + lastUpdateRef.current = Date.now() } - }, [state.isGameActive, state.style, state.stations, state.passengers]); + }, [state.isGameActive, state.style, state.stations, state.passengers]) // Calculate exit threshold when route changes or config updates useEffect(() => { if (state.passengers.length > 0 && state.stations.length > 0) { - const CAR_SPACING = 7; + const CAR_SPACING = 7 // Use server-calculated maxConcurrentPassengers - const maxCars = Math.max(1, state.maxConcurrentPassengers || 3); - routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING; + const maxCars = Math.max(1, state.maxConcurrentPassengers || 3) + routeExitThresholdRef.current = 100 + maxCars * CAR_SPACING } - }, [ - state.currentRoute, - state.passengers, - state.stations, - state.maxConcurrentPassengers, - ]); + }, [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 @@ -81,270 +72,224 @@ export function useSteamJourney() { // Remove passengers from pending boarding set if they've been claimed or delivered state.passengers.forEach((passenger) => { if (passenger.claimedBy !== null || passenger.deliveredBy !== null) { - pendingBoardingRef.current.delete(passenger.id); + pendingBoardingRef.current.delete(passenger.id) } - }); - }, [state.passengers]); + }) + }, [state.passengers]) // Clear all pending boarding and delivery 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]); + pendingBoardingRef.current.clear() + pendingDeliveryRef.current.clear() + missedPassengersRef.current.clear() + previousTrainPositionRef.current = 0 // Reset previous position for new route + }, [state.currentRoute]) // Momentum decay and position update loop useEffect(() => { - if (!state.isGameActive || state.style !== "sprint") return; + if (!state.isGameActive || state.style !== 'sprint') return const interval = setInterval(() => { - const now = Date.now(); - const elapsed = now - gameStartTimeRef.current; - const deltaTime = now - lastUpdateRef.current; - lastUpdateRef.current = now; + const now = Date.now() + const elapsed = now - gameStartTimeRef.current + const deltaTime = now - lastUpdateRef.current + lastUpdateRef.current = now // Steam Sprint is infinite - no time limit // Train position, momentum, and pressure are all managed by the Provider's game loop // This hook only reads those values and handles game logic (boarding, delivery, route completion) - const trainPosition = state.trainPosition; + const trainPosition = state.trainPosition // Check for passengers that should board // Passengers board when an EMPTY car reaches their station - const CAR_SPACING = 7; // Must match SteamTrainJourney component + const CAR_SPACING = 7 // Must match SteamTrainJourney component // Use server-calculated maxConcurrentPassengers (updates per route based on passenger layout) - const maxCars = Math.max(1, state.maxConcurrentPassengers || 3); + const maxCars = Math.max(1, state.maxConcurrentPassengers || 3) // Debug: Log train configuration at start (only once per route) if (trainPosition < 1 && state.passengers.length > 0) { - const lastLoggedRoute = (window as any).__lastLoggedRoute || 0; + const lastLoggedRoute = (window as any).__lastLoggedRoute || 0 if (lastLoggedRoute !== state.currentRoute) { console.log( - `\n🚆 ROUTE ${state.currentRoute} START - Train has ${maxCars} cars (server maxConcurrentPassengers: ${state.maxConcurrentPassengers}) for ${state.passengers.length} passengers`, - ); + `\n🚆 ROUTE ${state.currentRoute} START - Train has ${maxCars} cars (server maxConcurrentPassengers: ${state.maxConcurrentPassengers}) for ${state.passengers.length} passengers` + ) state.passengers.forEach((p) => { - const origin = state.stations.find( - (s) => s.id === p.originStationId, - ); - const dest = state.stations.find( - (s) => s.id === p.destinationStationId, - ); + const origin = state.stations.find((s) => s.id === p.originStationId) + const dest = state.stations.find((s) => s.id === p.destinationStationId) console.log( - ` 📍 ${p.name}: ${origin?.emoji} ${origin?.name} (${origin?.position}) → ${dest?.emoji} ${dest?.name} (${dest?.position}) ${p.isUrgent ? "⚡" : ""}`, - ); - }); - console.log(""); // Blank line for readability - (window as any).__lastLoggedRoute = state.currentRoute; + ` 📍 ${p.name}: ${origin?.emoji} ${origin?.name} (${origin?.position}) → ${dest?.emoji} ${dest?.name} (${dest?.position}) ${p.isUrgent ? '⚡' : ''}` + ) + }) + console.log('') // Blank line for readability + ;(window as any).__lastLoggedRoute = state.currentRoute } } const currentBoardedPassengers = state.passengers.filter( - (p) => p.claimedBy !== null && p.deliveredBy === null, - ); + (p) => p.claimedBy !== null && p.deliveredBy === null + ) // FIRST: Identify which passengers will be delivered in this frame - const passengersToDeliver = new Set(); + const passengersToDeliver = new Set() currentBoardedPassengers.forEach((passenger) => { - if ( - !passenger || - passenger.deliveredBy !== null || - passenger.carIndex === null - ) - return; + if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return - const station = state.stations.find( - (s) => s.id === passenger.destinationStationId, - ); - if (!station) return; + const station = state.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); + const carPosition = Math.max(0, trainPosition - (passenger.carIndex + 1) * CAR_SPACING) + const distance = Math.abs(carPosition - station.position) // If this car is at the destination station (within 5% tolerance), mark for delivery if (distance < 5) { - passengersToDeliver.add(passenger.id); + passengersToDeliver.add(passenger.id) } - }); + }) // Build a map of which cars are occupied (using PHYSICAL car index, not array index!) // This is critical: passenger.carIndex stores the physical car (0-N) they're seated in - const occupiedCars = new Map< - number, - (typeof currentBoardedPassengers)[0] - >(); + const occupiedCars = new Map() currentBoardedPassengers.forEach((passenger) => { // Don't count a car as occupied if its passenger is being delivered this frame - if ( - !passengersToDeliver.has(passenger.id) && - passenger.carIndex !== null - ) { - occupiedCars.set(passenger.carIndex, passenger); // Use physical carIndex, NOT array index! + if (!passengersToDeliver.has(passenger.id) && passenger.carIndex !== null) { + occupiedCars.set(passenger.carIndex, passenger) // Use physical carIndex, NOT array index! } - }); + }) // PRIORITY 1: Process deliveries FIRST (dispatch DELIVER moves before BOARD moves) // This ensures the server frees up cars before processing new boarding requests currentBoardedPassengers.forEach((passenger) => { - if ( - !passenger || - passenger.deliveredBy !== null || - passenger.carIndex === null - ) - return; + if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return // Skip if already has a pending delivery request - if (pendingDeliveryRef.current.has(passenger.id)) return; + if (pendingDeliveryRef.current.has(passenger.id)) return - const station = state.stations.find( - (s) => s.id === passenger.destinationStationId, - ); - if (!station) return; + const station = state.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); + 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) { - const points = passenger.isUrgent ? 20 : 10; + const points = passenger.isUrgent ? 20 : 10 console.log( - `🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`, - ); + `🎯 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); + pendingDeliveryRef.current.add(passenger.id) dispatch({ - type: "DELIVER_PASSENGER", + type: 'DELIVER_PASSENGER', passengerId: passenger.id, points, - }); + }) } - }); + }) // Debug: Log car states periodically at stations - const isAtStation = state.stations.some( - (s) => Math.abs(trainPosition - s.position) < 3, - ); - if ( - isAtStation && - Math.floor(trainPosition) !== Math.floor(state.trainPosition) - ) { - const nearStation = state.stations.find( - (s) => Math.abs(trainPosition - s.position) < 3, - ); + const isAtStation = state.stations.some((s) => Math.abs(trainPosition - s.position) < 3) + if (isAtStation && Math.floor(trainPosition) !== Math.floor(state.trainPosition)) { + const nearStation = state.stations.find((s) => Math.abs(trainPosition - s.position) < 3) console.log( - `\n🚃 Train arriving at ${nearStation?.emoji} ${nearStation?.name} (trainPos=${trainPosition.toFixed(1)}) - ${maxCars} cars total:`, - ); + `\n🚃 Train arriving at ${nearStation?.emoji} ${nearStation?.name} (trainPos=${trainPosition.toFixed(1)}) - ${maxCars} cars total:` + ) for (let i = 0; i < maxCars; i++) { - const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING); - const occupant = occupiedCars.get(i); + const carPos = Math.max(0, trainPosition - (i + 1) * CAR_SPACING) + const occupant = occupiedCars.get(i) if (occupant) { - const dest = state.stations.find( - (s) => s.id === occupant.destinationStationId, - ); + const dest = state.stations.find((s) => s.id === occupant.destinationStationId) console.log( - ` Car ${i}: @ ${carPos.toFixed(1)}% - ${occupant.name} → ${dest?.emoji} ${dest?.name}`, - ); + ` Car ${i}: @ ${carPos.toFixed(1)}% - ${occupant.name} → ${dest?.emoji} ${dest?.name}` + ) } else { - console.log(` Car ${i}: @ ${carPos.toFixed(1)}% - EMPTY`); + console.log(` Car ${i}: @ ${carPos.toFixed(1)}% - EMPTY`) } } } // Track which cars are assigned in THIS frame to prevent double-boarding - const carsAssignedThisFrame = new Set(); + const carsAssignedThisFrame = new Set() // Track which passengers are assigned in THIS frame to prevent same passenger boarding multiple cars - const passengersAssignedThisFrame = new Set(); + const passengersAssignedThisFrame = new Set() // PRIORITY 2: Process boardings AFTER deliveries // Find waiting passengers whose origin station has an empty car nearby state.passengers.forEach((passenger) => { // Skip if already claimed or delivered (optimistic update marks immediately) - if (passenger.claimedBy !== null || passenger.deliveredBy !== null) - return; + if (passenger.claimedBy !== null || passenger.deliveredBy !== null) return // Skip if already assigned in this frame OR has a pending boarding request from previous frames if ( passengersAssignedThisFrame.has(passenger.id) || pendingBoardingRef.current.has(passenger.id) ) - return; + return - const station = state.stations.find( - (s) => s.id === passenger.originStationId, - ); - if (!station) return; + const station = state.stations.find((s) => s.id === passenger.originStationId) + if (!station) return // Don't allow boarding if locomotive has passed too far beyond this station // Station stays open until the LAST car has passed (accounting for train length) - const STATION_CLOSURE_BUFFER = 10; // Extra buffer beyond the last car - const lastCarOffset = maxCars * CAR_SPACING; // Distance from locomotive to last car - const stationClosureThreshold = lastCarOffset + STATION_CLOSURE_BUFFER; + const STATION_CLOSURE_BUFFER = 10 // Extra buffer beyond the last car + const lastCarOffset = maxCars * CAR_SPACING // Distance from locomotive to last car + const stationClosureThreshold = lastCarOffset + STATION_CLOSURE_BUFFER if (trainPosition > station.position + stationClosureThreshold) { console.log( - `❌ MISSED: ${passenger.name} at ${station.emoji} ${station.name} - train too far past (trainPos=${trainPosition.toFixed(1)}, station=${station.position}, threshold=${stationClosureThreshold})`, - ); - return; + `❌ MISSED: ${passenger.name} at ${station.emoji} ${station.name} - train too far past (trainPos=${trainPosition.toFixed(1)}, station=${station.position}, threshold=${stationClosureThreshold})` + ) + return } // Check if any empty car is at this station // Cars are at positions: trainPosition - 7, trainPosition - 14, etc. - let closestCarDistance = 999; - let closestCarReason = ""; + let closestCarDistance = 999 + let closestCarReason = '' for (let carIndex = 0; carIndex < maxCars; carIndex++) { - const carPosition = Math.max( - 0, - trainPosition - (carIndex + 1) * CAR_SPACING, - ); - const distance = Math.abs(carPosition - station.position); + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * CAR_SPACING) + const distance = Math.abs(carPosition - station.position) if (distance < closestCarDistance) { - closestCarDistance = distance; + closestCarDistance = distance if (occupiedCars.has(carIndex)) { - const occupant = occupiedCars.get(carIndex); - closestCarReason = `Car ${carIndex} occupied by ${occupant?.name}`; + const occupant = occupiedCars.get(carIndex) + closestCarReason = `Car ${carIndex} occupied by ${occupant?.name}` } else if (carsAssignedThisFrame.has(carIndex)) { - closestCarReason = `Car ${carIndex} just assigned`; + closestCarReason = `Car ${carIndex} just assigned` } else if (distance >= 5) { - closestCarReason = `Car ${carIndex} too far (dist=${distance.toFixed(1)})`; + closestCarReason = `Car ${carIndex} too far (dist=${distance.toFixed(1)})` } else { - closestCarReason = "available"; + closestCarReason = 'available' } } // Skip if this car already has a passenger OR was assigned this frame - if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) - continue; + if (occupiedCars.has(carIndex) || carsAssignedThisFrame.has(carIndex)) continue // If car is at or near station (within 5% tolerance for fast trains), board this passenger if (distance < 5) { console.log( - `🚂 BOARDING: ${passenger.name} boarding Car ${carIndex} at ${station.emoji} ${station.name} (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`, - ); + `🚂 BOARDING: ${passenger.name} boarding Car ${carIndex} at ${station.emoji} ${station.name} (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})` + ) // Mark as pending BEFORE dispatch to prevent duplicate boarding attempts across frames - pendingBoardingRef.current.add(passenger.id); + pendingBoardingRef.current.add(passenger.id) dispatch({ - type: "BOARD_PASSENGER", + type: 'BOARD_PASSENGER', passengerId: passenger.id, carIndex, // Pass physical car index to server - }); + }) // Mark this car and passenger as assigned in this frame - carsAssignedThisFrame.add(carIndex); - passengersAssignedThisFrame.add(passenger.id); - return; // Board this passenger and move on + carsAssignedThisFrame.add(carIndex) + passengersAssignedThisFrame.add(passenger.id) + return // Board this passenger and move on } } @@ -352,105 +297,96 @@ export function useSteamJourney() { if (closestCarDistance < 10) { // Only log if train is somewhat near console.log( - `⏸️ WAITING: ${passenger.name} at ${station.emoji} ${station.name} - ${closestCarReason} (trainPos=${trainPosition.toFixed(1)}, maxCars=${maxCars})`, - ); + `⏸️ WAITING: ${passenger.name} at ${station.emoji} ${station.name} - ${closestCarReason} (trainPos=${trainPosition.toFixed(1)}, maxCars=${maxCars})` + ) } - }); + }) // Check for route completion (entire train exits tunnel) - const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current; - const previousPosition = previousTrainPositionRef.current; + const ENTIRE_TRAIN_EXIT_THRESHOLD = routeExitThresholdRef.current + const previousPosition = previousTrainPositionRef.current if ( trainPosition >= ENTIRE_TRAIN_EXIT_THRESHOLD && previousPosition < ENTIRE_TRAIN_EXIT_THRESHOLD ) { // Play celebration whistle - playSound("train_whistle", 0.6); + playSound('train_whistle', 0.6) setTimeout(() => { - playSound("celebration", 0.4); - }, 800); + playSound('celebration', 0.4) + }, 800) // Auto-advance to next route - const nextRoute = state.currentRoute + 1; + const nextRoute = state.currentRoute + 1 console.log( - `🏁 ROUTE COMPLETE: Train crossed exit threshold (${trainPosition.toFixed(1)} >= ${ENTIRE_TRAIN_EXIT_THRESHOLD}). Advancing to Route ${nextRoute}`, - ); + `🏁 ROUTE COMPLETE: Train crossed exit threshold (${trainPosition.toFixed(1)} >= ${ENTIRE_TRAIN_EXIT_THRESHOLD}). Advancing to Route ${nextRoute}` + ) dispatch({ - type: "START_NEW_ROUTE", + type: 'START_NEW_ROUTE', routeNumber: nextRoute, stations: state.stations, - }); + }) // Note: New passengers will be generated by the server when it handles START_NEW_ROUTE } // Update previous position for next frame - previousTrainPositionRef.current = trainPosition; - }, UPDATE_INTERVAL); + previousTrainPositionRef.current = trainPosition + }, UPDATE_INTERVAL) - return () => clearInterval(interval); - }, [ - state.isGameActive, - state.style, - state.timeoutSetting, - dispatch, - playSound, - ]); + return () => clearInterval(interval) + }, [state.isGameActive, state.style, state.timeoutSetting, dispatch, playSound]) // Add momentum on correct answer useEffect(() => { // Only for sprint mode - if (state.style !== "sprint") return; + if (state.style !== 'sprint') return // This effect triggers when correctAnswers increases // We use a ref to track previous value to detect changes - }, [state.style]); + }, [state.style]) // Function to boost momentum (called when answer is correct) const boostMomentum = () => { - if (state.style !== "sprint") return; + if (state.style !== 'sprint') return - const newMomentum = Math.min( - 100, - state.momentum + MOMENTUM_GAIN_PER_CORRECT, - ); + const newMomentum = Math.min(100, state.momentum + MOMENTUM_GAIN_PER_CORRECT) dispatch({ - type: "UPDATE_STEAM_JOURNEY", + type: 'UPDATE_STEAM_JOURNEY', momentum: newMomentum, trainPosition: state.trainPosition, // Keep current position pressure: state.pressure, elapsedTime: state.elapsedTime, - }); - }; + }) + } // Calculate time of day period (0-5 for 6 periods, cycles infinitely) const getTimeOfDayPeriod = (): number => { - if (state.elapsedTime === 0) return 0; - const periodDuration = GAME_DURATION / 6; - return Math.floor(state.elapsedTime / periodDuration) % 6; - }; + if (state.elapsedTime === 0) return 0 + const periodDuration = GAME_DURATION / 6 + return Math.floor(state.elapsedTime / periodDuration) % 6 + } // Get sky gradient colors based on time of day const getSkyGradient = (): { top: string; bottom: string } => { - const period = getTimeOfDayPeriod(); + const period = getTimeOfDayPeriod() // 6 periods over 60 seconds: dawn → morning → midday → afternoon → dusk → night const gradients = [ - { top: "#1e3a8a", bottom: "#f59e0b" }, // Dawn - deep blue to orange - { top: "#3b82f6", bottom: "#fbbf24" }, // Morning - blue to yellow - { top: "#60a5fa", bottom: "#93c5fd" }, // Midday - bright blue - { top: "#3b82f6", bottom: "#f59e0b" }, // Afternoon - blue to orange - { top: "#7c3aed", bottom: "#f97316" }, // Dusk - purple to orange - { top: "#1e1b4b", bottom: "#312e81" }, // Night - dark purple - ]; + { top: '#1e3a8a', bottom: '#f59e0b' }, // Dawn - deep blue to orange + { top: '#3b82f6', bottom: '#fbbf24' }, // Morning - blue to yellow + { top: '#60a5fa', bottom: '#93c5fd' }, // Midday - bright blue + { top: '#3b82f6', bottom: '#f59e0b' }, // Afternoon - blue to orange + { top: '#7c3aed', bottom: '#f97316' }, // Dusk - purple to orange + { top: '#1e1b4b', bottom: '#312e81' }, // Night - dark purple + ] - return gradients[period] || gradients[0]; - }; + return gradients[period] || gradients[0] + } return { boostMomentum, getTimeOfDayPeriod, getSkyGradient, - }; + } } diff --git a/apps/web/src/app/arcade/complement-race/hooks/useTrackManagement.ts b/apps/web/src/app/arcade/complement-race/hooks/useTrackManagement.ts index 76e098ba..fce9cdfc 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useTrackManagement.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useTrackManagement.ts @@ -1,17 +1,17 @@ -import { useEffect, useRef, useState } from "react"; -import type { Passenger, Station } from "@/arcade-games/complement-race/types"; -import { generateLandmarks, type Landmark } from "../lib/landmarks"; -import type { RailroadTrackGenerator } from "../lib/RailroadTrackGenerator"; +import { useEffect, useRef, useState } from 'react' +import type { Passenger, Station } from '@/arcade-games/complement-race/types' +import { generateLandmarks, type Landmark } from '../lib/landmarks' +import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator' interface UseTrackManagementParams { - currentRoute: number; - trainPosition: number; - trackGenerator: RailroadTrackGenerator; - pathRef: React.RefObject; - stations: Station[]; - passengers: Passenger[]; - maxCars: number; - carSpacing: number; + currentRoute: number + trainPosition: number + trackGenerator: RailroadTrackGenerator + pathRef: React.RefObject + stations: Station[] + passengers: Passenger[] + maxCars: number + carSpacing: number } export function useTrackManagement({ @@ -26,128 +26,122 @@ export function useTrackManagement({ }: UseTrackManagementParams) { const [trackData, setTrackData] = useState | null>(null); + > | null>(null) const [tiesAndRails, setTiesAndRails] = useState<{ - ties: Array<{ x1: number; y1: number; x2: number; y2: number }>; - leftRailPath: string; - rightRailPath: string; - } | null>(null); - const [stationPositions, setStationPositions] = useState< - Array<{ x: number; y: number }> - >([]); - const [landmarks, setLandmarks] = useState([]); - const [landmarkPositions, setLandmarkPositions] = useState< - Array<{ x: number; y: number }> - >([]); - const [displayPassengers, setDisplayPassengers] = - useState(passengers); + ties: Array<{ x1: number; y1: number; x2: number; y2: number }> + leftRailPath: string + rightRailPath: string + } | null>(null) + const [stationPositions, setStationPositions] = useState>([]) + const [landmarks, setLandmarks] = useState([]) + const [landmarkPositions, setLandmarkPositions] = useState>([]) + const [displayPassengers, setDisplayPassengers] = useState(passengers) // Track previous route data to maintain visuals during transition - const previousRouteRef = useRef(currentRoute); + const previousRouteRef = useRef(currentRoute) const [pendingTrackData, setPendingTrackData] = useState | null>(null); - const displayRouteRef = useRef(currentRoute); // Track which route's passengers are being displayed + > | null>(null) + const displayRouteRef = useRef(currentRoute) // Track which route's passengers are being displayed // Generate landmarks when route changes useEffect(() => { - const newLandmarks = generateLandmarks(currentRoute); - setLandmarks(newLandmarks); - }, [currentRoute]); + const newLandmarks = generateLandmarks(currentRoute) + setLandmarks(newLandmarks) + }, [currentRoute]) // Generate track on mount and when route changes useEffect(() => { - const track = trackGenerator.generateTrack(currentRoute); + const track = trackGenerator.generateTrack(currentRoute) // If we're in the middle of a route (position > 0), store as pending // Only apply new track when position resets to beginning (< 0) if (trainPosition > 0 && previousRouteRef.current !== currentRoute) { - setPendingTrackData(track); + setPendingTrackData(track) } else { - setTrackData(track); - previousRouteRef.current = currentRoute; - setPendingTrackData(null); + setTrackData(track) + previousRouteRef.current = currentRoute + setPendingTrackData(null) } - }, [trackGenerator, currentRoute, trainPosition]); + }, [trackGenerator, currentRoute, trainPosition]) // Apply pending track when train resets to beginning useEffect(() => { if (pendingTrackData && trainPosition <= 0) { - setTrackData(pendingTrackData); - previousRouteRef.current = currentRoute; - setPendingTrackData(null); + setTrackData(pendingTrackData) + previousRouteRef.current = currentRoute + setPendingTrackData(null) } - }, [pendingTrackData, trainPosition, currentRoute]); + }, [pendingTrackData, trainPosition, currentRoute]) // Manage passenger display during route transitions useEffect(() => { // Only switch to new passengers when: // 1. Train has reset to start position (<= 0) - track has changed, OR // 2. Same route AND (in middle of track OR passengers have changed state) - const trainReset = trainPosition <= 0; - const sameRoute = currentRoute === displayRouteRef.current; - const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90; // Avoid start/end transition zones + const trainReset = trainPosition <= 0 + const sameRoute = currentRoute === displayRouteRef.current + const inMiddleOfTrack = trainPosition >= 10 && trainPosition < 90 // Avoid start/end transition zones // Detect if passenger states have changed (boarding or delivery) // This allows updates even when train is past 90% threshold const passengerStatesChanged = sameRoute && passengers.some((p) => { - const oldPassenger = displayPassengers.find((dp) => dp.id === p.id); + const oldPassenger = displayPassengers.find((dp) => dp.id === p.id) return ( oldPassenger && - (oldPassenger.claimedBy !== p.claimedBy || - oldPassenger.deliveredBy !== p.deliveredBy) - ); - }); + (oldPassenger.claimedBy !== p.claimedBy || oldPassenger.deliveredBy !== p.deliveredBy) + ) + }) if (trainReset) { // Train reset - update to new route's passengers - setDisplayPassengers(passengers); - displayRouteRef.current = currentRoute; + setDisplayPassengers(passengers) + displayRouteRef.current = currentRoute } else if (sameRoute && (inMiddleOfTrack || passengerStatesChanged)) { // Same route and either in middle of track OR passenger states changed - update for gameplay - setDisplayPassengers(passengers); + setDisplayPassengers(passengers) } // Otherwise, keep displaying old passengers until train resets - }, [passengers, displayPassengers, trainPosition, currentRoute]); + }, [passengers, displayPassengers, trainPosition, currentRoute]) // Generate ties and rails when path is ready useEffect(() => { if (pathRef.current && trackData) { - const result = trackGenerator.generateTiesAndRails(pathRef.current); - setTiesAndRails(result); + const result = trackGenerator.generateTiesAndRails(pathRef.current) + setTiesAndRails(result) } - }, [trackData, trackGenerator, pathRef]); + }, [trackData, trackGenerator, pathRef]) // Calculate station positions when path is ready useEffect(() => { if (pathRef.current) { const positions = stations.map((station) => { - const pathLength = pathRef.current!.getTotalLength(); - const distance = (station.position / 100) * pathLength; - const point = pathRef.current!.getPointAtLength(distance); - return { x: point.x, y: point.y }; - }); - setStationPositions(positions); + const pathLength = pathRef.current!.getTotalLength() + const distance = (station.position / 100) * pathLength + const point = pathRef.current!.getPointAtLength(distance) + return { x: point.x, y: point.y } + }) + setStationPositions(positions) } - }, [stations, pathRef]); + }, [stations, pathRef]) // Calculate landmark positions when path is ready useEffect(() => { if (pathRef.current && landmarks.length > 0) { const positions = landmarks.map((landmark) => { - const pathLength = pathRef.current!.getTotalLength(); - const distance = (landmark.position / 100) * pathLength; - const point = pathRef.current!.getPointAtLength(distance); + const pathLength = pathRef.current!.getTotalLength() + const distance = (landmark.position / 100) * pathLength + const point = pathRef.current!.getPointAtLength(distance) return { x: point.x + landmark.offset.x, y: point.y + landmark.offset.y, - }; - }); - setLandmarkPositions(positions); + } + }) + setLandmarkPositions(positions) } - }, [landmarks, pathRef]); + }, [landmarks, pathRef]) return { trackData, @@ -156,5 +150,5 @@ export function useTrackManagement({ landmarks, landmarkPositions, displayPassengers, - }; + } } diff --git a/apps/web/src/app/arcade/complement-race/hooks/useTrainTransforms.ts b/apps/web/src/app/arcade/complement-race/hooks/useTrainTransforms.ts index 1cacd5d6..a13cbd4b 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useTrainTransforms.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useTrainTransforms.ts @@ -1,23 +1,23 @@ -import { useEffect, useMemo, useState } from "react"; -import type { RailroadTrackGenerator } from "../lib/RailroadTrackGenerator"; +import { useEffect, useMemo, useState } from 'react' +import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator' interface TrainTransform { - x: number; - y: number; - rotation: number; + x: number + y: number + rotation: number } interface TrainCarTransform extends TrainTransform { - position: number; - opacity: number; + position: number + opacity: number } interface UseTrainTransformsParams { - trainPosition: number; - trackGenerator: RailroadTrackGenerator; - pathRef: React.RefObject; - maxCars: number; - carSpacing: number; + trainPosition: number + trackGenerator: RailroadTrackGenerator + pathRef: React.RefObject + maxCars: number + carSpacing: number } export function useTrainTransforms({ @@ -31,18 +31,15 @@ export function useTrainTransforms({ x: 50, y: 300, rotation: 0, - }); + }) // Update train position and rotation useEffect(() => { if (pathRef.current) { - const transform = trackGenerator.getTrainTransform( - pathRef.current, - trainPosition, - ); - setTrainTransform(transform); + const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition) + setTrainTransform(transform) } - }, [trainPosition, trackGenerator, pathRef]); + }, [trainPosition, trackGenerator, pathRef]) // Calculate train car transforms (each car follows behind the locomotive) const trainCars = useMemo((): TrainCarTransform[] => { @@ -53,72 +50,68 @@ export function useTrainTransforms({ rotation: 0, position: 0, opacity: 0, - })); + })) } return Array.from({ length: maxCars }).map((_, carIndex) => { // Calculate position for this car (behind the locomotive) - const carPosition = Math.max( - 0, - trainPosition - (carIndex + 1) * carSpacing, - ); + const carPosition = Math.max(0, trainPosition - (carIndex + 1) * carSpacing) // Calculate opacity: fade in at left tunnel (3-8%), fade out at right tunnel (92-97%) - const fadeInStart = 3; - const fadeInEnd = 8; - const fadeOutStart = 92; - const fadeOutEnd = 97; + const fadeInStart = 3 + const fadeInEnd = 8 + const fadeOutStart = 92 + const fadeOutEnd = 97 - let opacity = 1; // Default to fully visible + let opacity = 1 // Default to fully visible // Fade in from left tunnel if (carPosition <= fadeInStart) { - opacity = 0; + opacity = 0 } else if (carPosition < fadeInEnd) { - opacity = (carPosition - fadeInStart) / (fadeInEnd - fadeInStart); + opacity = (carPosition - fadeInStart) / (fadeInEnd - fadeInStart) } // Fade out into right tunnel else if (carPosition >= fadeOutEnd) { - opacity = 0; + opacity = 0 } else if (carPosition > fadeOutStart) { - opacity = - 1 - (carPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart); + opacity = 1 - (carPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart) } return { ...trackGenerator.getTrainTransform(pathRef.current!, carPosition), position: carPosition, opacity, - }; - }); - }, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing]); + } + }) + }, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing]) // Calculate locomotive opacity (fade in/out through tunnels) const locomotiveOpacity = useMemo(() => { - const fadeInStart = 3; - const fadeInEnd = 8; - const fadeOutStart = 92; - const fadeOutEnd = 97; + const fadeInStart = 3 + const fadeInEnd = 8 + const fadeOutStart = 92 + const fadeOutEnd = 97 // Fade in from left tunnel if (trainPosition <= fadeInStart) { - return 0; + return 0 } else if (trainPosition < fadeInEnd) { - return (trainPosition - fadeInStart) / (fadeInEnd - fadeInStart); + return (trainPosition - fadeInStart) / (fadeInEnd - fadeInStart) } // Fade out into right tunnel else if (trainPosition >= fadeOutEnd) { - return 0; + return 0 } else if (trainPosition > fadeOutStart) { - return 1 - (trainPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart); + return 1 - (trainPosition - fadeOutStart) / (fadeOutEnd - fadeOutStart) } - return 1; // Default to fully visible - }, [trainPosition]); + return 1 // Default to fully visible + }, [trainPosition]) return { trainTransform, trainCars, locomotiveOpacity, - }; + } } diff --git a/apps/web/src/app/arcade/complement-race/lib/RailroadTrackGenerator.ts b/apps/web/src/app/arcade/complement-race/lib/RailroadTrackGenerator.ts index e5e5fbd0..d4886e38 100644 --- a/apps/web/src/app/arcade/complement-race/lib/RailroadTrackGenerator.ts +++ b/apps/web/src/app/arcade/complement-race/lib/RailroadTrackGenerator.ts @@ -6,49 +6,49 @@ */ export interface Waypoint { - x: number; - y: number; + x: number + y: number } export interface TrackElements { - ballastPath: string; - referencePath: string; - ties: Array<{ x1: number; y1: number; x2: number; y2: number }>; - leftRailPath: string; - rightRailPath: string; + ballastPath: string + referencePath: string + ties: Array<{ x1: number; y1: number; x2: number; y2: number }> + leftRailPath: string + rightRailPath: string } export class RailroadTrackGenerator { - private viewWidth: number; - private viewHeight: number; + private viewWidth: number + private viewHeight: number constructor(viewWidth = 800, viewHeight = 600) { - this.viewWidth = viewWidth; - this.viewHeight = viewHeight; + this.viewWidth = viewWidth + this.viewHeight = viewHeight } /** * Generate complete track elements for rendering */ generateTrack(routeNumber: number = 1): TrackElements { - const waypoints = this.generateTrackWaypoints(routeNumber); - const pathData = this.generateSmoothPath(waypoints); + const waypoints = this.generateTrackWaypoints(routeNumber) + const pathData = this.generateSmoothPath(waypoints) return { ballastPath: pathData, referencePath: pathData, ties: [], - leftRailPath: "", - rightRailPath: "", - }; + leftRailPath: '', + rightRailPath: '', + } } /** * Seeded random number generator for deterministic randomness */ private seededRandom(seed: number): number { - const x = Math.sin(seed) * 10000; - return x - Math.floor(x); + const x = Math.sin(seed) * 10000 + return x - Math.floor(x) } /** @@ -66,52 +66,52 @@ export class RailroadTrackGenerator { { x: 520, y: 220 }, // Descent to valley { x: 660, y: 160 }, // Bridge over canyon { x: 780, y: 300 }, // Enter right tunnel center - ]; + ] // Add deterministic randomness based on route number (but keep start/end fixed) return baseWaypoints.map((point, index) => { if (index === 0 || index === baseWaypoints.length - 1) { - return point; // Keep start/end points fixed + return point // Keep start/end points fixed } // Use seeded randomness for consistent track per route - const seed1 = routeNumber * 12.9898 + index * 78.233; - const seed2 = routeNumber * 43.789 + index * 67.123; - const randomX = (this.seededRandom(seed1) - 0.5) * 60; // ±30 pixels - const randomY = (this.seededRandom(seed2) - 0.5) * 80; // ±40 pixels + const seed1 = routeNumber * 12.9898 + index * 78.233 + const seed2 = routeNumber * 43.789 + index * 67.123 + const randomX = (this.seededRandom(seed1) - 0.5) * 60 // ±30 pixels + const randomY = (this.seededRandom(seed2) - 0.5) * 80 // ±40 pixels return { x: point.x + randomX, y: point.y + randomY, - }; - }); + } + }) } /** * Generate smooth cubic bezier curves through waypoints */ private generateSmoothPath(waypoints: Waypoint[]): string { - if (waypoints.length < 2) return ""; + if (waypoints.length < 2) return '' - let pathData = `M ${waypoints[0].x} ${waypoints[0].y}`; + let pathData = `M ${waypoints[0].x} ${waypoints[0].y}` for (let i = 1; i < waypoints.length; i++) { - const current = waypoints[i]; - const previous = waypoints[i - 1]; + const current = waypoints[i] + const previous = waypoints[i - 1] // Calculate control points for smooth curves - const dx = current.x - previous.x; - const dy = current.y - previous.y; + const dx = current.x - previous.x + const dy = current.y - previous.y - const cp1x = previous.x + dx * 0.3; - const cp1y = previous.y + dy * 0.2; - const cp2x = current.x - dx * 0.3; - const cp2y = current.y - dy * 0.2; + const cp1x = previous.x + dx * 0.3 + const cp1y = previous.y + dy * 0.2 + const cp2x = current.x - dx * 0.3 + const cp2y = current.y - dy * 0.2 - pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}`; + pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}` } - return pathData; + return pathData } /** @@ -119,27 +119,27 @@ export class RailroadTrackGenerator { * Uses very gentle control points to avoid wobbles in straight sections */ private generateGentlePath(waypoints: Waypoint[]): string { - if (waypoints.length < 2) return ""; + if (waypoints.length < 2) return '' - let pathData = `M ${waypoints[0].x} ${waypoints[0].y}`; + let pathData = `M ${waypoints[0].x} ${waypoints[0].y}` for (let i = 1; i < waypoints.length; i++) { - const current = waypoints[i]; - const previous = waypoints[i - 1]; + const current = waypoints[i] + const previous = waypoints[i - 1] // Use extremely gentle control points for very dense sampling - const dx = current.x - previous.x; - const dy = current.y - previous.y; + const dx = current.x - previous.x + const dy = current.y - previous.y - const cp1x = previous.x + dx * 0.33; - const cp1y = previous.y + dy * 0.33; - const cp2x = current.x - dx * 0.33; - const cp2y = current.y - dy * 0.33; + const cp1x = previous.x + dx * 0.33 + const cp1y = previous.y + dy * 0.33 + const cp2x = current.x - dx * 0.33 + const cp2y = current.y - dy * 0.33 - pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}`; + pathData += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${current.x} ${current.y}` } - return pathData; + return pathData } /** @@ -147,71 +147,71 @@ export class RailroadTrackGenerator { * This requires an SVG path element to measure */ generateTiesAndRails(pathElement: SVGPathElement): { - ties: Array<{ x1: number; y1: number; x2: number; y2: number }>; - leftRailPath: string; - rightRailPath: string; + ties: Array<{ x1: number; y1: number; x2: number; y2: number }> + leftRailPath: string + rightRailPath: string } { - const pathLength = pathElement.getTotalLength(); - const tieSpacing = 12; // Distance between ties in pixels - const gaugeWidth = 15; // Standard gauge (tie extends 15px each side) - const tieCount = Math.floor(pathLength / tieSpacing); + const pathLength = pathElement.getTotalLength() + const tieSpacing = 12 // Distance between ties in pixels + const gaugeWidth = 15 // Standard gauge (tie extends 15px each side) + const tieCount = Math.floor(pathLength / tieSpacing) - const ties: Array<{ x1: number; y1: number; x2: number; y2: number }> = []; + const ties: Array<{ x1: number; y1: number; x2: number; y2: number }> = [] // Generate ties at normal spacing for (let i = 0; i < tieCount; i++) { - const distance = i * tieSpacing; - const point = pathElement.getPointAtLength(distance); + const distance = i * tieSpacing + const point = pathElement.getPointAtLength(distance) // Calculate perpendicular angle for tie orientation - const nextDistance = Math.min(distance + 2, pathLength); - const nextPoint = pathElement.getPointAtLength(nextDistance); - const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x); - const perpAngle = angle + Math.PI / 2; + const nextDistance = Math.min(distance + 2, pathLength) + const nextPoint = pathElement.getPointAtLength(nextDistance) + const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x) + const perpAngle = angle + Math.PI / 2 // Calculate tie end points - const leftX = point.x + Math.cos(perpAngle) * gaugeWidth; - const leftY = point.y + Math.sin(perpAngle) * gaugeWidth; - const rightX = point.x - Math.cos(perpAngle) * gaugeWidth; - const rightY = point.y - Math.sin(perpAngle) * gaugeWidth; + const leftX = point.x + Math.cos(perpAngle) * gaugeWidth + const leftY = point.y + Math.sin(perpAngle) * gaugeWidth + const rightX = point.x - Math.cos(perpAngle) * gaugeWidth + const rightY = point.y - Math.sin(perpAngle) * gaugeWidth // Store tie - ties.push({ x1: leftX, y1: leftY, x2: rightX, y2: rightY }); + ties.push({ x1: leftX, y1: leftY, x2: rightX, y2: rightY }) } // Generate rail paths as smooth curves (not polylines) // Sample points along the path and create offset waypoints - const railSampling = 2; // Sample every 2 pixels for waypoints (very dense sampling for smooth curves) - const sampleCount = Math.floor(pathLength / railSampling); + const railSampling = 2 // Sample every 2 pixels for waypoints (very dense sampling for smooth curves) + const sampleCount = Math.floor(pathLength / railSampling) - const leftRailWaypoints: Waypoint[] = []; - const rightRailWaypoints: Waypoint[] = []; + const leftRailWaypoints: Waypoint[] = [] + const rightRailWaypoints: Waypoint[] = [] for (let i = 0; i <= sampleCount; i++) { - const distance = Math.min(i * railSampling, pathLength); - const point = pathElement.getPointAtLength(distance); + const distance = Math.min(i * railSampling, pathLength) + const point = pathElement.getPointAtLength(distance) // Calculate perpendicular angle with longer lookahead for smoother curves - const nextDistance = Math.min(distance + 8, pathLength); - const nextPoint = pathElement.getPointAtLength(nextDistance); - const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x); - const perpAngle = angle + Math.PI / 2; + const nextDistance = Math.min(distance + 8, pathLength) + const nextPoint = pathElement.getPointAtLength(nextDistance) + const angle = Math.atan2(nextPoint.y - point.y, nextPoint.x - point.x) + const perpAngle = angle + Math.PI / 2 // Calculate offset positions for rails - const leftX = point.x + Math.cos(perpAngle) * gaugeWidth; - const leftY = point.y + Math.sin(perpAngle) * gaugeWidth; - const rightX = point.x - Math.cos(perpAngle) * gaugeWidth; - const rightY = point.y - Math.sin(perpAngle) * gaugeWidth; + const leftX = point.x + Math.cos(perpAngle) * gaugeWidth + const leftY = point.y + Math.sin(perpAngle) * gaugeWidth + const rightX = point.x - Math.cos(perpAngle) * gaugeWidth + const rightY = point.y - Math.sin(perpAngle) * gaugeWidth - leftRailWaypoints.push({ x: leftX, y: leftY }); - rightRailWaypoints.push({ x: rightX, y: rightY }); + leftRailWaypoints.push({ x: leftX, y: leftY }) + rightRailWaypoints.push({ x: rightX, y: rightY }) } // Generate smooth curved paths through the rail waypoints with gentle control points - const leftRailPath = this.generateGentlePath(leftRailWaypoints); - const rightRailPath = this.generateGentlePath(rightRailWaypoints); + const leftRailPath = this.generateGentlePath(leftRailWaypoints) + const rightRailPath = this.generateGentlePath(rightRailWaypoints) - return { ties, leftRailPath, rightRailPath }; + return { ties, leftRailPath, rightRailPath } } /** @@ -219,30 +219,28 @@ export class RailroadTrackGenerator { */ getTrainTransform( pathElement: SVGPathElement, - progress: number, // 0-100% + progress: number // 0-100% ): { x: number; y: number; rotation: number } { - const pathLength = pathElement.getTotalLength(); - const targetLength = (progress / 100) * pathLength; + const pathLength = pathElement.getTotalLength() + const targetLength = (progress / 100) * pathLength // Get exact point on curved path - const point = pathElement.getPointAtLength(targetLength); + const point = pathElement.getPointAtLength(targetLength) // Calculate rotation based on path direction - const lookAheadDistance = Math.min(5, pathLength - targetLength); - const nextPoint = pathElement.getPointAtLength( - targetLength + lookAheadDistance, - ); + const lookAheadDistance = Math.min(5, pathLength - targetLength) + const nextPoint = pathElement.getPointAtLength(targetLength + lookAheadDistance) // Calculate angle between current and next point - const deltaX = nextPoint.x - point.x; - const deltaY = nextPoint.y - point.y; - const angleRadians = Math.atan2(deltaY, deltaX); - const angleDegrees = angleRadians * (180 / Math.PI); + const deltaX = nextPoint.x - point.x + const deltaY = nextPoint.y - point.y + const angleRadians = Math.atan2(deltaY, deltaX) + const angleDegrees = angleRadians * (180 / Math.PI) return { x: point.x, y: point.y, rotation: angleDegrees, - }; + } } } diff --git a/apps/web/src/app/arcade/complement-race/lib/gameTypes.ts b/apps/web/src/app/arcade/complement-race/lib/gameTypes.ts index 905a228e..2349847b 100644 --- a/apps/web/src/app/arcade/complement-race/lib/gameTypes.ts +++ b/apps/web/src/app/arcade/complement-race/lib/gameTypes.ts @@ -1,181 +1,181 @@ -export type GameMode = "friends5" | "friends10" | "mixed"; -export type GameStyle = "practice" | "sprint" | "survival"; +export type GameMode = 'friends5' | 'friends10' | 'mixed' +export type GameStyle = 'practice' | 'sprint' | 'survival' export type TimeoutSetting = - | "preschool" - | "kindergarten" - | "relaxed" - | "slow" - | "normal" - | "fast" - | "expert"; -export type ComplementDisplay = "number" | "abacus" | "random"; // How to display the complement number + | 'preschool' + | 'kindergarten' + | 'relaxed' + | 'slow' + | 'normal' + | 'fast' + | 'expert' +export type ComplementDisplay = 'number' | 'abacus' | 'random' // How to display the complement number export interface ComplementQuestion { - number: number; - targetSum: number; - correctAnswer: number; - showAsAbacus: boolean; // For random mode, this is decided once per question + number: number + targetSum: number + correctAnswer: number + showAsAbacus: boolean // For random mode, this is decided once per question } export interface AIRacer { - id: string; - position: number; - speed: number; - name: string; - personality: "competitive" | "analytical"; - icon: string; - lastComment: number; - commentCooldown: number; - previousPosition: number; + id: string + position: number + speed: number + name: string + personality: 'competitive' | 'analytical' + icon: string + lastComment: number + commentCooldown: number + previousPosition: number } export interface DifficultyTracker { - pairPerformance: Map; - baseTimeLimit: number; - currentTimeLimit: number; - difficultyLevel: number; - consecutiveCorrect: number; - consecutiveIncorrect: number; - learningMode: boolean; - adaptationRate: number; + pairPerformance: Map + baseTimeLimit: number + currentTimeLimit: number + difficultyLevel: number + consecutiveCorrect: number + consecutiveIncorrect: number + learningMode: boolean + adaptationRate: number } export interface PairPerformance { - attempts: number; - correct: number; - avgTime: number; - difficulty: number; + attempts: number + correct: number + avgTime: number + difficulty: number } export interface Station { - id: string; - name: string; - position: number; // 0-100% along track - icon: string; - emoji: string; // Alias for icon (for backward compatibility) + id: string + name: string + position: number // 0-100% along track + icon: string + emoji: string // Alias for icon (for backward compatibility) } export interface Passenger { - id: string; - name: string; - avatar: string; - originStationId: string; - destinationStationId: string; - isUrgent: boolean; - isBoarded: boolean; - isDelivered: boolean; + id: string + name: string + avatar: string + originStationId: string + destinationStationId: string + isUrgent: boolean + isBoarded: boolean + isDelivered: boolean } export interface GameState { // Game configuration - mode: GameMode; - style: GameStyle; - timeoutSetting: TimeoutSetting; - complementDisplay: ComplementDisplay; // How to display the complement number + mode: GameMode + style: GameStyle + timeoutSetting: TimeoutSetting + complementDisplay: ComplementDisplay // How to display the complement number // Current question - currentQuestion: ComplementQuestion | null; - previousQuestion: ComplementQuestion | null; + currentQuestion: ComplementQuestion | null + previousQuestion: ComplementQuestion | null // Game progress - score: number; - streak: number; - bestStreak: number; - totalQuestions: number; - correctAnswers: number; + score: number + streak: number + bestStreak: number + totalQuestions: number + correctAnswers: number // Game status - isGameActive: boolean; - isPaused: boolean; - gamePhase: "intro" | "controls" | "countdown" | "playing" | "results"; + isGameActive: boolean + isPaused: boolean + gamePhase: 'intro' | 'controls' | 'countdown' | 'playing' | 'results' // Timing - gameStartTime: number | null; - questionStartTime: number; + gameStartTime: number | null + questionStartTime: number // Race mechanics - raceGoal: number; - timeLimit: number | null; - speedMultiplier: number; - aiRacers: AIRacer[]; + raceGoal: number + timeLimit: number | null + speedMultiplier: number + aiRacers: AIRacer[] // Adaptive difficulty - difficultyTracker: DifficultyTracker; + difficultyTracker: DifficultyTracker // Survival mode specific - playerLap: number; - aiLaps: Map; - survivalMultiplier: number; + playerLap: number + aiLaps: Map + survivalMultiplier: number // Sprint mode specific - momentum: number; - trainPosition: number; - pressure: number; // 0-150 PSI - elapsedTime: number; // milliseconds elapsed in 60-second journey - lastCorrectAnswerTime: number; - currentRoute: number; - stations: Station[]; - passengers: Passenger[]; - deliveredPassengers: number; - cumulativeDistance: number; // Total distance across all routes - showRouteCelebration: boolean; + momentum: number + trainPosition: number + pressure: number // 0-150 PSI + elapsedTime: number // milliseconds elapsed in 60-second journey + lastCorrectAnswerTime: number + currentRoute: number + stations: Station[] + passengers: Passenger[] + deliveredPassengers: number + cumulativeDistance: number // Total distance across all routes + showRouteCelebration: boolean // Input - currentInput: string; + currentInput: string // UI state - showScoreModal: boolean; - activeSpeechBubbles: Map; // racerId -> message - adaptiveFeedback: { message: string; type: string } | null; + showScoreModal: boolean + activeSpeechBubbles: Map // racerId -> message + adaptiveFeedback: { message: string; type: string } | null } export type GameAction = - | { type: "SET_MODE"; mode: GameMode } - | { type: "SET_STYLE"; style: GameStyle } - | { type: "SET_TIMEOUT"; timeout: TimeoutSetting } - | { type: "SET_COMPLEMENT_DISPLAY"; display: ComplementDisplay } - | { type: "SHOW_CONTROLS" } - | { type: "START_COUNTDOWN" } - | { type: "BEGIN_GAME" } - | { type: "NEXT_QUESTION" } - | { type: "SUBMIT_ANSWER"; answer: number } - | { type: "UPDATE_INPUT"; input: string } + | { type: 'SET_MODE'; mode: GameMode } + | { type: 'SET_STYLE'; style: GameStyle } + | { type: 'SET_TIMEOUT'; timeout: TimeoutSetting } + | { type: 'SET_COMPLEMENT_DISPLAY'; display: ComplementDisplay } + | { type: 'SHOW_CONTROLS' } + | { type: 'START_COUNTDOWN' } + | { type: 'BEGIN_GAME' } + | { type: 'NEXT_QUESTION' } + | { type: 'SUBMIT_ANSWER'; answer: number } + | { type: 'UPDATE_INPUT'; input: string } | { - type: "UPDATE_AI_POSITIONS"; - positions: Array<{ id: string; position: number }>; + type: 'UPDATE_AI_POSITIONS' + positions: Array<{ id: string; position: number }> } | { - type: "TRIGGER_AI_COMMENTARY"; - racerId: string; - message: string; - context: string; + type: 'TRIGGER_AI_COMMENTARY' + racerId: string + message: string + context: string } - | { type: "CLEAR_AI_COMMENT"; racerId: string } - | { type: "UPDATE_DIFFICULTY_TRACKER"; tracker: DifficultyTracker } - | { type: "UPDATE_AI_SPEEDS"; racers: AIRacer[] } + | { type: 'CLEAR_AI_COMMENT'; racerId: string } + | { type: 'UPDATE_DIFFICULTY_TRACKER'; tracker: DifficultyTracker } + | { type: 'UPDATE_AI_SPEEDS'; racers: AIRacer[] } | { - type: "SHOW_ADAPTIVE_FEEDBACK"; - feedback: { message: string; type: string }; + type: 'SHOW_ADAPTIVE_FEEDBACK' + feedback: { message: string; type: string } } - | { type: "CLEAR_ADAPTIVE_FEEDBACK" } - | { type: "UPDATE_MOMENTUM"; momentum: number } - | { type: "UPDATE_TRAIN_POSITION"; position: number } + | { type: 'CLEAR_ADAPTIVE_FEEDBACK' } + | { type: 'UPDATE_MOMENTUM'; momentum: number } + | { type: 'UPDATE_TRAIN_POSITION'; position: number } | { - type: "UPDATE_STEAM_JOURNEY"; - momentum: number; - trainPosition: number; - pressure: number; - elapsedTime: number; + type: 'UPDATE_STEAM_JOURNEY' + momentum: number + trainPosition: number + pressure: number + elapsedTime: number } - | { type: "COMPLETE_LAP"; racerId: string } - | { type: "PAUSE_RACE" } - | { type: "RESUME_RACE" } - | { type: "END_RACE" } - | { type: "SHOW_RESULTS" } - | { type: "RESET_GAME" } - | { type: "GENERATE_PASSENGERS"; passengers: Passenger[] } - | { type: "BOARD_PASSENGER"; passengerId: string } - | { type: "DELIVER_PASSENGER"; passengerId: string; points: number } - | { type: "START_NEW_ROUTE"; routeNumber: number; stations: Station[] } - | { type: "COMPLETE_ROUTE" } - | { type: "HIDE_ROUTE_CELEBRATION" }; + | { type: 'COMPLETE_LAP'; racerId: string } + | { type: 'PAUSE_RACE' } + | { type: 'RESUME_RACE' } + | { type: 'END_RACE' } + | { type: 'SHOW_RESULTS' } + | { type: 'RESET_GAME' } + | { type: 'GENERATE_PASSENGERS'; passengers: Passenger[] } + | { type: 'BOARD_PASSENGER'; passengerId: string } + | { type: 'DELIVER_PASSENGER'; passengerId: string; points: number } + | { type: 'START_NEW_ROUTE'; routeNumber: number; stations: Station[] } + | { type: 'COMPLETE_ROUTE' } + | { type: 'HIDE_ROUTE_CELEBRATION' } diff --git a/apps/web/src/app/arcade/complement-race/lib/landmarks.ts b/apps/web/src/app/arcade/complement-race/lib/landmarks.ts index 13d9bbb1..fe52a970 100644 --- a/apps/web/src/app/arcade/complement-race/lib/landmarks.ts +++ b/apps/web/src/app/arcade/complement-race/lib/landmarks.ts @@ -4,10 +4,10 @@ */ export interface Landmark { - emoji: string; - position: number; // 0-100% along track - offset: { x: number; y: number }; // Offset from track position - size: number; // Font size multiplier + emoji: string + position: number // 0-100% along track + offset: { x: number; y: number } // Offset from track position + size: number // Font size multiplier } /** @@ -15,77 +15,77 @@ export interface Landmark { * Different route themes have different landmark types */ export function generateLandmarks(routeNumber: number): Landmark[] { - const seed = routeNumber * 456.789; + const seed = routeNumber * 456.789 // Deterministic randomness for landmark placement const random = (index: number) => { - return Math.abs(Math.sin(seed + index * 2.7)); - }; + return Math.abs(Math.sin(seed + index * 2.7)) + } - const landmarks: Landmark[] = []; + const landmarks: Landmark[] = [] // Route theme determines landmark types - const themeIndex = (routeNumber - 1) % 10; + const themeIndex = (routeNumber - 1) % 10 // Generate 4-6 landmarks along the route - const landmarkCount = Math.floor(random(0) * 3) + 4; + const landmarkCount = Math.floor(random(0) * 3) + 4 for (let i = 0; i < landmarkCount; i++) { - const position = (i + 1) * (100 / (landmarkCount + 1)); - const offsetSide = random(i) > 0.5 ? 1 : -1; - const offsetDistance = 30 + random(i + 10) * 40; + const position = (i + 1) * (100 / (landmarkCount + 1)) + const offsetSide = random(i) > 0.5 ? 1 : -1 + const offsetDistance = 30 + random(i + 10) * 40 - let emoji = "🌳"; // Default tree - let size = 24; + let emoji = '🌳' // Default tree + let size = 24 // Choose emoji based on theme and position switch (themeIndex) { case 0: // Prairie Express - emoji = random(i) > 0.6 ? "🌾" : "🌻"; - size = 20; - break; + emoji = random(i) > 0.6 ? '🌾' : '🌻' + size = 20 + break case 1: // Mountain Climb - emoji = random(i) > 0.5 ? "⛰️" : "🗻"; - size = 32; - break; + emoji = random(i) > 0.5 ? '⛰️' : '🗻' + size = 32 + break case 2: // Coastal Run - emoji = random(i) > 0.7 ? "🌊" : random(i) > 0.4 ? "🏖️" : "⛵"; - size = 24; - break; + emoji = random(i) > 0.7 ? '🌊' : random(i) > 0.4 ? '🏖️' : '⛵' + size = 24 + break case 3: // Desert Crossing - emoji = random(i) > 0.6 ? "🌵" : "🏜️"; - size = 28; - break; + emoji = random(i) > 0.6 ? '🌵' : '🏜️' + size = 28 + break case 4: // Forest Trail - emoji = random(i) > 0.7 ? "🌲" : random(i) > 0.4 ? "🌳" : "🦌"; - size = 26; - break; + emoji = random(i) > 0.7 ? '🌲' : random(i) > 0.4 ? '🌳' : '🦌' + size = 26 + break case 5: // Canyon Route - emoji = random(i) > 0.5 ? "🏞️" : "🪨"; - size = 30; - break; + emoji = random(i) > 0.5 ? '🏞️' : '🪨' + size = 30 + break case 6: // River Valley - emoji = random(i) > 0.6 ? "🌊" : random(i) > 0.3 ? "🌳" : "🦆"; - size = 24; - break; + emoji = random(i) > 0.6 ? '🌊' : random(i) > 0.3 ? '🌳' : '🦆' + size = 24 + break case 7: // Highland Pass - emoji = random(i) > 0.6 ? "🗻" : "☁️"; - size = 28; - break; + emoji = random(i) > 0.6 ? '🗻' : '☁️' + size = 28 + break case 8: // Lakeside Journey - emoji = random(i) > 0.7 ? "🏞️" : random(i) > 0.4 ? "🌳" : "🦢"; - size = 26; - break; + emoji = random(i) > 0.7 ? '🏞️' : random(i) > 0.4 ? '🌳' : '🦢' + size = 26 + break case 9: // Grand Circuit - emoji = random(i) > 0.7 ? "🎪" : random(i) > 0.4 ? "🎡" : "🎠"; - size = 28; - break; + emoji = random(i) > 0.7 ? '🎪' : random(i) > 0.4 ? '🎡' : '🎠' + size = 28 + break } // Add bridges at specific positions (around 40-60%) if (position > 40 && position < 60 && random(i + 20) > 0.7) { - emoji = "🌉"; - size = 36; + emoji = '🌉' + size = 36 } landmarks.push({ @@ -96,8 +96,8 @@ export function generateLandmarks(routeNumber: number): Landmark[] { y: random(i + 5) * 20 - 10, }, size, - }); + }) } - return landmarks; + return landmarks } diff --git a/apps/web/src/app/arcade/complement-race/lib/passengerGenerator.ts b/apps/web/src/app/arcade/complement-race/lib/passengerGenerator.ts index 5795fbb8..d069b950 100644 --- a/apps/web/src/app/arcade/complement-race/lib/passengerGenerator.ts +++ b/apps/web/src/app/arcade/complement-race/lib/passengerGenerator.ts @@ -1,217 +1,212 @@ -import type { Passenger, Station } from "./gameTypes"; +import type { Passenger, Station } from './gameTypes' // Names and avatars organized by gender presentation const MASCULINE_NAMES = [ - "Ahmed", - "Bob", - "Carlos", - "Elias", - "Ethan", - "George", - "Ian", - "Kevin", - "Marcus", - "Oliver", - "Victor", - "Xavier", - "Raj", - "David", - "Miguel", - "Jin", -]; + 'Ahmed', + 'Bob', + 'Carlos', + 'Elias', + 'Ethan', + 'George', + 'Ian', + 'Kevin', + 'Marcus', + 'Oliver', + 'Victor', + 'Xavier', + 'Raj', + 'David', + 'Miguel', + 'Jin', +] const FEMININE_NAMES = [ - "Alice", - "Bella", - "Diana", - "Devi", - "Fatima", - "Fiona", - "Hannah", - "Julia", - "Laura", - "Nina", - "Petra", - "Rosa", - "Tessa", - "Uma", - "Wendy", - "Zara", - "Yuki", -]; + 'Alice', + 'Bella', + 'Diana', + 'Devi', + 'Fatima', + 'Fiona', + 'Hannah', + 'Julia', + 'Laura', + 'Nina', + 'Petra', + 'Rosa', + 'Tessa', + 'Uma', + 'Wendy', + 'Zara', + 'Yuki', +] const GENDER_NEUTRAL_NAMES = [ - "Alex", - "Charlie", - "Jordan", - "Morgan", - "Quinn", - "Riley", - "Sam", - "Taylor", -]; + 'Alex', + 'Charlie', + 'Jordan', + 'Morgan', + 'Quinn', + 'Riley', + 'Sam', + 'Taylor', +] // Masculine-presenting avatars const MASCULINE_AVATARS = [ - "👨", - "👨🏻", - "👨🏼", - "👨🏽", - "👨🏾", - "👨🏿", - "👴", - "👴🏻", - "👴🏼", - "👴🏽", - "👴🏾", - "👴🏿", - "👦", - "👦🏻", - "👦🏼", - "👦🏽", - "👦🏾", - "👦🏿", - "🧔", - "🧔🏻", - "🧔🏼", - "🧔🏽", - "🧔🏾", - "🧔🏿", - "👨‍🦱", - "👨🏻‍🦱", - "👨🏼‍🦱", - "👨🏽‍🦱", - "👨🏾‍🦱", - "👨🏿‍🦱", - "👨‍🦰", - "👨🏻‍🦰", - "👨🏼‍🦰", - "👨🏽‍🦰", - "👨🏾‍🦰", - "👨🏿‍🦰", - "👱", - "👱🏻", - "👱🏼", - "👱🏽", - "👱🏾", - "👱🏿", -]; + '👨', + '👨🏻', + '👨🏼', + '👨🏽', + '👨🏾', + '👨🏿', + '👴', + '👴🏻', + '👴🏼', + '👴🏽', + '👴🏾', + '👴🏿', + '👦', + '👦🏻', + '👦🏼', + '👦🏽', + '👦🏾', + '👦🏿', + '🧔', + '🧔🏻', + '🧔🏼', + '🧔🏽', + '🧔🏾', + '🧔🏿', + '👨‍🦱', + '👨🏻‍🦱', + '👨🏼‍🦱', + '👨🏽‍🦱', + '👨🏾‍🦱', + '👨🏿‍🦱', + '👨‍🦰', + '👨🏻‍🦰', + '👨🏼‍🦰', + '👨🏽‍🦰', + '👨🏾‍🦰', + '👨🏿‍🦰', + '👱', + '👱🏻', + '👱🏼', + '👱🏽', + '👱🏾', + '👱🏿', +] // Feminine-presenting avatars const FEMININE_AVATARS = [ - "👩", - "👩🏻", - "👩🏼", - "👩🏽", - "👩🏾", - "👩🏿", - "👵", - "👵🏻", - "👵🏼", - "👵🏽", - "👵🏾", - "👵🏿", - "👧", - "👧🏻", - "👧🏼", - "👧🏽", - "👧🏾", - "👧🏿", - "👩‍🦱", - "👩🏻‍🦱", - "👩🏼‍🦱", - "👩🏽‍🦱", - "👩🏾‍🦱", - "👩🏿‍🦱", - "👩‍🦰", - "👩🏻‍🦰", - "👩🏼‍🦰", - "👩🏽‍🦰", - "👩🏾‍🦰", - "👩🏿‍🦰", - "👱‍♀️", - "👱🏻‍♀️", - "👱🏼‍♀️", - "👱🏽‍♀️", - "👱🏾‍♀️", - "👱🏿‍♀️", -]; + '👩', + '👩🏻', + '👩🏼', + '👩🏽', + '👩🏾', + '👩🏿', + '👵', + '👵🏻', + '👵🏼', + '👵🏽', + '👵🏾', + '👵🏿', + '👧', + '👧🏻', + '👧🏼', + '👧🏽', + '👧🏾', + '👧🏿', + '👩‍🦱', + '👩🏻‍🦱', + '👩🏼‍🦱', + '👩🏽‍🦱', + '👩🏾‍🦱', + '👩🏿‍🦱', + '👩‍🦰', + '👩🏻‍🦰', + '👩🏼‍🦰', + '👩🏽‍🦰', + '👩🏾‍🦰', + '👩🏿‍🦰', + '👱‍♀️', + '👱🏻‍♀️', + '👱🏼‍♀️', + '👱🏽‍♀️', + '👱🏾‍♀️', + '👱🏿‍♀️', +] // Gender-neutral avatars -const NEUTRAL_AVATARS = ["🧑", "🧑🏻", "🧑🏼", "🧑🏽", "🧑🏾", "🧑🏿"]; +const NEUTRAL_AVATARS = ['🧑', '🧑🏻', '🧑🏼', '🧑🏽', '🧑🏾', '🧑🏿'] /** * Generate 3-5 passengers with random names and destinations * 30% chance of urgent passengers */ export function generatePassengers(stations: Station[]): Passenger[] { - const count = Math.floor(Math.random() * 3) + 3; // 3-5 passengers - const passengers: Passenger[] = []; - const usedCombos = new Set(); + const count = Math.floor(Math.random() * 3) + 3 // 3-5 passengers + const passengers: Passenger[] = [] + const usedCombos = new Set() for (let i = 0; i < count; i++) { - let name: string; - let avatar: string; - let comboKey: string; + let name: string + let avatar: string + let comboKey: string // Keep trying until we get a unique name/avatar combo do { // Randomly choose a gender category - const genderRoll = Math.random(); - let namePool: string[]; - let avatarPool: string[]; + const genderRoll = Math.random() + let namePool: string[] + let avatarPool: string[] if (genderRoll < 0.45) { // 45% masculine - namePool = MASCULINE_NAMES; - avatarPool = MASCULINE_AVATARS; + namePool = MASCULINE_NAMES + avatarPool = MASCULINE_AVATARS } else if (genderRoll < 0.9) { // 45% feminine - namePool = FEMININE_NAMES; - avatarPool = FEMININE_AVATARS; + namePool = FEMININE_NAMES + avatarPool = FEMININE_AVATARS } else { // 10% neutral - namePool = GENDER_NEUTRAL_NAMES; - avatarPool = NEUTRAL_AVATARS; + namePool = GENDER_NEUTRAL_NAMES + avatarPool = NEUTRAL_AVATARS } // Pick from the chosen category - name = namePool[Math.floor(Math.random() * namePool.length)]; - avatar = avatarPool[Math.floor(Math.random() * avatarPool.length)]; - comboKey = `${name}-${avatar}`; - } while (usedCombos.has(comboKey) && usedCombos.size < 100); // Prevent infinite loop + name = namePool[Math.floor(Math.random() * namePool.length)] + avatar = avatarPool[Math.floor(Math.random() * avatarPool.length)] + comboKey = `${name}-${avatar}` + } while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop - usedCombos.add(comboKey); + usedCombos.add(comboKey) // Pick random origin and destination stations (must be different) // Destination must be ahead of origin (higher position on track) // 40% chance to start at depot, 60% chance to start at other stations - let originStation: Station; - let destination: Station; + let originStation: Station + let destination: Station if (Math.random() < 0.4 || stations.length < 3) { // Start at depot (first station) - originStation = stations[0]; + originStation = stations[0] // Pick any station ahead as destination - const stationsAhead = stations.slice(1); - destination = - stationsAhead[Math.floor(Math.random() * stationsAhead.length)]; + const stationsAhead = stations.slice(1) + destination = stationsAhead[Math.floor(Math.random() * stationsAhead.length)] } else { // Start at a random non-depot, non-final station - const nonDepotStations = stations.slice(1, -1); // Exclude depot and final station - originStation = - nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]; + const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station + originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)] // Pick a station ahead of origin (higher position) - const stationsAhead = stations.filter( - (s) => s.position > originStation.position, - ); - destination = - stationsAhead[Math.floor(Math.random() * stationsAhead.length)]; + const stationsAhead = stations.filter((s) => s.position > originStation.position) + destination = stationsAhead[Math.floor(Math.random() * stationsAhead.length)] } // 30% chance of urgent - const isUrgent = Math.random() < 0.3; + const isUrgent = Math.random() < 0.3 passengers.push({ id: `passenger-${Date.now()}-${i}`, @@ -222,20 +217,17 @@ export function generatePassengers(stations: Station[]): Passenger[] { isUrgent, isBoarded: false, isDelivered: false, - }); + }) } - return passengers; + return passengers } /** * Check if train is at a station (within 3% tolerance) */ -export function isTrainAtStation( - trainPosition: number, - stationPosition: number, -): boolean { - return Math.abs(trainPosition - stationPosition) < 3; +export function isTrainAtStation(trainPosition: number, stationPosition: number): boolean { + return Math.abs(trainPosition - stationPosition) < 3 } /** @@ -244,23 +236,23 @@ export function isTrainAtStation( export function findBoardablePassengers( passengers: Passenger[], stations: Station[], - trainPosition: number, + trainPosition: number ): Passenger[] { - const boardable: Passenger[] = []; + const boardable: Passenger[] = [] for (const passenger of passengers) { // Skip if already boarded or delivered - if (passenger.isBoarded || passenger.isDelivered) continue; + if (passenger.isBoarded || passenger.isDelivered) continue - const station = stations.find((s) => s.id === passenger.originStationId); - if (!station) continue; + const station = stations.find((s) => s.id === passenger.originStationId) + if (!station) continue if (isTrainAtStation(trainPosition, station.position)) { - boardable.push(passenger); + boardable.push(passenger) } } - return boardable; + return boardable } /** @@ -269,30 +261,28 @@ export function findBoardablePassengers( export function findDeliverablePassengers( passengers: Passenger[], stations: Station[], - trainPosition: number, + trainPosition: number ): Array<{ passenger: Passenger; station: Station; points: number }> { const deliverable: Array<{ - passenger: Passenger; - station: Station; - points: number; - }> = []; + passenger: Passenger + station: Station + points: number + }> = [] for (const passenger of passengers) { // Only check boarded passengers - if (!passenger.isBoarded || passenger.isDelivered) continue; + if (!passenger.isBoarded || passenger.isDelivered) continue - const station = stations.find( - (s) => s.id === passenger.destinationStationId, - ); - if (!station) continue; + const station = stations.find((s) => s.id === passenger.destinationStationId) + if (!station) continue if (isTrainAtStation(trainPosition, station.position)) { - const points = passenger.isUrgent ? 20 : 10; - deliverable.push({ passenger, station, points }); + const points = passenger.isUrgent ? 20 : 10 + deliverable.push({ passenger, station, points }) } } - return deliverable; + return deliverable } /** @@ -301,49 +291,45 @@ export function findDeliverablePassengers( */ export function calculateMaxConcurrentPassengers( passengers: Passenger[], - stations: Station[], + stations: Station[] ): number { // Create events for boarding and delivery interface StationEvent { - position: number; - isBoarding: boolean; // true = board, false = delivery + position: number + isBoarding: boolean // true = board, false = delivery } - const events: StationEvent[] = []; + const events: StationEvent[] = [] for (const passenger of passengers) { - const originStation = stations.find( - (s) => s.id === passenger.originStationId, - ); - const destStation = stations.find( - (s) => s.id === passenger.destinationStationId, - ); + const originStation = stations.find((s) => s.id === passenger.originStationId) + const destStation = stations.find((s) => s.id === passenger.destinationStationId) if (originStation && destStation) { - events.push({ position: originStation.position, isBoarding: true }); - events.push({ position: destStation.position, isBoarding: false }); + events.push({ position: originStation.position, isBoarding: true }) + events.push({ position: destStation.position, isBoarding: false }) } } // Sort events by position, with deliveries before boardings at the same position events.sort((a, b) => { - if (a.position !== b.position) return a.position - b.position; + if (a.position !== b.position) return a.position - b.position // At same position, deliveries happen before boarding - return a.isBoarding ? 1 : -1; - }); + return a.isBoarding ? 1 : -1 + }) // Track current passenger count and maximum - let currentCount = 0; - let maxCount = 0; + let currentCount = 0 + let maxCount = 0 for (const event of events) { if (event.isBoarding) { - currentCount++; - maxCount = Math.max(maxCount, currentCount); + currentCount++ + maxCount = Math.max(maxCount, currentCount) } else { - currentCount--; + currentCount-- } } - return maxCount; + return maxCount } diff --git a/apps/web/src/app/arcade/complement-race/lib/routeThemes.ts b/apps/web/src/app/arcade/complement-race/lib/routeThemes.ts index 5fef3bb8..82524787 100644 --- a/apps/web/src/app/arcade/complement-race/lib/routeThemes.ts +++ b/apps/web/src/app/arcade/complement-race/lib/routeThemes.ts @@ -4,26 +4,26 @@ */ export const ROUTE_THEMES = [ - { name: "Prairie Express", emoji: "🌾" }, - { name: "Mountain Climb", emoji: "⛰️" }, - { name: "Coastal Run", emoji: "🌊" }, - { name: "Desert Crossing", emoji: "🏜️" }, - { name: "Forest Trail", emoji: "🌲" }, - { name: "Canyon Route", emoji: "🏞️" }, - { name: "River Valley", emoji: "🏞️" }, - { name: "Highland Pass", emoji: "🗻" }, - { name: "Lakeside Journey", emoji: "🏔️" }, - { name: "Grand Circuit", emoji: "🎪" }, -]; + { name: 'Prairie Express', emoji: '🌾' }, + { name: 'Mountain Climb', emoji: '⛰️' }, + { name: 'Coastal Run', emoji: '🌊' }, + { name: 'Desert Crossing', emoji: '🏜️' }, + { name: 'Forest Trail', emoji: '🌲' }, + { name: 'Canyon Route', emoji: '🏞️' }, + { name: 'River Valley', emoji: '🏞️' }, + { name: 'Highland Pass', emoji: '🗻' }, + { name: 'Lakeside Journey', emoji: '🏔️' }, + { name: 'Grand Circuit', emoji: '🎪' }, +] /** * Get route theme for a given route number * Cycles through themes if route number exceeds available themes */ export function getRouteTheme(routeNumber: number): { - name: string; - emoji: string; + name: string + emoji: string } { - const index = (routeNumber - 1) % ROUTE_THEMES.length; - return ROUTE_THEMES[index]; + const index = (routeNumber - 1) % ROUTE_THEMES.length + return ROUTE_THEMES[index] } diff --git a/apps/web/src/app/arcade/complement-race/page.tsx b/apps/web/src/app/arcade/complement-race/page.tsx index b0d5d6a8..3aef2b81 100644 --- a/apps/web/src/app/arcade/complement-race/page.tsx +++ b/apps/web/src/app/arcade/complement-race/page.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { PageWithNav } from "@/components/PageWithNav"; -import { ComplementRaceGame } from "./components/ComplementRaceGame"; -import { ComplementRaceProvider } from "@/arcade-games/complement-race/Provider"; +import { PageWithNav } from '@/components/PageWithNav' +import { ComplementRaceGame } from './components/ComplementRaceGame' +import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider' export default function ComplementRacePage() { return ( @@ -11,5 +11,5 @@ export default function ComplementRacePage() { - ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/practice/page.tsx b/apps/web/src/app/arcade/complement-race/practice/page.tsx index 245b35dd..9398a986 100644 --- a/apps/web/src/app/arcade/complement-race/practice/page.tsx +++ b/apps/web/src/app/arcade/complement-race/practice/page.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { PageWithNav } from "@/components/PageWithNav"; -import { ComplementRaceGame } from "../components/ComplementRaceGame"; -import { ComplementRaceProvider } from "@/arcade-games/complement-race/Provider"; +import { PageWithNav } from '@/components/PageWithNav' +import { ComplementRaceGame } from '../components/ComplementRaceGame' +import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider' export default function PracticeModePage() { return ( @@ -11,5 +11,5 @@ export default function PracticeModePage() { - ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/sprint/page.tsx b/apps/web/src/app/arcade/complement-race/sprint/page.tsx index 6d805162..a7f40dd6 100644 --- a/apps/web/src/app/arcade/complement-race/sprint/page.tsx +++ b/apps/web/src/app/arcade/complement-race/sprint/page.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { PageWithNav } from "@/components/PageWithNav"; -import { ComplementRaceGame } from "../components/ComplementRaceGame"; -import { ComplementRaceProvider } from "@/arcade-games/complement-race/Provider"; +import { PageWithNav } from '@/components/PageWithNav' +import { ComplementRaceGame } from '../components/ComplementRaceGame' +import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider' export default function SprintModePage() { return ( @@ -11,5 +11,5 @@ export default function SprintModePage() { - ); + ) } diff --git a/apps/web/src/app/arcade/complement-race/survival/page.tsx b/apps/web/src/app/arcade/complement-race/survival/page.tsx index 6ed2828e..6ba300c3 100644 --- a/apps/web/src/app/arcade/complement-race/survival/page.tsx +++ b/apps/web/src/app/arcade/complement-race/survival/page.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { PageWithNav } from "@/components/PageWithNav"; -import { ComplementRaceGame } from "../components/ComplementRaceGame"; -import { ComplementRaceProvider } from "@/arcade-games/complement-race/Provider"; +import { PageWithNav } from '@/components/PageWithNav' +import { ComplementRaceGame } from '../components/ComplementRaceGame' +import { ComplementRaceProvider } from '@/arcade-games/complement-race/Provider' export default function SurvivalModePage() { return ( @@ -11,5 +11,5 @@ export default function SurvivalModePage() { - ); + ) } diff --git a/apps/web/src/app/arcade/memory-quiz/page.tsx b/apps/web/src/app/arcade/memory-quiz/page.tsx index 22538934..54675149 100644 --- a/apps/web/src/app/arcade/memory-quiz/page.tsx +++ b/apps/web/src/app/arcade/memory-quiz/page.tsx @@ -1,7 +1,7 @@ -"use client"; +'use client' -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' /** * Memory Quiz redirect page @@ -15,50 +15,50 @@ import { useRouter } from "next/navigation"; * 3. Play in multiplayer or solo (single-player room) */ export default function MemoryQuizRedirectPage() { - const router = useRouter(); + const router = useRouter() useEffect(() => { // Redirect to arcade - router.replace("/arcade"); - }, [router]); + router.replace('/arcade') + }, [router]) return (
🧠

Redirecting to Champion Arena...

Memory Lightning is now part of the Champion Arena. @@ -66,5 +66,5 @@ export default function MemoryQuizRedirectPage() { You'll be able to play solo or with friends in multiplayer mode!

- ); + ) } diff --git a/apps/web/src/app/arcade/page.tsx b/apps/web/src/app/arcade/page.tsx index 50b7b19f..be929a0f 100644 --- a/apps/web/src/app/arcade/page.tsx +++ b/apps/web/src/app/arcade/page.tsx @@ -1,18 +1,14 @@ -"use client"; +'use client' -import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; -import { - useRoomData, - useSetRoomGame, - useCreateRoom, -} from "@/hooks/useRoomData"; -import { useViewerId } from "@/hooks/useViewerId"; -import { GAMES_CONFIG } from "@/components/GameSelector"; -import type { GameType } from "@/components/GameSelector"; -import { PageWithNav } from "@/components/PageWithNav"; -import { css } from "../../../styled-system/css"; -import { getAllGames, getGame, hasGame } from "@/lib/arcade/game-registry"; +import { useRouter } from 'next/navigation' +import { useState, useEffect } from 'react' +import { useRoomData, useSetRoomGame, useCreateRoom } from '@/hooks/useRoomData' +import { useViewerId } from '@/hooks/useViewerId' +import { GAMES_CONFIG } from '@/components/GameSelector' +import type { GameType } from '@/components/GameSelector' +import { PageWithNav } from '@/components/PageWithNav' +import { css } from '../../../styled-system/css' +import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry' /** * /arcade - Renders the game for the user's current room @@ -30,12 +26,12 @@ import { getAllGames, getGame, hasGame } from "@/lib/arcade/game-registry"; * Test: Verifying compose-updater automatic deployment cycle */ export default function RoomPage() { - const router = useRouter(); - const { roomData, isLoading } = useRoomData(); - const { data: viewerId } = useViewerId(); - const { mutate: setRoomGame } = useSetRoomGame(); - const { mutate: createRoom, isPending: isCreatingRoom } = useCreateRoom(); - const [permissionError, setPermissionError] = useState(null); + const router = useRouter() + const { roomData, isLoading } = useRoomData() + const { data: viewerId } = useViewerId() + const { mutate: setRoomGame } = useSetRoomGame() + const { mutate: createRoom, isPending: isCreatingRoom } = useCreateRoom() + const [permissionError, setPermissionError] = useState(null) // Auto-create room when user has no room // This happens when: @@ -43,46 +39,43 @@ export default function RoomPage() { // 2. After leaving a room useEffect(() => { if (!isLoading && !roomData && viewerId && !isCreatingRoom) { - console.log( - "[RoomPage] No room found, auto-creating room for user:", - viewerId, - ); + console.log('[RoomPage] No room found, auto-creating room for user:', viewerId) createRoom( { - name: "My Room", + 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 + accessMode: 'open' as const, // Open by default - user can change settings later }, { onSuccess: (newRoom) => { - console.log("[RoomPage] Successfully created room:", newRoom.id); + console.log('[RoomPage] Successfully created room:', newRoom.id) }, onError: (error) => { - console.error("[RoomPage] Failed to auto-create room:", error); + console.error('[RoomPage] Failed to auto-create room:', error) }, - }, - ); + } + ) } - }, [isLoading, roomData, viewerId, isCreatingRoom, createRoom]); + }, [isLoading, roomData, viewerId, isCreatingRoom, createRoom]) // Show loading state (includes both initial load and room creation) if (isLoading || isCreatingRoom) { return (
- {isCreatingRoom ? "Creating solo room..." : "Loading room..."} + {isCreatingRoom ? 'Creating solo room...' : 'Loading room...'}
- ); + ) } // If still no room after loading and creation attempt, show fallback @@ -91,61 +84,54 @@ export default function RoomPage() { return (
Unable to create room
-
- Please try refreshing the page -
+
Please try refreshing the page
- ); + ) } // Show game selection if no game is set if (!roomData.gameName) { // Determine if current user is the host - const currentMember = roomData.members.find((m) => m.userId === viewerId); - const isHost = currentMember?.isCreator === true; - const hostMember = roomData.members.find((m) => m.isCreator); + const currentMember = roomData.members.find((m) => m.userId === viewerId) + const isHost = currentMember?.isCreator === true + const hostMember = roomData.members.find((m) => m.isCreator) const handleGameSelect = (gameType: GameType) => { - console.log( - "[RoomPage] handleGameSelect called with gameType:", - gameType, - ); + console.log('[RoomPage] handleGameSelect called with gameType:', gameType) // Check if user is host before allowing selection if (!isHost) { setPermissionError( - `Only the room host can select a game. Ask ${hostMember?.displayName || "the host"} to choose.`, - ); + `Only the room host can select a game. Ask ${hostMember?.displayName || 'the host'} to choose.` + ) // Clear error after 5 seconds - setTimeout(() => setPermissionError(null), 5000); - return; + setTimeout(() => setPermissionError(null), 5000) + return } // Clear any previous errors - setPermissionError(null); + setPermissionError(null) // All games are now in the registry if (hasGame(gameType)) { - const gameDef = getGame(gameType); + const gameDef = getGame(gameType) if (!gameDef?.manifest.available) { - console.log( - "[RoomPage] Registry game not available, blocking selection", - ); - return; + console.log('[RoomPage] Registry game not available, blocking selection') + return } - console.log("[RoomPage] Selecting registry game:", gameType); + console.log('[RoomPage] Selecting registry game:', gameType) setRoomGame( { roomId: roomData.id, @@ -153,47 +139,45 @@ export default function RoomPage() { }, { onError: (error: any) => { - console.error("[RoomPage] Failed to set game:", error); + console.error('[RoomPage] Failed to set game:', error) setPermissionError( - error.message || - "Failed to select game. Only the host can change games.", - ); - setTimeout(() => setPermissionError(null), 5000); + error.message || 'Failed to select game. Only the host can change games.' + ) + setTimeout(() => setPermissionError(null), 5000) }, - }, - ); - return; + } + ) + return } - console.log("[RoomPage] Unknown game type:", gameType); - }; + console.log('[RoomPage] Unknown game type:', gameType) + } return ( router.push("/arcade")} + onExitSession={() => router.push('/arcade')} >

Choose a Game @@ -202,21 +186,21 @@ export default function RoomPage() { {/* Host info and permission messaging */}
{isHost ? (
👑 You're the room host. Select a game to start playing. @@ -224,17 +208,16 @@ export default function RoomPage() { ) : (
- ⏳ Waiting for {hostMember?.displayName || "the host"} to select - a game... + ⏳ Waiting for {hostMember?.displayName || 'the host'} to select a game...
)} @@ -242,14 +225,14 @@ export default function RoomPage() { {permissionError && (
⚠️ {permissionError} @@ -259,76 +242,73 @@ export default function RoomPage() {
{/* Legacy games */} - {Object.entries(GAMES_CONFIG).map( - ([gameType, config]: [string, any]) => { - const isAvailable = - !("available" in config) || config.available !== false; - const isDisabled = !isHost || !isAvailable; - return ( - - ); - }, - )} + {config.icon} +
+

+ {config.name} +

+

+ {config.description} +

+ + ) + })} {/* Registry games */} {getAllGames().map((gameDef) => { - const isAvailable = gameDef.manifest.available; - const isDisabled = !isHost || !isAvailable; + const isAvailable = gameDef.manifest.available + const isDisabled = !isHost || !isAvailable return ( - ); + ) })}
- ); + ) } // Check if this is a registry game first if (hasGame(roomData.gameName)) { - const gameDef = getGame(roomData.gameName); + const gameDef = getGame(roomData.gameName) if (!gameDef) { return ( router.push("/arcade")} + onExitSession={() => router.push('/arcade')} >
Game "{roomData.gameName}" not found in registry
- ); + ) } // Render registry game dynamically - const { Provider, GameComponent } = gameDef; + const { Provider, GameComponent } = gameDef return ( - ); + ) } // Render legacy games based on room's gameName @@ -433,21 +413,21 @@ export default function RoomPage() { navTitle="Game Not Available" navEmoji="⚠️" emphasizePlayerSelection={true} - onExitSession={() => router.push("/arcade")} + onExitSession={() => router.push('/arcade')} >
Game "{roomData.gameName}" not yet supported
- ); + ) } } diff --git a/apps/web/src/app/arcade/rithmomachia/guide/page.tsx b/apps/web/src/app/arcade/rithmomachia/guide/page.tsx index 5bad181d..1a854b21 100644 --- a/apps/web/src/app/arcade/rithmomachia/guide/page.tsx +++ b/apps/web/src/app/arcade/rithmomachia/guide/page.tsx @@ -1,26 +1,22 @@ -"use client"; +'use client' -import { useState } from "react"; -import { PlayingGuideModal } from "@/arcade-games/rithmomachia/components/PlayingGuideModal"; +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); + const [isOpen] = useState(true) return (
- window.close()} - standalone={true} - /> + window.close()} standalone={true} />
- ); + ) } diff --git a/apps/web/src/app/arcade/rithmomachia/page.tsx b/apps/web/src/app/arcade/rithmomachia/page.tsx index 40f6cf9e..7cf602ff 100644 --- a/apps/web/src/app/arcade/rithmomachia/page.tsx +++ b/apps/web/src/app/arcade/rithmomachia/page.tsx @@ -1,16 +1,16 @@ -"use client"; +'use client' -import { rithmomachiaGame } from "@/arcade-games/rithmomachia"; +import { rithmomachiaGame } from '@/arcade-games/rithmomachia' // Force dynamic rendering to avoid build-time initialization errors -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic' -const { Provider, GameComponent } = rithmomachiaGame; +const { Provider, GameComponent } = rithmomachiaGame export default function RithmomachiaPage() { return ( - ); + ) } diff --git a/apps/web/src/app/auto-instruction-demo/page.tsx b/apps/web/src/app/auto-instruction-demo/page.tsx index 99fb2a72..7987f793 100644 --- a/apps/web/src/app/auto-instruction-demo/page.tsx +++ b/apps/web/src/app/auto-instruction-demo/page.tsx @@ -1,17 +1,17 @@ -"use client"; +'use client' -import { AutoInstructionDemo } from "../../components/tutorial/AutoInstructionDemo"; +import { AutoInstructionDemo } from '../../components/tutorial/AutoInstructionDemo' export default function AutoInstructionDemoPage() { return (
- ); + ) } diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index b8c35459..0fab0094 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -1,27 +1,27 @@ -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import Link from "next/link"; -import { getPostBySlug, getAllPostSlugs } from "@/lib/blog"; -import { css } from "../../../../styled-system/css"; +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { getPostBySlug, getAllPostSlugs } from '@/lib/blog' +import { css } from '../../../../styled-system/css' interface Props { params: { - slug: string; - }; + slug: string + } } // Generate static params for all blog posts export async function generateStaticParams() { - const slugs = getAllPostSlugs(); - return slugs.map((slug) => ({ slug })); + const slugs = getAllPostSlugs() + return slugs.map((slug) => ({ slug })) } // Generate metadata for SEO export async function generateMetadata({ params }: Props): Promise { - const post = await getPostBySlug(params.slug); + const post = await getPostBySlug(params.slug) - const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://abaci.one"; - const postUrl = `${siteUrl}/blog/${params.slug}`; + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://abaci.one' + const postUrl = `${siteUrl}/blog/${params.slug}` return { title: `${post.title} | Abaci.one Blog`, @@ -31,95 +31,95 @@ export async function generateMetadata({ params }: Props): Promise { title: post.title, description: post.description, url: postUrl, - siteName: "Abaci.one", - type: "article", + siteName: 'Abaci.one', + type: 'article', publishedTime: post.publishedAt, modifiedTime: post.updatedAt, authors: [post.author], tags: post.tags, }, twitter: { - card: "summary_large_image", + card: 'summary_large_image', title: post.title, description: post.description, }, alternates: { canonical: postUrl, }, - }; + } } export default async function BlogPost({ params }: Props) { - let post; + let post try { - post = await getPostBySlug(params.slug); + post = await getPostBySlug(params.slug) } catch { - notFound(); + notFound() } // Format date for display - const publishedDate = new Date(post.publishedAt).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); + const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) - const updatedDate = new Date(post.updatedAt).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); + const updatedDate = new Date(post.updatedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) - const showUpdatedDate = post.publishedAt !== post.updatedAt; + const showUpdatedDate = post.publishedAt !== post.updatedAt return (
{/* Background pattern */}
{/* Back link */} @@ -132,19 +132,19 @@ export default async function BlogPost({ params }: Props) {

{post.title} @@ -152,10 +152,10 @@ export default async function BlogPost({ params }: Props) {

{post.description} @@ -164,12 +164,12 @@ export default async function BlogPost({ params }: Props) {

{post.author} @@ -187,23 +187,23 @@ export default async function BlogPost({ params }: Props) {
{post.tags.map((tag) => ( {tag} @@ -217,123 +217,123 @@ export default async function BlogPost({ params }: Props) {
- ); + ) } diff --git a/apps/web/src/app/blog/layout.tsx b/apps/web/src/app/blog/layout.tsx index a3c5d162..5f889243 100644 --- a/apps/web/src/app/blog/layout.tsx +++ b/apps/web/src/app/blog/layout.tsx @@ -1,16 +1,12 @@ -"use client"; +'use client' -import { AppNavBar } from "@/components/AppNavBar"; +import { AppNavBar } from '@/components/AppNavBar' -export default function BlogLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function BlogLayout({ children }: { children: React.ReactNode }) { return ( <> {children} - ); + ) } diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index e668fa14..db43071b 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -1,90 +1,89 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { getAllPostsMetadata, getFeaturedPosts } from "@/lib/blog"; -import { css } from "../../../styled-system/css"; +import type { Metadata } from 'next' +import Link from 'next/link' +import { getAllPostsMetadata, getFeaturedPosts } from '@/lib/blog' +import { css } from '../../../styled-system/css' export const metadata: Metadata = { - title: "Blog | Abaci.one", + title: 'Blog | Abaci.one', description: - "Articles about educational technology, pedagogy, and innovative approaches to learning with the abacus.", + 'Articles about educational technology, pedagogy, and innovative approaches to learning with the abacus.', openGraph: { - title: "Abaci.one Blog", + title: 'Abaci.one Blog', description: - "Articles about educational technology, pedagogy, and innovative approaches to learning with the abacus.", - url: `${process.env.NEXT_PUBLIC_SITE_URL || "https://abaci.one"}/blog`, - siteName: "Abaci.one", - type: "website", + 'Articles about educational technology, pedagogy, and innovative approaches to learning with the abacus.', + url: `${process.env.NEXT_PUBLIC_SITE_URL || 'https://abaci.one'}/blog`, + siteName: 'Abaci.one', + type: 'website', }, -}; +} export default async function BlogIndex() { - const featuredPosts = await getFeaturedPosts(); - const allPosts = await getAllPostsMetadata(); + const featuredPosts = await getFeaturedPosts() + const allPosts = await getAllPostsMetadata() return (
{/* Background pattern */}
{/* Page Header */}

Blog

- Exploring educational technology, pedagogy, and innovative - approaches to learning. + Exploring educational technology, pedagogy, and innovative approaches to learning.

@@ -93,37 +92,35 @@ export default async function BlogIndex() {

Featured

{featuredPosts.map((post) => { - const publishedDate = new Date( - post.publishedAt, - ).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); + const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) return (

{post.title}

{post.excerpt || post.description}

{post.author} @@ -183,23 +180,23 @@ export default async function BlogIndex() { {post.tags.length > 0 && (
{post.tags.slice(0, 3).map((tag) => ( {tag} @@ -208,7 +205,7 @@ export default async function BlogIndex() {
)} - ); + ) })}
@@ -218,40 +215,38 @@ export default async function BlogIndex() {

All Posts

{allPosts.map((post) => { - const publishedDate = new Date( - post.publishedAt, - ).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); + const publishedDate = new Date(post.publishedAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) return (
@@ -259,21 +254,21 @@ export default async function BlogIndex() { href={`/blog/${post.slug}`} data-action="view-post" className={css({ - display: "block", + display: 'block', _hover: { - "& h3": { - color: "accent.emphasis", + '& h3': { + color: 'accent.emphasis', }, }, })} >

{post.title} @@ -282,13 +277,13 @@ export default async function BlogIndex() {
{post.author} @@ -298,9 +293,9 @@ export default async function BlogIndex() {

{post.excerpt || post.description} @@ -309,22 +304,22 @@ export default async function BlogIndex() { {post.tags.length > 0 && (

{post.tags.map((tag) => ( {tag} @@ -333,11 +328,11 @@ export default async function BlogIndex() {
)}

- ); + ) })}
- ); + ) } diff --git a/apps/web/src/app/create/calendar/components/CalendarConfigPanel.tsx b/apps/web/src/app/create/calendar/components/CalendarConfigPanel.tsx index 6f1541fc..1f2a3419 100644 --- a/apps/web/src/app/create/calendar/components/CalendarConfigPanel.tsx +++ b/apps/web/src/app/create/calendar/components/CalendarConfigPanel.tsx @@ -1,21 +1,21 @@ -"use client"; +'use client' -import { useTranslations } from "next-intl"; -import { css } from "../../../../../styled-system/css"; -import { AbacusReact, useAbacusConfig } from "@soroban/abacus-react"; -import { AbacusDisplayDropdown } from "@/components/AbacusDisplayDropdown"; +import { useTranslations } from 'next-intl' +import { css } from '../../../../../styled-system/css' +import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react' +import { AbacusDisplayDropdown } from '@/components/AbacusDisplayDropdown' interface CalendarConfigPanelProps { - month: number; - year: number; - format: "monthly" | "daily"; - paperSize: "us-letter" | "a4" | "a3" | "tabloid"; - isGenerating: boolean; - onMonthChange: (month: number) => void; - onYearChange: (year: number) => void; - onFormatChange: (format: "monthly" | "daily") => void; - onPaperSizeChange: (size: "us-letter" | "a4" | "a3" | "tabloid") => void; - onGenerate: () => void; + month: number + year: number + format: 'monthly' | 'daily' + paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid' + isGenerating: boolean + onMonthChange: (month: number) => void + onYearChange: (year: number) => void + onFormatChange: (format: 'monthly' | 'daily') => void + onPaperSizeChange: (size: 'us-letter' | 'a4' | 'a3' | 'tabloid') => void + onGenerate: () => void } export function CalendarConfigPanel({ @@ -30,109 +30,105 @@ export function CalendarConfigPanel({ onPaperSizeChange, onGenerate, }: CalendarConfigPanelProps) { - const t = useTranslations("calendar"); - const abacusConfig = useAbacusConfig(); + const t = useTranslations('calendar') + const abacusConfig = useAbacusConfig() const MONTHS = [ - t("months.january"), - t("months.february"), - t("months.march"), - t("months.april"), - t("months.may"), - t("months.june"), - t("months.july"), - t("months.august"), - t("months.september"), - t("months.october"), - t("months.november"), - t("months.december"), - ]; + t('months.january'), + t('months.february'), + t('months.march'), + t('months.april'), + t('months.may'), + t('months.june'), + t('months.july'), + t('months.august'), + t('months.september'), + t('months.october'), + t('months.november'), + t('months.december'), + ] return (
{/* Format Selection */}
- {t("format.title")} + {t('format.title')}
@@ -141,25 +137,25 @@ export function CalendarConfigPanel({
- {t("date.title")} + {t('date.title')}
- onPaperSizeChange( - e.target.value as "us-letter" | "a4" | "a3" | "tabloid", - ) + onPaperSizeChange(e.target.value as 'us-letter' | 'a4' | 'a3' | 'tabloid') } className={css({ - width: "100%", - padding: "0.5rem", - borderRadius: "6px", - bg: "bg.subtle", - color: "text.primary", - border: "1px solid", - borderColor: "border.default", - cursor: "pointer", - _hover: { borderColor: "border.emphasized" }, + width: '100%', + padding: '0.5rem', + borderRadius: '6px', + bg: 'bg.subtle', + color: 'text.primary', + border: '1px solid', + borderColor: 'border.default', + cursor: 'pointer', + _hover: { borderColor: 'border.emphasized' }, })} > - - - - + + + +
@@ -255,25 +249,25 @@ export function CalendarConfigPanel({

- {t("styling.preview")} + {t('styling.preview')}

@@ -301,27 +295,27 @@ export function CalendarConfigPanel({ onClick={onGenerate} disabled={isGenerating} className={css({ - padding: "1rem", - bg: "accent.default", - color: "accent.fg", - fontWeight: "600", - fontSize: "1.125rem", - borderRadius: "8px", - border: "none", - cursor: "pointer", - transition: "all 0.2s", + padding: '1rem', + bg: 'accent.default', + color: 'accent.fg', + fontWeight: '600', + fontSize: '1.125rem', + borderRadius: '8px', + border: 'none', + cursor: 'pointer', + transition: 'all 0.2s', _hover: { - bg: "accent.emphasis", + bg: 'accent.emphasis', }, _disabled: { - bg: "bg.disabled", - color: "text.disabled", - cursor: "not-allowed", + bg: 'bg.disabled', + color: 'text.disabled', + cursor: 'not-allowed', }, })} > - {isGenerating ? t("generate.generating") : t("generate.button")} + {isGenerating ? t('generate.generating') : t('generate.button')}
- ); + ) } diff --git a/apps/web/src/app/create/calendar/components/CalendarPreview.tsx b/apps/web/src/app/create/calendar/components/CalendarPreview.tsx index 19afb8c7..f5718aff 100644 --- a/apps/web/src/app/create/calendar/components/CalendarPreview.tsx +++ b/apps/web/src/app/create/calendar/components/CalendarPreview.tsx @@ -1,54 +1,47 @@ -"use client"; +'use client' -import { useQuery } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; -import { css } from "../../../../../styled-system/css"; +import { useQuery } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' +import { css } from '../../../../../styled-system/css' interface CalendarPreviewProps { - month: number; - year: number; - format: "monthly" | "daily"; - previewSvg: string | null; + month: number + year: number + format: 'monthly' | 'daily' + previewSvg: string | null } async function fetchTypstPreview( month: number, year: number, - format: string, + format: string ): Promise { - const response = await fetch("/api/create/calendar/preview", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/create/calendar/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ month, year, format }), - }); + }) if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || errorData.message || "Failed to fetch preview", - ); + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || errorData.message || 'Failed to fetch preview') } - const data = await response.json(); - return data.svg; + const data = await response.json() + return data.svg } -export function CalendarPreview({ - month, - year, - format, - previewSvg, -}: CalendarPreviewProps) { - const t = useTranslations("calendar"); +export function CalendarPreview({ month, year, format, previewSvg }: CalendarPreviewProps) { + const t = useTranslations('calendar') // Use React Query to fetch Typst-generated preview (client-side only) const { data: typstPreviewSvg, isLoading } = useQuery({ - queryKey: ["calendar-typst-preview", month, year, format], + queryKey: ['calendar-typst-preview', month, year, format], queryFn: () => fetchTypstPreview(month, year, format), - enabled: typeof window !== "undefined", // Run on client for both formats - }); + enabled: typeof window !== 'undefined', // Run on client for both formats + }) // Use generated PDF SVG if available, otherwise use Typst live preview - const displaySvg = previewSvg || typstPreviewSvg; + const displaySvg = previewSvg || typstPreviewSvg // Show loading state while fetching preview if (isLoading || !displaySvg) { @@ -56,66 +49,66 @@ export function CalendarPreview({

- {isLoading ? t("preview.loading") : t("preview.noPreview")} + {isLoading ? t('preview.loading') : t('preview.noPreview')}

- ); + ) } return (

{previewSvg - ? t("preview.generatedPdf") - : format === "daily" - ? t("preview.livePreviewFirstDay") - : t("preview.livePreview")} + ? t('preview.generatedPdf') + : format === 'daily' + ? t('preview.livePreviewFirstDay') + : t('preview.livePreview')}

- ); + ) } diff --git a/apps/web/src/app/create/calendar/page.tsx b/apps/web/src/app/create/calendar/page.tsx index acc011e2..14cfdd68 100644 --- a/apps/web/src/app/create/calendar/page.tsx +++ b/apps/web/src/app/create/calendar/page.tsx @@ -1,48 +1,44 @@ -"use client"; +'use client' -import { useState, useEffect } from "react"; -import { useTranslations } from "next-intl"; -import { css } from "../../../../styled-system/css"; -import { useAbacusConfig } from "@soroban/abacus-react"; -import { PageWithNav } from "@/components/PageWithNav"; -import { CalendarConfigPanel } from "./components/CalendarConfigPanel"; -import { CalendarPreview } from "./components/CalendarPreview"; +import { useState, useEffect } from 'react' +import { useTranslations } from 'next-intl' +import { css } from '../../../../styled-system/css' +import { useAbacusConfig } from '@soroban/abacus-react' +import { PageWithNav } from '@/components/PageWithNav' +import { CalendarConfigPanel } from './components/CalendarConfigPanel' +import { CalendarPreview } from './components/CalendarPreview' export default function CalendarCreatorPage() { - const t = useTranslations("calendar"); - const currentDate = new Date(); - const abacusConfig = useAbacusConfig(); - const [month, setMonth] = useState(currentDate.getMonth() + 1); // 1-12 - const [year, setYear] = useState(currentDate.getFullYear()); - const [format, setFormat] = useState<"monthly" | "daily">("monthly"); - const [paperSize, setPaperSize] = useState< - "us-letter" | "a4" | "a3" | "tabloid" - >("us-letter"); - const [isGenerating, setIsGenerating] = useState(false); - const [previewSvg, setPreviewSvg] = useState(null); + const t = useTranslations('calendar') + const currentDate = new Date() + const abacusConfig = useAbacusConfig() + const [month, setMonth] = useState(currentDate.getMonth() + 1) // 1-12 + const [year, setYear] = useState(currentDate.getFullYear()) + const [format, setFormat] = useState<'monthly' | 'daily'>('monthly') + const [paperSize, setPaperSize] = useState<'us-letter' | 'a4' | 'a3' | 'tabloid'>('us-letter') + const [isGenerating, setIsGenerating] = useState(false) + const [previewSvg, setPreviewSvg] = useState(null) // Detect default paper size based on user's locale (client-side only) useEffect(() => { // Get user's locale - const locale = navigator.language || navigator.languages?.[0] || "en-US"; - const country = locale.split("-")[1]?.toUpperCase(); + const locale = navigator.language || navigator.languages?.[0] || 'en-US' + const country = locale.split('-')[1]?.toUpperCase() // Countries that use US Letter (8.5" × 11") - const letterCountries = ["US", "CA", "MX", "GT", "PA", "DO", "PR", "PH"]; + const letterCountries = ['US', 'CA', 'MX', 'GT', 'PA', 'DO', 'PR', 'PH'] - const detectedSize = letterCountries.includes(country || "") - ? "us-letter" - : "a4"; - setPaperSize(detectedSize); - }, []); + const detectedSize = letterCountries.includes(country || '') ? 'us-letter' : 'a4' + setPaperSize(detectedSize) + }, []) const handleGenerate = async () => { - setIsGenerating(true); + setIsGenerating(true) try { - const response = await fetch("/api/create/calendar/generate", { - method: "POST", + const response = await fetch('/api/create/calendar/generate', { + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ month, @@ -51,87 +47,87 @@ export default function CalendarCreatorPage() { paperSize, abacusConfig, }), - }); + }) if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || "Failed to generate calendar"); + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || 'Failed to generate calendar') } - const data = await response.json(); + const data = await response.json() // Convert base64 PDF to blob and trigger download - const pdfBytes = Uint8Array.from(atob(data.pdf), (c) => c.charCodeAt(0)); - const blob = new Blob([pdfBytes], { type: "application/pdf" }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = data.filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); + const pdfBytes = Uint8Array.from(atob(data.pdf), (c) => c.charCodeAt(0)) + const blob = new Blob([pdfBytes], { type: 'application/pdf' }) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = data.filename + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) } catch (error) { - console.error("Error generating calendar:", error); + console.error('Error generating calendar:', error) alert( - `Failed to generate calendar: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + `Failed to generate calendar: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } finally { - setIsGenerating(false); + setIsGenerating(false) } - }; + } return (
{/* Header */}

- {t("pageTitle")} + {t('pageTitle')}

- {t("pageSubtitle")} + {t('pageSubtitle')}

{/* Main Content */}
{/* Configuration Panel */} @@ -149,15 +145,10 @@ export default function CalendarCreatorPage() { /> {/* Preview */} - +
- ); + ) } diff --git a/apps/web/src/app/create/flashcards/page.tsx b/apps/web/src/app/create/flashcards/page.tsx index 636570df..1cedb7c2 100644 --- a/apps/web/src/app/create/flashcards/page.tsx +++ b/apps/web/src/app/create/flashcards/page.tsx @@ -1,139 +1,131 @@ -"use client"; +'use client' -import { useAbacusConfig } from "@soroban/abacus-react"; -import { useForm } from "@tanstack/react-form"; -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { ConfigurationFormWithoutGenerate } from "@/components/ConfigurationFormWithoutGenerate"; -import { GenerationProgress } from "@/components/GenerationProgress"; -import { FlashcardPreview } from "@/components/FlashcardPreview"; -import { PageWithNav } from "@/components/PageWithNav"; -import { StyleControls } from "@/components/StyleControls"; -import { css } from "../../../../styled-system/css"; -import { - container, - grid, - hstack, - stack, -} from "../../../../styled-system/patterns"; +import { useAbacusConfig } from '@soroban/abacus-react' +import { useForm } from '@tanstack/react-form' +import { useTranslations } from 'next-intl' +import { useState } from 'react' +import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationFormWithoutGenerate' +import { GenerationProgress } from '@/components/GenerationProgress' +import { FlashcardPreview } from '@/components/FlashcardPreview' +import { PageWithNav } from '@/components/PageWithNav' +import { StyleControls } from '@/components/StyleControls' +import { css } from '../../../../styled-system/css' +import { container, grid, hstack, stack } from '../../../../styled-system/patterns' // Complete, validated configuration ready for generation export interface FlashcardConfig { - range: string; - step?: number; - cardsPerPage?: number; - paperSize?: "us-letter" | "a4" | "a3" | "a5"; - orientation?: "portrait" | "landscape"; + range: string + step?: number + cardsPerPage?: number + paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5' + orientation?: 'portrait' | 'landscape' margins?: { - top?: string; - bottom?: string; - left?: string; - right?: string; - }; - gutter?: string; - shuffle?: boolean; - seed?: number; - showCutMarks?: boolean; - showRegistration?: boolean; - fontFamily?: string; - fontSize?: string; - columns?: string | number; - showEmptyColumns?: boolean; - hideInactiveBeads?: boolean; - beadShape?: "diamond" | "circle" | "square"; - colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating"; - colorPalette?: "default" | "pastel" | "vibrant" | "earth-tones"; - coloredNumerals?: boolean; - scaleFactor?: number; - format?: "pdf" | "html" | "png" | "svg"; + top?: string + bottom?: string + left?: string + right?: string + } + gutter?: string + shuffle?: boolean + seed?: number + showCutMarks?: boolean + showRegistration?: boolean + fontFamily?: string + fontSize?: string + columns?: string | number + showEmptyColumns?: boolean + hideInactiveBeads?: boolean + beadShape?: 'diamond' | 'circle' | 'square' + colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating' + colorPalette?: 'default' | 'pastel' | 'vibrant' | 'earth-tones' + coloredNumerals?: boolean + scaleFactor?: number + format?: 'pdf' | 'html' | 'png' | 'svg' } // Partial form state during editing (may have undefined values) export interface FlashcardFormState { - range?: string; - step?: number; - cardsPerPage?: number; - paperSize?: "us-letter" | "a4" | "a3" | "a5"; - orientation?: "portrait" | "landscape"; + range?: string + step?: number + cardsPerPage?: number + paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5' + orientation?: 'portrait' | 'landscape' margins?: { - top?: string; - bottom?: string; - left?: string; - right?: string; - }; - gutter?: string; - shuffle?: boolean; - seed?: number; - showCutMarks?: boolean; - showRegistration?: boolean; - fontFamily?: string; - fontSize?: string; - columns?: string | number; - showEmptyColumns?: boolean; - hideInactiveBeads?: boolean; - beadShape?: "diamond" | "circle" | "square"; - colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating"; - colorPalette?: "default" | "pastel" | "vibrant" | "earth-tones"; - coloredNumerals?: boolean; - scaleFactor?: number; - format?: "pdf" | "html" | "png" | "svg"; + top?: string + bottom?: string + left?: string + right?: string + } + gutter?: string + shuffle?: boolean + seed?: number + showCutMarks?: boolean + showRegistration?: boolean + fontFamily?: string + fontSize?: string + columns?: string | number + showEmptyColumns?: boolean + hideInactiveBeads?: boolean + beadShape?: 'diamond' | 'circle' | 'square' + colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating' + colorPalette?: 'default' | 'pastel' | 'vibrant' | 'earth-tones' + coloredNumerals?: boolean + scaleFactor?: number + format?: 'pdf' | 'html' | 'png' | 'svg' } // Validation function to convert form state to complete config -function validateAndCompleteConfig( - formState: FlashcardFormState, -): FlashcardConfig { +function validateAndCompleteConfig(formState: FlashcardFormState): FlashcardConfig { return { // Required fields with defaults - range: formState.range || "0-99", + range: formState.range || '0-99', // Optional fields with defaults step: formState.step ?? 1, cardsPerPage: formState.cardsPerPage ?? 6, - paperSize: formState.paperSize ?? "us-letter", - orientation: formState.orientation ?? "portrait", - gutter: formState.gutter ?? "5mm", + paperSize: formState.paperSize ?? 'us-letter', + orientation: formState.orientation ?? 'portrait', + gutter: formState.gutter ?? '5mm', shuffle: formState.shuffle ?? false, seed: formState.seed, showCutMarks: formState.showCutMarks ?? false, showRegistration: formState.showRegistration ?? false, - fontFamily: formState.fontFamily ?? "DejaVu Sans", - fontSize: formState.fontSize ?? "48pt", - columns: formState.columns ?? "auto", + fontFamily: formState.fontFamily ?? 'DejaVu Sans', + fontSize: formState.fontSize ?? '48pt', + columns: formState.columns ?? 'auto', showEmptyColumns: formState.showEmptyColumns ?? false, hideInactiveBeads: formState.hideInactiveBeads ?? false, - beadShape: formState.beadShape ?? "diamond", - colorScheme: formState.colorScheme ?? "place-value", + beadShape: formState.beadShape ?? 'diamond', + colorScheme: formState.colorScheme ?? 'place-value', coloredNumerals: formState.coloredNumerals ?? false, scaleFactor: formState.scaleFactor ?? 0.9, - format: formState.format ?? "pdf", + format: formState.format ?? 'pdf', margins: formState.margins, - }; + } } -type GenerationStatus = "idle" | "generating" | "error"; +type GenerationStatus = 'idle' | 'generating' | 'error' export default function CreatePage() { - const t = useTranslations("create.flashcards"); - const [generationStatus, setGenerationStatus] = - useState("idle"); - const [error, setError] = useState(null); - const globalConfig = useAbacusConfig(); + const t = useTranslations('create.flashcards') + const [generationStatus, setGenerationStatus] = useState('idle') + const [error, setError] = useState(null) + const globalConfig = useAbacusConfig() const form = useForm({ defaultValues: { - range: "0-99", + range: '0-99', step: 1, cardsPerPage: 6, - paperSize: "us-letter", - orientation: "portrait", - gutter: "5mm", + paperSize: 'us-letter', + orientation: 'portrait', + gutter: '5mm', shuffle: false, showCutMarks: false, showRegistration: false, - fontFamily: "DejaVu Sans", - fontSize: "48pt", - columns: "auto", + fontFamily: 'DejaVu Sans', + fontSize: '48pt', + columns: 'auto', showEmptyColumns: false, // Use global config for abacus display settings hideInactiveBeads: globalConfig.hideInactiveBeads, @@ -141,83 +133,83 @@ export default function CreatePage() { colorScheme: globalConfig.colorScheme, coloredNumerals: globalConfig.coloredNumerals, scaleFactor: globalConfig.scaleFactor, - format: "pdf", + format: 'pdf', }, - }); + }) const handleGenerate = async (formState: FlashcardFormState) => { - setGenerationStatus("generating"); - setError(null); + setGenerationStatus('generating') + setError(null) try { // Validate and complete the configuration - const config = validateAndCompleteConfig(formState); + const config = validateAndCompleteConfig(formState) - const response = await fetch("/api/generate", { - method: "POST", + const response = await fetch('/api/generate', { + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify(config), - }); + }) if (!response.ok) { // Handle error response (should be JSON) - const errorResult = await response.json(); - throw new Error(errorResult.error || "Generation failed"); + const errorResult = await response.json() + throw new Error(errorResult.error || 'Generation failed') } // Success - response is binary PDF data, trigger download - const blob = await response.blob(); - const filename = `soroban-flashcards-${config.range || "cards"}.pdf`; + const blob = await response.blob() + const filename = `soroban-flashcards-${config.range || 'cards'}.pdf` // Create download link and trigger download - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.style.display = "none"; - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.style.display = 'none' + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) - setGenerationStatus("idle"); // Reset to idle after successful download + setGenerationStatus('idle') // Reset to idle after successful download } catch (err) { - console.error("Generation error:", err); - setError(err instanceof Error ? err.message : "Unknown error occurred"); - setGenerationStatus("error"); + console.error('Generation error:', err) + setError(err instanceof Error ? err.message : 'Unknown error occurred') + setGenerationStatus('error') } - }; + } const handleNewGeneration = () => { - setGenerationStatus("idle"); - setError(null); - }; + setGenerationStatus('idle') + setError(null) + } return ( - -
+ +
{/* Main Content */} -
-
-
+
+
+

- {t("pageTitle")} + {t('pageTitle')}

- {t("pageSubtitle")} + {t('pageSubtitle')}

@@ -226,17 +218,17 @@ export default function CreatePage() {
{/* Main Configuration Panel */}
@@ -245,30 +237,30 @@ export default function CreatePage() { {/* Style Controls Panel */}
-
-
+
+

- {t("stylePanel.title")} + {t('stylePanel.title')}

- {t("stylePanel.subtitle")} + {t('stylePanel.subtitle')}

@@ -282,84 +274,79 @@ export default function CreatePage() { {/* Live Preview Panel */}
-
+
state} - children={(state) => ( - - )} + children={(state) => } /> {/* Generate Button within Preview */}
{/* Generation Status */} - {generationStatus === "generating" && ( -
+ {generationStatus === 'generating' && ( +
)}
@@ -424,5 +411,5 @@ export default function CreatePage() {
- ); + ) } diff --git a/apps/web/src/app/create/page.tsx b/apps/web/src/app/create/page.tsx index 9c0f4acb..45577018 100644 --- a/apps/web/src/app/create/page.tsx +++ b/apps/web/src/app/create/page.tsx @@ -1,84 +1,84 @@ -"use client"; +'use client' -import Link from "next/link"; -import { useTranslations } from "next-intl"; -import { PageWithNav } from "@/components/PageWithNav"; -import { css } from "../../../styled-system/css"; +import Link from 'next/link' +import { useTranslations } from 'next-intl' +import { PageWithNav } from '@/components/PageWithNav' +import { css } from '../../../styled-system/css' export default function CreateHubPage() { - const t = useTranslations("create.hub"); + const t = useTranslations('create.hub') return (
{/* Header */}

- {t("pageTitle")} + {t('pageTitle')}

- {t("pageSubtitle")} + {t('pageSubtitle')}

{/* Creator Cards */}
{/* Icon with gradient background */}
🃏 @@ -138,117 +135,117 @@ export default function CreateHubPage() { {/* Title */}

- {t("flashcards.title")} + {t('flashcards.title')}

{/* Description */}

- {t("flashcards.description")} + {t('flashcards.description')}

{/* Features */}
  • - {t("flashcards.feature1")} + {t('flashcards.feature1')}
  • - {t("flashcards.feature2")} + {t('flashcards.feature2')}
  • - {t("flashcards.feature3")} + {t('flashcards.feature3')}
@@ -260,27 +257,26 @@ export default function CreateHubPage() { >
- {t("flashcards.button")} - + {t('flashcards.button')} +
@@ -291,48 +287,45 @@ export default function CreateHubPage() {
{/* Icon with gradient background */}
📝 @@ -341,117 +334,117 @@ export default function CreateHubPage() { {/* Title */}

- {t("worksheets.title")} + {t('worksheets.title')}

{/* Description */}

- {t("worksheets.description")} + {t('worksheets.description')}

{/* Features */}
  • - {t("worksheets.feature1")} + {t('worksheets.feature1')}
  • - {t("worksheets.feature2")} + {t('worksheets.feature2')}
  • - {t("worksheets.feature3")} + {t('worksheets.feature3')}
@@ -463,27 +456,26 @@ export default function CreateHubPage() { >
- {t("worksheets.button")} - + {t('worksheets.button')} +
@@ -494,48 +486,45 @@ export default function CreateHubPage() {
{/* Icon with gradient background */}
📅 @@ -544,117 +533,117 @@ export default function CreateHubPage() { {/* Title */}

- {t("calendar.title")} + {t('calendar.title')}

{/* Description */}

- {t("calendar.description")} + {t('calendar.description')}

{/* Features */}
  • - {t("calendar.feature1")} + {t('calendar.feature1')}
  • - {t("calendar.feature2")} + {t('calendar.feature2')}
  • - {t("calendar.feature3")} + {t('calendar.feature3')}
@@ -666,27 +655,26 @@ export default function CreateHubPage() { >
- {t("calendar.button")} - + {t('calendar.button')} +
@@ -695,5 +683,5 @@ export default function CreateHubPage() {
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.34.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.34.png new file mode 100644 index 00000000..ec558e7b Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.34.png differ diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.43.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.43.png new file mode 100644 index 00000000..b06c9baf Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.43.png differ diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.50.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.50.png new file mode 100644 index 00000000..1e601431 Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.43.50.png differ diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.31.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.31.png new file mode 100644 index 00000000..34a7689d Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.31.png differ diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.36.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.36.png new file mode 100644 index 00000000..e923f861 Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.36.png differ diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.42.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.42.png new file mode 100644 index 00000000..31b3f1a7 Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.42.png differ diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.47.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.47.png new file mode 100644 index 00000000..a6544da6 Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.47.png differ diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.52.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.52.png new file mode 100644 index 00000000..db308926 Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.52.png differ diff --git a/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.57.png b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.57.png new file mode 100644 index 00000000..097438d4 Binary files /dev/null and b/apps/web/src/app/create/worksheets/addition/Screenshot 2025-11-10 at 08.55.57.png differ diff --git a/apps/web/src/app/create/worksheets/addition/__tests__/tenFrames.test.ts b/apps/web/src/app/create/worksheets/addition/__tests__/tenFrames.test.ts new file mode 100644 index 00000000..43855dc6 --- /dev/null +++ b/apps/web/src/app/create/worksheets/addition/__tests__/tenFrames.test.ts @@ -0,0 +1,336 @@ +// Unit tests for ten-frames rendering +// These tests verify the complete path from config -> display rules -> Typst template + +import { describe, it, expect } from 'vitest' +import { analyzeProblem } from '../problemAnalysis' +import { evaluateRule, resolveDisplayForProblem } from '../displayRules' +import type { DisplayRules } from '../displayRules' +import { generateTypstSource } from '../typstGenerator' +import type { WorksheetConfig, WorksheetProblem } from '../types' + +describe('Ten-frames rendering', () => { + describe('Problem analysis', () => { + it('should detect regrouping in 45 + 27', () => { + const meta = analyzeProblem(45, 27) + expect(meta.requiresRegrouping).toBe(true) + expect(meta.regroupCount).toBe(1) // Ones place: 5 + 7 = 12 + }) + + it('should detect regrouping in 8 + 7', () => { + const meta = analyzeProblem(8, 7) + expect(meta.requiresRegrouping).toBe(true) + expect(meta.regroupCount).toBe(1) + }) + + it('should not detect regrouping in 12 + 23', () => { + const meta = analyzeProblem(12, 23) + expect(meta.requiresRegrouping).toBe(false) + expect(meta.regroupCount).toBe(0) + }) + }) + + describe('Display rule evaluation', () => { + it('should resolve "whenRegrouping" to true for problem with regrouping', () => { + const meta = analyzeProblem(45, 27) // Has regrouping + const result = evaluateRule('whenRegrouping', meta) + expect(result).toBe(true) + }) + + it('should resolve "whenRegrouping" to false for problem without regrouping', () => { + const meta = analyzeProblem(12, 23) // No regrouping + const result = evaluateRule('whenRegrouping', meta) + expect(result).toBe(false) + }) + + it('should resolve "always" to true regardless of problem', () => { + const meta = analyzeProblem(12, 23) // No regrouping + const result = evaluateRule('always', meta) + expect(result).toBe(true) + }) + + it('should resolve "never" to false regardless of problem', () => { + const meta = analyzeProblem(45, 27) // Has regrouping + const result = evaluateRule('never', meta) + expect(result).toBe(false) + }) + }) + + describe('Full display rules resolution', () => { + it('should resolve showTenFrames: true for regrouping problem with "whenRegrouping" rule', () => { + const rules: DisplayRules = { + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', // ← This should trigger for 45 + 27 + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + } + + const meta = analyzeProblem(45, 27) // Has regrouping (5 + 7 = 12) + const resolved = resolveDisplayForProblem(rules, meta) + + expect(resolved.showTenFrames).toBe(true) + }) + + it('should resolve showTenFrames: false for non-regrouping problem with "whenRegrouping" rule', () => { + const rules: DisplayRules = { + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + } + + const meta = analyzeProblem(12, 23) // No regrouping + const resolved = resolveDisplayForProblem(rules, meta) + + expect(resolved.showTenFrames).toBe(false) + }) + }) + + describe('Typst template generation', () => { + it('should pass showTenFrames: true to Typst template for regrouping problems', () => { + const config: WorksheetConfig = { + version: 4, + mode: 'smart', + problemsPerPage: 4, + cols: 2, + pages: 1, + total: 4, + rows: 2, + orientation: 'portrait', + name: 'Test Student', + date: '2025-11-10', + seed: 12345, + fontSize: 12, + digitRange: { min: 2, max: 2 }, + operator: 'addition', + pAnyStart: 1.0, // 100% regrouping + pAllStart: 0, + displayRules: { + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', // ← Key rule being tested + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + }, + interpolate: false, + page: { wIn: 8.5, hIn: 11 }, + margins: { left: 0.5, right: 0.5, top: 0.5, bottom: 0.5 }, + } + + const problems: WorksheetProblem[] = [ + { operator: '+', a: 45, b: 27 }, // Has regrouping + { operator: '+', a: 38, b: 54 }, // Has regrouping + ] + + const typstPages = generateTypstSource(config, problems) + + // Should generate at least one page + expect(typstPages.length).toBeGreaterThan(0) + + const firstPage = typstPages[0] + + // Should include showTenFrames: true in problem data + expect(firstPage).toContain('showTenFrames: true') + + // Should NOT contain showTenFrames: false (all problems have regrouping) + expect(firstPage).not.toContain('showTenFrames: false') + }) + + it('should include ten-frames rendering code when showTenFrames: true', () => { + const config: WorksheetConfig = { + version: 4, + mode: 'smart', + problemsPerPage: 2, + cols: 1, + pages: 1, + total: 2, + rows: 2, + orientation: 'portrait', + name: 'Test Student', + date: '2025-11-10', + seed: 12345, + fontSize: 12, + digitRange: { min: 1, max: 1 }, + operator: 'addition', + pAnyStart: 1.0, + pAllStart: 0, + displayRules: { + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + }, + interpolate: false, + page: { wIn: 8.5, hIn: 11 }, + margins: { left: 0.5, right: 0.5, top: 0.5, bottom: 0.5 }, + } + + const problems: WorksheetProblem[] = [ + { operator: '+', a: 8, b: 7 }, // 8 + 7 = 15, has regrouping + ] + + const typstPages = generateTypstSource(config, problems) + const firstPage = typstPages[0] + + // Should include the ten-frames function + expect(firstPage).toContain('ten-frames-stacked') + + // Should include regrouping detection logic + expect(firstPage).toContain('regrouping-places') + + // Should include contains() method for array membership + expect(firstPage).toContain('.contains(') + }) + + it('should handle manual mode with showTenFrames: true', () => { + const config: WorksheetConfig = { + version: 4, + mode: 'manual', + problemsPerPage: 2, + cols: 1, + pages: 1, + total: 2, + rows: 2, + orientation: 'portrait', + name: 'Test Student', + date: '2025-11-10', + seed: 12345, + fontSize: 12, + digitRange: { min: 2, max: 2 }, + operator: 'addition', + pAnyStart: 0.5, + pAllStart: 0, + // Manual mode uses boolean flags + showCarryBoxes: true, + showAnswerBoxes: true, + showPlaceValueColors: true, + showTenFrames: true, // ← Explicitly enabled + showProblemNumbers: true, + showCellBorder: true, + showTenFramesForAll: false, + interpolate: false, + page: { wIn: 8.5, hIn: 11 }, + margins: { left: 0.5, right: 0.5, top: 0.5, bottom: 0.5 }, + } + + const problems: WorksheetProblem[] = [ + { operator: '+', a: 45, b: 27 }, + { operator: '+', a: 12, b: 23 }, // No regrouping, but should still show frames + ] + + const typstPages = generateTypstSource(config, problems) + const firstPage = typstPages[0] + + // All problems should have showTenFrames: true in manual mode + const tenFramesMatches = firstPage.match(/showTenFrames: true/g) + expect(tenFramesMatches?.length).toBe(2) // Both problems + }) + }) + + describe('Mastery progression mode', () => { + it('should use displayRules when in mastery mode (step 0: full scaffolding)', () => { + // Simulating mastery mode step 0 with full scaffolding + const config: WorksheetConfig = { + version: 4, + mode: 'mastery', + problemsPerPage: 20, + cols: 4, + pages: 1, + total: 20, + rows: 5, + orientation: 'portrait', + name: 'Test Student', + date: '2025-11-10', + seed: 12345, + fontSize: 12, + digitRange: { min: 1, max: 1 }, + operator: 'addition', + pAnyStart: 1.0, // 100% regrouping + pAllStart: 0, + displayRules: { + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', // ← Full scaffolding + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + }, + interpolate: false, + page: { wIn: 8.5, hIn: 11 }, + margins: { left: 0.5, right: 0.5, top: 0.5, bottom: 0.5 }, + } + + const problems: WorksheetProblem[] = [ + { operator: '+', a: 8, b: 7 }, // Has regrouping + { operator: '+', a: 9, b: 6 }, // Has regrouping + ] + + const typstPages = generateTypstSource(config, problems) + const firstPage = typstPages[0] + + // Should include ten-frames for all regrouping problems + expect(firstPage).toContain('showTenFrames: true') + expect(firstPage).toContain('ten-frames-stacked') + }) + + it('should not show ten-frames in mastery mode step 1 (scaffolding faded)', () => { + const config: WorksheetConfig = { + version: 4, + mode: 'mastery', + problemsPerPage: 20, + cols: 4, + pages: 1, + total: 20, + rows: 5, + orientation: 'portrait', + name: 'Test Student', + date: '2025-11-10', + seed: 12345, + fontSize: 12, + digitRange: { min: 1, max: 1 }, + operator: 'addition', + pAnyStart: 1.0, + pAllStart: 0, + displayRules: { + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'never', // ← Scaffolding faded + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', + }, + interpolate: false, + page: { wIn: 8.5, hIn: 11 }, + margins: { left: 0.5, right: 0.5, top: 0.5, bottom: 0.5 }, + } + + const problems: WorksheetProblem[] = [ + { operator: '+', a: 8, b: 7 }, // Has regrouping + ] + + const typstPages = generateTypstSource(config, problems) + const firstPage = typstPages[0] + + // Should NOT show ten-frames (rule is 'never') + expect(firstPage).toContain('showTenFrames: false') + }) + }) +}) diff --git a/apps/web/src/app/create/worksheets/addition/components/DisplayOptionsPreview.tsx b/apps/web/src/app/create/worksheets/addition/components/DisplayOptionsPreview.tsx index 1576aa93..bb9d2e44 100644 --- a/apps/web/src/app/create/worksheets/addition/components/DisplayOptionsPreview.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/DisplayOptionsPreview.tsx @@ -1,19 +1,19 @@ -"use client"; +'use client' -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { css } from "../../../../../../styled-system/css"; -import type { WorksheetFormState } from "../types"; +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { css } from '../../../../../../styled-system/css' +import type { WorksheetFormState } from '../types' interface DisplayOptionsPreviewProps { - formState: WorksheetFormState; + formState: WorksheetFormState } interface MathSentenceProps { - operands: number[]; - operator: string; - onChange: (operands: number[]) => void; - labels?: string[]; + operands: number[] + operator: string + onChange: (operands: number[]) => void + labels?: string[] } /** @@ -23,20 +23,15 @@ interface MathSentenceProps { * Arity 2 (binary): [45, 27] with "+" → "45 + 27" * Arity 3 (ternary): [5, 10, 15] with "between" → "5 < 10 < 15" */ -function MathSentence({ - operands, - operator, - onChange, - labels, -}: MathSentenceProps) { +function MathSentence({ operands, operator, onChange, labels }: MathSentenceProps) { const handleOperandChange = (index: number, value: string) => { - const numValue = Number.parseInt(value, 10); + const numValue = Number.parseInt(value, 10) if (!Number.isNaN(numValue) && numValue >= 0 && numValue <= 99) { - const newOperands = [...operands]; - newOperands[index] = numValue; - onChange(newOperands); + const newOperands = [...operands] + newOperands[index] = numValue + onChange(newOperands) } - }; + } const renderInput = (value: number, index: number) => ( handleOperandChange(index, e.target.value)} aria-label={labels?.[index] || `operand ${index + 1}`} className={css({ - width: "3.5em", - px: "1", - py: "0.5", - fontSize: "sm", - fontWeight: "medium", - textAlign: "center", - border: "1px solid", - borderColor: "transparent", - rounded: "sm", - outline: "none", - transition: "border-color 0.2s", + width: '3.5em', + px: '1', + py: '0.5', + fontSize: 'sm', + fontWeight: 'medium', + textAlign: 'center', + border: '1px solid', + borderColor: 'transparent', + rounded: 'sm', + outline: 'none', + transition: 'border-color 0.2s', _hover: { - borderColor: "gray.300", + borderColor: 'gray.300', }, _focus: { - borderColor: "brand.500", - ring: "1px", - ringColor: "brand.200", + borderColor: 'brand.500', + ring: '1px', + ringColor: 'brand.200', }, })} /> - ); + ) // Render based on arity if (operands.length === 1) { @@ -78,17 +73,17 @@ function MathSentence({
{operator} {renderInput(operands[0], 0)}
- ); + ) } if (operands.length === 2) { @@ -97,18 +92,18 @@ function MathSentence({
{renderInput(operands[0], 0)} {operator} {renderInput(operands[1], 1)}
- ); + ) } if (operands.length === 3) { @@ -117,11 +112,11 @@ function MathSentence({
{renderInput(operands[0], 0)} @@ -130,51 +125,49 @@ function MathSentence({ {operator} {renderInput(operands[2], 2)}
- ); + ) } - return null; + return null } async function fetchExample(options: { - showCarryBoxes: boolean; - showAnswerBoxes: boolean; - showPlaceValueColors: boolean; - showProblemNumbers: boolean; - showCellBorder: boolean; - showTenFrames: boolean; - showTenFramesForAll: boolean; - showBorrowNotation: boolean; - operator: "addition" | "subtraction" | "mixed"; - addend1?: number; - addend2?: number; - minuend?: number; - subtrahend?: number; + showCarryBoxes: boolean + showAnswerBoxes: boolean + showPlaceValueColors: boolean + showProblemNumbers: boolean + showCellBorder: boolean + showTenFrames: boolean + showTenFramesForAll: boolean + showBorrowNotation: boolean + operator: 'addition' | 'subtraction' | 'mixed' + addend1?: number + addend2?: number + minuend?: number + subtrahend?: number }): Promise { - const response = await fetch("/api/create/worksheets/addition/example", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/create/worksheets/addition/example', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...options, fontSize: 16, }), - }); + }) if (!response.ok) { - throw new Error("Failed to fetch example"); + throw new Error('Failed to fetch example') } - const data = await response.json(); - return data.svg; + const data = await response.json() + return data.svg } -export function DisplayOptionsPreview({ - formState, -}: DisplayOptionsPreviewProps) { - const operator = formState.operator ?? "addition"; +export function DisplayOptionsPreview({ formState }: DisplayOptionsPreviewProps) { + const operator = formState.operator ?? 'addition' // Local state for operands (not debounced - we want immediate feedback) - const [operands, setOperands] = useState([45, 27]); + const [operands, setOperands] = useState([45, 27]) // Build options based on operator type const buildOptions = () => { @@ -189,33 +182,33 @@ export function DisplayOptionsPreview({ showBorrowNotation: formState.showBorrowNotation ?? true, showBorrowingHints: formState.showBorrowingHints ?? false, operator, - }; + } - if (operator === "addition") { + if (operator === 'addition') { return { ...base, addend1: operands[0], addend2: operands[1], - }; + } } else { // Subtraction (mixed mode shows subtraction in preview) return { ...base, minuend: operands[0], subtrahend: operands[1], - }; + } } - }; + } // Debounce the display options to avoid hammering the server - const [debouncedOptions, setDebouncedOptions] = useState(buildOptions()); + const [debouncedOptions, setDebouncedOptions] = useState(buildOptions()) useEffect(() => { const timer = setTimeout(() => { - setDebouncedOptions(buildOptions()); - }, 300); // 300ms debounce + setDebouncedOptions(buildOptions()) + }, 300) // 300ms debounce - return () => clearTimeout(timer); + return () => clearTimeout(timer) }, [ formState.showCarryBoxes, formState.showAnswerBoxes, @@ -228,69 +221,65 @@ export function DisplayOptionsPreview({ formState.showBorrowingHints, formState.operator, operands, - ]); + ]) const { data: svg, isLoading } = useQuery({ - queryKey: ["display-example", debouncedOptions], + queryKey: ['display-example', debouncedOptions], queryFn: () => fetchExample(debouncedOptions), staleTime: 5 * 60 * 1000, // 5 minutes - }); + }) return (
Preview
{isLoading ? (
Generating preview... @@ -298,18 +287,18 @@ export function DisplayOptionsPreview({ ) : svg ? (
) : null}
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/GenerateButton.tsx b/apps/web/src/app/create/worksheets/addition/components/GenerateButton.tsx index 55b7183c..8d677303 100644 --- a/apps/web/src/app/create/worksheets/addition/components/GenerateButton.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/GenerateButton.tsx @@ -1,37 +1,33 @@ -"use client"; +'use client' -import { useTranslations } from "next-intl"; -import { css } from "../../../../../../styled-system/css"; -import { hstack } from "../../../../../../styled-system/patterns"; +import { useTranslations } from 'next-intl' +import { css } from '../../../../../../styled-system/css' +import { hstack } from '../../../../../../styled-system/patterns' -type GenerationStatus = "idle" | "generating" | "error"; +type GenerationStatus = 'idle' | 'generating' | 'error' interface GenerateButtonProps { - status: GenerationStatus; - onGenerate: () => void; - isDark?: boolean; + status: GenerationStatus + onGenerate: () => void + isDark?: boolean } /** * Button to trigger worksheet PDF generation * Shows loading state during generation */ -export function GenerateButton({ - status, - onGenerate, - isDark = false, -}: GenerateButtonProps) { - const t = useTranslations("create.worksheets.addition"); - const isGenerating = status === "generating"; +export function GenerateButton({ status, onGenerate, isDark = false }: GenerateButtonProps) { + const t = useTranslations('create.worksheets.addition') + const isGenerating = status === 'generating' return (
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/GenerationErrorDisplay.tsx b/apps/web/src/app/create/worksheets/addition/components/GenerationErrorDisplay.tsx index ecbb7e59..3714e268 100644 --- a/apps/web/src/app/create/worksheets/addition/components/GenerationErrorDisplay.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/GenerationErrorDisplay.tsx @@ -1,63 +1,59 @@ -"use client"; +'use client' -import { useTranslations } from "next-intl"; -import { css } from "../../../../../../styled-system/css"; -import { stack, hstack } from "../../../../../../styled-system/patterns"; +import { useTranslations } from 'next-intl' +import { css } from '../../../../../../styled-system/css' +import { stack, hstack } from '../../../../../../styled-system/patterns' interface GenerationErrorDisplayProps { - error: string | null; - visible: boolean; - onRetry: () => void; + error: string | null + visible: boolean + onRetry: () => void } /** * Display generation errors with retry button * Only visible when error state is active */ -export function GenerationErrorDisplay({ - error, - visible, - onRetry, -}: GenerationErrorDisplayProps) { - const t = useTranslations("create.worksheets.addition"); +export function GenerationErrorDisplay({ error, visible, onRetry }: GenerationErrorDisplayProps) { + const t = useTranslations('create.worksheets.addition') if (!visible || !error) { - return null; + return null } return (
-
-
-
+
+
+

- {t("error.title")} + {t('error.title')}

           {error}
@@ -67,20 +63,20 @@ export function GenerationErrorDisplay({
           data-action="try-again"
           onClick={onRetry}
           className={css({
-            alignSelf: "start",
-            px: "4",
-            py: "2",
-            bg: "red.600",
-            color: "white",
-            fontWeight: "medium",
-            rounded: "lg",
-            transition: "all",
-            _hover: { bg: "red.700" },
+            alignSelf: 'start',
+            px: '4',
+            py: '2',
+            bg: 'red.600',
+            color: 'white',
+            fontWeight: 'medium',
+            rounded: 'lg',
+            transition: 'all',
+            _hover: { bg: 'red.700' },
           })}
         >
-          {t("error.tryAgain")}
+          {t('error.tryAgain')}
         
       
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/OrientationPanel.tsx b/apps/web/src/app/create/worksheets/addition/components/OrientationPanel.tsx index b00b932a..4327e481 100644 --- a/apps/web/src/app/create/worksheets/addition/components/OrientationPanel.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/OrientationPanel.tsx @@ -1,22 +1,22 @@ -"use client"; +'use client' -import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import { css } from "../../../../../../styled-system/css"; -import { getDefaultColsForProblemsPerPage } from "../utils/layoutCalculations"; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { css } from '../../../../../../styled-system/css' +import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations' interface OrientationPanelProps { - orientation: "portrait" | "landscape"; - problemsPerPage: number; - pages: number; - cols: number; + orientation: 'portrait' | 'landscape' + problemsPerPage: number + pages: number + cols: number onOrientationChange: ( - orientation: "portrait" | "landscape", + orientation: 'portrait' | 'landscape', problemsPerPage: number, - cols: number, - ) => void; - onProblemsPerPageChange: (problemsPerPage: number, cols: number) => void; - onPagesChange: (pages: number) => void; - isDark?: boolean; + cols: number + ) => void + onProblemsPerPageChange: (problemsPerPage: number, cols: number) => void + onPagesChange: (pages: number) => void + isDark?: boolean } /** @@ -33,112 +33,92 @@ export function OrientationPanel({ onPagesChange, isDark = false, }: OrientationPanelProps) { - const handleOrientationChange = ( - newOrientation: "portrait" | "landscape", - ) => { - const newProblemsPerPage = newOrientation === "portrait" ? 15 : 20; - const newCols = getDefaultColsForProblemsPerPage( - newProblemsPerPage, - newOrientation, - ); - onOrientationChange(newOrientation, newProblemsPerPage, newCols); - }; + const handleOrientationChange = (newOrientation: 'portrait' | 'landscape') => { + const newProblemsPerPage = newOrientation === 'portrait' ? 15 : 20 + const newCols = getDefaultColsForProblemsPerPage(newProblemsPerPage, newOrientation) + onOrientationChange(newOrientation, newProblemsPerPage, newCols) + } const handleProblemsPerPageChange = (count: number) => { - const newCols = getDefaultColsForProblemsPerPage(count, orientation); - onProblemsPerPageChange(count, newCols); - }; + const newCols = getDefaultColsForProblemsPerPage(count, orientation) + onProblemsPerPageChange(count, newCols) + } - const total = problemsPerPage * pages; + const total = problemsPerPage * pages const problemsForOrientation = - orientation === "portrait" ? [6, 8, 10, 12, 15] : [8, 10, 12, 15, 16, 20]; + orientation === 'portrait' ? [6, 8, 10, 12, 15] : [8, 10, 12, 15, 16, 20] return (
-
+
{/* Row 1: Orientation + Pages */}
{/* Orientation */}
Orientation
-
+
- ); + ) })}
@@ -267,22 +222,22 @@ export function OrientationPanel({ {/* Row 2: Problems per page dropdown + Total badge */}
Problems per Page @@ -293,34 +248,34 @@ export function OrientationPanel({ type="button" data-action="open-problems-dropdown" className={css({ - w: "full", - px: "3", - py: "2", - border: "2px solid", - borderColor: isDark ? "gray.600" : "gray.300", - bg: isDark ? "gray.700" : "white", - rounded: "lg", - cursor: "pointer", - fontSize: "sm", - fontWeight: "medium", - color: isDark ? "gray.200" : "gray.700", - transition: "all 0.15s", - display: "flex", - alignItems: "center", - justifyContent: "space-between", + w: 'full', + px: '3', + py: '2', + border: '2px solid', + borderColor: isDark ? 'gray.600' : 'gray.300', + bg: isDark ? 'gray.700' : 'white', + rounded: 'lg', + cursor: 'pointer', + fontSize: 'sm', + fontWeight: 'medium', + color: isDark ? 'gray.200' : 'gray.700', + transition: 'all 0.15s', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', _hover: { - borderColor: "brand.400", + borderColor: 'brand.400', }, })} > - {problemsPerPage} problems ({cols} cols ×{" "} - {Math.ceil(problemsPerPage / cols)} rows) + {problemsPerPage} problems ({cols} cols × {Math.ceil(problemsPerPage / cols)}{' '} + rows) ▼ @@ -331,33 +286,30 @@ export function OrientationPanel({
{problemsForOrientation.map((count) => { - const itemCols = getDefaultColsForProblemsPerPage( - count, - orientation, - ); - const rows = Math.ceil(count / itemCols); - const isSelected = problemsPerPage === count; + const itemCols = getDefaultColsForProblemsPerPage(count, orientation) + const rows = Math.ceil(count / itemCols) + const isSelected = problemsPerPage === count return ( handleProblemsPerPageChange(count)} className={css({ - display: "flex", - alignItems: "center", - gap: "3", - px: "3", - py: "2", - rounded: "md", - cursor: "pointer", - outline: "none", - bg: isSelected ? "brand.50" : "transparent", + display: 'flex', + alignItems: 'center', + gap: '3', + px: '3', + py: '2', + rounded: 'md', + cursor: 'pointer', + outline: 'none', + bg: isSelected ? 'brand.50' : 'transparent', _hover: { - bg: "brand.50", + bg: 'brand.50', }, _focus: { - bg: "brand.100", + bg: 'brand.100', }, })} > {/* Grid visualization */}
{Array.from({ length: count }).map((_, i) => (
))} @@ -413,32 +365,24 @@ export function OrientationPanel({
{count} problems
{itemCols} cols × {rows} rows
- ); + ) })}
@@ -449,32 +393,32 @@ export function OrientationPanel({ {/* Total problems badge */}
Total
{total} @@ -483,5 +427,5 @@ export function OrientationPanel({
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/WorksheetErrorBoundary.tsx b/apps/web/src/app/create/worksheets/addition/components/WorksheetErrorBoundary.tsx index 4d27a2e2..d6686bb7 100644 --- a/apps/web/src/app/create/worksheets/addition/components/WorksheetErrorBoundary.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/WorksheetErrorBoundary.tsx @@ -1,17 +1,17 @@ -"use client"; +'use client' -import React from "react"; -import { css } from "../../../../../../styled-system/css"; -import { stack, hstack } from "../../../../../../styled-system/patterns"; +import React from 'react' +import { css } from '../../../../../../styled-system/css' +import { stack, hstack } from '../../../../../../styled-system/patterns' interface Props { - children: React.ReactNode; + children: React.ReactNode } interface State { - hasError: boolean; - error: Error | null; - errorInfo: React.ErrorInfo | null; + hasError: boolean + error: Error | null + errorInfo: React.ErrorInfo | null } /** @@ -24,28 +24,24 @@ interface State { */ export class WorksheetErrorBoundary extends React.Component { constructor(props: Props) { - super(props); - this.state = { hasError: false, error: null, errorInfo: null }; + super(props) + this.state = { hasError: false, error: null, errorInfo: null } } static getDerivedStateFromError(error: Error): Partial { // Update state so the next render will show the fallback UI - return { hasError: true, error }; + return { hasError: true, error } } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // Log error to console for debugging - console.error( - "Worksheet Error Boundary caught an error:", - error, - errorInfo, - ); + console.error('Worksheet Error Boundary caught an error:', error, errorInfo) // Store error info in state for display this.setState({ error, errorInfo, - }); + }) // TODO: Send error to error reporting service (Sentry, etc.) // Example: @@ -60,113 +56,112 @@ export class WorksheetErrorBoundary extends React.Component { handleReset = () => { // Clear error state and try to recover - this.setState({ hasError: false, error: null, errorInfo: null }); + this.setState({ hasError: false, error: null, errorInfo: null }) // Reload the page to get fresh state - window.location.reload(); - }; + window.location.reload() + } render() { if (this.state.hasError && this.state.error) { - const error = this.state.error; + const error = this.state.error return (
-
+
{/* Error Icon & Title */} -
-
⚠️
+
+
⚠️

Something went wrong

-

- We encountered an unexpected error while loading the worksheet - creator. This shouldn't happen, and we apologize for the - inconvenience. +

+ We encountered an unexpected error while loading the worksheet creator. This + shouldn't happen, and we apologize for the inconvenience.

{/* Error Details */}
-
+
Error Details:
                     {error.toString()}
                   
{this.state.errorInfo?.componentStack && ( -
+
Component Stack (for developers)
                         {this.state.errorInfo.componentStack}
@@ -177,23 +172,23 @@ export class WorksheetErrorBoundary extends React.Component {
               
{/* Action Buttons */} -
- ); + ) } - return this.props.children; + return this.props.children } } diff --git a/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx b/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx index 77a97526..9f924fe2 100644 --- a/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx @@ -1,109 +1,90 @@ -"use client"; +'use client' -import { Suspense, useState, useEffect, useRef } from "react"; -import { useSuspenseQuery } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; -import { css } from "../../../../../../styled-system/css"; -import { hstack, stack } from "../../../../../../styled-system/patterns"; -import type { WorksheetFormState } from "../types"; +import { Suspense, useState, useEffect, useRef } from 'react' +import { useSuspenseQuery } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' +import { css } from '../../../../../../styled-system/css' +import { hstack, stack } from '../../../../../../styled-system/patterns' +import type { WorksheetFormState } from '../types' interface WorksheetPreviewProps { - formState: WorksheetFormState; - initialData?: string[]; - isDark?: boolean; + formState: WorksheetFormState + initialData?: string[] + isDark?: boolean } function getDefaultDate(): string { - const now = new Date(); - return now.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }); + const now = new Date() + return now.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }) } -async function fetchWorksheetPreview( - formState: WorksheetFormState, -): Promise { - const fetchId = Math.random().toString(36).slice(2, 9); - console.log( - `[WorksheetPreview] fetchWorksheetPreview called (ID: ${fetchId})`, - { - seed: formState.seed, - problemsPerPage: formState.problemsPerPage, - }, - ); +async function fetchWorksheetPreview(formState: WorksheetFormState): Promise { + const fetchId = Math.random().toString(36).slice(2, 9) + console.log(`[WorksheetPreview] fetchWorksheetPreview called (ID: ${fetchId})`, { + seed: formState.seed, + problemsPerPage: formState.problemsPerPage, + }) // Set current date for preview const configWithDate = { ...formState, date: getDefaultDate(), - }; - - // Use absolute URL for SSR compatibility - const baseUrl = - typeof window !== "undefined" - ? window.location.origin - : "http://localhost:3000"; - const url = `${baseUrl}/api/create/worksheets/addition/preview`; - - console.log(`[WorksheetPreview] Fetching from API (ID: ${fetchId})...`); - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(configWithDate), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const errorMsg = - errorData.error || errorData.message || "Failed to fetch preview"; - const details = errorData.details ? `\n\n${errorData.details}` : ""; - const errors = errorData.errors - ? `\n\nErrors:\n${errorData.errors.join("\n")}` - : ""; - throw new Error(errorMsg + details + errors); } - const data = await response.json(); - console.log( - `[WorksheetPreview] Fetch complete (ID: ${fetchId}), pages:`, - data.pages.length, - ); - return data.pages; + // Use absolute URL for SSR compatibility + const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000' + const url = `${baseUrl}/api/create/worksheets/addition/preview` + + console.log(`[WorksheetPreview] Fetching from API (ID: ${fetchId})...`) + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(configWithDate), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + const errorMsg = errorData.error || errorData.message || 'Failed to fetch preview' + const details = errorData.details ? `\n\n${errorData.details}` : '' + const errors = errorData.errors ? `\n\nErrors:\n${errorData.errors.join('\n')}` : '' + throw new Error(errorMsg + details + errors) + } + + const data = await response.json() + console.log(`[WorksheetPreview] Fetch complete (ID: ${fetchId}), pages:`, data.pages.length) + return data.pages } -function PreviewContent({ - formState, - initialData, - isDark = false, -}: WorksheetPreviewProps) { - const t = useTranslations("create.worksheets.addition"); - const [currentPage, setCurrentPage] = useState(0); +function PreviewContent({ formState, initialData, isDark = false }: WorksheetPreviewProps) { + const t = useTranslations('create.worksheets.addition') + const [currentPage, setCurrentPage] = useState(0) // Track if we've used the initial data (so we only use it once) - const initialDataUsed = useRef(false); + const initialDataUsed = useRef(false) - console.log("[WorksheetPreview] Rendering with formState:", { + console.log('[WorksheetPreview] Rendering with formState:', { seed: formState.seed, problemsPerPage: formState.problemsPerPage, hasInitialData: !!initialData, initialDataUsed: initialDataUsed.current, - }); + }) // Only use initialData on the very first query, not on subsequent fetches - const queryInitialData = - !initialDataUsed.current && initialData ? initialData : undefined; + const queryInitialData = !initialDataUsed.current && initialData ? initialData : undefined if (queryInitialData) { - console.log("[WorksheetPreview] Using server-generated initial data"); - initialDataUsed.current = true; + console.log('[WorksheetPreview] Using server-generated initial data') + initialDataUsed.current = true } // Use Suspense Query - will suspend during loading const { data: pages } = useSuspenseQuery({ queryKey: [ - "worksheet-preview", + 'worksheet-preview', // PRIMARY state formState.problemsPerPage, formState.cols, @@ -136,40 +117,40 @@ function PreviewContent({ // (rows and total are derived from primary state) ], queryFn: () => { - console.log("[WorksheetPreview] Fetching preview from API..."); - return fetchWorksheetPreview(formState); + console.log('[WorksheetPreview] Fetching preview from API...') + return fetchWorksheetPreview(formState) }, initialData: queryInitialData, // Only use on first render - }); + }) - console.log("[WorksheetPreview] Preview fetched, pages:", pages.length); + console.log('[WorksheetPreview] Preview fetched, pages:', pages.length) - const totalPages = pages.length; + const totalPages = pages.length // Reset to first page when preview updates useEffect(() => { - setCurrentPage(0); - }, [pages]); + setCurrentPage(0) + }, [pages]) return ( -
-
+
+

- {t("preview.title")} + {t('preview.title')}

- {totalPages > 1 ? `${totalPages} pages` : t("preview.subtitle")} + {totalPages > 1 ? `${totalPages} pages` : t('preview.subtitle')}

@@ -177,28 +158,28 @@ function PreviewContent({ {totalPages > 1 && (
Page {currentPage + 1} of {totalPages} Page {currentPage + 1} of {totalPages}
- ); + ) } function PreviewFallback() { - console.log("[WorksheetPreview] Showing fallback (Suspense boundary)"); + console.log('[WorksheetPreview] Showing fallback (Suspense boundary)') return (

Generating preview...

- ); + ) } export function WorksheetPreview({ @@ -385,11 +362,7 @@ export function WorksheetPreview({ }: WorksheetPreviewProps) { return ( }> - + - ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/config-panel/DigitRangeSection.tsx b/apps/web/src/app/create/worksheets/addition/components/config-panel/DigitRangeSection.tsx index 7a006855..659d9fd3 100644 --- a/apps/web/src/app/create/worksheets/addition/components/config-panel/DigitRangeSection.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/config-panel/DigitRangeSection.tsx @@ -1,10 +1,10 @@ -import * as Slider from "@radix-ui/react-slider"; -import { css } from "../../../../../../../styled-system/css"; +import * as Slider from '@radix-ui/react-slider' +import { css } from '../../../../../../../styled-system/css' export interface DigitRangeSectionProps { - digitRange: { min: number; max: number } | undefined; - onChange: (digitRange: { min: number; max: number }) => void; - isDark?: boolean; + digitRange: { min: number; max: number } | undefined + onChange: (digitRange: { min: number; max: number }) => void + isDark?: boolean } export function DigitRangeSection({ @@ -12,42 +12,42 @@ export function DigitRangeSection({ onChange, isDark = false, }: DigitRangeSectionProps) { - const min = digitRange?.min ?? 2; - const max = digitRange?.max ?? 2; + const min = digitRange?.min ?? 2 + const max = digitRange?.max ?? 2 return (
-
+
{min === max ? `${min}` : `${min}-${max}`} @@ -55,56 +55,56 @@ export function DigitRangeSection({

{min === max - ? `All problems: exactly ${min} digit${min > 1 ? "s" : ""}` + ? `All problems: exactly ${min} digit${min > 1 ? 's' : ''}` : `Mixed problem sizes from ${min} to ${max} digits`}

{/* Range Slider with Tick Marks */} -
+
{/* Tick marks */}
{[1, 2, 3, 4, 5].map((digit) => (
{digit}
@@ -114,21 +114,21 @@ export function DigitRangeSection({ {/* Double-thumbed range slider */} { onChange({ min: values[0], max: values[1], - }); + }) }} min={1} max={5} @@ -137,64 +137,64 @@ export function DigitRangeSection({ >
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/config-panel/ManualModeControls.tsx b/apps/web/src/app/create/worksheets/addition/components/config-panel/ManualModeControls.tsx index fca4e442..eba1bf9d 100644 --- a/apps/web/src/app/create/worksheets/addition/components/config-panel/ManualModeControls.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/config-panel/ManualModeControls.tsx @@ -1,17 +1,17 @@ -"use client"; +'use client' -import * as Slider from "@radix-ui/react-slider"; -import { css } from "../../../../../../../styled-system/css"; -import { stack } from "../../../../../../../styled-system/patterns"; -import type { WorksheetFormState } from "../../types"; -import { DisplayOptionsPreview } from "../DisplayOptionsPreview"; -import { ToggleOption } from "./ToggleOption"; -import { SubOption } from "./SubOption"; +import * as Slider from '@radix-ui/react-slider' +import { css } from '../../../../../../../styled-system/css' +import { stack } from '../../../../../../../styled-system/patterns' +import type { WorksheetFormState } from '../../types' +import { DisplayOptionsPreview } from '../DisplayOptionsPreview' +import { ToggleOption } from './ToggleOption' +import { SubOption } from './SubOption' export interface ManualModeControlsProps { - formState: WorksheetFormState; - onChange: (updates: Partial) => void; - isDark?: boolean; + formState: WorksheetFormState + onChange: (updates: Partial) => void + isDark?: boolean } export function ManualModeControls({ @@ -25,33 +25,33 @@ export function ManualModeControls({
-
+
Display Options
-
+
-

- {operator === "mixed" - ? "Problems will randomly use addition or subtraction" - : operator === "subtraction" - ? "All problems will be subtraction" - : "All problems will be addition"} +

+ {operator === 'mixed' + ? 'Problems will randomly use addition or subtraction' + : operator === 'subtraction' + ? 'All problems will be subtraction' + : 'All problems will be addition'}

- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/config-panel/ProgressiveDifficultyToggle.tsx b/apps/web/src/app/create/worksheets/addition/components/config-panel/ProgressiveDifficultyToggle.tsx index f8d0f657..5d895cfd 100644 --- a/apps/web/src/app/create/worksheets/addition/components/config-panel/ProgressiveDifficultyToggle.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/config-panel/ProgressiveDifficultyToggle.tsx @@ -1,10 +1,10 @@ -import * as Switch from "@radix-ui/react-switch"; -import { css } from "../../../../../../../styled-system/css"; +import * as Switch from '@radix-ui/react-switch' +import { css } from '../../../../../../../styled-system/css' export interface ProgressiveDifficultyToggleProps { - interpolate: boolean | undefined; - onChange: (interpolate: boolean) => void; - isDark?: boolean; + interpolate: boolean | undefined + onChange: (interpolate: boolean) => void + isDark?: boolean } export function ProgressiveDifficultyToggle({ @@ -16,28 +16,28 @@ export function ProgressiveDifficultyToggle({
Start easier and gradually build up throughout the worksheet
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/config-panel/SmartModeControls.tsx b/apps/web/src/app/create/worksheets/addition/components/config-panel/SmartModeControls.tsx index 2f8b3cbc..f499615a 100644 --- a/apps/web/src/app/create/worksheets/addition/components/config-panel/SmartModeControls.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/config-panel/SmartModeControls.tsx @@ -1,13 +1,13 @@ -"use client"; +'use client' -import { useState } from "react"; -import type React from "react"; -import * as Slider from "@radix-ui/react-slider"; -import * as Tooltip from "@radix-ui/react-tooltip"; -import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import { css } from "../../../../../../../styled-system/css"; -import { stack } from "../../../../../../../styled-system/patterns"; -import type { WorksheetFormState } from "../../types"; +import { useState } from 'react' +import type React from 'react' +import * as Slider from '@radix-ui/react-slider' +import * as Tooltip from '@radix-ui/react-tooltip' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import { css } from '../../../../../../../styled-system/css' +import { stack } from '../../../../../../../styled-system/patterns' +import type { WorksheetFormState } from '../../types' import { DIFFICULTY_PROFILES, DIFFICULTY_PROGRESSION, @@ -22,37 +22,28 @@ import { getProfileFromConfig, type DifficultyLevel, type DifficultyMode, -} from "../../difficultyProfiles"; -import type { DisplayRules } from "../../displayRules"; -import { getScaffoldingSummary } from "./utils"; +} from '../../difficultyProfiles' +import type { DisplayRules } from '../../displayRules' +import { getScaffoldingSummary } from './utils' export interface SmartModeControlsProps { - formState: WorksheetFormState; - onChange: (updates: Partial) => void; - isDark?: boolean; + formState: WorksheetFormState + onChange: (updates: Partial) => void + isDark?: boolean } -export function SmartModeControls({ - formState, - onChange, - isDark = false, -}: SmartModeControlsProps) { - const [showDebugPlot, setShowDebugPlot] = useState(false); - const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number } | null>( - null, - ); +export function SmartModeControls({ formState, onChange, isDark = false }: SmartModeControlsProps) { + const [showDebugPlot, setShowDebugPlot] = useState(false) + const [hoverPoint, setHoverPoint] = useState<{ x: number; y: number } | null>(null) const [hoverPreview, setHoverPreview] = useState<{ - pAnyStart: number; - pAllStart: number; - displayRules: DisplayRules; - matchedProfile: string | "custom"; - } | null>(null); + pAnyStart: number + pAllStart: number + displayRules: DisplayRules + matchedProfile: string | 'custom' + } | null>(null) // Helper function to handle difficulty adjustments - const handleDifficultyChange = ( - mode: DifficultyMode, - direction: "harder" | "easier", - ) => { + const handleDifficultyChange = (mode: DifficultyMode, direction: 'harder' | 'easier') => { const currentState = { pAnyStart: formState.pAnyStart ?? 0.25, pAllStart: formState.pAllStart ?? 0, @@ -66,43 +57,41 @@ export function SmartModeControls({ (formState.displayRules as any)?.borrowingHints ?? DIFFICULTY_PROFILES.earlyLearner.displayRules.borrowingHints, }, - }; + } const result = - direction === "harder" + direction === 'harder' ? makeHarder(currentState, mode, formState.operator) - : makeEasier(currentState, mode, formState.operator); + : makeEasier(currentState, mode, formState.operator) onChange({ pAnyStart: result.pAnyStart, pAllStart: result.pAllStart, displayRules: result.displayRules, difficultyProfile: - result.difficultyProfile !== "custom" - ? result.difficultyProfile - : undefined, - }); - }; + result.difficultyProfile !== 'custom' ? result.difficultyProfile : undefined, + }) + } return (
-
+
Difficulty Level @@ -110,115 +99,97 @@ export function SmartModeControls({ {/* Get current profile and state */} {(() => { - const currentProfile = formState.difficultyProfile as - | DifficultyLevel - | undefined; + const currentProfile = formState.difficultyProfile as DifficultyLevel | undefined const profile = currentProfile ? DIFFICULTY_PROFILES[currentProfile] - : DIFFICULTY_PROFILES.earlyLearner; + : DIFFICULTY_PROFILES.earlyLearner // Use defaults from profile if form state values are undefined - const pAnyStart = formState.pAnyStart ?? profile.regrouping.pAnyStart; - const pAllStart = formState.pAllStart ?? profile.regrouping.pAllStart; + const pAnyStart = formState.pAnyStart ?? profile.regrouping.pAnyStart + const pAllStart = formState.pAllStart ?? profile.regrouping.pAllStart const displayRules: DisplayRules = { ...(formState.displayRules ?? profile.displayRules), // Ensure new fields have defaults (backward compatibility with old configs) - borrowNotation: (formState.displayRules as any)?.borrowNotation ?? profile.displayRules.borrowNotation, - borrowingHints: (formState.displayRules as any)?.borrowingHints ?? profile.displayRules.borrowingHints, - }; + borrowNotation: + (formState.displayRules as any)?.borrowNotation ?? + profile.displayRules.borrowNotation, + borrowingHints: + (formState.displayRules as any)?.borrowingHints ?? + profile.displayRules.borrowingHints, + } // Check if current state matches the selected profile const matchesProfile = pAnyStart === profile.regrouping.pAnyStart && pAllStart === profile.regrouping.pAllStart && - JSON.stringify(displayRules) === - JSON.stringify(profile.displayRules); - const isCustom = !matchesProfile; + JSON.stringify(displayRules) === JSON.stringify(profile.displayRules) + const isCustom = !matchesProfile // Find nearest presets for custom configurations - let nearestEasier: DifficultyLevel | null = null; - let nearestHarder: DifficultyLevel | null = null; - let customDescription: React.ReactNode = ""; + let nearestEasier: DifficultyLevel | null = null + let nearestHarder: DifficultyLevel | null = null + let customDescription: React.ReactNode = '' if (isCustom) { - const currentRegrouping = calculateRegroupingIntensity( - pAnyStart, - pAllStart, - ); - const currentScaffolding = calculateScaffoldingLevel( - displayRules, - currentRegrouping, - ); + const currentRegrouping = calculateRegroupingIntensity(pAnyStart, pAllStart) + const currentScaffolding = calculateScaffoldingLevel(displayRules, currentRegrouping) // Calculate distances to all presets const distances = DIFFICULTY_PROGRESSION.map((presetName) => { - const preset = DIFFICULTY_PROFILES[presetName]; + const preset = DIFFICULTY_PROFILES[presetName] const presetRegrouping = calculateRegroupingIntensity( preset.regrouping.pAnyStart, - preset.regrouping.pAllStart, - ); + preset.regrouping.pAllStart + ) const presetScaffolding = calculateScaffoldingLevel( preset.displayRules, - presetRegrouping, - ); + presetRegrouping + ) const distance = Math.sqrt( (currentRegrouping - presetRegrouping) ** 2 + - (currentScaffolding - presetScaffolding) ** 2, - ); + (currentScaffolding - presetScaffolding) ** 2 + ) return { presetName, distance, difficulty: calculateOverallDifficulty( preset.regrouping.pAnyStart, preset.regrouping.pAllStart, - preset.displayRules, + preset.displayRules ), - }; - }).sort((a, b) => a.distance - b.distance); + } + }).sort((a, b) => a.distance - b.distance) const currentDifficultyValue = calculateOverallDifficulty( pAnyStart, pAllStart, - displayRules, - ); + displayRules + ) // Find closest easier and harder presets - const easierPresets = distances.filter( - (d) => d.difficulty < currentDifficultyValue, - ); - const harderPresets = distances.filter( - (d) => d.difficulty > currentDifficultyValue, - ); + const easierPresets = distances.filter((d) => d.difficulty < currentDifficultyValue) + const harderPresets = distances.filter((d) => d.difficulty > currentDifficultyValue) nearestEasier = - easierPresets.length > 0 - ? easierPresets[0].presetName - : distances[0].presetName; + easierPresets.length > 0 ? easierPresets[0].presetName : distances[0].presetName nearestHarder = harderPresets.length > 0 ? harderPresets[0].presetName - : distances[distances.length - 1].presetName; + : distances[distances.length - 1].presetName // Generate custom description - const regroupingPercent = Math.round(pAnyStart * 100); - const scaffoldingSummary = getScaffoldingSummary( - displayRules, - formState.operator, - ); + const regroupingPercent = Math.round(pAnyStart * 100) + const scaffoldingSummary = getScaffoldingSummary(displayRules, formState.operator) customDescription = ( <>
{regroupingPercent}% regrouping
{scaffoldingSummary} - ); + ) } // Calculate current difficulty position - const currentDifficulty = calculateOverallDifficulty( - pAnyStart, - pAllStart, - displayRules, - ); + const currentDifficulty = calculateOverallDifficulty(pAnyStart, pAllStart, displayRules) // Calculate make easier/harder results for preview (all modes) const easierResultBoth = makeEasier( @@ -227,9 +198,9 @@ export function SmartModeControls({ pAllStart, displayRules, }, - "both", - formState.operator, - ); + 'both', + formState.operator + ) const easierResultChallenge = makeEasier( { @@ -237,9 +208,9 @@ export function SmartModeControls({ pAllStart, displayRules, }, - "challenge", - formState.operator, - ); + 'challenge', + formState.operator + ) const easierResultSupport = makeEasier( { @@ -247,9 +218,9 @@ export function SmartModeControls({ pAllStart, displayRules, }, - "support", - formState.operator, - ); + 'support', + formState.operator + ) const harderResultBoth = makeHarder( { @@ -257,9 +228,9 @@ export function SmartModeControls({ pAllStart, displayRules, }, - "both", - formState.operator, - ); + 'both', + formState.operator + ) const harderResultChallenge = makeHarder( { @@ -267,9 +238,9 @@ export function SmartModeControls({ pAllStart, displayRules, }, - "challenge", - formState.operator, - ); + 'challenge', + formState.operator + ) const harderResultSupport = makeHarder( { @@ -277,43 +248,37 @@ export function SmartModeControls({ pAllStart, displayRules, }, - "support", - formState.operator, - ); + 'support', + formState.operator + ) const canMakeEasierBoth = - easierResultBoth.changeDescription !== - "Already at minimum difficulty"; + easierResultBoth.changeDescription !== 'Already at minimum difficulty' const canMakeEasierChallenge = - easierResultChallenge.changeDescription !== - "Already at minimum difficulty"; + easierResultChallenge.changeDescription !== 'Already at minimum difficulty' const canMakeEasierSupport = - easierResultSupport.changeDescription !== - "Already at minimum difficulty"; + easierResultSupport.changeDescription !== 'Already at minimum difficulty' const canMakeHarderBoth = - harderResultBoth.changeDescription !== - "Already at maximum difficulty"; + harderResultBoth.changeDescription !== 'Already at maximum difficulty' const canMakeHarderChallenge = - harderResultChallenge.changeDescription !== - "Already at maximum difficulty"; + harderResultChallenge.changeDescription !== 'Already at maximum difficulty' const canMakeHarderSupport = - harderResultSupport.changeDescription !== - "Already at maximum difficulty"; + harderResultSupport.changeDescription !== 'Already at maximum difficulty' // Keep legacy names for compatibility - const canMakeEasier = canMakeEasierBoth; - const canMakeHarder = canMakeHarderBoth; + const canMakeEasier = canMakeEasierBoth + const canMakeHarder = canMakeHarderBoth return ( <> {/* Preset Selector Dropdown */} -
+
Difficulty Preset @@ -324,54 +289,50 @@ export function SmartModeControls({ type="button" data-action="open-preset-dropdown" className={css({ - w: "full", - h: "24", - px: "3", - py: "2.5", - border: "2px solid", - borderColor: isCustom ? "orange.400" : "gray.300", - bg: isCustom ? "orange.50" : "white", - rounded: "lg", - cursor: "pointer", - transition: "all 0.15s", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - textAlign: "left", - gap: "2", + w: 'full', + h: '24', + px: '3', + py: '2.5', + border: '2px solid', + borderColor: isCustom ? 'orange.400' : 'gray.300', + bg: isCustom ? 'orange.50' : 'white', + rounded: 'lg', + cursor: 'pointer', + transition: 'all 0.15s', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + textAlign: 'left', + gap: '2', _hover: { - borderColor: isCustom ? "orange.500" : "brand.400", + borderColor: isCustom ? 'orange.500' : 'brand.400', }, })} >
{hoverPreview ? ( <> - {hoverPreview.matchedProfile !== "custom" ? ( + {hoverPreview.matchedProfile !== 'custom' ? ( <> - { - DIFFICULTY_PROFILES[ - hoverPreview.matchedProfile - ].label - }{" "} + {DIFFICULTY_PROFILES[hoverPreview.matchedProfile].label}{' '} (hover preview) @@ -379,11 +340,11 @@ export function SmartModeControls({ ) : ( <> - ✨ Custom{" "} + ✨ Custom{' '} (hover preview) @@ -395,76 +356,72 @@ export function SmartModeControls({ nearestEasier && nearestHarder ? ( <> {DIFFICULTY_PROFILES[nearestEasier].label} - {" ↔ "} + {' ↔ '} {DIFFICULTY_PROFILES[nearestHarder].label} ) : ( - "✨ Custom" + '✨ Custom' ) ) : currentProfile ? ( DIFFICULTY_PROFILES[currentProfile].label ) : ( - "Early Learner" + 'Early Learner' )}
{hoverPreview ? ( (() => { - const regroupingPercent = Math.round( - hoverPreview.pAnyStart * 100, - ); + const regroupingPercent = Math.round(hoverPreview.pAnyStart * 100) const scaffoldingSummary = getScaffoldingSummary( hoverPreview.displayRules, - formState.operator, - ); + formState.operator + ) return ( <>
{regroupingPercent}% regrouping
{scaffoldingSummary} - ); + ) })() ) : isCustom ? ( customDescription ) : currentProfile ? ( (() => { - const preset = - DIFFICULTY_PROFILES[currentProfile]; + const preset = DIFFICULTY_PROFILES[currentProfile] const regroupingPercent = Math.round( - preset.regrouping.pAnyStart * 100, - ); + preset.regrouping.pAnyStart * 100 + ) const scaffoldingSummary = getScaffoldingSummary( preset.displayRules, - formState.operator, - ); + formState.operator + ) return ( <>
{regroupingPercent}% regrouping
{scaffoldingSummary} - ); + ) })() ) : ( <>
25% regrouping
- Always: carry boxes, answer boxes, place value - colors, ten-frames + Always: carry boxes, answer boxes, place value colors, ten-frames
)} @@ -472,8 +429,8 @@ export function SmartModeControls({
@@ -485,41 +442,40 @@ export function SmartModeControls({ {DIFFICULTY_PROGRESSION.map((presetName) => { - const preset = DIFFICULTY_PROFILES[presetName]; - const isSelected = - currentProfile === presetName && !isCustom; + const preset = DIFFICULTY_PROFILES[presetName] + const isSelected = currentProfile === presetName && !isCustom // Generate preset description const regroupingPercent = Math.round( calculateRegroupingIntensity( preset.regrouping.pAnyStart, - preset.regrouping.pAllStart, - ) * 10, - ); + preset.regrouping.pAllStart + ) * 10 + ) const scaffoldingSummary = getScaffoldingSummary( preset.displayRules, - formState.operator, - ); + formState.operator + ) const presetDescription = ( <>
{regroupingPercent}% regrouping
{scaffoldingSummary} - ); + ) return (
{preset.label}
{presetDescription}
- ); + ) })}
@@ -581,71 +537,66 @@ export function SmartModeControls({ {/* Make Easier/Harder buttons with preview */}
{/* Four-Button Layout: [Alt-35%][Rec-65%][Rec-65%][Alt-35%] */} -
+
{/* Determine which mode is alternative for easier */} {(() => { const easierAlternativeMode = easierResultBoth.changeDescription === easierResultChallenge.changeDescription - ? "support" - : "challenge"; + ? 'support' + : 'challenge' const easierAlternativeResult = - easierAlternativeMode === "support" + easierAlternativeMode === 'support' ? easierResultSupport - : easierResultChallenge; + : easierResultChallenge const easierAlternativeLabel = - easierAlternativeMode === "support" - ? "↑ More support" - : "← Less challenge"; + easierAlternativeMode === 'support' ? '↑ More support' : '← Less challenge' const canEasierAlternative = - easierAlternativeMode === "support" + easierAlternativeMode === 'support' ? canMakeEasierSupport - : canMakeEasierChallenge; + : canMakeEasierChallenge return ( -
+
{/* Alternative Easier Button - Hidden if disabled and main is enabled */} {canEasierAlternative && (
- ); + ) })()} {/* Determine which mode is alternative for harder */} @@ -754,66 +695,56 @@ export function SmartModeControls({ const harderAlternativeMode = harderResultBoth.changeDescription === harderResultChallenge.changeDescription - ? "support" - : "challenge"; + ? 'support' + : 'challenge' const harderAlternativeResult = - harderAlternativeMode === "support" + harderAlternativeMode === 'support' ? harderResultSupport - : harderResultChallenge; + : harderResultChallenge const harderAlternativeLabel = - harderAlternativeMode === "support" - ? "↓ Less support" - : "→ More challenge"; + harderAlternativeMode === 'support' ? '↓ Less support' : '→ More challenge' const canHarderAlternative = - harderAlternativeMode === "support" + harderAlternativeMode === 'support' ? canMakeHarderSupport - : canMakeHarderChallenge; + : canMakeHarderChallenge return ( -
+
{/* Recommended Harder Button - Expands to full width if alternative is hidden */}
- ); + ) })()}
{/* Overall Difficulty Slider */} -
+
Overall Difficulty: {currentDifficulty.toFixed(1)} / 10
{/* Difficulty Slider */} -
+
{ - const targetDifficulty = value[0] / 10; + const targetDifficulty = value[0] / 10 // Calculate preset positions in 2D space - const presetPoints = DIFFICULTY_PROGRESSION.map( - (presetName) => { - const preset = DIFFICULTY_PROFILES[presetName]; - const regrouping = calculateRegroupingIntensity( - preset.regrouping.pAnyStart, - preset.regrouping.pAllStart, - ); - const scaffolding = calculateScaffoldingLevel( - preset.displayRules, - regrouping, - ); - const difficulty = calculateOverallDifficulty( - preset.regrouping.pAnyStart, - preset.regrouping.pAllStart, - preset.displayRules, - ); - return { - regrouping, - scaffolding, - difficulty, - name: presetName, - }; - }, - ); + const presetPoints = DIFFICULTY_PROGRESSION.map((presetName) => { + const preset = DIFFICULTY_PROFILES[presetName] + const regrouping = calculateRegroupingIntensity( + preset.regrouping.pAnyStart, + preset.regrouping.pAllStart + ) + const scaffolding = calculateScaffoldingLevel( + preset.displayRules, + regrouping + ) + const difficulty = calculateOverallDifficulty( + preset.regrouping.pAnyStart, + preset.regrouping.pAllStart, + preset.displayRules + ) + return { + regrouping, + scaffolding, + difficulty, + name: presetName, + } + }) // Find which path segment we're on and interpolate - let idealRegrouping = 0; - let idealScaffolding = 10; + let idealRegrouping = 0 + let idealScaffolding = 10 for (let i = 0; i < presetPoints.length - 1; i++) { - const start = presetPoints[i]; - const end = presetPoints[i + 1]; + const start = presetPoints[i] + const end = presetPoints[i + 1] if ( targetDifficulty >= start.difficulty && @@ -974,210 +898,192 @@ export function SmartModeControls({ // Interpolate between start and end const t = (targetDifficulty - start.difficulty) / - (end.difficulty - start.difficulty); + (end.difficulty - start.difficulty) idealRegrouping = - start.regrouping + - t * (end.regrouping - start.regrouping); + start.regrouping + t * (end.regrouping - start.regrouping) idealScaffolding = - start.scaffolding + - t * (end.scaffolding - start.scaffolding); + start.scaffolding + t * (end.scaffolding - start.scaffolding) console.log( - "[Slider] Interpolating between", + '[Slider] Interpolating between', start.name, - "and", + 'and', end.name, { t, idealRegrouping, idealScaffolding, - }, - ); - break; + } + ) + break } } // Handle edge cases (before first or after last preset) if (targetDifficulty < presetPoints[0].difficulty) { - idealRegrouping = presetPoints[0].regrouping; - idealScaffolding = presetPoints[0].scaffolding; + idealRegrouping = presetPoints[0].regrouping + idealScaffolding = presetPoints[0].scaffolding } else if ( - targetDifficulty > - presetPoints[presetPoints.length - 1].difficulty + targetDifficulty > presetPoints[presetPoints.length - 1].difficulty ) { - idealRegrouping = - presetPoints[presetPoints.length - 1].regrouping; - idealScaffolding = - presetPoints[presetPoints.length - 1].scaffolding; + idealRegrouping = presetPoints[presetPoints.length - 1].regrouping + idealScaffolding = presetPoints[presetPoints.length - 1].scaffolding } // Find valid configuration closest to ideal point on path let closestConfig: { - pAnyStart: number; - pAllStart: number; - displayRules: any; - distance: number; - } | null = null; + pAnyStart: number + pAllStart: number + displayRules: any + distance: number + } | null = null - for ( - let regIdx = 0; - regIdx < REGROUPING_PROGRESSION.length; - regIdx++ - ) { + for (let regIdx = 0; regIdx < REGROUPING_PROGRESSION.length; regIdx++) { for ( let scaffIdx = 0; scaffIdx < SCAFFOLDING_PROGRESSION.length; scaffIdx++ ) { - const validState = findNearestValidState( - regIdx, - scaffIdx, - ); + const validState = findNearestValidState(regIdx, scaffIdx) if ( validState.regroupingIdx !== regIdx || validState.scaffoldingIdx !== scaffIdx ) { - continue; + continue } - const regrouping = REGROUPING_PROGRESSION[regIdx]; - const displayRules = - SCAFFOLDING_PROGRESSION[scaffIdx]; + const regrouping = REGROUPING_PROGRESSION[regIdx] + const displayRules = SCAFFOLDING_PROGRESSION[scaffIdx] const actualRegrouping = calculateRegroupingIntensity( regrouping.pAnyStart, - regrouping.pAllStart, - ); + regrouping.pAllStart + ) const actualScaffolding = calculateScaffoldingLevel( displayRules, - actualRegrouping, - ); + actualRegrouping + ) // Euclidean distance to ideal point on pedagogical path const distance = Math.sqrt( (actualRegrouping - idealRegrouping) ** 2 + - (actualScaffolding - idealScaffolding) ** 2, - ); + (actualScaffolding - idealScaffolding) ** 2 + ) - if ( - closestConfig === null || - distance < closestConfig.distance - ) { + if (closestConfig === null || distance < closestConfig.distance) { closestConfig = { pAnyStart: regrouping.pAnyStart, pAllStart: regrouping.pAllStart, displayRules, distance, - }; + } } } } if (closestConfig) { - console.log("[Slider] Closest config:", { + console.log('[Slider] Closest config:', { ...closestConfig, regrouping: calculateRegroupingIntensity( closestConfig.pAnyStart, - closestConfig.pAllStart, + closestConfig.pAllStart ), scaffolding: calculateScaffoldingLevel( closestConfig.displayRules, calculateRegroupingIntensity( closestConfig.pAnyStart, - closestConfig.pAllStart, - ), + closestConfig.pAllStart + ) ), - }); + }) const matchedProfile = getProfileFromConfig( closestConfig.pAllStart, closestConfig.pAnyStart, - closestConfig.displayRules, - ); + closestConfig.displayRules + ) onChange({ pAnyStart: closestConfig.pAnyStart, pAllStart: closestConfig.pAllStart, displayRules: closestConfig.displayRules, difficultyProfile: - matchedProfile !== "custom" - ? matchedProfile - : undefined, - }); + matchedProfile !== 'custom' ? matchedProfile : undefined, + }) } }} className={css({ - position: "relative", - display: "flex", - alignItems: "center", - userSelect: "none", - touchAction: "none", - h: "8", + position: 'relative', + display: 'flex', + alignItems: 'center', + userSelect: 'none', + touchAction: 'none', + h: '8', })} > {/* Preset markers on track */} {DIFFICULTY_PROGRESSION.map((profileName) => { - const p = DIFFICULTY_PROFILES[profileName]; + const p = DIFFICULTY_PROFILES[profileName] const presetDifficulty = calculateOverallDifficulty( p.regrouping.pAnyStart, p.regrouping.pAllStart, - p.displayRules, - ); - const position = (presetDifficulty / 10) * 100; + p.displayRules + ) + const position = (presetDifficulty / 10) * 100 return (
- ); + ) })} @@ -1188,49 +1094,47 @@ export function SmartModeControls({ {/* 2D Difficulty Space Visualizer */}
{showDebugPlot && ( -
+
{/* Responsive SVG container */}
{(() => { // Make responsive - use container width with max size - const maxSize = 500; - const width = maxSize; - const height = maxSize; - const padding = 40; - const graphWidth = width - padding * 2; - const graphHeight = height - padding * 2; + const maxSize = 500 + const width = maxSize + const height = maxSize + const padding = 40 + const graphWidth = width - padding * 2 + const graphHeight = height - padding * 2 - const currentReg = calculateRegroupingIntensity( - pAnyStart, - pAllStart, - ); - const currentScaf = calculateScaffoldingLevel( - displayRules, - currentReg, - ); + const currentReg = calculateRegroupingIntensity(pAnyStart, pAllStart) + const currentScaf = calculateScaffoldingLevel(displayRules, currentReg) // Convert 0-10 scale to SVG coordinates - const toX = (val: number) => - padding + (val / 10) * graphWidth; - const toY = (val: number) => - height - padding - (val / 10) * graphHeight; + const toX = (val: number) => padding + (val / 10) * graphWidth + const toY = (val: number) => height - padding - (val / 10) * graphHeight // Convert SVG coordinates to 0-10 scale const fromX = (x: number) => - Math.max( - 0, - Math.min(10, ((x - padding) / graphWidth) * 10), - ); + Math.max(0, Math.min(10, ((x - padding) / graphWidth) * 10)) const fromY = (y: number) => - Math.max( - 0, - Math.min( - 10, - ((height - padding - y) / graphHeight) * 10, - ), - ); + Math.max(0, Math.min(10, ((height - padding - y) / graphHeight) * 10)) // Helper to calculate valid target from mouse position const calculateValidTarget = ( clientX: number, clientY: number, - svg: SVGSVGElement, + svg: SVGSVGElement ) => { - const rect = svg.getBoundingClientRect(); - const x = clientX - rect.left; - const y = clientY - rect.top; + const rect = svg.getBoundingClientRect() + const x = clientX - rect.left + const y = clientY - rect.top // Convert to difficulty space (0-10) - const regroupingIntensity = fromX(x); - const scaffoldingLevel = fromY(y); + const regroupingIntensity = fromX(x) + const scaffoldingLevel = fromY(y) // Check if we're near a preset (within snap threshold) - const snapThreshold = 1.0; // 1.0 units in 0-10 scale + const snapThreshold = 1.0 // 1.0 units in 0-10 scale let nearestPreset: { - distance: number; - profile: (typeof DIFFICULTY_PROFILES)[keyof typeof DIFFICULTY_PROFILES]; - } | null = null; + distance: number + profile: (typeof DIFFICULTY_PROFILES)[keyof typeof DIFFICULTY_PROFILES] + } | null = null for (const profileName of DIFFICULTY_PROGRESSION) { - const p = DIFFICULTY_PROFILES[profileName]; + const p = DIFFICULTY_PROFILES[profileName] const presetReg = calculateRegroupingIntensity( p.regrouping.pAnyStart, - p.regrouping.pAllStart, - ); - const presetScaf = calculateScaffoldingLevel( - p.displayRules, - presetReg, - ); + p.regrouping.pAllStart + ) + const presetScaf = calculateScaffoldingLevel(p.displayRules, presetReg) // Calculate Euclidean distance const distance = Math.sqrt( (regroupingIntensity - presetReg) ** 2 + - (scaffoldingLevel - presetScaf) ** 2, - ); + (scaffoldingLevel - presetScaf) ** 2 + ) if (distance <= snapThreshold) { - if ( - !nearestPreset || - distance < nearestPreset.distance - ) { - nearestPreset = { distance, profile: p }; + if (!nearestPreset || distance < nearestPreset.distance) { + nearestPreset = { distance, profile: p } } } } @@ -1343,61 +1224,50 @@ export function SmartModeControls({ if (nearestPreset) { return { newRegrouping: nearestPreset.profile.regrouping, - newDisplayRules: - nearestPreset.profile.displayRules, + newDisplayRules: nearestPreset.profile.displayRules, matchedProfile: nearestPreset.profile.name, reg: calculateRegroupingIntensity( nearestPreset.profile.regrouping.pAnyStart, - nearestPreset.profile.regrouping.pAllStart, + nearestPreset.profile.regrouping.pAllStart ), scaf: calculateScaffoldingLevel( nearestPreset.profile.displayRules, calculateRegroupingIntensity( nearestPreset.profile.regrouping.pAnyStart, - nearestPreset.profile.regrouping.pAllStart, - ), + nearestPreset.profile.regrouping.pAllStart + ) ), - }; + } } // No preset nearby, use normal progression indices const regroupingIdx = Math.round( - (regroupingIntensity / 10) * - (REGROUPING_PROGRESSION.length - 1), - ); + (regroupingIntensity / 10) * (REGROUPING_PROGRESSION.length - 1) + ) const scaffoldingIdx = Math.round( - ((10 - scaffoldingLevel) / 10) * - (SCAFFOLDING_PROGRESSION.length - 1), - ); + ((10 - scaffoldingLevel) / 10) * (SCAFFOLDING_PROGRESSION.length - 1) + ) // Find nearest valid state (applies pedagogical constraints) - const validState = findNearestValidState( - regroupingIdx, - scaffoldingIdx, - ); + const validState = findNearestValidState(regroupingIdx, scaffoldingIdx) // Get actual values from progressions - const newRegrouping = - REGROUPING_PROGRESSION[validState.regroupingIdx]; - const newDisplayRules = - SCAFFOLDING_PROGRESSION[validState.scaffoldingIdx]; + const newRegrouping = REGROUPING_PROGRESSION[validState.regroupingIdx] + const newDisplayRules = SCAFFOLDING_PROGRESSION[validState.scaffoldingIdx] // Calculate display coordinates const reg = calculateRegroupingIntensity( newRegrouping.pAnyStart, - newRegrouping.pAllStart, - ); - const scaf = calculateScaffoldingLevel( - newDisplayRules, - reg, - ); + newRegrouping.pAllStart + ) + const scaf = calculateScaffoldingLevel(newDisplayRules, reg) // Check if this matches a preset const matchedProfile = getProfileFromConfig( newRegrouping.pAllStart, newRegrouping.pAnyStart, - newDisplayRules, - ); + newDisplayRules + ) return { newRegrouping, @@ -1405,41 +1275,29 @@ export function SmartModeControls({ matchedProfile, reg, scaf, - }; - }; + } + } - const handleMouseMove = ( - e: React.MouseEvent, - ) => { - const svg = e.currentTarget; - const target = calculateValidTarget( - e.clientX, - e.clientY, - svg, - ); - setHoverPoint({ x: target.reg, y: target.scaf }); + const handleMouseMove = (e: React.MouseEvent) => { + const svg = e.currentTarget + const target = calculateValidTarget(e.clientX, e.clientY, svg) + setHoverPoint({ x: target.reg, y: target.scaf }) setHoverPreview({ pAnyStart: target.newRegrouping.pAnyStart, pAllStart: target.newRegrouping.pAllStart, displayRules: target.newDisplayRules, matchedProfile: target.matchedProfile, - }); - }; + }) + } const handleMouseLeave = () => { - setHoverPoint(null); - setHoverPreview(null); - }; + setHoverPoint(null) + setHoverPreview(null) + } - const handleClick = ( - e: React.MouseEvent, - ) => { - const svg = e.currentTarget; - const target = calculateValidTarget( - e.clientX, - e.clientY, - svg, - ); + const handleClick = (e: React.MouseEvent) => { + const svg = e.currentTarget + const target = calculateValidTarget(e.clientX, e.clientY, svg) // Update via onChange onChange({ @@ -1447,11 +1305,11 @@ export function SmartModeControls({ pAllStart: target.newRegrouping.pAllStart, displayRules: target.newDisplayRules, difficultyProfile: - target.matchedProfile !== "custom" + target.matchedProfile !== 'custom' ? target.matchedProfile : undefined, - }); - }; + }) + } return ( {/* Grid lines */} @@ -1534,15 +1392,12 @@ export function SmartModeControls({ {/* Preset points */} {DIFFICULTY_PROGRESSION.map((profileName) => { - const p = DIFFICULTY_PROFILES[profileName]; + const p = DIFFICULTY_PROFILES[profileName] const reg = calculateRegroupingIntensity( p.regrouping.pAnyStart, - p.regrouping.pAllStart, - ); - const scaf = calculateScaffoldingLevel( - p.displayRules, - reg, - ); + p.regrouping.pAllStart + ) + const scaf = calculateScaffoldingLevel(p.displayRules, reg) return ( @@ -1566,7 +1421,7 @@ export function SmartModeControls({ {p.label} - ); + ) })} {/* Hover preview - show where click will land */} @@ -1611,23 +1466,18 @@ export function SmartModeControls({ stroke="#059669" strokeWidth="3" /> - + - ); + ) })()}
)}
- ); + ) })()}
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/config-panel/StudentNameInput.tsx b/apps/web/src/app/create/worksheets/addition/components/config-panel/StudentNameInput.tsx index edd4d8e7..bd3946ac 100644 --- a/apps/web/src/app/create/worksheets/addition/components/config-panel/StudentNameInput.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/config-panel/StudentNameInput.tsx @@ -1,40 +1,36 @@ -import { css } from "../../../../../../../styled-system/css"; +import { css } from '../../../../../../../styled-system/css' export interface StudentNameInputProps { - value: string | undefined; - onChange: (value: string) => void; - isDark?: boolean; + value: string | undefined + onChange: (value: string) => void + isDark?: boolean } -export function StudentNameInput({ - value, - onChange, - isDark = false, -}: StudentNameInputProps) { +export function StudentNameInput({ value, onChange, isDark = false }: StudentNameInputProps) { return ( onChange(e.target.value)} placeholder="Student Name" className={css({ - w: "full", - px: "3", - py: "2", - border: "1px solid", - borderColor: isDark ? "gray.600" : "gray.300", - bg: isDark ? "gray.700" : "white", - color: isDark ? "gray.100" : "gray.900", - rounded: "lg", - fontSize: "sm", + w: 'full', + px: '3', + py: '2', + border: '1px solid', + borderColor: isDark ? 'gray.600' : 'gray.300', + bg: isDark ? 'gray.700' : 'white', + color: isDark ? 'gray.100' : 'gray.900', + rounded: 'lg', + fontSize: 'sm', _focus: { - outline: "none", - borderColor: "brand.500", - ring: "2px", - ringColor: "brand.200", + outline: 'none', + borderColor: 'brand.500', + ring: '2px', + ringColor: 'brand.200', }, - _placeholder: { color: isDark ? "gray.500" : "gray.400" }, + _placeholder: { color: isDark ? 'gray.500' : 'gray.400' }, })} /> - ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/config-panel/SubOption.tsx b/apps/web/src/app/create/worksheets/addition/components/config-panel/SubOption.tsx index 81a956e0..80eb91d3 100644 --- a/apps/web/src/app/create/worksheets/addition/components/config-panel/SubOption.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/config-panel/SubOption.tsx @@ -1,52 +1,47 @@ -import { css } from "../../../../../../../styled-system/css"; +import { css } from '../../../../../../../styled-system/css' export interface SubOptionProps { - checked: boolean; - onChange: (checked: boolean) => void; - label: string; - parentEnabled: boolean; + checked: boolean + onChange: (checked: boolean) => void + label: string + parentEnabled: boolean } /** * Reusable sub-option component for nested toggles * Used for options like "Show for all problems" under "Ten-Frames" */ -export function SubOption({ - checked, - onChange, - label, - parentEnabled, -}: SubOptionProps) { +export function SubOption({ checked, onChange, label, parentEnabled }: SubOptionProps) { return (
{ - e.stopPropagation(); - onChange(!checked); + e.stopPropagation() + onChange(!checked) }} >
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/config-panel/ToggleOption.tsx b/apps/web/src/app/create/worksheets/addition/components/config-panel/ToggleOption.tsx index 35ad26ea..28f06d7a 100644 --- a/apps/web/src/app/create/worksheets/addition/components/config-panel/ToggleOption.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/config-panel/ToggleOption.tsx @@ -1,14 +1,14 @@ -import type React from "react"; -import * as Checkbox from "@radix-ui/react-checkbox"; -import { css } from "../../../../../../../styled-system/css"; +import type React from 'react' +import * as Checkbox from '@radix-ui/react-checkbox' +import { css } from '../../../../../../../styled-system/css' export interface ToggleOptionProps { - checked: boolean; - onChange: (checked: boolean) => void; - label: string; - description: string; - children?: React.ReactNode; - isDark?: boolean; + checked: boolean + onChange: (checked: boolean) => void + label: string + description: string + children?: React.ReactNode + isDark?: boolean } export function ToggleOption({ @@ -23,17 +23,17 @@ export function ToggleOption({
@@ -42,72 +42,72 @@ export function ToggleOption({ onCheckedChange={onChange} data-element="toggle-option" className={css({ - display: "flex", - flexDirection: "column", - justifyContent: "flex-start", - gap: "1.5", - p: "2.5", - bg: "transparent", - border: "none", - rounded: "lg", - cursor: "pointer", - textAlign: "left", - w: "full", + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + gap: '1.5', + p: '2.5', + bg: 'transparent', + border: 'none', + rounded: 'lg', + cursor: 'pointer', + textAlign: 'left', + w: 'full', _focus: { - outline: "none", - ring: "2px", - ringColor: "brand.300", + outline: 'none', + ring: '2px', + ringColor: 'brand.300', }, })} >
{label}
{description} @@ -115,5 +115,5 @@ export function ToggleOption({ {children}
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/components/config-panel/utils.tsx b/apps/web/src/app/create/worksheets/addition/components/config-panel/utils.tsx index e99bdf0e..1067325c 100644 --- a/apps/web/src/app/create/worksheets/addition/components/config-panel/utils.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/config-panel/utils.tsx @@ -1,5 +1,5 @@ -import type React from "react"; -import { css } from "../../../../../../../styled-system/css"; +import type React from 'react' +import { css } from '../../../../../../../styled-system/css' /** * Generate a human-readable summary of enabled scaffolding aids @@ -9,83 +9,70 @@ import { css } from "../../../../../../../styled-system/css"; */ export function getScaffoldingSummary( displayRules: any, - operator?: "addition" | "subtraction" | "mixed", + operator?: 'addition' | 'subtraction' | 'mixed' ): React.ReactNode { - console.log( - "[getScaffoldingSummary] displayRules:", - displayRules, - "operator:", - operator, - ); + console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator) - const alwaysItems: string[] = []; - const conditionalItems: string[] = []; + const alwaysItems: string[] = [] + const conditionalItems: string[] = [] // Addition-specific scaffolds (skip for subtraction-only) - if (operator !== "subtraction") { - if (displayRules.carryBoxes === "always") { - alwaysItems.push("carry boxes"); - } else if (displayRules.carryBoxes !== "never") { - conditionalItems.push("carry boxes"); + if (operator !== 'subtraction') { + if (displayRules.carryBoxes === 'always') { + alwaysItems.push('carry boxes') + } else if (displayRules.carryBoxes !== 'never') { + conditionalItems.push('carry boxes') } - if (displayRules.tenFrames === "always") { - alwaysItems.push("ten-frames"); - } else if (displayRules.tenFrames !== "never") { - conditionalItems.push("ten-frames"); + if (displayRules.tenFrames === 'always') { + alwaysItems.push('ten-frames') + } else if (displayRules.tenFrames !== 'never') { + conditionalItems.push('ten-frames') } } // Universal scaffolds (always show) - if (displayRules.answerBoxes === "always") { - alwaysItems.push("answer boxes"); - } else if (displayRules.answerBoxes !== "never") { - conditionalItems.push("answer boxes"); + if (displayRules.answerBoxes === 'always') { + alwaysItems.push('answer boxes') + } else if (displayRules.answerBoxes !== 'never') { + conditionalItems.push('answer boxes') } - if (displayRules.placeValueColors === "always") { - alwaysItems.push("place value colors"); - } else if (displayRules.placeValueColors !== "never") { - conditionalItems.push("place value colors"); + if (displayRules.placeValueColors === 'always') { + alwaysItems.push('place value colors') + } else if (displayRules.placeValueColors !== 'never') { + conditionalItems.push('place value colors') } // Subtraction-specific scaffolds (skip for addition-only) - if (operator !== "addition") { - if (displayRules.borrowNotation === "always") { - alwaysItems.push("borrow notation"); - } else if (displayRules.borrowNotation !== "never") { - conditionalItems.push("borrow notation"); + if (operator !== 'addition') { + if (displayRules.borrowNotation === 'always') { + alwaysItems.push('borrow notation') + } else if (displayRules.borrowNotation !== 'never') { + conditionalItems.push('borrow notation') } - if (displayRules.borrowingHints === "always") { - alwaysItems.push("borrowing hints"); - } else if (displayRules.borrowingHints !== "never") { - conditionalItems.push("borrowing hints"); + if (displayRules.borrowingHints === 'always') { + alwaysItems.push('borrowing hints') + } else if (displayRules.borrowingHints !== 'never') { + conditionalItems.push('borrowing hints') } } if (alwaysItems.length === 0 && conditionalItems.length === 0) { - console.log("[getScaffoldingSummary] Final summary: no scaffolding"); - return ( - - no scaffolding - - ); + console.log('[getScaffoldingSummary] Final summary: no scaffolding') + return no scaffolding } - console.log("[getScaffoldingSummary] Final summary:", { + console.log('[getScaffoldingSummary] Final summary:', { alwaysItems, conditionalItems, - }); + }) return ( -
- {alwaysItems.length > 0 &&
Always: {alwaysItems.join(", ")}
} - {conditionalItems.length > 0 && ( -
When needed: {conditionalItems.join(", ")}
- )} +
+ {alwaysItems.length > 0 &&
Always: {alwaysItems.join(', ')}
} + {conditionalItems.length > 0 &&
When needed: {conditionalItems.join(', ')}
}
- ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/difficultyProfiles.ts b/apps/web/src/app/create/worksheets/addition/difficultyProfiles.ts index c9cb15fd..61fb1b23 100644 --- a/apps/web/src/app/create/worksheets/addition/difficultyProfiles.ts +++ b/apps/web/src/app/create/worksheets/addition/difficultyProfiles.ts @@ -6,7 +6,7 @@ // quantization and cycling issues. Each dimension has a discrete progression // from easiest to hardest, and make harder/easier simply navigate these arrays. -import type { DisplayRules } from "./displayRules"; +import type { DisplayRules } from './displayRules' /** * SCAFFOLDING_PROGRESSION: Ordered array of scaffolding configurations @@ -18,160 +18,160 @@ import type { DisplayRules } from "./displayRules"; export const SCAFFOLDING_PROGRESSION: DisplayRules[] = [ // Level 0: Maximum scaffolding - everything always visible { - carryBoxes: "always", - answerBoxes: "always", - placeValueColors: "always", - tenFrames: "always", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "always", - borrowingHints: "always", + carryBoxes: 'always', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'always', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'always', + borrowingHints: 'always', }, // Level 1: Carry boxes become conditional { - carryBoxes: "whenRegrouping", - answerBoxes: "always", - placeValueColors: "always", - tenFrames: "always", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "always", + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'always', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'always', }, // Level 2: Place value colors become conditional { - carryBoxes: "whenRegrouping", - answerBoxes: "always", - placeValueColors: "whenRegrouping", - tenFrames: "always", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "whenRegrouping", + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'whenRegrouping', + tenFrames: 'always', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'whenRegrouping', }, // Level 3: Answer boxes become conditional { - carryBoxes: "whenRegrouping", - answerBoxes: "whenRegrouping", - placeValueColors: "whenRegrouping", - tenFrames: "always", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "whenRegrouping", + carryBoxes: 'whenRegrouping', + answerBoxes: 'whenRegrouping', + placeValueColors: 'whenRegrouping', + tenFrames: 'always', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'whenRegrouping', }, // Level 4: Multiple helpers become more conditional { - carryBoxes: "whenRegrouping", - answerBoxes: "whenMultipleRegroups", - placeValueColors: "whenRegrouping", - tenFrames: "always", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "whenMultipleRegroups", + carryBoxes: 'whenRegrouping', + answerBoxes: 'whenMultipleRegroups', + placeValueColors: 'whenRegrouping', + tenFrames: 'always', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'whenMultipleRegroups', }, // Level 5: More helpers get more conditional { - carryBoxes: "whenMultipleRegroups", - answerBoxes: "whenMultipleRegroups", - placeValueColors: "whenMultipleRegroups", - tenFrames: "always", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "whenMultipleRegroups", + carryBoxes: 'whenMultipleRegroups', + answerBoxes: 'whenMultipleRegroups', + placeValueColors: 'whenMultipleRegroups', + tenFrames: 'always', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'whenMultipleRegroups', }, // Level 6: Ten frames become conditional (only when regrouping) { - carryBoxes: "whenMultipleRegroups", - answerBoxes: "whenMultipleRegroups", - placeValueColors: "whenMultipleRegroups", - tenFrames: "whenRegrouping", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "whenMultipleRegroups", + carryBoxes: 'whenMultipleRegroups', + answerBoxes: 'whenMultipleRegroups', + placeValueColors: 'whenMultipleRegroups', + tenFrames: 'whenRegrouping', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'whenMultipleRegroups', }, // Level 7: Ten frames become more conditional { - carryBoxes: "whenMultipleRegroups", - answerBoxes: "whenMultipleRegroups", - placeValueColors: "whenMultipleRegroups", - tenFrames: "whenMultipleRegroups", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "never", + carryBoxes: 'whenMultipleRegroups', + answerBoxes: 'whenMultipleRegroups', + placeValueColors: 'whenMultipleRegroups', + tenFrames: 'whenMultipleRegroups', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'never', }, // Level 8: Carry boxes removed { - carryBoxes: "never", - answerBoxes: "whenMultipleRegroups", - placeValueColors: "whenMultipleRegroups", - tenFrames: "whenMultipleRegroups", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "never", + carryBoxes: 'never', + answerBoxes: 'whenMultipleRegroups', + placeValueColors: 'whenMultipleRegroups', + tenFrames: 'whenMultipleRegroups', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'never', }, // Level 9: Answer boxes removed { - carryBoxes: "never", - answerBoxes: "never", - placeValueColors: "whenMultipleRegroups", - tenFrames: "whenMultipleRegroups", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "never", + carryBoxes: 'never', + answerBoxes: 'never', + placeValueColors: 'whenMultipleRegroups', + tenFrames: 'whenMultipleRegroups', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'never', }, // Level 10: Ten frames removed { - carryBoxes: "never", - answerBoxes: "never", - placeValueColors: "whenMultipleRegroups", - tenFrames: "never", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "never", + carryBoxes: 'never', + answerBoxes: 'never', + placeValueColors: 'whenMultipleRegroups', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'never', }, // Level 11: Place value colors only for large numbers { - carryBoxes: "never", - answerBoxes: "never", - placeValueColors: "when3PlusDigits", - tenFrames: "never", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "never", + carryBoxes: 'never', + answerBoxes: 'never', + placeValueColors: 'when3PlusDigits', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'never', }, // Level 12: Minimal scaffolding - place value colors removed { - carryBoxes: "never", - answerBoxes: "never", - placeValueColors: "never", - tenFrames: "never", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "never", - borrowingHints: "never", + carryBoxes: 'never', + answerBoxes: 'never', + placeValueColors: 'never', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', + borrowingHints: 'never', }, -]; +] /** * REGROUPING_PROGRESSION: Ordered array of regrouping configurations @@ -179,8 +179,8 @@ export const SCAFFOLDING_PROGRESSION: DisplayRules[] = [ * Index N = maximum regrouping (hardest) */ export const REGROUPING_PROGRESSION: Array<{ - pAnyStart: number; - pAllStart: number; + pAnyStart: number + pAllStart: number }> = [ { pAnyStart: 0.0, pAllStart: 0.0 }, // 0: No regrouping { pAnyStart: 0.15, pAllStart: 0.0 }, // 1: Minimal @@ -201,7 +201,7 @@ export const REGROUPING_PROGRESSION: Array<{ { pAnyStart: 0.98, pAllStart: 0.78 }, // 16: { pAnyStart: 1.0, pAllStart: 0.9 }, // 17: { pAnyStart: 1.0, pAllStart: 1.0 }, // 18: Maximum regrouping -]; +] /** * Find the closest scaffolding index for given display rules @@ -210,57 +210,54 @@ export function findScaffoldingIndex(rules: DisplayRules): number { // Try exact match first for (let i = 0; i < SCAFFOLDING_PROGRESSION.length; i++) { if (JSON.stringify(SCAFFOLDING_PROGRESSION[i]) === JSON.stringify(rules)) { - return i; + return i } } // No exact match - find closest by counting matching rules - let bestIndex = 0; - let bestMatchCount = 0; + let bestIndex = 0 + let bestMatchCount = 0 for (let i = 0; i < SCAFFOLDING_PROGRESSION.length; i++) { - const level = SCAFFOLDING_PROGRESSION[i]; - let matchCount = 0; + const level = SCAFFOLDING_PROGRESSION[i] + let matchCount = 0 - if (level.carryBoxes === rules.carryBoxes) matchCount++; - if (level.answerBoxes === rules.answerBoxes) matchCount++; - if (level.placeValueColors === rules.placeValueColors) matchCount++; - if (level.tenFrames === rules.tenFrames) matchCount++; - if (level.problemNumbers === rules.problemNumbers) matchCount++; - if (level.cellBorders === rules.cellBorders) matchCount++; + if (level.carryBoxes === rules.carryBoxes) matchCount++ + if (level.answerBoxes === rules.answerBoxes) matchCount++ + if (level.placeValueColors === rules.placeValueColors) matchCount++ + if (level.tenFrames === rules.tenFrames) matchCount++ + if (level.problemNumbers === rules.problemNumbers) matchCount++ + if (level.cellBorders === rules.cellBorders) matchCount++ if (matchCount > bestMatchCount) { - bestMatchCount = matchCount; - bestIndex = i; + bestMatchCount = matchCount + bestIndex = i } } - return bestIndex; + return bestIndex } /** * Find the closest regrouping index for given probabilities */ -export function findRegroupingIndex( - pAnyStart: number, - pAllStart: number, -): number { - let bestIndex = 0; - let bestDistance = Infinity; +export function findRegroupingIndex(pAnyStart: number, pAllStart: number): number { + let bestIndex = 0 + let bestDistance = Infinity for (let i = 0; i < REGROUPING_PROGRESSION.length; i++) { - const level = REGROUPING_PROGRESSION[i]; + const level = REGROUPING_PROGRESSION[i] const distance = Math.sqrt( - (level.pAnyStart - pAnyStart) ** 2 + (level.pAllStart - pAllStart) ** 2, - ); + (level.pAnyStart - pAnyStart) ** 2 + (level.pAllStart - pAllStart) ** 2 + ) if (distance < bestDistance) { - bestDistance = distance; - bestIndex = i; + bestDistance = distance + bestIndex = i } } - return bestIndex; + return bestIndex } /** @@ -273,54 +270,46 @@ export function findRegroupingIndex( function describeScaffoldingChange( fromRules: DisplayRules, toRules: DisplayRules, - direction: "added" | "reduced", - operator?: "addition" | "subtraction" | "mixed", + direction: 'added' | 'reduced', + operator?: 'addition' | 'subtraction' | 'mixed' ): string { - const changes: string[] = []; + const changes: string[] = [] const ruleNames: Record = { - carryBoxes: "carry boxes", - answerBoxes: "answer boxes", - placeValueColors: "place value colors", - tenFrames: "ten frames", - problemNumbers: "problem numbers", - cellBorders: "cell borders", - borrowNotation: "borrow notation", - borrowingHints: "borrowing hints", - }; + carryBoxes: 'carry boxes', + answerBoxes: 'answer boxes', + placeValueColors: 'place value colors', + tenFrames: 'ten frames', + problemNumbers: 'problem numbers', + cellBorders: 'cell borders', + borrowNotation: 'borrow notation', + borrowingHints: 'borrowing hints', + } // Operator-specific scaffolds to filter - const additionOnlyScaffolds: Array = [ - "carryBoxes", - "tenFrames", - ]; - const subtractionOnlyScaffolds: Array = [ - "borrowNotation", - "borrowingHints", - ]; + const additionOnlyScaffolds: Array = ['carryBoxes', 'tenFrames'] + const subtractionOnlyScaffolds: Array = ['borrowNotation', 'borrowingHints'] for (const key of Object.keys(ruleNames) as Array) { if (fromRules[key] !== toRules[key]) { // Filter out operator-specific scaffolds when not relevant - if (operator === "addition" && subtractionOnlyScaffolds.includes(key)) { - continue; // Skip subtraction scaffolds for addition-only worksheets + if (operator === 'addition' && subtractionOnlyScaffolds.includes(key)) { + continue // Skip subtraction scaffolds for addition-only worksheets } - if (operator === "subtraction" && additionOnlyScaffolds.includes(key)) { - continue; // Skip addition scaffolds for subtraction-only worksheets + if (operator === 'subtraction' && additionOnlyScaffolds.includes(key)) { + continue // Skip addition scaffolds for subtraction-only worksheets } // For 'mixed' or undefined operator, show all scaffolds - changes.push(ruleNames[key]); + changes.push(ruleNames[key]) } } - if (changes.length === 0) return "Adjust difficulty"; + if (changes.length === 0) return 'Adjust difficulty' if (changes.length === 1) { - return direction === "added" ? `Add ${changes[0]}` : `Reduce ${changes[0]}`; + return direction === 'added' ? `Add ${changes[0]}` : `Reduce ${changes[0]}` } - return direction === "added" - ? `Add ${changes.join(", ")}` - : `Reduce ${changes.join(", ")}`; + return direction === 'added' ? `Add ${changes.join(', ')}` : `Reduce ${changes.join(', ')}` } // ============================================================================= @@ -339,58 +328,52 @@ function describeScaffoldingChange( * - Low regrouping + Low scaffolding (pointless - nothing to scaffold) */ function getValidScaffoldingRange(regroupingIdx: number): { - min: number; - max: number; + min: number + max: number } { // No/minimal regrouping (0-2): Need scaffolding to learn structure if (regroupingIdx <= 2) { - return { min: 0, max: 4 }; + return { min: 0, max: 4 } } // Light regrouping (3-5): Still need significant scaffolding if (regroupingIdx <= 5) { - return { min: 0, max: 6 }; + return { min: 0, max: 6 } } // Light-medium regrouping (6-9): Transitional phase if (regroupingIdx <= 9) { - return { min: 1, max: 8 }; + return { min: 1, max: 8 } } // Medium-high regrouping (10-12): Reducing scaffolding, wider range if (regroupingIdx <= 12) { - return { min: 3, max: 11 }; + return { min: 3, max: 11 } } // High regrouping (13-15): Can work independently, allow full range if (regroupingIdx <= 15) { - return { min: 4, max: 12 }; + return { min: 4, max: 12 } } // Very high regrouping (16-18): Should prefer independence - return { min: 6, max: 12 }; + return { min: 6, max: 12 } } /** * Check if a regrouping/scaffolding combination is pedagogically valid */ -function isValidCombination( - regroupingIdx: number, - scaffoldingIdx: number, -): boolean { - const range = getValidScaffoldingRange(regroupingIdx); - return scaffoldingIdx >= range.min && scaffoldingIdx <= range.max; +function isValidCombination(regroupingIdx: number, scaffoldingIdx: number): boolean { + const range = getValidScaffoldingRange(regroupingIdx) + return scaffoldingIdx >= range.min && scaffoldingIdx <= range.max } /** * Clamp scaffolding index to valid range for given regrouping index */ -function clampScaffoldingToValidRange( - regroupingIdx: number, - scaffoldingIdx: number, -): number { - const range = getValidScaffoldingRange(regroupingIdx); - return Math.max(range.min, Math.min(range.max, scaffoldingIdx)); +function clampScaffoldingToValidRange(regroupingIdx: number, scaffoldingIdx: number): number { + const range = getValidScaffoldingRange(regroupingIdx) + return Math.max(range.min, Math.min(range.max, scaffoldingIdx)) } /** @@ -399,33 +382,27 @@ function clampScaffoldingToValidRange( */ export function findNearestValidState( regroupingIdx: number, - scaffoldingIdx: number, + scaffoldingIdx: number ): { regroupingIdx: number; scaffoldingIdx: number } { // Clamp indices to valid progression ranges - const clampedRegrouping = Math.max( - 0, - Math.min(REGROUPING_PROGRESSION.length - 1, regroupingIdx), - ); - const clampedScaffolding = clampScaffoldingToValidRange( - clampedRegrouping, - scaffoldingIdx, - ); + const clampedRegrouping = Math.max(0, Math.min(REGROUPING_PROGRESSION.length - 1, regroupingIdx)) + const clampedScaffolding = clampScaffoldingToValidRange(clampedRegrouping, scaffoldingIdx) return { regroupingIdx: clampedRegrouping, scaffoldingIdx: clampedScaffolding, - }; + } } export interface DifficultyProfile { - name: string; - label: string; - description: string; + name: string + label: string + description: string regrouping: { - pAllStart: number; - pAnyStart: number; - }; - displayRules: DisplayRules; + pAllStart: number + pAnyStart: number + } + displayRules: DisplayRules } /** @@ -434,110 +411,109 @@ export interface DifficultyProfile { */ export const DIFFICULTY_PROFILES: Record = { beginner: { - name: "beginner", - label: "Beginner", + name: 'beginner', + label: 'Beginner', description: - "Full scaffolding with no regrouping. Focus on learning the structure of addition.", + 'Full scaffolding with no regrouping. Focus on learning the structure of addition.', regrouping: { pAllStart: 0, pAnyStart: 0 }, displayRules: { - carryBoxes: "always", // Show structure even when not needed - answerBoxes: "always", // Guide digit placement - placeValueColors: "always", // Reinforce place value concept - tenFrames: "always", // Visual aid for understanding place value - problemNumbers: "always", // Help track progress - cellBorders: "always", // Visual organization - borrowNotation: "always", // Subtraction: show scratch work - borrowingHints: "always", // Subtraction: show visual hints + carryBoxes: 'always', // Show structure even when not needed + answerBoxes: 'always', // Guide digit placement + placeValueColors: 'always', // Reinforce place value concept + tenFrames: 'always', // Visual aid for understanding place value + problemNumbers: 'always', // Help track progress + cellBorders: 'always', // Visual organization + borrowNotation: 'always', // Subtraction: show scratch work + borrowingHints: 'always', // Subtraction: show visual hints }, }, earlyLearner: { - name: "earlyLearner", - label: "Early Learner", - description: - "Scaffolds appear when needed. Introduces occasional regrouping.", + name: 'earlyLearner', + label: 'Early Learner', + description: 'Scaffolds appear when needed. Introduces occasional regrouping.', regrouping: { pAllStart: 0, pAnyStart: 0.25 }, displayRules: { - carryBoxes: "whenRegrouping", // Show scaffold only when needed - answerBoxes: "always", // Still guide placement - placeValueColors: "always", // Reinforce concepts - tenFrames: "whenRegrouping", // Visual aid for new concept - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", // Subtraction: show when borrowing - borrowingHints: "always", // Subtraction: keep hints visible + carryBoxes: 'whenRegrouping', // Show scaffold only when needed + answerBoxes: 'always', // Still guide placement + placeValueColors: 'always', // Reinforce concepts + tenFrames: 'whenRegrouping', // Visual aid for new concept + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', // Subtraction: show when borrowing + borrowingHints: 'always', // Subtraction: keep hints visible }, }, practice: { - name: "practice", - label: "Practice", + name: 'practice', + label: 'Practice', description: - "High scaffolding with frequent regrouping. Master regrouping WITH support before training wheels come off.", + 'High scaffolding with frequent regrouping. Master regrouping WITH support before training wheels come off.', regrouping: { pAllStart: 0.25, pAnyStart: 0.75 }, displayRules: { - carryBoxes: "whenRegrouping", // Show when regrouping happens - answerBoxes: "always", // Keep guiding placement during intensive practice - placeValueColors: "always", // Keep visual support during intensive practice - tenFrames: "whenRegrouping", // Visual aid for regrouping - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", // Subtraction: show when borrowing - borrowingHints: "whenRegrouping", // Subtraction: show hints during practice + carryBoxes: 'whenRegrouping', // Show when regrouping happens + answerBoxes: 'always', // Keep guiding placement during intensive practice + placeValueColors: 'always', // Keep visual support during intensive practice + tenFrames: 'whenRegrouping', // Visual aid for regrouping + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', // Subtraction: show when borrowing + borrowingHints: 'whenRegrouping', // Subtraction: show hints during practice }, }, intermediate: { - name: "intermediate", - label: "Intermediate", - description: "Reduced scaffolding with regular regrouping practice.", + name: 'intermediate', + label: 'Intermediate', + description: 'Reduced scaffolding with regular regrouping practice.', regrouping: { pAllStart: 0.25, pAnyStart: 0.75 }, displayRules: { - carryBoxes: "whenRegrouping", // Still helpful for regrouping - answerBoxes: "whenMultipleRegroups", // Only for complex problems - placeValueColors: "whenRegrouping", // Only when it matters - tenFrames: "whenRegrouping", // Concrete aid when needed - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", // Subtraction: show when borrowing - borrowingHints: "whenMultipleRegroups", // Subtraction: only for complex problems + carryBoxes: 'whenRegrouping', // Still helpful for regrouping + answerBoxes: 'whenMultipleRegroups', // Only for complex problems + placeValueColors: 'whenRegrouping', // Only when it matters + tenFrames: 'whenRegrouping', // Concrete aid when needed + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', // Subtraction: show when borrowing + borrowingHints: 'whenMultipleRegroups', // Subtraction: only for complex problems }, }, advanced: { - name: "advanced", - label: "Advanced", - description: "Minimal scaffolding with frequent complex regrouping.", + name: 'advanced', + label: 'Advanced', + description: 'Minimal scaffolding with frequent complex regrouping.', regrouping: { pAllStart: 0.5, pAnyStart: 0.9 }, displayRules: { - carryBoxes: "never", // Should internalize concept - answerBoxes: "never", // Should know alignment - placeValueColors: "when3PlusDigits", // Only for larger numbers - tenFrames: "never", // Beyond concrete representations - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", // Subtraction: still helpful - borrowingHints: "never", // Subtraction: no hints + carryBoxes: 'never', // Should internalize concept + answerBoxes: 'never', // Should know alignment + placeValueColors: 'when3PlusDigits', // Only for larger numbers + tenFrames: 'never', // Beyond concrete representations + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', // Subtraction: still helpful + borrowingHints: 'never', // Subtraction: no hints }, }, expert: { - name: "expert", - label: "Expert", - description: "No scaffolding. Frequent complex regrouping for mastery.", + name: 'expert', + label: 'Expert', + description: 'No scaffolding. Frequent complex regrouping for mastery.', regrouping: { pAllStart: 0.5, pAnyStart: 0.9 }, displayRules: { - carryBoxes: "never", - answerBoxes: "never", - placeValueColors: "never", - tenFrames: "never", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "never", // Subtraction: no scaffolding - borrowingHints: "never", // Subtraction: no hints + carryBoxes: 'never', + answerBoxes: 'never', + placeValueColors: 'never', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'never', // Subtraction: no scaffolding + borrowingHints: 'never', // Subtraction: no hints }, }, -}; +} /** * Ordered progression of difficulty levels @@ -545,15 +521,15 @@ export const DIFFICULTY_PROFILES: Record = { * WITH scaffolding before we remove support (intermediate/advanced/expert) */ export const DIFFICULTY_PROGRESSION = [ - "beginner", - "earlyLearner", - "practice", // NEW: High regrouping + high scaffolding - "intermediate", - "advanced", - "expert", -] as const; + 'beginner', + 'earlyLearner', + 'practice', // NEW: High regrouping + high scaffolding + 'intermediate', + 'advanced', + 'expert', +] as const -export type DifficultyLevel = (typeof DIFFICULTY_PROGRESSION)[number]; +export type DifficultyLevel = (typeof DIFFICULTY_PROGRESSION)[number] // ============================================================================= // 2D DIFFICULTY SYSTEM: Regrouping Intensity × Scaffolding Level @@ -563,14 +539,11 @@ export type DifficultyLevel = (typeof DIFFICULTY_PROGRESSION)[number]; * Calculate regrouping intensity on 0-10 scale * Maps probability settings to single dimension */ -export function calculateRegroupingIntensity( - pAnyStart: number, - pAllStart: number, -): number { +export function calculateRegroupingIntensity(pAnyStart: number, pAllStart: number): number { // pAnyStart (occasional regrouping) contributes 70% of score // pAllStart (compound regrouping) contributes 30% of score // This reflects pedagogical importance: frequency matters more than compound complexity - return pAnyStart * 7 + pAllStart * 3; + return pAnyStart * 7 + pAllStart * 3 } /** @@ -578,23 +551,23 @@ export function calculateRegroupingIntensity( * Uses pedagogical progression: introduce frequency first, then compound */ function intensityToRegrouping(intensity: number): { - pAnyStart: number; - pAllStart: number; + pAnyStart: number + pAllStart: number } { // Below 5: Focus on pAnyStart, keep pAllStart minimal if (intensity <= 5) { return { pAnyStart: Math.min(intensity / 7, 1), pAllStart: 0, - }; + } } // Above 5: pAnyStart near max, start increasing pAllStart - const excessIntensity = intensity - 5; + const excessIntensity = intensity - 5 return { pAnyStart: Math.min(5 / 7 + excessIntensity / 14, 1), pAllStart: Math.min(excessIntensity / 10, 1), - }; + } } /** @@ -616,7 +589,7 @@ function intensityToRegrouping(intensity: number): { */ export function calculateScaffoldingLevel( rules: DisplayRules, - regroupingIntensity?: number, + regroupingIntensity?: number ): number { const ruleScores: Record = { always: 10, // Maximum scaffolding (easiest) @@ -624,7 +597,7 @@ export function calculateScaffoldingLevel( whenMultipleRegroups: 5, when3PlusDigits: 3, never: 0, // No scaffolding (hardest) - }; + } const weights = { carryBoxes: 1.5, // Most pedagogically important @@ -633,33 +606,29 @@ export function calculateScaffoldingLevel( tenFrames: 1.0, // Concrete visual aid (contextual - see above) problemNumbers: 0.2, // Organizational, not scaffolding cellBorders: 0.2, // Visual structure, not scaffolding - }; + } // Determine if tenFrames should be included in calculation // When regrouping is minimal, tenFrames isn't pedagogically relevant - const includeTenFrames = - regroupingIntensity === undefined || regroupingIntensity >= 4; + const includeTenFrames = regroupingIntensity === undefined || regroupingIntensity >= 4 const weightedScores = [ ruleScores[rules.carryBoxes] * weights.carryBoxes, ruleScores[rules.answerBoxes] * weights.answerBoxes, ruleScores[rules.placeValueColors] * weights.placeValueColors, - ...(includeTenFrames - ? [ruleScores[rules.tenFrames] * weights.tenFrames] - : []), + ...(includeTenFrames ? [ruleScores[rules.tenFrames] * weights.tenFrames] : []), ruleScores[rules.problemNumbers] * weights.problemNumbers, ruleScores[rules.cellBorders] * weights.cellBorders, - ]; + ] // Recalculate total weight excluding tenFrames if not included const totalWeight = includeTenFrames ? Object.values(weights).reduce((a, b) => a + b, 0) - : Object.values(weights).reduce((a, b) => a + b, 0) - weights.tenFrames; + : Object.values(weights).reduce((a, b) => a + b, 0) - weights.tenFrames - const weightedAverage = - weightedScores.reduce((a, b) => a + b, 0) / totalWeight; + const weightedAverage = weightedScores.reduce((a, b) => a + b, 0) / totalWeight - return Math.min(10, Math.max(0, weightedAverage)); + return Math.min(10, Math.max(0, weightedAverage)) } /** @@ -669,50 +638,48 @@ export function calculateScaffoldingLevel( export function calculateOverallDifficulty( pAnyStart: number, pAllStart: number, - displayRules: DisplayRules, + displayRules: DisplayRules ): number { - const regrouping = calculateRegroupingIntensity(pAnyStart, pAllStart); - const scaffolding = calculateScaffoldingLevel(displayRules, regrouping); + const regrouping = calculateRegroupingIntensity(pAnyStart, pAllStart) + const scaffolding = calculateScaffoldingLevel(displayRules, regrouping) - console.log("[calculateOverallDifficulty]", { + console.log('[calculateOverallDifficulty]', { pAnyStart, pAllStart, regrouping, scaffolding, displayRules, - }); + }) // Pedagogical path: start at (0, 10) [no regrouping, all scaffolding] // end at (10, 0) [all regrouping, no scaffolding] // Use Euclidean distance to measure progress along this diagonal path - const startPoint = { regrouping: 0, scaffolding: 10 }; - const endPoint = { regrouping: 10, scaffolding: 0 }; + const startPoint = { regrouping: 0, scaffolding: 10 } + const endPoint = { regrouping: 10, scaffolding: 0 } // Distance from start to current position const distFromStart = Math.sqrt( - (regrouping - startPoint.regrouping) ** 2 + - (scaffolding - startPoint.scaffolding) ** 2, - ); + (regrouping - startPoint.regrouping) ** 2 + (scaffolding - startPoint.scaffolding) ** 2 + ) // Distance from end to current position const distFromEnd = Math.sqrt( - (regrouping - endPoint.regrouping) ** 2 + - (scaffolding - endPoint.scaffolding) ** 2, - ); + (regrouping - endPoint.regrouping) ** 2 + (scaffolding - endPoint.scaffolding) ** 2 + ) // Progress ratio: 0 at start, 1 at end - const progress = distFromStart / (distFromStart + distFromEnd); + const progress = distFromStart / (distFromStart + distFromEnd) - console.log("[calculateOverallDifficulty] distances:", { + console.log('[calculateOverallDifficulty] distances:', { distFromStart, distFromEnd, progress, result: progress * 10, - }); + }) // Scale to 0-10 range - return progress * 10; + return progress * 10 } /** @@ -723,82 +690,77 @@ function levelToScaffoldingRules(level: number): DisplayRules { // Level 0-2: Full scaffolding if (level <= 2) { return { - carryBoxes: "always", - answerBoxes: "always", - placeValueColors: "always", - tenFrames: level < 1 ? "never" : "whenRegrouping", // Ten-frames only when regrouping introduced - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "always", - borrowingHints: "always", - }; + carryBoxes: 'always', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: level < 1 ? 'never' : 'whenRegrouping', // Ten-frames only when regrouping introduced + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'always', + borrowingHints: 'always', + } } // Level 2-4: Transition to conditional if (level <= 4) { return { - carryBoxes: "whenRegrouping", - answerBoxes: "always", - placeValueColors: "always", - tenFrames: "whenRegrouping", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "always", - }; + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'always', + } } // Level 4-6: Reduce non-critical scaffolds if (level <= 6) { return { - carryBoxes: "whenRegrouping", - answerBoxes: level < 5.5 ? "always" : "whenMultipleRegroups", - placeValueColors: "whenRegrouping", - tenFrames: "whenRegrouping", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: level < 5.5 ? "whenRegrouping" : "whenMultipleRegroups", - }; + carryBoxes: 'whenRegrouping', + answerBoxes: level < 5.5 ? 'always' : 'whenMultipleRegroups', + placeValueColors: 'whenRegrouping', + tenFrames: 'whenRegrouping', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: level < 5.5 ? 'whenRegrouping' : 'whenMultipleRegroups', + } } // Level 6-8: Remove critical scaffolds if (level <= 8) { return { - carryBoxes: level < 7 ? "whenRegrouping" : "never", - answerBoxes: "never", - placeValueColors: level < 7.5 ? "whenRegrouping" : "when3PlusDigits", - tenFrames: "never", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", - borrowingHints: "never", - }; + carryBoxes: level < 7 ? 'whenRegrouping' : 'never', + answerBoxes: 'never', + placeValueColors: level < 7.5 ? 'whenRegrouping' : 'when3PlusDigits', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', + borrowingHints: 'never', + } } // Level 8-10: Minimal to no scaffolding return { - carryBoxes: "never", - answerBoxes: "never", - placeValueColors: level < 9 ? "when3PlusDigits" : "never", - tenFrames: "never", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: level < 9 ? "whenRegrouping" : "never", - borrowingHints: "never", - }; + carryBoxes: 'never', + answerBoxes: 'never', + placeValueColors: level < 9 ? 'when3PlusDigits' : 'never', + tenFrames: 'never', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: level < 9 ? 'whenRegrouping' : 'never', + borrowingHints: 'never', + } } /** * Calculate Euclidean distance between two points in difficulty space */ -function difficultyDistance( - reg1: number, - scaf1: number, - reg2: number, - scaf2: number, -): number { - return Math.sqrt((reg1 - reg2) ** 2 + (scaf1 - scaf2) ** 2); +function difficultyDistance(reg1: number, scaf1: number, reg2: number, scaf2: number): number { + return Math.sqrt((reg1 - reg2) ** 2 + (scaf1 - scaf2) ** 2) } /** @@ -808,24 +770,21 @@ function difficultyDistance( export function findNearestPreset( currentRegrouping: number, currentScaffolding: number, - direction: "harder" | "easier" | "any", + direction: 'harder' | 'easier' | 'any' ): { profile: DifficultyProfile; distance: number } | null { const candidates = DIFFICULTY_PROGRESSION.map((name) => { - const profile = DIFFICULTY_PROFILES[name]; + const profile = DIFFICULTY_PROFILES[name] const regrouping = calculateRegroupingIntensity( profile.regrouping.pAnyStart, - profile.regrouping.pAllStart, - ); - const scaffolding = calculateScaffoldingLevel( - profile.displayRules, - regrouping, - ); + profile.regrouping.pAllStart + ) + const scaffolding = calculateScaffoldingLevel(profile.displayRules, regrouping) const distance = difficultyDistance( currentRegrouping, currentScaffolding, regrouping, - scaffolding, - ); + scaffolding + ) // Calculate if this preset is harder or easier // Harder = not easier in ANY dimension AND harder in AT LEAST ONE dimension @@ -833,33 +792,33 @@ export function findNearestPreset( const isHarder = regrouping >= currentRegrouping && scaffolding >= currentScaffolding && - (regrouping > currentRegrouping || scaffolding > currentScaffolding); + (regrouping > currentRegrouping || scaffolding > currentScaffolding) // Easier = not harder in ANY dimension AND easier in AT LEAST ONE dimension const isEasier = regrouping <= currentRegrouping && scaffolding <= currentScaffolding && - (regrouping < currentRegrouping || scaffolding < currentScaffolding); + (regrouping < currentRegrouping || scaffolding < currentScaffolding) - return { profile, distance, regrouping, scaffolding, isHarder, isEasier }; - }); + return { profile, distance, regrouping, scaffolding, isHarder, isEasier } + }) // Filter by direction const filtered = candidates.filter((c) => { - if (direction === "any") return true; - if (direction === "harder") return c.isHarder; - if (direction === "easier") return c.isEasier; - return false; - }); + if (direction === 'any') return true + if (direction === 'harder') return c.isHarder + if (direction === 'easier') return c.isEasier + return false + }) - if (filtered.length === 0) return null; + if (filtered.length === 0) return null // Find closest - const nearest = filtered.reduce((a, b) => (a.distance < b.distance ? a : b)); - return { profile: nearest.profile, distance: nearest.distance }; + const nearest = filtered.reduce((a, b) => (a.distance < b.distance ? a : b)) + return { profile: nearest.profile, distance: nearest.distance } } -export type DifficultyMode = "both" | "challenge" | "support"; +export type DifficultyMode = 'both' | 'challenge' | 'support' /** * Make worksheet harder using discrete progression indices with pedagogical constraints @@ -876,33 +835,27 @@ export type DifficultyMode = "both" | "challenge" | "support"; */ export function makeHarder( currentState: { - pAnyStart: number; - pAllStart: number; - displayRules: DisplayRules; + pAnyStart: number + pAllStart: number + displayRules: DisplayRules }, - mode: DifficultyMode = "both", - operator?: "addition" | "subtraction" | "mixed", + mode: DifficultyMode = 'both', + operator?: 'addition' | 'subtraction' | 'mixed' ): { - pAnyStart: number; - pAllStart: number; - displayRules: DisplayRules; - difficultyProfile?: string; - changeDescription: string; + pAnyStart: number + pAllStart: number + displayRules: DisplayRules + difficultyProfile?: string + changeDescription: string } { // Find current indices in discrete progressions - const currentRegroupingIdx = findRegroupingIndex( - currentState.pAnyStart, - currentState.pAllStart, - ); - const currentScaffoldingIdx = findScaffoldingIndex(currentState.displayRules); + const currentRegroupingIdx = findRegroupingIndex(currentState.pAnyStart, currentState.pAllStart) + const currentScaffoldingIdx = findScaffoldingIndex(currentState.displayRules) // Ensure current state is valid (adjust if needed) - const validCurrent = findNearestValidState( - currentRegroupingIdx, - currentScaffoldingIdx, - ); - let newRegroupingIdx = validCurrent.regroupingIdx; - let newScaffoldingIdx = validCurrent.scaffoldingIdx; + const validCurrent = findNearestValidState(currentRegroupingIdx, currentScaffoldingIdx) + let newRegroupingIdx = validCurrent.regroupingIdx + let newScaffoldingIdx = validCurrent.scaffoldingIdx // Check if at maximum if ( @@ -911,67 +864,60 @@ export function makeHarder( ) { return { ...currentState, - changeDescription: "Already at maximum difficulty", - }; + changeDescription: 'Already at maximum difficulty', + } } // Calculate current position in 2D difficulty space const currentRegrouping = calculateRegroupingIntensity( currentState.pAnyStart, - currentState.pAllStart, - ); - const currentScaffolding = calculateScaffoldingLevel( - currentState.displayRules, - currentRegrouping, - ); + currentState.pAllStart + ) + const currentScaffolding = calculateScaffoldingLevel(currentState.displayRules, currentRegrouping) // Try to move based on mode - let moved = false; + let moved = false - if (mode === "challenge") { + if (mode === 'challenge') { // Only increase regrouping (more complex problems) if (newRegroupingIdx < REGROUPING_PROGRESSION.length - 1) { - const testRegroupingIdx = newRegroupingIdx + 1; + const testRegroupingIdx = newRegroupingIdx + 1 const adjustedScaffoldingIdx = clampScaffoldingToValidRange( testRegroupingIdx, - newScaffoldingIdx, - ); - newRegroupingIdx = testRegroupingIdx; - newScaffoldingIdx = adjustedScaffoldingIdx; - moved = true; + newScaffoldingIdx + ) + newRegroupingIdx = testRegroupingIdx + newScaffoldingIdx = adjustedScaffoldingIdx + moved = true } - } else if (mode === "support") { + } else if (mode === 'support') { // Only reduce scaffolding (remove help) if ( newScaffoldingIdx < SCAFFOLDING_PROGRESSION.length - 1 && isValidCombination(newRegroupingIdx, newScaffoldingIdx + 1) ) { - newScaffoldingIdx++; - moved = true; + newScaffoldingIdx++ + moved = true } } else { // mode === 'both': Smart diagonal navigation toward preset // Find nearest harder preset to guide direction - const nearestPreset = findNearestPreset( - currentRegrouping, - currentScaffolding, - "harder", - ); + const nearestPreset = findNearestPreset(currentRegrouping, currentScaffolding, 'harder') if (nearestPreset) { // Calculate target position from preset const targetRegrouping = calculateRegroupingIntensity( nearestPreset.profile.regrouping.pAnyStart, - nearestPreset.profile.regrouping.pAllStart, - ); + nearestPreset.profile.regrouping.pAllStart + ) const targetScaffolding = calculateScaffoldingLevel( nearestPreset.profile.displayRules, - targetRegrouping, - ); + targetRegrouping + ) // Calculate gaps in both dimensions - const regroupingGap = targetRegrouping - currentRegrouping; - const scaffoldingGap = targetScaffolding - currentScaffolding; + const regroupingGap = targetRegrouping - currentRegrouping + const scaffoldingGap = targetScaffolding - currentScaffolding // Try dimension with larger gap first if ( @@ -979,20 +925,20 @@ export function makeHarder( newRegroupingIdx < REGROUPING_PROGRESSION.length - 1 ) { // Try increasing regrouping - const testRegroupingIdx = newRegroupingIdx + 1; + const testRegroupingIdx = newRegroupingIdx + 1 const adjustedScaffoldingIdx = clampScaffoldingToValidRange( testRegroupingIdx, - newScaffoldingIdx, - ); - newRegroupingIdx = testRegroupingIdx; - newScaffoldingIdx = adjustedScaffoldingIdx; - moved = true; + newScaffoldingIdx + ) + newRegroupingIdx = testRegroupingIdx + newScaffoldingIdx = adjustedScaffoldingIdx + moved = true } else if (newScaffoldingIdx < SCAFFOLDING_PROGRESSION.length - 1) { // Try increasing scaffolding (reducing help) - const testScaffoldingIdx = newScaffoldingIdx + 1; + const testScaffoldingIdx = newScaffoldingIdx + 1 if (isValidCombination(newRegroupingIdx, testScaffoldingIdx)) { - newScaffoldingIdx = testScaffoldingIdx; - moved = true; + newScaffoldingIdx = testScaffoldingIdx + moved = true } } } @@ -1002,20 +948,17 @@ export function makeHarder( if (!moved) { // Try increasing regrouping (complexity) first if (newRegroupingIdx < REGROUPING_PROGRESSION.length - 1) { - newRegroupingIdx++; - newScaffoldingIdx = clampScaffoldingToValidRange( - newRegroupingIdx, - newScaffoldingIdx, - ); - moved = true; + newRegroupingIdx++ + newScaffoldingIdx = clampScaffoldingToValidRange(newRegroupingIdx, newScaffoldingIdx) + moved = true } // Otherwise try increasing scaffolding (removing help) else if ( newScaffoldingIdx < SCAFFOLDING_PROGRESSION.length - 1 && isValidCombination(newRegroupingIdx, newScaffoldingIdx + 1) ) { - newScaffoldingIdx++; - moved = true; + newScaffoldingIdx++ + moved = true } } } @@ -1023,16 +966,16 @@ export function makeHarder( if (!moved) { return { ...currentState, - changeDescription: "Already at maximum difficulty", - }; + changeDescription: 'Already at maximum difficulty', + } } // Get new values from progressions - const newRegrouping = REGROUPING_PROGRESSION[newRegroupingIdx]; - const newRules = SCAFFOLDING_PROGRESSION[newScaffoldingIdx]; + const newRegrouping = REGROUPING_PROGRESSION[newRegroupingIdx] + const newRules = SCAFFOLDING_PROGRESSION[newScaffoldingIdx] // Generate description - let description = ""; + let description = '' if ( newRegroupingIdx > validCurrent.regroupingIdx && newScaffoldingIdx > validCurrent.scaffoldingIdx @@ -1040,44 +983,44 @@ export function makeHarder( const scaffoldingChange = describeScaffoldingChange( currentState.displayRules, newRules, - "reduced", - operator, - ); - description = `Increase regrouping to ${Math.round(newRegrouping.pAnyStart * 100)}% + ${scaffoldingChange.toLowerCase()}`; + 'reduced', + operator + ) + description = `Increase regrouping to ${Math.round(newRegrouping.pAnyStart * 100)}% + ${scaffoldingChange.toLowerCase()}` } else if (newRegroupingIdx > validCurrent.regroupingIdx) { - description = `Increase regrouping to ${Math.round(newRegrouping.pAnyStart * 100)}%`; + description = `Increase regrouping to ${Math.round(newRegrouping.pAnyStart * 100)}%` if (newScaffoldingIdx !== validCurrent.scaffoldingIdx) { const scaffoldingChange = describeScaffoldingChange( currentState.displayRules, newRules, - newScaffoldingIdx < validCurrent.scaffoldingIdx ? "added" : "reduced", - operator, - ); - description += ` (auto-adjust: ${scaffoldingChange.toLowerCase()})`; + newScaffoldingIdx < validCurrent.scaffoldingIdx ? 'added' : 'reduced', + operator + ) + description += ` (auto-adjust: ${scaffoldingChange.toLowerCase()})` } } else if (newScaffoldingIdx > validCurrent.scaffoldingIdx) { description = describeScaffoldingChange( currentState.displayRules, newRules, - "reduced", - operator, - ); + 'reduced', + operator + ) } // Check if result matches a preset const matchedProfile = getProfileFromConfig( newRegrouping.pAllStart, newRegrouping.pAnyStart, - newRules, - ); + newRules + ) return { pAnyStart: newRegrouping.pAnyStart, pAllStart: newRegrouping.pAllStart, displayRules: newRules, - difficultyProfile: matchedProfile !== "custom" ? matchedProfile : undefined, + difficultyProfile: matchedProfile !== 'custom' ? matchedProfile : undefined, changeDescription: description, - }; + } } /** @@ -1095,120 +1038,101 @@ export function makeHarder( */ export function makeEasier( currentState: { - pAnyStart: number; - pAllStart: number; - displayRules: DisplayRules; + pAnyStart: number + pAllStart: number + displayRules: DisplayRules }, - mode: DifficultyMode = "both", - operator?: "addition" | "subtraction" | "mixed", + mode: DifficultyMode = 'both', + operator?: 'addition' | 'subtraction' | 'mixed' ): { - pAnyStart: number; - pAllStart: number; - displayRules: DisplayRules; - difficultyProfile?: string; - changeDescription: string; + pAnyStart: number + pAllStart: number + displayRules: DisplayRules + difficultyProfile?: string + changeDescription: string } { // Find current indices in discrete progressions - const currentRegroupingIdx = findRegroupingIndex( - currentState.pAnyStart, - currentState.pAllStart, - ); - const currentScaffoldingIdx = findScaffoldingIndex(currentState.displayRules); + const currentRegroupingIdx = findRegroupingIndex(currentState.pAnyStart, currentState.pAllStart) + const currentScaffoldingIdx = findScaffoldingIndex(currentState.displayRules) // Ensure current state is valid (adjust if needed) - const validCurrent = findNearestValidState( - currentRegroupingIdx, - currentScaffoldingIdx, - ); - let newRegroupingIdx = validCurrent.regroupingIdx; - let newScaffoldingIdx = validCurrent.scaffoldingIdx; + const validCurrent = findNearestValidState(currentRegroupingIdx, currentScaffoldingIdx) + let newRegroupingIdx = validCurrent.regroupingIdx + let newScaffoldingIdx = validCurrent.scaffoldingIdx // Check if at minimum if (newRegroupingIdx === 0 && newScaffoldingIdx === 0) { return { ...currentState, - changeDescription: "Already at minimum difficulty", - }; + changeDescription: 'Already at minimum difficulty', + } } // Calculate current position in 2D difficulty space const currentRegrouping = calculateRegroupingIntensity( currentState.pAnyStart, - currentState.pAllStart, - ); - const currentScaffolding = calculateScaffoldingLevel( - currentState.displayRules, - currentRegrouping, - ); + currentState.pAllStart + ) + const currentScaffolding = calculateScaffoldingLevel(currentState.displayRules, currentRegrouping) // Try to move based on mode - let moved = false; + let moved = false - if (mode === "challenge") { + if (mode === 'challenge') { // Only decrease regrouping (simpler problems) if (newRegroupingIdx > 0) { - const testRegroupingIdx = newRegroupingIdx - 1; + const testRegroupingIdx = newRegroupingIdx - 1 const adjustedScaffoldingIdx = clampScaffoldingToValidRange( testRegroupingIdx, - newScaffoldingIdx, - ); - newRegroupingIdx = testRegroupingIdx; - newScaffoldingIdx = adjustedScaffoldingIdx; - moved = true; + newScaffoldingIdx + ) + newRegroupingIdx = testRegroupingIdx + newScaffoldingIdx = adjustedScaffoldingIdx + moved = true } - } else if (mode === "support") { + } else if (mode === 'support') { // Only increase scaffolding (add help) - if ( - newScaffoldingIdx > 0 && - isValidCombination(newRegroupingIdx, newScaffoldingIdx - 1) - ) { - newScaffoldingIdx--; - moved = true; + if (newScaffoldingIdx > 0 && isValidCombination(newRegroupingIdx, newScaffoldingIdx - 1)) { + newScaffoldingIdx-- + moved = true } } else { // mode === 'both': Smart diagonal navigation toward preset // Find nearest easier preset to guide direction - const nearestPreset = findNearestPreset( - currentRegrouping, - currentScaffolding, - "easier", - ); + const nearestPreset = findNearestPreset(currentRegrouping, currentScaffolding, 'easier') if (nearestPreset) { // Calculate target position from preset const targetRegrouping = calculateRegroupingIntensity( nearestPreset.profile.regrouping.pAnyStart, - nearestPreset.profile.regrouping.pAllStart, - ); + nearestPreset.profile.regrouping.pAllStart + ) const targetScaffolding = calculateScaffoldingLevel( nearestPreset.profile.displayRules, - targetRegrouping, - ); + targetRegrouping + ) // Calculate gaps in both dimensions - const regroupingGap = targetRegrouping - currentRegrouping; - const scaffoldingGap = targetScaffolding - currentScaffolding; + const regroupingGap = targetRegrouping - currentRegrouping + const scaffoldingGap = targetScaffolding - currentScaffolding // Try dimension with larger gap first - if ( - Math.abs(regroupingGap) > Math.abs(scaffoldingGap) && - newRegroupingIdx > 0 - ) { + if (Math.abs(regroupingGap) > Math.abs(scaffoldingGap) && newRegroupingIdx > 0) { // Try decreasing regrouping - const testRegroupingIdx = newRegroupingIdx - 1; + const testRegroupingIdx = newRegroupingIdx - 1 const adjustedScaffoldingIdx = clampScaffoldingToValidRange( testRegroupingIdx, - newScaffoldingIdx, - ); - newRegroupingIdx = testRegroupingIdx; - newScaffoldingIdx = adjustedScaffoldingIdx; - moved = true; + newScaffoldingIdx + ) + newRegroupingIdx = testRegroupingIdx + newScaffoldingIdx = adjustedScaffoldingIdx + moved = true } else if (newScaffoldingIdx > 0) { // Try decreasing scaffolding (adding help) - const testScaffoldingIdx = newScaffoldingIdx - 1; + const testScaffoldingIdx = newScaffoldingIdx - 1 if (isValidCombination(newRegroupingIdx, testScaffoldingIdx)) { - newScaffoldingIdx = testScaffoldingIdx; - moved = true; + newScaffoldingIdx = testScaffoldingIdx + moved = true } } } @@ -1217,22 +1141,16 @@ export function makeEasier( // Per spec: makeEasier should add support (scaffolding) first, then reduce complexity (regrouping) if (!moved) { // Try decreasing scaffolding (adding help) first - if ( - newScaffoldingIdx > 0 && - isValidCombination(newRegroupingIdx, newScaffoldingIdx - 1) - ) { - newScaffoldingIdx--; - moved = true; + if (newScaffoldingIdx > 0 && isValidCombination(newRegroupingIdx, newScaffoldingIdx - 1)) { + newScaffoldingIdx-- + moved = true } // Otherwise try decreasing regrouping (reducing complexity) else if (newRegroupingIdx > 0) { - const testRegroupingIdx = newRegroupingIdx - 1; - newRegroupingIdx = testRegroupingIdx; - newScaffoldingIdx = clampScaffoldingToValidRange( - testRegroupingIdx, - newScaffoldingIdx, - ); - moved = true; + const testRegroupingIdx = newRegroupingIdx - 1 + newRegroupingIdx = testRegroupingIdx + newScaffoldingIdx = clampScaffoldingToValidRange(testRegroupingIdx, newScaffoldingIdx) + moved = true } } } @@ -1240,16 +1158,16 @@ export function makeEasier( if (!moved) { return { ...currentState, - changeDescription: "Already at minimum difficulty", - }; + changeDescription: 'Already at minimum difficulty', + } } // Get new values from progressions - const newRegrouping = REGROUPING_PROGRESSION[newRegroupingIdx]; - const newRules = SCAFFOLDING_PROGRESSION[newScaffoldingIdx]; + const newRegrouping = REGROUPING_PROGRESSION[newRegroupingIdx] + const newRules = SCAFFOLDING_PROGRESSION[newScaffoldingIdx] // Generate description - let description = ""; + let description = '' if ( newRegroupingIdx < validCurrent.regroupingIdx && newScaffoldingIdx < validCurrent.scaffoldingIdx @@ -1257,44 +1175,39 @@ export function makeEasier( const scaffoldingChange = describeScaffoldingChange( currentState.displayRules, newRules, - "added", - operator, - ); - description = `Reduce regrouping to ${Math.round(newRegrouping.pAnyStart * 100)}% + ${scaffoldingChange.toLowerCase()}`; + 'added', + operator + ) + description = `Reduce regrouping to ${Math.round(newRegrouping.pAnyStart * 100)}% + ${scaffoldingChange.toLowerCase()}` } else if (newRegroupingIdx < validCurrent.regroupingIdx) { - description = `Reduce regrouping to ${Math.round(newRegrouping.pAnyStart * 100)}%`; + description = `Reduce regrouping to ${Math.round(newRegrouping.pAnyStart * 100)}%` if (newScaffoldingIdx !== validCurrent.scaffoldingIdx) { const scaffoldingChange = describeScaffoldingChange( currentState.displayRules, newRules, - newScaffoldingIdx < validCurrent.scaffoldingIdx ? "added" : "reduced", - operator, - ); - description += ` (auto-adjust: ${scaffoldingChange.toLowerCase()})`; + newScaffoldingIdx < validCurrent.scaffoldingIdx ? 'added' : 'reduced', + operator + ) + description += ` (auto-adjust: ${scaffoldingChange.toLowerCase()})` } } else if (newScaffoldingIdx < validCurrent.scaffoldingIdx) { - description = describeScaffoldingChange( - currentState.displayRules, - newRules, - "added", - operator, - ); + description = describeScaffoldingChange(currentState.displayRules, newRules, 'added', operator) } // Check if result matches a preset const matchedProfile = getProfileFromConfig( newRegrouping.pAllStart, newRegrouping.pAnyStart, - newRules, - ); + newRules + ) return { pAnyStart: newRegrouping.pAnyStart, pAllStart: newRegrouping.pAllStart, displayRules: newRules, - difficultyProfile: matchedProfile !== "custom" ? matchedProfile : undefined, + difficultyProfile: matchedProfile !== 'custom' ? matchedProfile : undefined, changeDescription: description, - }; + } } /** @@ -1303,22 +1216,21 @@ export function makeEasier( export function getProfileFromConfig( pAllStart: number, pAnyStart: number, - displayRules?: DisplayRules, + displayRules?: DisplayRules ): string { - if (!displayRules) return "custom"; + if (!displayRules) return 'custom' for (const profile of Object.values(DIFFICULTY_PROFILES)) { const regroupMatch = Math.abs(profile.regrouping.pAllStart - pAllStart) < 0.05 && - Math.abs(profile.regrouping.pAnyStart - pAnyStart) < 0.05; + Math.abs(profile.regrouping.pAnyStart - pAnyStart) < 0.05 - const rulesMatch = - JSON.stringify(profile.displayRules) === JSON.stringify(displayRules); + const rulesMatch = JSON.stringify(profile.displayRules) === JSON.stringify(displayRules) if (regroupMatch && rulesMatch) { - return profile.name; + return profile.name } } - return "custom"; + return 'custom' } diff --git a/apps/web/src/app/create/worksheets/addition/displayRules.ts b/apps/web/src/app/create/worksheets/addition/displayRules.ts index 4cce28b6..767a2c86 100644 --- a/apps/web/src/app/create/worksheets/addition/displayRules.ts +++ b/apps/web/src/app/create/worksheets/addition/displayRules.ts @@ -1,36 +1,36 @@ // Display rules for conditional per-problem scaffolding -import type { ProblemMeta, SubtractionProblemMeta } from "./problemAnalysis"; +import type { ProblemMeta, SubtractionProblemMeta } from './problemAnalysis' -export type AnyProblemMeta = ProblemMeta | SubtractionProblemMeta; +export type AnyProblemMeta = ProblemMeta | SubtractionProblemMeta export type RuleMode = - | "always" // Always show this display option - | "never" // Never show this display option - | "whenRegrouping" // Show when problem requires any regrouping - | "whenMultipleRegroups" // Show when 2+ place values regroup - | "when3PlusDigits"; // Show when maxDigits >= 3 + | 'always' // Always show this display option + | 'never' // Never show this display option + | 'whenRegrouping' // Show when problem requires any regrouping + | 'whenMultipleRegroups' // Show when 2+ place values regroup + | 'when3PlusDigits' // Show when maxDigits >= 3 export interface DisplayRules { - carryBoxes: RuleMode; - answerBoxes: RuleMode; - placeValueColors: RuleMode; - tenFrames: RuleMode; - problemNumbers: RuleMode; - cellBorders: RuleMode; - borrowNotation: RuleMode; // Subtraction: scratch boxes showing borrowed 10s - borrowingHints: RuleMode; // Subtraction: arrows and visual hints + carryBoxes: RuleMode + answerBoxes: RuleMode + placeValueColors: RuleMode + tenFrames: RuleMode + problemNumbers: RuleMode + cellBorders: RuleMode + borrowNotation: RuleMode // Subtraction: scratch boxes showing borrowed 10s + borrowingHints: RuleMode // Subtraction: arrows and visual hints } export interface ResolvedDisplayOptions { - showCarryBoxes: boolean; - showAnswerBoxes: boolean; - showPlaceValueColors: boolean; - showTenFrames: boolean; - showProblemNumbers: boolean; - showCellBorder: boolean; - showBorrowNotation: boolean; // Subtraction: scratch work boxes in minuend - showBorrowingHints: boolean; // Subtraction: hints with arrows + showCarryBoxes: boolean + showAnswerBoxes: boolean + showPlaceValueColors: boolean + showTenFrames: boolean + showProblemNumbers: boolean + showCellBorder: boolean + showBorrowNotation: boolean // Subtraction: scratch work boxes in minuend + showBorrowingHints: boolean // Subtraction: hints with arrows } /** @@ -39,26 +39,24 @@ export interface ResolvedDisplayOptions { */ export function evaluateRule(mode: RuleMode, problem: AnyProblemMeta): boolean { switch (mode) { - case "always": - return true; + case 'always': + return true - case "never": - return false; + case 'never': + return false - case "whenRegrouping": + case 'whenRegrouping': // Works for both: requiresRegrouping (addition) or requiresBorrowing (subtraction) - return "requiresRegrouping" in problem + return 'requiresRegrouping' in problem ? problem.requiresRegrouping - : problem.requiresBorrowing; + : problem.requiresBorrowing - case "whenMultipleRegroups": + case 'whenMultipleRegroups': // Works for both: regroupCount (addition) or borrowCount (subtraction) - return "regroupCount" in problem - ? problem.regroupCount >= 2 - : problem.borrowCount >= 2; + return 'regroupCount' in problem ? problem.regroupCount >= 2 : problem.borrowCount >= 2 - case "when3PlusDigits": - return problem.maxDigits >= 3; + case 'when3PlusDigits': + return problem.maxDigits >= 3 } } @@ -68,11 +66,8 @@ export function evaluateRule(mode: RuleMode, problem: AnyProblemMeta): boolean { */ export function resolveDisplayForProblem( rules: DisplayRules, - problem: AnyProblemMeta, + problem: AnyProblemMeta ): ResolvedDisplayOptions { - console.log("[resolveDisplayForProblem] Input rules:", rules); - console.log("[resolveDisplayForProblem] Problem meta:", problem); - const resolved = { showCarryBoxes: evaluateRule(rules.carryBoxes, problem), showAnswerBoxes: evaluateRule(rules.answerBoxes, problem), @@ -82,15 +77,15 @@ export function resolveDisplayForProblem( showCellBorder: evaluateRule(rules.cellBorders, problem), showBorrowNotation: evaluateRule(rules.borrowNotation, problem), showBorrowingHints: evaluateRule(rules.borrowingHints, problem), - }; + } - console.log("[resolveDisplayForProblem] Resolved display options:", resolved); - console.log( - "[resolveDisplayForProblem] Ten-frames rule:", - rules.tenFrames, - "-> showTenFrames:", - resolved.showTenFrames, - ); + // DEBUG: Ten-frames evaluation + console.log('[TEN-FRAMES DEBUG]', { + rule: rules.tenFrames, + requiresRegrouping: 'requiresRegrouping' in problem ? problem.requiresRegrouping : problem.requiresBorrowing, + regroupCount: 'regroupCount' in problem ? problem.regroupCount : problem.borrowCount, + resolved: resolved.showTenFrames, + }) - return resolved; + return resolved } diff --git a/apps/web/src/app/create/worksheets/addition/generatePreview.ts b/apps/web/src/app/create/worksheets/addition/generatePreview.ts index b366aee1..879e79b6 100644 --- a/apps/web/src/app/create/worksheets/addition/generatePreview.ts +++ b/apps/web/src/app/create/worksheets/addition/generatePreview.ts @@ -1,62 +1,60 @@ // Shared logic for generating worksheet previews (used by both API route and SSR) -import { execSync } from "child_process"; -import { validateWorksheetConfig } from "./validation"; +import { execSync } from 'child_process' +import { validateWorksheetConfig } from './validation' import { generateProblems, generateSubtractionProblems, generateMixedProblems, -} from "./problemGenerator"; -import { generateTypstSource } from "./typstGenerator"; -import type { WorksheetFormState } from "./types"; +} from './problemGenerator' +import { generateTypstSource } from './typstGenerator' +import type { WorksheetFormState } from './types' export interface PreviewResult { - success: boolean; - pages?: string[]; - error?: string; - details?: string; + success: boolean + pages?: string[] + error?: string + details?: string } /** * Generate worksheet preview SVG pages * Can be called from API routes or Server Components */ -export function generateWorksheetPreview( - config: WorksheetFormState, -): PreviewResult { +export function generateWorksheetPreview(config: WorksheetFormState): PreviewResult { try { // Validate configuration - const validation = validateWorksheetConfig(config); + const validation = validateWorksheetConfig(config) if (!validation.isValid || !validation.config) { return { success: false, - error: "Invalid configuration", - details: validation.errors?.join(", "), - }; + error: 'Invalid configuration', + details: validation.errors?.join(', '), + } } - const validatedConfig = validation.config; + const validatedConfig = validation.config // Generate all problems for full preview based on operator - const operator = validatedConfig.operator ?? "addition"; + const operator = validatedConfig.operator ?? 'addition' const problems = - operator === "addition" + operator === 'addition' ? generateProblems( validatedConfig.total, validatedConfig.pAnyStart, validatedConfig.pAllStart, validatedConfig.interpolate, validatedConfig.seed, - validatedConfig.digitRange, + validatedConfig.digitRange ) - : operator === "subtraction" + : operator === 'subtraction' ? generateSubtractionProblems( validatedConfig.total, validatedConfig.digitRange, validatedConfig.pAnyStart, validatedConfig.pAllStart, validatedConfig.interpolate, - validatedConfig.seed, + validatedConfig.seed ) : generateMixedProblems( validatedConfig.total, @@ -64,55 +62,55 @@ export function generateWorksheetPreview( validatedConfig.pAnyStart, validatedConfig.pAllStart, validatedConfig.interpolate, - validatedConfig.seed, - ); + validatedConfig.seed + ) // Generate Typst sources (one per page) - const typstSources = generateTypstSource(validatedConfig, problems); + const typstSources = generateTypstSource(validatedConfig, problems) // Compile each page source to SVG (using stdout for single-page output) - const pages: string[] = []; + const pages: string[] = [] for (let i = 0; i < typstSources.length; i++) { - const typstSource = typstSources[i]; + const typstSource = typstSources[i] // Compile to SVG via stdin/stdout try { - const svgOutput = execSync("typst compile --format svg - -", { + const svgOutput = execSync('typst compile --format svg - -', { input: typstSource, - encoding: "utf8", + encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, // 10MB limit - }); - pages.push(svgOutput); + }) + pages.push(svgOutput) } catch (error) { - console.error(`Typst compilation error (page ${i + 1}):`, error); + console.error(`Typst compilation error (page ${i + 1}):`, error) // Extract the actual Typst error message const stderr = - error instanceof Error && "stderr" in error + error instanceof Error && 'stderr' in error ? String((error as any).stderr) - : "Unknown compilation error"; + : 'Unknown compilation error' return { success: false, error: `Failed to compile preview (page ${i + 1})`, details: stderr, - }; + } } } return { success: true, pages, - }; + } } catch (error) { - console.error("Error generating preview:", error); + console.error('Error generating preview:', error) - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, - error: "Failed to generate preview", + error: 'Failed to generate preview', details: errorMessage, - }; + } } } diff --git a/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetAutoSave.ts b/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetAutoSave.ts index 46719d75..0ca4d519 100644 --- a/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetAutoSave.ts +++ b/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetAutoSave.ts @@ -1,11 +1,11 @@ -"use client"; +'use client' -import { useState, useEffect, useRef } from "react"; -import type { WorksheetFormState } from "../types"; +import { useState, useEffect, useRef } from 'react' +import type { WorksheetFormState } from '../types' interface UseWorksheetAutoSaveReturn { - isSaving: boolean; - lastSaved: Date | null; + isSaving: boolean + lastSaved: Date | null } /** @@ -20,31 +20,29 @@ interface UseWorksheetAutoSaveReturn { */ export function useWorksheetAutoSave( formState: WorksheetFormState, - worksheetType: "addition", + worksheetType: 'addition' ): UseWorksheetAutoSaveReturn { - const [isSaving, setIsSaving] = useState(false); - const [lastSaved, setLastSaved] = useState(null); + const [isSaving, setIsSaving] = useState(false) + const [lastSaved, setLastSaved] = useState(null) // Store the previous formState for auto-save to detect real changes - const prevAutoSaveFormStateRef = useRef(formState); + const prevAutoSaveFormStateRef = useRef(formState) // Auto-save settings when they change (debounced) - skip on initial mount useEffect(() => { // Skip auto-save if formState hasn't actually changed (handles StrictMode double-render) if (formState === prevAutoSaveFormStateRef.current) { - console.log( - "[useWorksheetAutoSave] Skipping auto-save - formState reference unchanged", - ); - return; + console.log('[useWorksheetAutoSave] Skipping auto-save - formState reference unchanged') + return } - prevAutoSaveFormStateRef.current = formState; + prevAutoSaveFormStateRef.current = formState - console.log("[useWorksheetAutoSave] Settings changed, will save in 1s..."); + console.log('[useWorksheetAutoSave] Settings changed, will save in 1s...') const timer = setTimeout(async () => { - console.log("[useWorksheetAutoSave] Attempting to save settings..."); - setIsSaving(true); + console.log('[useWorksheetAutoSave] Attempting to save settings...') + setIsSaving(true) try { // Extract only the fields we want to persist (exclude date, seed, derived state) const { @@ -72,11 +70,11 @@ export function useWorksheetAutoSave( difficultyProfile, displayRules, manualPreset, - } = formState; + } = formState - const response = await fetch("/api/worksheets/settings", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/worksheets/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: worksheetType, config: { @@ -106,36 +104,33 @@ export function useWorksheetAutoSave( manualPreset, }, }), - }); + }) if (response.ok) { - const data = await response.json(); - console.log("[useWorksheetAutoSave] Save response:", data); + const data = await response.json() + console.log('[useWorksheetAutoSave] Save response:', data) if (data.success) { - console.log("[useWorksheetAutoSave] ✓ Settings saved successfully"); - setLastSaved(new Date()); + console.log('[useWorksheetAutoSave] ✓ Settings saved successfully') + setLastSaved(new Date()) } else { - console.log("[useWorksheetAutoSave] Save skipped"); + console.log('[useWorksheetAutoSave] Save skipped') } } else { - console.error( - "[useWorksheetAutoSave] Save failed with status:", - response.status, - ); + console.error('[useWorksheetAutoSave] Save failed with status:', response.status) } } catch (error) { // Silently fail - settings persistence is not critical - console.error("[useWorksheetAutoSave] Settings save error:", error); + console.error('[useWorksheetAutoSave] Settings save error:', error) } finally { - setIsSaving(false); + setIsSaving(false) } - }, 1000); // 1 second debounce for auto-save + }, 1000) // 1 second debounce for auto-save - return () => clearTimeout(timer); - }, [formState, worksheetType]); + return () => clearTimeout(timer) + }, [formState, worksheetType]) return { isSaving, lastSaved, - }; + } } diff --git a/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetGeneration.ts b/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetGeneration.ts index 25482984..8be414b2 100644 --- a/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetGeneration.ts +++ b/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetGeneration.ts @@ -1,16 +1,16 @@ -"use client"; +'use client' -import { useState } from "react"; -import type { WorksheetFormState } from "../types"; -import { validateWorksheetConfig } from "../validation"; +import { useState } from 'react' +import type { WorksheetFormState } from '../types' +import { validateWorksheetConfig } from '../validation' -type GenerationStatus = "idle" | "generating" | "error"; +type GenerationStatus = 'idle' | 'generating' | 'error' interface UseWorksheetGenerationReturn { - status: GenerationStatus; - error: string | null; - generate: (config: WorksheetFormState) => Promise; - reset: () => void; + status: GenerationStatus + error: string | null + generate: (config: WorksheetFormState) => Promise + reset: () => void } /** @@ -24,70 +24,68 @@ interface UseWorksheetGenerationReturn { * - Error handling with detailed messages */ export function useWorksheetGeneration(): UseWorksheetGenerationReturn { - const [status, setStatus] = useState("idle"); - const [error, setError] = useState(null); + const [status, setStatus] = useState('idle') + const [error, setError] = useState(null) const generate = async (config: WorksheetFormState) => { - setStatus("generating"); - setError(null); + setStatus('generating') + setError(null) try { // Validate configuration - const validation = validateWorksheetConfig(config); + const validation = validateWorksheetConfig(config) if (!validation.isValid || !validation.config) { - throw new Error( - validation.errors?.join(", ") || "Invalid configuration", - ); + throw new Error(validation.errors?.join(', ') || 'Invalid configuration') } - const response = await fetch("/api/create/worksheets/addition", { - method: "POST", + const response = await fetch('/api/create/worksheets/addition', { + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify(config), - }); + }) if (!response.ok) { - const errorResult = await response.json(); + const errorResult = await response.json() const errorMsg = errorResult.details ? `${errorResult.error}\n\n${errorResult.details}` - : errorResult.error || "Generation failed"; - throw new Error(errorMsg); + : errorResult.error || 'Generation failed' + throw new Error(errorMsg) } // Success - response is binary PDF data, trigger download - const blob = await response.blob(); - const filename = `addition-worksheet-${config.name || "student"}-${Date.now()}.pdf`; + const blob = await response.blob() + const filename = `addition-worksheet-${config.name || 'student'}-${Date.now()}.pdf` // Create download link and trigger download - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.style.display = "none"; - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.style.display = 'none' + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) - setStatus("idle"); + setStatus('idle') } catch (err) { - console.error("Generation error:", err); - setError(err instanceof Error ? err.message : "Unknown error occurred"); - setStatus("error"); + console.error('Generation error:', err) + setError(err instanceof Error ? err.message : 'Unknown error occurred') + setStatus('error') } - }; + } const reset = () => { - setStatus("idle"); - setError(null); - }; + setStatus('idle') + setError(null) + } return { status, error, generate, reset, - }; + } } diff --git a/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetState.ts b/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetState.ts index c1f566a4..4055fac2 100644 --- a/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetState.ts +++ b/apps/web/src/app/create/worksheets/addition/hooks/useWorksheetState.ts @@ -1,13 +1,13 @@ -"use client"; +'use client' -import { useState, useEffect, useRef } from "react"; -import type { WorksheetFormState } from "../types"; -import { defaultAdditionConfig } from "../../config-schemas"; +import { useState, useEffect, useRef } from 'react' +import type { WorksheetFormState } from '../types' +import { defaultAdditionConfig } from '../../config-schemas' interface UseWorksheetStateReturn { - formState: WorksheetFormState; - debouncedFormState: WorksheetFormState; - updateFormState: (updates: Partial) => void; + formState: WorksheetFormState + debouncedFormState: WorksheetFormState + updateFormState: (updates: Partial) => void } /** @@ -20,14 +20,14 @@ interface UseWorksheetStateReturn { * - StrictMode-safe (handles double renders) */ export function useWorksheetState( - initialSettings: Omit, + initialSettings: Omit ): UseWorksheetStateReturn { // Calculate derived state from initial settings - const problemsPerPage = initialSettings.problemsPerPage ?? 20; - const pages = initialSettings.pages ?? 1; - const cols = initialSettings.cols ?? 5; - const rows = Math.ceil((problemsPerPage * pages) / cols); - const total = problemsPerPage * pages; + const problemsPerPage = initialSettings.problemsPerPage ?? 20 + const pages = initialSettings.pages ?? 1 + const cols = initialSettings.cols ?? 5 + const rows = Math.ceil((problemsPerPage * pages) / cols) + const total = problemsPerPage * pages // Immediate form state (for controls - updates instantly) const [formState, setFormState] = useState(() => { @@ -35,84 +35,68 @@ export function useWorksheetState( ...initialSettings, rows, total, - date: "", // Will be set at generation time + date: '', // Will be set at generation time // Ensure displayRules is always defined (critical for difficulty adjustment) - displayRules: - initialSettings.displayRules ?? defaultAdditionConfig.displayRules, + displayRules: initialSettings.displayRules ?? defaultAdditionConfig.displayRules, pAnyStart: initialSettings.pAnyStart ?? defaultAdditionConfig.pAnyStart, pAllStart: initialSettings.pAllStart ?? defaultAdditionConfig.pAllStart, - }; - console.log("[useWorksheetState] Initial formState:", { + } + console.log('[useWorksheetState] Initial formState:', { seed: initial.seed, displayRules: initial.displayRules, - }); - return initial; - }); + }) + return initial + }) // Debounced form state (for preview - updates after delay) - const [debouncedFormState, setDebouncedFormState] = - useState(() => { - console.log( - "[useWorksheetState] Initial debouncedFormState (same as formState)", - ); - return formState; - }); + const [debouncedFormState, setDebouncedFormState] = useState(() => { + console.log('[useWorksheetState] Initial debouncedFormState (same as formState)') + return formState + }) // Store the previous formState to detect real changes - const prevFormStateRef = useRef(formState); + const prevFormStateRef = useRef(formState) // Log whenever debouncedFormState changes (this triggers preview re-fetch) useEffect(() => { - console.log( - "[useWorksheetState] debouncedFormState changed - preview will re-fetch:", - { - seed: debouncedFormState.seed, - problemsPerPage: debouncedFormState.problemsPerPage, - }, - ); - }, [debouncedFormState]); + console.log('[useWorksheetState] debouncedFormState changed - preview will re-fetch:', { + seed: debouncedFormState.seed, + problemsPerPage: debouncedFormState.problemsPerPage, + }) + }, [debouncedFormState]) // Debounce preview updates (500ms delay) - only when formState actually changes useEffect(() => { - console.log("[useWorksheetState Debounce] Triggered"); + console.log('[useWorksheetState Debounce] Triggered') + console.log('[useWorksheetState Debounce] Current formState seed:', formState.seed) console.log( - "[useWorksheetState Debounce] Current formState seed:", - formState.seed, - ); - console.log( - "[useWorksheetState Debounce] Previous formState seed:", - prevFormStateRef.current.seed, - ); + '[useWorksheetState Debounce] Previous formState seed:', + prevFormStateRef.current.seed + ) // Skip if formState hasn't actually changed (handles StrictMode double-render) if (formState === prevFormStateRef.current) { - console.log( - "[useWorksheetState Debounce] Skipping - formState reference unchanged", - ); - return; + console.log('[useWorksheetState Debounce] Skipping - formState reference unchanged') + return } - prevFormStateRef.current = formState; + prevFormStateRef.current = formState - console.log( - "[useWorksheetState Debounce] Setting timer to update debouncedFormState in 500ms", - ); + console.log('[useWorksheetState Debounce] Setting timer to update debouncedFormState in 500ms') const timer = setTimeout(() => { - console.log( - "[useWorksheetState Debounce] Timer fired - updating debouncedFormState", - ); - setDebouncedFormState(formState); - }, 500); + console.log('[useWorksheetState Debounce] Timer fired - updating debouncedFormState') + setDebouncedFormState(formState) + }, 500) return () => { - console.log("[useWorksheetState Debounce] Cleanup - clearing timer"); - clearTimeout(timer); - }; - }, [formState]); + console.log('[useWorksheetState Debounce] Cleanup - clearing timer') + clearTimeout(timer) + } + }, [formState]) const updateFormState = (updates: Partial) => { setFormState((prev) => { - const newState = { ...prev, ...updates }; + const newState = { ...prev, ...updates } // Generate new seed when problem settings change const affectsProblems = @@ -122,19 +106,19 @@ export function useWorksheetState( updates.orientation !== undefined || updates.pAnyStart !== undefined || updates.pAllStart !== undefined || - updates.interpolate !== undefined; + updates.interpolate !== undefined if (affectsProblems) { - newState.seed = Date.now() % 2147483647; + newState.seed = Date.now() % 2147483647 } - return newState; - }); - }; + return newState + }) + } return { formState, debouncedFormState, updateFormState, - }; + } } diff --git a/apps/web/src/app/create/worksheets/addition/manualModePresets.ts b/apps/web/src/app/create/worksheets/addition/manualModePresets.ts index 4bd828c3..cd894091 100644 --- a/apps/web/src/app/create/worksheets/addition/manualModePresets.ts +++ b/apps/web/src/app/create/worksheets/addition/manualModePresets.ts @@ -1,16 +1,16 @@ // Manual mode presets for direct display control export interface ManualModePreset { - name: string; - label: string; - description: string; - showCarryBoxes: boolean; - showAnswerBoxes: boolean; - showPlaceValueColors: boolean; - showTenFrames: boolean; - showProblemNumbers: boolean; - showCellBorder: boolean; - showTenFramesForAll: boolean; + name: string + label: string + description: string + showCarryBoxes: boolean + showAnswerBoxes: boolean + showPlaceValueColors: boolean + showTenFrames: boolean + showProblemNumbers: boolean + showCellBorder: boolean + showTenFramesForAll: boolean } /** @@ -19,9 +19,9 @@ export interface ManualModePreset { */ export const MANUAL_MODE_PRESETS = { fullScaffolding: { - name: "fullScaffolding", - label: "Full Scaffolding", - description: "All visual aids enabled for maximum support", + name: 'fullScaffolding', + label: 'Full Scaffolding', + description: 'All visual aids enabled for maximum support', showCarryBoxes: true, showAnswerBoxes: true, showPlaceValueColors: true, @@ -32,9 +32,9 @@ export const MANUAL_MODE_PRESETS = { }, minimalScaffolding: { - name: "minimalScaffolding", - label: "Minimal Scaffolding", - description: "Basic structure only - for students building independence", + name: 'minimalScaffolding', + label: 'Minimal Scaffolding', + description: 'Basic structure only - for students building independence', showCarryBoxes: false, showAnswerBoxes: false, showPlaceValueColors: false, @@ -45,9 +45,9 @@ export const MANUAL_MODE_PRESETS = { }, assessmentMode: { - name: "assessmentMode", - label: "Assessment Mode", - description: "Clean layout for testing - minimal visual aids", + name: 'assessmentMode', + label: 'Assessment Mode', + description: 'Clean layout for testing - minimal visual aids', showCarryBoxes: false, showAnswerBoxes: false, showPlaceValueColors: false, @@ -58,9 +58,9 @@ export const MANUAL_MODE_PRESETS = { }, tenFramesFocus: { - name: "tenFramesFocus", - label: "Ten-Frames Focus", - description: "All aids plus ten-frames for concrete visualization", + name: 'tenFramesFocus', + label: 'Ten-Frames Focus', + description: 'All aids plus ten-frames for concrete visualization', showCarryBoxes: true, showAnswerBoxes: true, showPlaceValueColors: true, @@ -69,22 +69,22 @@ export const MANUAL_MODE_PRESETS = { showCellBorder: true, showTenFramesForAll: false, }, -} as const satisfies Record; +} as const satisfies Record -export type ManualModePresetName = keyof typeof MANUAL_MODE_PRESETS; +export type ManualModePresetName = keyof typeof MANUAL_MODE_PRESETS /** * Check if manual display settings match a preset */ export function getManualPresetFromConfig(config: { - showCarryBoxes: boolean; - showAnswerBoxes: boolean; - showPlaceValueColors: boolean; - showTenFrames: boolean; - showProblemNumbers: boolean; - showCellBorder: boolean; - showTenFramesForAll: boolean; -}): ManualModePresetName | "custom" { + showCarryBoxes: boolean + showAnswerBoxes: boolean + showPlaceValueColors: boolean + showTenFrames: boolean + showProblemNumbers: boolean + showCellBorder: boolean + showTenFramesForAll: boolean +}): ManualModePresetName | 'custom' { for (const [name, preset] of Object.entries(MANUAL_MODE_PRESETS)) { if ( preset.showCarryBoxes === config.showCarryBoxes && @@ -95,8 +95,8 @@ export function getManualPresetFromConfig(config: { preset.showCellBorder === config.showCellBorder && preset.showTenFramesForAll === config.showTenFramesForAll ) { - return name as ManualModePresetName; + return name as ManualModePresetName } } - return "custom"; + return 'custom' } diff --git a/apps/web/src/app/create/worksheets/addition/page.tsx b/apps/web/src/app/create/worksheets/addition/page.tsx index 06e54bda..cd32e61c 100644 --- a/apps/web/src/app/create/worksheets/addition/page.tsx +++ b/apps/web/src/app/create/worksheets/addition/page.tsx @@ -1,35 +1,32 @@ -import { eq, and } from "drizzle-orm"; -import { db, schema } from "@/db"; -import { getViewerId } from "@/lib/viewer"; -import { - parseAdditionConfig, - defaultAdditionConfig, -} from "@/app/create/worksheets/config-schemas"; -import { AdditionWorksheetClient } from "./components/AdditionWorksheetClient"; -import { WorksheetErrorBoundary } from "./components/WorksheetErrorBoundary"; -import type { WorksheetFormState } from "./types"; -import { generateWorksheetPreview } from "./generatePreview"; +import { eq, and } from 'drizzle-orm' +import { db, schema } from '@/db' +import { getViewerId } from '@/lib/viewer' +import { parseAdditionConfig, defaultAdditionConfig } from '@/app/create/worksheets/config-schemas' +import { AdditionWorksheetClient } from './components/AdditionWorksheetClient' +import { WorksheetErrorBoundary } from './components/WorksheetErrorBoundary' +import type { WorksheetFormState } from './types' +import { generateWorksheetPreview } from './generatePreview' /** * Get current date formatted as "Month Day, Year" */ function getDefaultDate(): string { - const now = new Date(); - return now.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }); + const now = new Date() + return now.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }) } /** * Load worksheet settings from database (server-side) */ async function loadWorksheetSettings(): Promise< - Omit + Omit > { try { - const viewerId = await getViewerId(); + const viewerId = await getViewerId() // Look up user's saved settings const [row] = await db @@ -38,46 +35,46 @@ async function loadWorksheetSettings(): Promise< .where( and( eq(schema.worksheetSettings.userId, viewerId), - eq(schema.worksheetSettings.worksheetType, "addition"), - ), + eq(schema.worksheetSettings.worksheetType, 'addition') + ) ) - .limit(1); + .limit(1) if (!row) { // No saved settings, return defaults with a stable seed return { ...defaultAdditionConfig, seed: Date.now() % 2147483647, - } as unknown as Omit; + } as unknown as Omit } // Parse and validate config (auto-migrates to latest version) - const config = parseAdditionConfig(row.config); + const config = parseAdditionConfig(row.config) return { ...config, seed: Date.now() % 2147483647, - } as unknown as Omit; + } as unknown as Omit } catch (error) { - console.error("Failed to load worksheet settings:", error); + console.error('Failed to load worksheet settings:', error) // Return defaults on error with a stable seed return { ...defaultAdditionConfig, seed: Date.now() % 2147483647, - } as unknown as Omit; + } as unknown as Omit } } export default async function AdditionWorksheetPage() { - const initialSettings = await loadWorksheetSettings(); + const initialSettings = await loadWorksheetSettings() // Calculate derived state needed for preview // Use defaults for required fields (loadWorksheetSettings should always provide these, but TypeScript needs guarantees) - const problemsPerPage = initialSettings.problemsPerPage ?? 20; - const pages = initialSettings.pages ?? 1; - const cols = initialSettings.cols ?? 5; + const problemsPerPage = initialSettings.problemsPerPage ?? 20 + const pages = initialSettings.pages ?? 1 + const cols = initialSettings.cols ?? 5 - const rows = Math.ceil((problemsPerPage * pages) / cols); - const total = problemsPerPage * pages; + const rows = Math.ceil((problemsPerPage * pages) / cols) + const total = problemsPerPage * pages // Create full config for preview generation const fullConfig: WorksheetFormState = { @@ -85,15 +82,12 @@ export default async function AdditionWorksheetPage() { rows, total, date: getDefaultDate(), - }; + } // Pre-generate worksheet preview on the server - console.log("[SSR] Generating worksheet preview on server..."); - const previewResult = generateWorksheetPreview(fullConfig); - console.log( - "[SSR] Preview generation complete:", - previewResult.success ? "success" : "failed", - ); + console.log('[SSR] Generating worksheet preview on server...') + const previewResult = generateWorksheetPreview(fullConfig) + console.log('[SSR] Preview generation complete:', previewResult.success ? 'success' : 'failed') // Pass settings and preview to client, wrapped in error boundary return ( @@ -103,5 +97,5 @@ export default async function AdditionWorksheetPage() { initialPreview={previewResult.success ? previewResult.pages : undefined} /> - ); + ) } diff --git a/apps/web/src/app/create/worksheets/addition/problemAnalysis.ts b/apps/web/src/app/create/worksheets/addition/problemAnalysis.ts index d5a87406..88819971 100644 --- a/apps/web/src/app/create/worksheets/addition/problemAnalysis.ts +++ b/apps/web/src/app/create/worksheets/addition/problemAnalysis.ts @@ -3,24 +3,24 @@ // Supports 1-5 digit problems (max sum: 99999 + 99999 = 199998) export type PlaceValue = - | "ones" - | "tens" - | "hundreds" - | "thousands" - | "tenThousands" - | "hundredThousands"; + | 'ones' + | 'tens' + | 'hundreds' + | 'thousands' + | 'tenThousands' + | 'hundredThousands' export interface ProblemMeta { - a: number; - b: number; - digitsA: number; - digitsB: number; - maxDigits: number; - sum: number; - digitsSum: number; - requiresRegrouping: boolean; - regroupCount: number; - regroupPlaces: PlaceValue[]; + a: number + b: number + digitsA: number + digitsB: number + maxDigits: number + sum: number + digitsSum: number + requiresRegrouping: boolean + regroupCount: number + regroupPlaces: PlaceValue[] } /** @@ -30,37 +30,37 @@ export interface ProblemMeta { */ export function analyzeProblem(a: number, b: number): ProblemMeta { // Basic properties - const digitsA = a.toString().length; - const digitsB = b.toString().length; - const maxDigits = Math.max(digitsA, digitsB); - const sum = a + b; - const digitsSum = sum.toString().length; + const digitsA = a.toString().length + const digitsB = b.toString().length + const maxDigits = Math.max(digitsA, digitsB) + const sum = a + b + const digitsSum = sum.toString().length // Analyze regrouping place by place // Pad to 6 digits for consistent indexing (supports up to 99999 + 99999 = 199998) - const aDigits = String(a).padStart(6, "0").split("").map(Number).reverse(); - const bDigits = String(b).padStart(6, "0").split("").map(Number).reverse(); + const aDigits = String(a).padStart(6, '0').split('').map(Number).reverse() + const bDigits = String(b).padStart(6, '0').split('').map(Number).reverse() - const regroupPlaces: PlaceValue[] = []; + const regroupPlaces: PlaceValue[] = [] const places: PlaceValue[] = [ - "ones", - "tens", - "hundreds", - "thousands", - "tenThousands", - "hundredThousands", - ]; + 'ones', + 'tens', + 'hundreds', + 'thousands', + 'tenThousands', + 'hundredThousands', + ] // Check each place value for carrying // We need to track carries propagating through place values - let carry = 0; + let carry = 0 for (let i = 0; i < 6; i++) { - const digitSum = aDigits[i] + bDigits[i] + carry; + const digitSum = aDigits[i] + bDigits[i] + carry if (digitSum >= 10) { - regroupPlaces.push(places[i]); - carry = 1; + regroupPlaces.push(places[i]) + carry = 1 } else { - carry = 0; + carry = 0 } } @@ -75,23 +75,23 @@ export function analyzeProblem(a: number, b: number): ProblemMeta { requiresRegrouping: regroupPlaces.length > 0, regroupCount: regroupPlaces.length, regroupPlaces, - }; + } } /** * Metadata for a subtraction problem */ export interface SubtractionProblemMeta { - minuend: number; - subtrahend: number; - digitsMinuend: number; - digitsSubtrahend: number; - maxDigits: number; - difference: number; - digitsDifference: number; - requiresBorrowing: boolean; - borrowCount: number; - borrowPlaces: PlaceValue[]; + minuend: number + subtrahend: number + digitsMinuend: number + digitsSubtrahend: number + maxDigits: number + difference: number + digitsDifference: number + requiresBorrowing: boolean + borrowCount: number + borrowPlaces: PlaceValue[] } /** @@ -101,50 +101,42 @@ export interface SubtractionProblemMeta { */ export function analyzeSubtractionProblem( minuend: number, - subtrahend: number, + subtrahend: number ): SubtractionProblemMeta { // Basic properties - const digitsMinuend = minuend.toString().length; - const digitsSubtrahend = subtrahend.toString().length; - const maxDigits = Math.max(digitsMinuend, digitsSubtrahend); - const difference = minuend - subtrahend; - const digitsDifference = difference === 0 ? 1 : difference.toString().length; + const digitsMinuend = minuend.toString().length + const digitsSubtrahend = subtrahend.toString().length + const maxDigits = Math.max(digitsMinuend, digitsSubtrahend) + const difference = minuend - subtrahend + const digitsDifference = difference === 0 ? 1 : difference.toString().length // Analyze borrowing place by place // Pad to 6 digits for consistent indexing - const mDigits = String(minuend) - .padStart(6, "0") - .split("") - .map(Number) - .reverse(); - const sDigits = String(subtrahend) - .padStart(6, "0") - .split("") - .map(Number) - .reverse(); + const mDigits = String(minuend).padStart(6, '0').split('').map(Number).reverse() + const sDigits = String(subtrahend).padStart(6, '0').split('').map(Number).reverse() - const borrowPlaces: PlaceValue[] = []; + const borrowPlaces: PlaceValue[] = [] const places: PlaceValue[] = [ - "ones", - "tens", - "hundreds", - "thousands", - "tenThousands", - "hundredThousands", - ]; + 'ones', + 'tens', + 'hundreds', + 'thousands', + 'tenThousands', + 'hundredThousands', + ] // Check each place value for borrowing // We need to track borrows propagating through place values - let borrow = 0; + let borrow = 0 for (let i = 0; i < 6; i++) { - const mDigit = mDigits[i] - borrow; - const sDigit = sDigits[i]; + const mDigit = mDigits[i] - borrow + const sDigit = sDigits[i] if (mDigit < sDigit) { - borrowPlaces.push(places[i]); - borrow = 1; // Need to borrow from next higher place + borrowPlaces.push(places[i]) + borrow = 1 // Need to borrow from next higher place } else { - borrow = 0; + borrow = 0 } } @@ -159,5 +151,5 @@ export function analyzeSubtractionProblem( requiresBorrowing: borrowPlaces.length > 0, borrowCount: borrowPlaces.length, borrowPlaces, - }; + } } diff --git a/apps/web/src/app/create/worksheets/addition/problemGenerator.ts b/apps/web/src/app/create/worksheets/addition/problemGenerator.ts index 2a13c6b3..110ff9e4 100644 --- a/apps/web/src/app/create/worksheets/addition/problemGenerator.ts +++ b/apps/web/src/app/create/worksheets/addition/problemGenerator.ts @@ -5,33 +5,33 @@ import type { SubtractionProblem, WorksheetProblem, ProblemCategory, -} from "./types"; +} from './types' /** * Mulberry32 PRNG for reproducible random number generation */ export function createPRNG(seed: number) { - let state = seed; + let state = seed return function rand(): number { - let t = (state += 0x6d2b79f5); - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; + let t = (state += 0x6d2b79f5) + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } } /** * Pick a random element from an array */ function pick(arr: T[], rand: () => number): T { - return arr[Math.floor(rand() * arr.length)]; + return arr[Math.floor(rand() * arr.length)] } /** * Generate random integer between min and max (inclusive) */ function randint(min: number, max: number, rand: () => number): number { - return Math.floor(rand() * (max - min + 1)) + min; + return Math.floor(rand() * (max - min + 1)) + min } /** @@ -45,18 +45,18 @@ function randint(min: number, max: number, rand: () => number): number { */ export function generateNumber(digits: number, rand: () => number): number { if (digits < 1 || digits > 5) { - throw new Error(`Invalid digit count: ${digits}. Must be 1-5.`); + throw new Error(`Invalid digit count: ${digits}. Must be 1-5.`) } // For 1 digit, range is 0-9 (allow 0 as first digit) if (digits === 1) { - return randint(0, 9, rand); + return randint(0, 9, rand) } // For 2+ digits, range is [10^(n-1), 10^n - 1] - const min = 10 ** (digits - 1); - const max = 10 ** digits - 1; - return randint(min, max, rand); + const min = 10 ** (digits - 1) + const max = 10 ** digits - 1 + return randint(min, max, rand) } /** @@ -64,7 +64,7 @@ export function generateNumber(digits: number, rand: () => number): number { * Generate a random two-digit number (10-99) */ function twoDigit(rand: () => number): number { - return generateNumber(2, rand); + return generateNumber(2, rand) } /** @@ -72,15 +72,15 @@ function twoDigit(rand: () => number): number { * position 0 = ones, 1 = tens, 2 = hundreds, etc. */ function getDigit(num: number, position: number): number { - return Math.floor((num % 10 ** (position + 1)) / 10 ** position); + return Math.floor((num % 10 ** (position + 1)) / 10 ** position) } /** * Count number of digits in a number (1-5) */ function countDigits(num: number): number { - if (num === 0) return 1; - return Math.floor(Math.log10(Math.abs(num))) + 1; + if (num === 0) return 1 + return Math.floor(Math.log10(Math.abs(num))) + 1 } /** @@ -93,32 +93,32 @@ function countDigits(num: number): number { export function generateNonRegroup( rand: () => number, minDigits: number = 2, - maxDigits: number = 2, + maxDigits: number = 2 ): [number, number] { for (let i = 0; i < 5000; i++) { - const digitsA = randint(minDigits, maxDigits, rand); - const digitsB = randint(minDigits, maxDigits, rand); - const a = generateNumber(digitsA, rand); - const b = generateNumber(digitsB, rand); + const digitsA = randint(minDigits, maxDigits, rand) + const digitsB = randint(minDigits, maxDigits, rand) + const a = generateNumber(digitsA, rand) + const b = generateNumber(digitsB, rand) // Check all place values for carries - const maxPlaces = Math.max(countDigits(a), countDigits(b)); - let hasCarry = false; + const maxPlaces = Math.max(countDigits(a), countDigits(b)) + let hasCarry = false for (let pos = 0; pos < maxPlaces; pos++) { - const digitA = getDigit(a, pos); - const digitB = getDigit(b, pos); + const digitA = getDigit(a, pos) + const digitB = getDigit(b, pos) if (digitA + digitB >= 10) { - hasCarry = true; - break; + hasCarry = true + break } } if (!hasCarry) { - return [a, b]; + return [a, b] } } // Fallback - return minDigits === 1 ? [1, 2] : [12, 34]; + return minDigits === 1 ? [1, 2] : [12, 34] } /** @@ -131,40 +131,40 @@ export function generateNonRegroup( export function generateOnesOnly( rand: () => number, minDigits: number = 2, - maxDigits: number = 2, + maxDigits: number = 2 ): [number, number] { for (let i = 0; i < 5000; i++) { - const digitsA = randint(minDigits, maxDigits, rand); - const digitsB = randint(minDigits, maxDigits, rand); - const a = generateNumber(digitsA, rand); - const b = generateNumber(digitsB, rand); + const digitsA = randint(minDigits, maxDigits, rand) + const digitsB = randint(minDigits, maxDigits, rand) + const a = generateNumber(digitsA, rand) + const b = generateNumber(digitsB, rand) - const onesA = getDigit(a, 0); - const onesB = getDigit(b, 0); + const onesA = getDigit(a, 0) + const onesB = getDigit(b, 0) // Must have ones carry - if (onesA + onesB < 10) continue; + if (onesA + onesB < 10) continue // Check that no other place values carry - const maxPlaces = Math.max(countDigits(a), countDigits(b)); - let carry = 1; // carry from ones - let hasOtherCarry = false; + const maxPlaces = Math.max(countDigits(a), countDigits(b)) + let carry = 1 // carry from ones + let hasOtherCarry = false for (let pos = 1; pos < maxPlaces; pos++) { - const digitA = getDigit(a, pos); - const digitB = getDigit(b, pos); + const digitA = getDigit(a, pos) + const digitB = getDigit(b, pos) if (digitA + digitB + carry >= 10) { - hasOtherCarry = true; - break; + hasOtherCarry = true + break } - carry = 0; // no more carries after first position + carry = 0 // no more carries after first position } if (!hasOtherCarry) { - return [a, b]; + return [a, b] } } // Fallback - return minDigits === 1 ? [5, 8] : [58, 31]; + return minDigits === 1 ? [5, 8] : [58, 31] } /** @@ -177,55 +177,50 @@ export function generateOnesOnly( export function generateBoth( rand: () => number, minDigits: number = 2, - maxDigits: number = 2, + maxDigits: number = 2 ): [number, number] { for (let i = 0; i < 5000; i++) { - const digitsA = randint(minDigits, maxDigits, rand); - const digitsB = randint(minDigits, maxDigits, rand); - const a = generateNumber(digitsA, rand); - const b = generateNumber(digitsB, rand); + const digitsA = randint(minDigits, maxDigits, rand) + const digitsB = randint(minDigits, maxDigits, rand) + const a = generateNumber(digitsA, rand) + const b = generateNumber(digitsB, rand) // Check for carries in each place value - const maxPlaces = Math.max(countDigits(a), countDigits(b)); - let carryCount = 0; - let carry = 0; + const maxPlaces = Math.max(countDigits(a), countDigits(b)) + let carryCount = 0 + let carry = 0 for (let pos = 0; pos < maxPlaces; pos++) { - const digitA = getDigit(a, pos); - const digitB = getDigit(b, pos); + const digitA = getDigit(a, pos) + const digitB = getDigit(b, pos) if (digitA + digitB + carry >= 10) { - carryCount++; - carry = 1; + carryCount++ + carry = 1 } else { - carry = 0; + carry = 0 } } // "Both" means at least 2 carries if (carryCount >= 2) { - return [a, b]; + return [a, b] } } // Fallback - return minDigits === 1 ? [8, 9] : [68, 47]; + return minDigits === 1 ? [8, 9] : [68, 47] } /** * Try to add a unique problem to the list * Returns true if added, false if duplicate */ -function uniquePush( - list: AdditionProblem[], - a: number, - b: number, - seen: Set, -): boolean { - const key = [Math.min(a, b), Math.max(a, b)].join("+"); +function uniquePush(list: AdditionProblem[], a: number, b: number, seen: Set): boolean { + const key = [Math.min(a, b), Math.max(a, b)].join('+') if (seen.has(key) || a === b) { - return false; + return false } - seen.add(key); - list.push({ a, b, operator: "+" }); - return true; + seen.add(key) + list.push({ a, b, operator: '+' }) + return true } /** @@ -244,68 +239,68 @@ export function generateProblems( pAllStart: number, interpolate: boolean, seed: number, - digitRange: { min: number; max: number } = { min: 2, max: 2 }, + digitRange: { min: number; max: number } = { min: 2, max: 2 } ): AdditionProblem[] { - const rand = createPRNG(seed); - const problems: AdditionProblem[] = []; - const seen = new Set(); + const rand = createPRNG(seed) + const problems: AdditionProblem[] = [] + const seen = new Set() - const { min: minDigits, max: maxDigits } = digitRange; + const { min: minDigits, max: maxDigits } = digitRange for (let i = 0; i < total; i++) { // Calculate position from start (0) to end (1) - const frac = total <= 1 ? 0 : i / (total - 1); + const frac = total <= 1 ? 0 : i / (total - 1) // Progressive difficulty: start easy, end hard - const difficultyMultiplier = interpolate ? frac : 1.0; + const difficultyMultiplier = interpolate ? frac : 1.0 // Effective probabilities at this position - const pAll = Math.max(0, Math.min(1, pAllStart * difficultyMultiplier)); - const pAny = Math.max(0, Math.min(1, pAnyStart * difficultyMultiplier)); - const pOnesOnly = Math.max(0, pAny - pAll); - const pNon = Math.max(0, 1 - pAny); + const pAll = Math.max(0, Math.min(1, pAllStart * difficultyMultiplier)) + const pAny = Math.max(0, Math.min(1, pAnyStart * difficultyMultiplier)) + const pOnesOnly = Math.max(0, pAny - pAll) + const pNon = Math.max(0, 1 - pAny) // Sample category based on probabilities - const r = rand(); - let picked: ProblemCategory; + const r = rand() + let picked: ProblemCategory if (r < pAll) { - picked = "both"; + picked = 'both' } else if (r < pAll + pOnesOnly) { - picked = "onesOnly"; + picked = 'onesOnly' } else { - picked = "non"; + picked = 'non' } // Generate problem with retries for uniqueness - let tries = 0; - let ok = false; + let tries = 0 + let ok = false while (tries++ < 3000 && !ok) { - let a: number, b: number; - if (picked === "both") { - [a, b] = generateBoth(rand, minDigits, maxDigits); - } else if (picked === "onesOnly") { - [a, b] = generateOnesOnly(rand, minDigits, maxDigits); + let a: number, b: number + if (picked === 'both') { + ;[a, b] = generateBoth(rand, minDigits, maxDigits) + } else if (picked === 'onesOnly') { + ;[a, b] = generateOnesOnly(rand, minDigits, maxDigits) } else { - [a, b] = generateNonRegroup(rand, minDigits, maxDigits); + ;[a, b] = generateNonRegroup(rand, minDigits, maxDigits) } - ok = uniquePush(problems, a, b, seen); + ok = uniquePush(problems, a, b, seen) // If stuck, try a different category if (!ok && tries % 50 === 0) { - picked = pick(["both", "onesOnly", "non"], rand); + picked = pick(['both', 'onesOnly', 'non'], rand) } } // Last resort: add any valid problem in digit range if (!ok) { - const digitsA = randint(minDigits, maxDigits, rand); - const digitsB = randint(minDigits, maxDigits, rand); - const a = generateNumber(digitsA, rand); - const b = generateNumber(digitsB, rand); - uniquePush(problems, a, b, seen); + const digitsA = randint(minDigits, maxDigits, rand) + const digitsB = randint(minDigits, maxDigits, rand) + const a = generateNumber(digitsA, rand) + const b = generateNumber(digitsB, rand) + uniquePush(problems, a, b, seen) } } - return problems; + return problems } // ============================================================================= @@ -322,35 +317,35 @@ export function generateProblems( export function generateNonBorrow( rand: () => number, minDigits: number = 2, - maxDigits: number = 2, + maxDigits: number = 2 ): [number, number] { for (let i = 0; i < 5000; i++) { - const digitsMinuend = randint(minDigits, maxDigits, rand); - const digitsSubtrahend = randint(minDigits, maxDigits, rand); - const minuend = generateNumber(digitsMinuend, rand); - const subtrahend = generateNumber(digitsSubtrahend, rand); + const digitsMinuend = randint(minDigits, maxDigits, rand) + const digitsSubtrahend = randint(minDigits, maxDigits, rand) + const minuend = generateNumber(digitsMinuend, rand) + const subtrahend = generateNumber(digitsSubtrahend, rand) // Ensure minuend >= subtrahend (no negative results) - if (minuend < subtrahend) continue; + if (minuend < subtrahend) continue // Check all place values for borrows - const maxPlaces = Math.max(countDigits(minuend), countDigits(subtrahend)); - let hasBorrow = false; + const maxPlaces = Math.max(countDigits(minuend), countDigits(subtrahend)) + let hasBorrow = false for (let pos = 0; pos < maxPlaces; pos++) { - const digitM = getDigit(minuend, pos); - const digitS = getDigit(subtrahend, pos); + const digitM = getDigit(minuend, pos) + const digitS = getDigit(subtrahend, pos) if (digitM < digitS) { - hasBorrow = true; - break; + hasBorrow = true + break } } if (!hasBorrow) { - return [minuend, subtrahend]; + return [minuend, subtrahend] } } // Fallback - return minDigits === 1 ? [9, 2] : [89, 34]; + return minDigits === 1 ? [9, 2] : [89, 34] } /** @@ -363,60 +358,60 @@ export function generateNonBorrow( export function generateOnesOnlyBorrow( rand: () => number, minDigits: number = 2, - maxDigits: number = 2, + maxDigits: number = 2 ): [number, number] { for (let i = 0; i < 5000; i++) { - const digitsMinuend = randint(minDigits, maxDigits, rand); - const digitsSubtrahend = randint(minDigits, maxDigits, rand); - const minuend = generateNumber(digitsMinuend, rand); - const subtrahend = generateNumber(digitsSubtrahend, rand); + const digitsMinuend = randint(minDigits, maxDigits, rand) + const digitsSubtrahend = randint(minDigits, maxDigits, rand) + const minuend = generateNumber(digitsMinuend, rand) + const subtrahend = generateNumber(digitsSubtrahend, rand) // Ensure minuend >= subtrahend - if (minuend < subtrahend) continue; + if (minuend < subtrahend) continue - const onesM = getDigit(minuend, 0); - const onesS = getDigit(subtrahend, 0); + const onesM = getDigit(minuend, 0) + const onesS = getDigit(subtrahend, 0) // Must borrow in ones place - if (onesM >= onesS) continue; + if (onesM >= onesS) continue // Check that no other place values borrow // Note: For subtraction, we need to track actual borrowing through the places - const maxPlaces = Math.max(countDigits(minuend), countDigits(subtrahend)); - let tempMinuend = minuend; - let hasOtherBorrow = false; + const maxPlaces = Math.max(countDigits(minuend), countDigits(subtrahend)) + let tempMinuend = minuend + let hasOtherBorrow = false // Simulate the subtraction place by place for (let pos = 0; pos < maxPlaces; pos++) { - const digitM = getDigit(tempMinuend, pos); - const digitS = getDigit(subtrahend, pos); + const digitM = getDigit(tempMinuend, pos) + const digitS = getDigit(subtrahend, pos) if (digitM < digitS) { if (pos === 0) { // Expected borrow in ones // Borrow from tens - const tensDigit = getDigit(tempMinuend, 1); + const tensDigit = getDigit(tempMinuend, 1) if (tensDigit === 0) { // Can't borrow from zero - this problem is invalid - hasOtherBorrow = true; - break; + hasOtherBorrow = true + break } // Apply the borrow - tempMinuend -= 10 ** 1; // Subtract 10 from tens place + tempMinuend -= 10 ** 1 // Subtract 10 from tens place } else { // Borrow in higher place - not allowed for ones-only - hasOtherBorrow = true; - break; + hasOtherBorrow = true + break } } } if (!hasOtherBorrow) { - return [minuend, subtrahend]; + return [minuend, subtrahend] } } // Fallback - return minDigits === 1 ? [5, 7] : [52, 17]; + return minDigits === 1 ? [5, 7] : [52, 17] } /** @@ -430,47 +425,47 @@ export function generateOnesOnlyBorrow( * - 1000 - 1: ones 0 < 1, borrow across 3 zeros → 3 borrows */ function countBorrows(minuend: number, subtrahend: number): number { - const maxPlaces = Math.max(countDigits(minuend), countDigits(subtrahend)); - let borrowCount = 0; - const minuendDigits: number[] = []; + const maxPlaces = Math.max(countDigits(minuend), countDigits(subtrahend)) + let borrowCount = 0 + const minuendDigits: number[] = [] // Extract all digits of minuend into array for (let pos = 0; pos < maxPlaces; pos++) { - minuendDigits[pos] = getDigit(minuend, pos); + minuendDigits[pos] = getDigit(minuend, pos) } // Simulate subtraction with borrowing for (let pos = 0; pos < maxPlaces; pos++) { - const digitS = getDigit(subtrahend, pos); - let digitM = minuendDigits[pos]; + const digitS = getDigit(subtrahend, pos) + const digitM = minuendDigits[pos] if (digitM < digitS) { // Need to borrow - borrowCount++; + borrowCount++ // Find next non-zero digit to borrow from - let borrowPos = pos + 1; + let borrowPos = pos + 1 while (borrowPos < maxPlaces && minuendDigits[borrowPos] === 0) { - borrowCount++; // Borrowing across a zero counts as an additional borrow - borrowPos++; + borrowCount++ // Borrowing across a zero counts as an additional borrow + borrowPos++ } // Perform the borrow operation if (borrowPos < maxPlaces) { - minuendDigits[borrowPos]--; // Take 1 from higher place + minuendDigits[borrowPos]-- // Take 1 from higher place // Set intermediate zeros to 9 for (let p = borrowPos - 1; p > pos; p--) { - minuendDigits[p] = 9; + minuendDigits[p] = 9 } // Add 10 to current position - minuendDigits[pos] += 10; + minuendDigits[pos] += 10 } } } - return borrowCount; + return borrowCount } /** @@ -487,35 +482,35 @@ function countBorrows(minuend: number, subtrahend: number): number { export function generateBothBorrow( rand: () => number, minDigits: number = 2, - maxDigits: number = 2, + maxDigits: number = 2 ): [number, number] { // For 1-2 digit ranges, 2+ borrows are impossible // Fall back to ones-only borrowing if (maxDigits <= 2) { - return generateOnesOnlyBorrow(rand, minDigits, maxDigits); + return generateOnesOnlyBorrow(rand, minDigits, maxDigits) } for (let i = 0; i < 5000; i++) { // Favor higher digit counts for better chance of 2+ borrows - const digitsMinuend = randint(Math.max(minDigits, 3), maxDigits, rand); - const digitsSubtrahend = randint(Math.max(minDigits, 2), maxDigits, rand); - const minuend = generateNumber(digitsMinuend, rand); - const subtrahend = generateNumber(digitsSubtrahend, rand); + const digitsMinuend = randint(Math.max(minDigits, 3), maxDigits, rand) + const digitsSubtrahend = randint(Math.max(minDigits, 2), maxDigits, rand) + const minuend = generateNumber(digitsMinuend, rand) + const subtrahend = generateNumber(digitsSubtrahend, rand) // Ensure minuend > subtrahend - if (minuend <= subtrahend) continue; + if (minuend <= subtrahend) continue // Count actual borrow operations - const borrowCount = countBorrows(minuend, subtrahend); + const borrowCount = countBorrows(minuend, subtrahend) // Need at least 2 actual borrow operations if (borrowCount >= 2) { - return [minuend, subtrahend]; + return [minuend, subtrahend] } } // Fallback: 534 - 178 requires borrowing in ones and tens // 100 - 1 requires borrowing across zero (2 borrows) - return [534, 178]; + return [534, 178] } /** @@ -535,86 +530,82 @@ export function generateSubtractionProblems( pAnyBorrow: number, pAllBorrow: number, interpolate: boolean, - seed: number, + seed: number ): SubtractionProblem[] { - const rand = createPRNG(seed); - const problems: SubtractionProblem[] = []; - const seen = new Set(); - const minDigits = digitRange.min; - const maxDigits = digitRange.max; + const rand = createPRNG(seed) + const problems: SubtractionProblem[] = [] + const seen = new Set() + const minDigits = digitRange.min + const maxDigits = digitRange.max function uniquePush(minuend: number, subtrahend: number): boolean { - const key = `${minuend}-${subtrahend}`; + const key = `${minuend}-${subtrahend}` if (!seen.has(key)) { - seen.add(key); - problems.push({ minuend, subtrahend, operator: "−" }); - return true; + seen.add(key) + problems.push({ minuend, subtrahend, operator: '−' }) + return true } - return false; + return false } for (let i = 0; i < count; i++) { - const t = i / Math.max(1, count - 1); // 0.0 to 1.0 - const difficultyMultiplier = interpolate ? t : 1; + const t = i / Math.max(1, count - 1) // 0.0 to 1.0 + const difficultyMultiplier = interpolate ? t : 1 // Effective probabilities at this position - const pAll = Math.max(0, Math.min(1, pAllBorrow * difficultyMultiplier)); - const pAny = Math.max(0, Math.min(1, pAnyBorrow * difficultyMultiplier)); - const pOnesOnly = Math.max(0, pAny - pAll); - const pNon = Math.max(0, 1 - pAny); + const pAll = Math.max(0, Math.min(1, pAllBorrow * difficultyMultiplier)) + const pAny = Math.max(0, Math.min(1, pAnyBorrow * difficultyMultiplier)) + const pOnesOnly = Math.max(0, pAny - pAll) + const pNon = Math.max(0, 1 - pAny) // Sample category based on probabilities - const r = rand(); - let picked: ProblemCategory; + const r = rand() + let picked: ProblemCategory if (r < pAll) { - picked = "both"; + picked = 'both' } else if (r < pAll + pOnesOnly) { - picked = "onesOnly"; + picked = 'onesOnly' } else { - picked = "non"; + picked = 'non' } // Generate problem with retries for uniqueness - let tries = 0; - let ok = false; + let tries = 0 + let ok = false while (tries++ < 3000 && !ok) { - let minuend: number, subtrahend: number; - if (picked === "both") { - [minuend, subtrahend] = generateBothBorrow(rand, minDigits, maxDigits); - } else if (picked === "onesOnly") { - [minuend, subtrahend] = generateOnesOnlyBorrow( - rand, - minDigits, - maxDigits, - ); + let minuend: number, subtrahend: number + if (picked === 'both') { + ;[minuend, subtrahend] = generateBothBorrow(rand, minDigits, maxDigits) + } else if (picked === 'onesOnly') { + ;[minuend, subtrahend] = generateOnesOnlyBorrow(rand, minDigits, maxDigits) } else { - [minuend, subtrahend] = generateNonBorrow(rand, minDigits, maxDigits); + ;[minuend, subtrahend] = generateNonBorrow(rand, minDigits, maxDigits) } - ok = uniquePush(minuend, subtrahend); + ok = uniquePush(minuend, subtrahend) // If stuck, try a different category if (!ok && tries % 50 === 0) { - picked = pick(["both", "onesOnly", "non"], rand); + picked = pick(['both', 'onesOnly', 'non'], rand) } } // Last resort: add any valid problem in digit range if (!ok) { - const digitsM = randint(minDigits, maxDigits, rand); - const digitsS = randint(minDigits, maxDigits, rand); - let minuend = generateNumber(digitsM, rand); - let subtrahend = generateNumber(digitsS, rand); + const digitsM = randint(minDigits, maxDigits, rand) + const digitsS = randint(minDigits, maxDigits, rand) + let minuend = generateNumber(digitsM, rand) + let subtrahend = generateNumber(digitsS, rand) // Ensure minuend >= subtrahend if (minuend < subtrahend) { - [minuend, subtrahend] = [subtrahend, minuend]; + ;[minuend, subtrahend] = [subtrahend, minuend] } - uniquePush(minuend, subtrahend); + uniquePush(minuend, subtrahend) } } - return problems; + return problems } /** @@ -634,14 +625,14 @@ export function generateMixedProblems( pAnyRegroup: number, pAllRegroup: number, interpolate: boolean, - seed: number, + seed: number ): WorksheetProblem[] { - const rand = createPRNG(seed); - const problems: WorksheetProblem[] = []; + const rand = createPRNG(seed) + const problems: WorksheetProblem[] = [] // Generate half addition, half subtraction (alternating with randomness) for (let i = 0; i < count; i++) { - const useAddition = rand() < 0.5; + const useAddition = rand() < 0.5 if (useAddition) { // Generate single addition problem @@ -651,9 +642,9 @@ export function generateMixedProblems( pAllRegroup, false, // Don't interpolate individual problems seed + i, - digitRange, - ); - problems.push(addProblems[0]); + digitRange + ) + problems.push(addProblems[0]) } else { // Generate single subtraction problem const subProblems = generateSubtractionProblems( @@ -662,11 +653,11 @@ export function generateMixedProblems( pAnyRegroup, pAllRegroup, false, // Don't interpolate individual problems - seed + i + 1000000, // Different seed space - ); - problems.push(subProblems[0]); + seed + i + 1000000 // Different seed space + ) + problems.push(subProblems[0]) } } - return problems; + return problems } diff --git a/apps/web/src/app/create/worksheets/addition/typstGenerator.ts b/apps/web/src/app/create/worksheets/addition/typstGenerator.ts index 516b7118..6b3d8c8b 100644 --- a/apps/web/src/app/create/worksheets/addition/typstGenerator.ts +++ b/apps/web/src/app/create/worksheets/addition/typstGenerator.ts @@ -1,27 +1,24 @@ // Typst document generator for addition worksheets -import type { WorksheetProblem, WorksheetConfig } from "./types"; +import type { WorksheetProblem, WorksheetConfig } from './types' import { generateTypstHelpers, generateProblemStackFunction, generateSubtractionProblemStackFunction, generatePlaceValueColors, -} from "./typstHelpers"; -import { analyzeProblem, analyzeSubtractionProblem } from "./problemAnalysis"; -import { resolveDisplayForProblem } from "./displayRules"; +} from './typstHelpers' +import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis' +import { resolveDisplayForProblem } from './displayRules' /** * Chunk array into pages of specified size */ -function chunkProblems( - problems: WorksheetProblem[], - pageSize: number, -): WorksheetProblem[][] { - const pages: WorksheetProblem[][] = []; +function chunkProblems(problems: WorksheetProblem[], pageSize: number): WorksheetProblem[][] { + const pages: WorksheetProblem[][] = [] for (let i = 0; i < problems.length; i += pageSize) { - pages.push(problems.slice(i, i + pageSize)); + pages.push(problems.slice(i, i + pageSize)) } - return pages; + return pages } /** @@ -29,22 +26,22 @@ function chunkProblems( * Returns max digits across all operands (handles both addition and subtraction) */ function calculateMaxDigits(problems: WorksheetProblem[]): number { - let maxDigits = 1; + let maxDigits = 1 for (const problem of problems) { - if (problem.operator === "+") { - const digitsA = problem.a.toString().length; - const digitsB = problem.b.toString().length; - const maxProblemDigits = Math.max(digitsA, digitsB); - maxDigits = Math.max(maxDigits, maxProblemDigits); + if (problem.operator === '+') { + const digitsA = problem.a.toString().length + const digitsB = problem.b.toString().length + const maxProblemDigits = Math.max(digitsA, digitsB) + maxDigits = Math.max(maxDigits, maxProblemDigits) } else { // Subtraction - const digitsMinuend = problem.minuend.toString().length; - const digitsSubtrahend = problem.subtrahend.toString().length; - const maxProblemDigits = Math.max(digitsMinuend, digitsSubtrahend); - maxDigits = Math.max(maxDigits, maxProblemDigits); + const digitsMinuend = problem.minuend.toString().length + const digitsSubtrahend = problem.subtrahend.toString().length + const maxProblemDigits = Math.max(digitsMinuend, digitsSubtrahend) + maxDigits = Math.max(maxDigits, maxProblemDigits) } } - return maxDigits; + return maxDigits } /** @@ -54,70 +51,31 @@ function generatePageTypst( config: WorksheetConfig, pageProblems: WorksheetProblem[], problemOffset: number, - rowsPerPage: number, + rowsPerPage: number ): string { - console.log("[typstGenerator] generatePageTypst called with config:", { - mode: config.mode, - displayRules: - config.mode === "smart" ? config.displayRules : "N/A (manual mode)", - showTenFrames: - config.mode === "manual" ? config.showTenFrames : "N/A (smart mode)", - }); - // Calculate maximum digits for proper column layout - const maxDigits = calculateMaxDigits(pageProblems); - console.log("[typstGenerator] Max digits on this page:", maxDigits); + const maxDigits = calculateMaxDigits(pageProblems) // Enrich problems with display options based on mode const enrichedProblems = pageProblems.map((p, index) => { - if (config.mode === "smart") { - // Smart mode: Per-problem conditional display based on problem complexity + if (config.mode === 'smart' || config.mode === 'mastery') { + // Smart & Mastery modes: Per-problem conditional display based on problem complexity + // Both modes use displayRules for conditional scaffolding const meta = - p.operator === "+" + p.operator === '+' ? analyzeProblem(p.a, p.b) - : analyzeSubtractionProblem(p.minuend, p.subtrahend); + : analyzeSubtractionProblem(p.minuend, p.subtrahend) const displayOptions = resolveDisplayForProblem( config.displayRules as any, // Cast for backward compatibility with configs missing new fields - meta, - ); - - if (index === 0) { - const problemStr = - p.operator === "+" - ? `${p.a} + ${p.b}` - : `${p.minuend} − ${p.subtrahend}`; - console.log( - "[typstGenerator] Smart mode - First problem display options:", - { - problem: problemStr, - meta, - displayOptions, - }, - ); - } + meta + ) return { ...p, ...displayOptions, // Now includes showBorrowNotation and showBorrowingHints from resolved rules - }; - } else { - // Manual mode: Uniform display across all problems - if (index === 0) { - const problemStr = - p.operator === "+" - ? `${p.a} + ${p.b}` - : `${p.minuend} − ${p.subtrahend}`; - console.log("[typstGenerator] Manual mode - Uniform display options:", { - problem: problemStr, - showCarryBoxes: config.showCarryBoxes, - showAnswerBoxes: config.showAnswerBoxes, - showPlaceValueColors: config.showPlaceValueColors, - showTenFrames: config.showTenFrames, - showProblemNumbers: config.showProblemNumbers, - showCellBorder: config.showCellBorder, - }); } - + } else { + // Manual mode: Uniform display across all problems using boolean flags return { ...p, showCarryBoxes: config.showCarryBoxes, @@ -126,44 +84,50 @@ function generatePageTypst( showTenFrames: config.showTenFrames, showProblemNumbers: config.showProblemNumbers, showCellBorder: config.showCellBorder, - showBorrowNotation: - "showBorrowNotation" in config ? config.showBorrowNotation : true, - showBorrowingHints: - "showBorrowingHints" in config ? config.showBorrowingHints : false, - }; + showBorrowNotation: 'showBorrowNotation' in config ? config.showBorrowNotation : true, + showBorrowingHints: 'showBorrowingHints' in config ? config.showBorrowingHints : false, + } } - }); + }) + + // DEBUG: Show first 3 problems' ten-frames status + console.log('[TYPST DEBUG] First 3 enriched problems:', enrichedProblems.slice(0, 3).map((p, i) => ({ + index: i, + problem: p.operator === '+' ? `${p.a} + ${p.b}` : `${p.minuend} − ${p.subtrahend}`, + showTenFrames: p.showTenFrames, + }))) // Generate Typst problem data with per-problem display flags const problemsTypst = enrichedProblems .map((p) => { - if (p.operator === "+") { - return ` (operator: "+", a: ${p.a}, b: ${p.b}, showCarryBoxes: ${p.showCarryBoxes}, showAnswerBoxes: ${p.showAnswerBoxes}, showPlaceValueColors: ${p.showPlaceValueColors}, showTenFrames: ${p.showTenFrames}, showProblemNumbers: ${p.showProblemNumbers}, showCellBorder: ${p.showCellBorder}, showBorrowNotation: ${p.showBorrowNotation}, showBorrowingHints: ${p.showBorrowingHints}),`; + if (p.operator === '+') { + return ` (operator: "+", a: ${p.a}, b: ${p.b}, showCarryBoxes: ${p.showCarryBoxes}, showAnswerBoxes: ${p.showAnswerBoxes}, showPlaceValueColors: ${p.showPlaceValueColors}, showTenFrames: ${p.showTenFrames}, showProblemNumbers: ${p.showProblemNumbers}, showCellBorder: ${p.showCellBorder}, showBorrowNotation: ${p.showBorrowNotation}, showBorrowingHints: ${p.showBorrowingHints}),` } else { - return ` (operator: "−", minuend: ${p.minuend}, subtrahend: ${p.subtrahend}, showCarryBoxes: ${p.showCarryBoxes}, showAnswerBoxes: ${p.showAnswerBoxes}, showPlaceValueColors: ${p.showPlaceValueColors}, showTenFrames: ${p.showTenFrames}, showProblemNumbers: ${p.showProblemNumbers}, showCellBorder: ${p.showCellBorder}, showBorrowNotation: ${p.showBorrowNotation}, showBorrowingHints: ${p.showBorrowingHints}),`; + return ` (operator: "−", minuend: ${p.minuend}, subtrahend: ${p.subtrahend}, showCarryBoxes: ${p.showCarryBoxes}, showAnswerBoxes: ${p.showAnswerBoxes}, showPlaceValueColors: ${p.showPlaceValueColors}, showTenFrames: ${p.showTenFrames}, showProblemNumbers: ${p.showProblemNumbers}, showCellBorder: ${p.showCellBorder}, showBorrowNotation: ${p.showBorrowNotation}, showBorrowingHints: ${p.showBorrowingHints}),` } }) - .join("\n"); + .join('\n') + + // DEBUG: Show Typst problem data for first problem + console.log('[TYPST DEBUG] First problem Typst data:', problemsTypst.split('\n')[0]) // Calculate actual number of rows on this page - const actualRows = Math.ceil(pageProblems.length / config.cols); + const actualRows = Math.ceil(pageProblems.length / config.cols) // Use smaller margins to maximize space - const margin = 0.4; - const contentWidth = config.page.wIn - margin * 2; - const contentHeight = config.page.hIn - margin * 2; + const margin = 0.4 + const contentWidth = config.page.wIn - margin * 2 + const contentHeight = config.page.hIn - margin * 2 // Calculate grid spacing based on ACTUAL rows on this page - const headerHeight = 0.35; // inches for header - const availableHeight = contentHeight - headerHeight; - const problemBoxHeight = availableHeight / actualRows; - const problemBoxWidth = contentWidth / config.cols; + const headerHeight = 0.35 // inches for header + const availableHeight = contentHeight - headerHeight + const problemBoxHeight = availableHeight / actualRows + const problemBoxWidth = contentWidth / config.cols // Calculate cell size assuming MAXIMUM possible embellishments // Check if ANY problem on this page might show ten-frames - const anyProblemMayShowTenFrames = enrichedProblems.some( - (p) => p.showTenFrames, - ); + const anyProblemMayShowTenFrames = enrichedProblems.some((p) => p.showTenFrames) // Calculate cell size to fill the entire problem box // Base vertical stack: carry row + addend1 + addend2 + line + answer = 5 rows @@ -172,13 +136,13 @@ function generatePageTypst( // // Horizontal constraint: maxDigits columns + 1 for + sign // Cell size must fit: (maxDigits + 1) * cellSize <= problemBoxWidth - const maxCellSizeForWidth = problemBoxWidth / (maxDigits + 1); + const maxCellSizeForWidth = problemBoxWidth / (maxDigits + 1) const maxCellSizeForHeight = anyProblemMayShowTenFrames ? problemBoxHeight / 6.0 - : problemBoxHeight / 4.5; + : problemBoxHeight / 4.5 // Use the smaller of width/height constraints - const cellSize = Math.min(maxCellSizeForWidth, maxCellSizeForHeight); + const cellSize = Math.min(maxCellSizeForWidth, maxCellSizeForHeight) return String.raw` // addition-worksheet-page.typ (auto-generated) @@ -196,13 +160,13 @@ function generatePageTypst( #let heavy-stroke = 0.8pt #let show-ten-frames-for-all = ${ - config.mode === "manual" + config.mode === 'manual' ? config.showTenFramesForAll - ? "true" - : "false" - : config.displayRules.tenFrames === "always" - ? "true" - : "false" + ? 'true' + : 'false' + : config.displayRules.tenFrames === 'always' + ? 'true' + : 'false' } ${generatePlaceValueColors()} @@ -280,7 +244,7 @@ ${problemsTypst} ) ] // End of constrained block -`; +` } /** @@ -288,22 +252,17 @@ ${problemsTypst} */ export function generateTypstSource( config: WorksheetConfig, - problems: WorksheetProblem[], + problems: WorksheetProblem[] ): string[] { // Use the problemsPerPage directly from config (primary state) - const problemsPerPage = config.problemsPerPage; - const rowsPerPage = problemsPerPage / config.cols; + const problemsPerPage = config.problemsPerPage + const rowsPerPage = problemsPerPage / config.cols // Chunk problems into discrete pages - const pages = chunkProblems(problems, problemsPerPage); + const pages = chunkProblems(problems, problemsPerPage) // Generate separate Typst source for each page return pages.map((pageProblems, pageIndex) => - generatePageTypst( - config, - pageProblems, - pageIndex * problemsPerPage, - rowsPerPage, - ), - ); + generatePageTypst(config, pageProblems, pageIndex * problemsPerPage, rowsPerPage) + ) } diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers/index.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers/index.ts index 37f38084..da43bd20 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers/index.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers/index.ts @@ -5,25 +5,25 @@ export { generatePlaceValueColors, getPlaceValueColorNames, -} from "./shared/colors"; -export { generateTypstHelpers } from "./shared/helpers"; +} from './shared/colors' +export { generateTypstHelpers } from './shared/helpers' export type { DisplayOptions, CellDimensions, TypstConstants, -} from "./shared/types"; -export { TYPST_CONSTANTS } from "./shared/types"; +} from './shared/types' +export { TYPST_CONSTANTS } from './shared/types' // Subtraction components -export { generateBorrowBoxesRow } from "./subtraction/borrowBoxes"; -export { generateMinuendRow } from "./subtraction/minuendRow"; -export { generateSubtrahendRow } from "./subtraction/subtrahendRow"; +export { generateBorrowBoxesRow } from './subtraction/borrowBoxes' +export { generateMinuendRow } from './subtraction/minuendRow' +export { generateSubtrahendRow } from './subtraction/subtrahendRow' export { generateLineRow, generateTenFramesRow, generateAnswerBoxesRow, -} from "./subtraction/answerRow"; -export { generateSubtractionProblemStackFunction } from "./subtraction/problemStack"; +} from './subtraction/answerRow' +export { generateSubtractionProblemStackFunction } from './subtraction/problemStack' // Addition components (TODO: extract in future phase) // export { generateCarryBoxesRow } from './addition/carryBoxes' diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/colors.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/colors.ts index 151b2dd8..82ac982d 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/colors.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/colors.ts @@ -14,7 +14,7 @@ export function generatePlaceValueColors(): string { #let color-ten-thousands = rgb(243, 229, 245) // Light purple/lavender (ten-thousands) #let color-hundred-thousands = rgb(255, 239, 213) // Light peach/orange (hundred-thousands) #let color-none = white // No color -`; +` } /** @@ -22,11 +22,11 @@ export function generatePlaceValueColors(): string { */ export function getPlaceValueColorNames(): string[] { return [ - "color-ones", - "color-tens", - "color-hundreds", - "color-thousands", - "color-ten-thousands", - "color-hundred-thousands", - ]; + 'color-ones', + 'color-tens', + 'color-hundreds', + 'color-thousands', + 'color-ten-thousands', + 'color-hundred-thousands', + ] } diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/helpers.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/helpers.ts index 733e3fe1..3c1a5749 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/helpers.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/helpers.ts @@ -1,7 +1,7 @@ // Shared Typst helper functions and components // Reusable across addition and subtraction worksheets -import { TYPST_CONSTANTS } from "./types"; +import { TYPST_CONSTANTS } from './types' /** * Generate Typst helper functions (ten-frames, diagonal boxes, etc.) @@ -71,5 +71,5 @@ export function generateTypstHelpers(cellSize: number): string { ) ] } -`; +` } diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/types.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/types.ts index 64c4d8c0..e8dbc0bb 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/types.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers/shared/types.ts @@ -1,37 +1,37 @@ // Shared TypeScript types for Typst generation export interface DisplayOptions { - showCarryBoxes: boolean; - showAnswerBoxes: boolean; - showPlaceValueColors: boolean; - showProblemNumbers: boolean; - showCellBorder: boolean; - showTenFrames: boolean; - showTenFramesForAll: boolean; - fontSize: number; + showCarryBoxes: boolean + showAnswerBoxes: boolean + showPlaceValueColors: boolean + showProblemNumbers: boolean + showCellBorder: boolean + showTenFrames: boolean + showTenFramesForAll: boolean + fontSize: number } export interface CellDimensions { - cellSize: number; // in inches - cellSizeIn: string; // formatted for Typst (e.g., "0.55in") - cellSizePt: number; // in points (for font sizing) + cellSize: number // in inches + cellSizeIn: string // formatted for Typst (e.g., "0.55in") + cellSizePt: number // in points (for font sizing) } export interface TypstConstants { - CELL_STROKE_WIDTH: number; - TEN_FRAME_STROKE_WIDTH: number; - TEN_FRAME_CELL_STROKE_WIDTH: number; - ARROW_STROKE_WIDTH: number; + CELL_STROKE_WIDTH: number + TEN_FRAME_STROKE_WIDTH: number + TEN_FRAME_CELL_STROKE_WIDTH: number + ARROW_STROKE_WIDTH: number // Positioning offsets for borrowing hint arrows - ARROW_START_DX: number; - ARROW_START_DY: number; - ARROWHEAD_DX: number; - ARROWHEAD_DY: number; + ARROW_START_DX: number + ARROW_START_DY: number + ARROWHEAD_DX: number + ARROWHEAD_DY: number // Sizing factors - HINT_TEXT_SIZE_FACTOR: number; - ARROWHEAD_SIZE_FACTOR: number; + HINT_TEXT_SIZE_FACTOR: number + ARROWHEAD_SIZE_FACTOR: number } export const TYPST_CONSTANTS: TypstConstants = { @@ -47,4 +47,4 @@ export const TYPST_CONSTANTS: TypstConstants = { HINT_TEXT_SIZE_FACTOR: 0.25, ARROWHEAD_SIZE_FACTOR: 0.35, -} as const; +} as const diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/borrowBoxes.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/borrowBoxes.ts index 75f7cb32..9796dd20 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/borrowBoxes.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/borrowBoxes.ts @@ -1,8 +1,8 @@ // Borrow boxes row rendering for subtraction problems // This row shows where borrows occur (FROM higher place TO lower place) -import type { CellDimensions } from "../shared/types"; -import { TYPST_CONSTANTS } from "../shared/types"; +import type { CellDimensions } from '../shared/types' +import { TYPST_CONSTANTS } from '../shared/types' /** * Generate Typst code for the borrow boxes row @@ -18,23 +18,19 @@ import { TYPST_CONSTANTS } from "../shared/types"; * @returns Typst code for borrow boxes row */ export function generateBorrowBoxesRow(cellDimensions: CellDimensions): string { - const { cellSize, cellSizeIn, cellSizePt } = cellDimensions; + const { cellSize, cellSizeIn, cellSizePt } = cellDimensions - const hintTextSize = ( - cellSizePt * TYPST_CONSTANTS.HINT_TEXT_SIZE_FACTOR - ).toFixed(1); - const arrowheadSize = ( - cellSizePt * TYPST_CONSTANTS.ARROWHEAD_SIZE_FACTOR - ).toFixed(1); + const hintTextSize = (cellSizePt * TYPST_CONSTANTS.HINT_TEXT_SIZE_FACTOR).toFixed(1) + const arrowheadSize = (cellSizePt * TYPST_CONSTANTS.ARROWHEAD_SIZE_FACTOR).toFixed(1) - const arrowStartDx = (cellSize * TYPST_CONSTANTS.ARROW_START_DX).toFixed(2); - const arrowStartDy = (cellSize * TYPST_CONSTANTS.ARROW_START_DY).toFixed(2); - const arrowEndX = (cellSize * 0.24).toFixed(2); - const arrowEndY = (cellSize * 0.7).toFixed(2); - const arrowControlX = (cellSize * 0.11).toFixed(2); - const arrowControlY = (cellSize * -0.5).toFixed(2); - const arrowheadDx = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DX).toFixed(2); - const arrowheadDy = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DY).toFixed(2); + const arrowStartDx = (cellSize * TYPST_CONSTANTS.ARROW_START_DX).toFixed(2) + const arrowStartDy = (cellSize * TYPST_CONSTANTS.ARROW_START_DY).toFixed(2) + const arrowEndX = (cellSize * 0.24).toFixed(2) + const arrowEndY = (cellSize * 0.7).toFixed(2) + const arrowControlX = (cellSize * 0.11).toFixed(2) + const arrowControlY = (cellSize * -0.5).toFixed(2) + const arrowheadDx = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DX).toFixed(2) + const arrowheadDy = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DY).toFixed(2) return String.raw` // Borrow boxes row (shows borrows FROM higher place TO lower place) @@ -104,5 +100,5 @@ export function generateBorrowBoxesRow(cellDimensions: CellDimensions): string { ],) } }, -`; +` } diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/minuendRow.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/minuendRow.ts index 50aa80e3..cd9b54ba 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/minuendRow.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/minuendRow.ts @@ -1,7 +1,7 @@ // Minuend row rendering for subtraction problems // Shows the top number with optional scratch work boxes for borrowing -import type { CellDimensions } from "../shared/types"; +import type { CellDimensions } from '../shared/types' /** * Generate Typst code for the minuend row @@ -14,7 +14,7 @@ import type { CellDimensions } from "../shared/types"; * @returns Typst code for minuend row */ export function generateMinuendRow(cellDimensions: CellDimensions): string { - const { cellSize, cellSizeIn, cellSizePt } = cellDimensions; + const { cellSize, cellSizeIn, cellSizePt } = cellDimensions return String.raw` // Minuend row (top number with optional scratch work boxes) @@ -93,5 +93,5 @@ export function generateMinuendRow(cellDimensions: CellDimensions): string { ],) } }, -`; +` } diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/problemStack.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/problemStack.ts index 667ff3ee..895cf7af 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/problemStack.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/problemStack.ts @@ -1,16 +1,12 @@ // Main subtraction problem stack function // Composes all row components into the complete problem rendering -import { getPlaceValueColorNames } from "../shared/colors"; -import type { CellDimensions } from "../shared/types"; -import { generateBorrowBoxesRow } from "./borrowBoxes"; -import { generateMinuendRow } from "./minuendRow"; -import { generateSubtrahendRow } from "./subtrahendRow"; -import { - generateLineRow, - generateTenFramesRow, - generateAnswerBoxesRow, -} from "./answerRow"; +import { getPlaceValueColorNames } from '../shared/colors' +import type { CellDimensions } from '../shared/types' +import { generateBorrowBoxesRow } from './borrowBoxes' +import { generateMinuendRow } from './minuendRow' +import { generateSubtrahendRow } from './subtrahendRow' +import { generateLineRow, generateTenFramesRow, generateAnswerBoxesRow } from './answerRow' /** * Generate the main subtraction problem stack function for Typst @@ -30,18 +26,18 @@ import { */ export function generateSubtractionProblemStackFunction( cellSize: number, - maxDigits: number = 3, + maxDigits: number = 3 ): string { - const cellSizeIn = `${cellSize}in`; - const cellSizePt = cellSize * 72; + const cellSizeIn = `${cellSize}in` + const cellSizePt = cellSize * 72 const cellDimensions: CellDimensions = { cellSize, cellSizeIn, cellSizePt, - }; + } - const placeColors = getPlaceValueColorNames(); + const placeColors = getPlaceValueColorNames() return String.raw` // Subtraction problem rendering function (supports 1-${maxDigits} digit problems) @@ -49,7 +45,7 @@ export function generateSubtractionProblemStackFunction( // Per-problem display flags: show-borrows, show-answers, show-colors, show-ten-frames, show-numbers, show-borrow-notation, show-borrowing-hints #let subtraction-problem-stack(minuend, subtrahend, index-or-none, show-borrows, show-answers, show-colors, show-ten-frames, show-numbers, show-borrow-notation, show-borrowing-hints) = { // Place value colors array for dynamic lookup - let place-colors = (${placeColors.join(", ")}) + let place-colors = (${placeColors.join(', ')}) // Extract digits dynamically based on problem size let max-digits = ${maxDigits} @@ -137,5 +133,5 @@ ${generateAnswerBoxesRow(cellDimensions)} ) ) } -`; +` } diff --git a/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/subtrahendRow.ts b/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/subtrahendRow.ts index 19d2809a..80a85f68 100644 --- a/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/subtrahendRow.ts +++ b/apps/web/src/app/create/worksheets/addition/typstHelpers/subtraction/subtrahendRow.ts @@ -1,7 +1,7 @@ // Subtrahend row rendering for subtraction problems // Shows the bottom number being subtracted with − sign -import type { CellDimensions } from "../shared/types"; +import type { CellDimensions } from '../shared/types' /** * Generate Typst code for the subtrahend row @@ -15,7 +15,7 @@ import type { CellDimensions } from "../shared/types"; * @returns Typst code for subtrahend row */ export function generateSubtrahendRow(cellDimensions: CellDimensions): string { - const { cellSize, cellSizeIn, cellSizePt } = cellDimensions; + const { cellSize, cellSizeIn, cellSizePt } = cellDimensions return String.raw` // Subtrahend row with − sign @@ -66,5 +66,5 @@ export function generateSubtrahendRow(cellDimensions: CellDimensions): string { ],) } }, -`; +` } diff --git a/apps/web/src/app/create/worksheets/addition/utils/dateFormatting.ts b/apps/web/src/app/create/worksheets/addition/utils/dateFormatting.ts index 44972eb0..076e0752 100644 --- a/apps/web/src/app/create/worksheets/addition/utils/dateFormatting.ts +++ b/apps/web/src/app/create/worksheets/addition/utils/dateFormatting.ts @@ -7,10 +7,10 @@ * @example "November 7, 2025" */ export function getDefaultDate(): string { - const now = new Date(); - return now.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }); + const now = new Date() + return now.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }) } diff --git a/apps/web/src/app/create/worksheets/addition/utils/layoutCalculations.ts b/apps/web/src/app/create/worksheets/addition/utils/layoutCalculations.ts index c8d04561..bab97471 100644 --- a/apps/web/src/app/create/worksheets/addition/utils/layoutCalculations.ts +++ b/apps/web/src/app/create/worksheets/addition/utils/layoutCalculations.ts @@ -10,23 +10,23 @@ */ export function getDefaultColsForProblemsPerPage( problemsPerPage: number, - orientation: "portrait" | "landscape", + orientation: 'portrait' | 'landscape' ): number { - if (orientation === "portrait") { - if (problemsPerPage === 6) return 2; - if (problemsPerPage === 8) return 2; - if (problemsPerPage === 10) return 2; - if (problemsPerPage === 12) return 3; - if (problemsPerPage === 15) return 3; - return 2; + if (orientation === 'portrait') { + if (problemsPerPage === 6) return 2 + if (problemsPerPage === 8) return 2 + if (problemsPerPage === 10) return 2 + if (problemsPerPage === 12) return 3 + if (problemsPerPage === 15) return 3 + return 2 } else { - if (problemsPerPage === 8) return 4; - if (problemsPerPage === 10) return 5; - if (problemsPerPage === 12) return 4; - if (problemsPerPage === 15) return 5; - if (problemsPerPage === 16) return 4; - if (problemsPerPage === 20) return 5; - return 4; + if (problemsPerPage === 8) return 4 + if (problemsPerPage === 10) return 5 + if (problemsPerPage === 12) return 4 + if (problemsPerPage === 15) return 5 + if (problemsPerPage === 16) return 4 + if (problemsPerPage === 20) return 5 + return 4 } } @@ -40,9 +40,9 @@ export function getDefaultColsForProblemsPerPage( export function calculateDerivedState( problemsPerPage: number, pages: number, - cols: number, + cols: number ): { rows: number; total: number } { - const total = problemsPerPage * pages; - const rows = Math.ceil(total / cols); - return { rows, total }; + const total = problemsPerPage * pages + const rows = Math.ceil(total / cols) + return { rows, total } } diff --git a/apps/web/src/app/create/worksheets/addition/validation.ts b/apps/web/src/app/create/worksheets/addition/validation.ts index 0360c77a..8177865b 100644 --- a/apps/web/src/app/create/worksheets/addition/validation.ts +++ b/apps/web/src/app/create/worksheets/addition/validation.ts @@ -1,105 +1,98 @@ // Validation logic for worksheet configuration -import type { - WorksheetFormState, - WorksheetConfig, - ValidationResult, -} from "./types"; -import type { DisplayRules } from "./displayRules"; +import type { WorksheetFormState, WorksheetConfig, ValidationResult } from './types' +import type { DisplayRules } from './displayRules' /** * Get current date formatted as "Month Day, Year" */ function getDefaultDate(): string { - const now = new Date(); - return now.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }); + const now = new Date() + return now.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }) } /** * Validate and create complete config from partial form state */ -export function validateWorksheetConfig( - formState: WorksheetFormState, -): ValidationResult { - const errors: string[] = []; +export function validateWorksheetConfig(formState: WorksheetFormState): ValidationResult { + const errors: string[] = [] // Validate total (must be positive, reasonable limit) - const total = formState.total ?? 20; + const total = formState.total ?? 20 if (total < 1 || total > 100) { - errors.push("Total problems must be between 1 and 100"); + errors.push('Total problems must be between 1 and 100') } // Validate cols and auto-calculate rows - const cols = formState.cols ?? 4; + const cols = formState.cols ?? 4 if (cols < 1 || cols > 10) { - errors.push("Columns must be between 1 and 10"); + errors.push('Columns must be between 1 and 10') } // Auto-calculate rows to fit all problems - const rows = Math.ceil(total / cols); + const rows = Math.ceil(total / cols) // Validate probabilities (0-1 range) - const pAnyStart = formState.pAnyStart ?? 0.75; - const pAllStart = formState.pAllStart ?? 0.25; + const pAnyStart = formState.pAnyStart ?? 0.75 + const pAllStart = formState.pAllStart ?? 0.25 if (pAnyStart < 0 || pAnyStart > 1) { - errors.push("pAnyStart must be between 0 and 1"); + errors.push('pAnyStart must be between 0 and 1') } if (pAllStart < 0 || pAllStart > 1) { - errors.push("pAllStart must be between 0 and 1"); + errors.push('pAllStart must be between 0 and 1') } if (pAllStart > pAnyStart) { - errors.push("pAllStart cannot be greater than pAnyStart"); + errors.push('pAllStart cannot be greater than pAnyStart') } // Validate fontSize - const fontSize = formState.fontSize ?? 16; + const fontSize = formState.fontSize ?? 16 if (fontSize < 8 || fontSize > 32) { - errors.push("Font size must be between 8 and 32"); + errors.push('Font size must be between 8 and 32') } // V4: Validate digitRange (min and max must be 1-5, min <= max) // Note: Same range applies to both addition and subtraction - const digitRange = formState.digitRange ?? { min: 2, max: 2 }; + const digitRange = formState.digitRange ?? { min: 2, max: 2 } if (!digitRange.min || digitRange.min < 1 || digitRange.min > 5) { - errors.push("Digit range min must be between 1 and 5"); + errors.push('Digit range min must be between 1 and 5') } if (!digitRange.max || digitRange.max < 1 || digitRange.max > 5) { - errors.push("Digit range max must be between 1 and 5"); + errors.push('Digit range max must be between 1 and 5') } if (digitRange.min > digitRange.max) { - errors.push("Digit range min cannot be greater than max"); + errors.push('Digit range min cannot be greater than max') } // V4: Validate operator (addition, subtraction, or mixed) - const operator = formState.operator ?? "addition"; - if (!["addition", "subtraction", "mixed"].includes(operator)) { - errors.push('Operator must be "addition", "subtraction", or "mixed"'); + const operator = formState.operator ?? 'addition' + if (!['addition', 'subtraction', 'mixed'].includes(operator)) { + errors.push('Operator must be "addition", "subtraction", or "mixed"') } // Validate seed (must be positive integer) - const seed = formState.seed ?? Date.now() % 2147483647; + const seed = formState.seed ?? Date.now() % 2147483647 if (!Number.isInteger(seed) || seed < 0) { - errors.push("Seed must be a non-negative integer"); + errors.push('Seed must be a non-negative integer') } if (errors.length > 0) { - return { isValid: false, errors }; + return { isValid: false, errors } } // Determine orientation based on columns (portrait = 2-3 cols, landscape = 4-5 cols) - const orientation = - formState.orientation || (cols <= 3 ? "portrait" : "landscape"); + const orientation = formState.orientation || (cols <= 3 ? 'portrait' : 'landscape') // Get primary state values - const problemsPerPage = formState.problemsPerPage ?? total; - const pages = formState.pages ?? 1; + const problemsPerPage = formState.problemsPerPage ?? total + const pages = formState.pages ?? 1 // Determine mode (default to 'smart' if not specified) - const mode = formState.mode ?? "smart"; + const mode = formState.mode ?? 'smart' // Shared fields for both modes const sharedFields = { @@ -114,7 +107,7 @@ export function validateWorksheetConfig( rows, // Other fields - name: formState.name?.trim() || "Student", + name: formState.name?.trim() || 'Student', date: formState.date?.trim() || getDefaultDate(), pAnyStart, pAllStart, @@ -124,12 +117,12 @@ export function validateWorksheetConfig( digitRange, // V4: Operator selection (addition, subtraction, or mixed) - operator: formState.operator ?? "addition", + operator: formState.operator ?? 'addition', // Layout page: { - wIn: orientation === "portrait" ? 8.5 : 11, - hIn: orientation === "portrait" ? 11 : 8.5, + wIn: orientation === 'portrait' ? 8.5 : 11, + hIn: orientation === 'portrait' ? 11 : 8.5, }, margins: { left: 0.6, @@ -140,37 +133,37 @@ export function validateWorksheetConfig( fontSize, seed, - }; + } // Build mode-specific config - let config: WorksheetConfig; + let config: WorksheetConfig - if (mode === "smart") { + if (mode === 'smart') { // Smart mode: Use displayRules for conditional scaffolding const displayRules: DisplayRules = { - carryBoxes: "whenRegrouping", - answerBoxes: "always", - placeValueColors: "always", - tenFrames: "whenRegrouping", - problemNumbers: "always", - cellBorders: "always", - borrowNotation: "whenRegrouping", // Subtraction: show when borrowing - borrowingHints: "never", // Subtraction: no hints by default + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', + problemNumbers: 'always', + cellBorders: 'always', + borrowNotation: 'whenRegrouping', // Subtraction: show when borrowing + borrowingHints: 'never', // Subtraction: no hints by default ...((formState.displayRules as any) ?? {}), // Override with provided rules if any - }; + } config = { version: 4, - mode: "smart", + mode: 'smart', displayRules, difficultyProfile: formState.difficultyProfile, ...sharedFields, - }; + } } else { // Manual mode: Use boolean flags for uniform display config = { version: 4, - mode: "manual", + mode: 'manual', showCarryBoxes: formState.showCarryBoxes ?? true, showAnswerBoxes: formState.showAnswerBoxes ?? true, showPlaceValueColors: formState.showPlaceValueColors ?? true, @@ -182,8 +175,8 @@ export function validateWorksheetConfig( showBorrowingHints: formState.showBorrowingHints ?? false, manualPreset: formState.manualPreset, ...sharedFields, - }; + } } - return { isValid: true, config }; + return { isValid: true, config } } diff --git a/apps/web/src/app/create/worksheets/config-schemas.ts b/apps/web/src/app/create/worksheets/config-schemas.ts index b80dbdd9..bc0dd52e 100644 --- a/apps/web/src/app/create/worksheets/config-schemas.ts +++ b/apps/web/src/app/create/worksheets/config-schemas.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import { getProfileFromConfig } from "./addition/difficultyProfiles"; +import { z } from 'zod' +import { getProfileFromConfig } from './addition/difficultyProfiles' /** * Versioned worksheet config schemas with type-safe validation and migration @@ -21,7 +21,7 @@ import { getProfileFromConfig } from "./addition/difficultyProfiles"; // ============================================================================= /** Current schema version for addition worksheets */ -const ADDITION_CURRENT_VERSION = 4; +const ADDITION_CURRENT_VERSION = 4 /** * Addition worksheet config - Version 1 @@ -32,7 +32,7 @@ export const additionConfigV1Schema = z.object({ problemsPerPage: z.number().int().min(1).max(100), cols: z.number().int().min(1).max(10), pages: z.number().int().min(1).max(20), - orientation: z.enum(["portrait", "landscape"]), + orientation: z.enum(['portrait', 'landscape']), name: z.string(), pAnyStart: z.number().min(0).max(1), pAllStart: z.number().min(0).max(1), @@ -45,9 +45,9 @@ export const additionConfigV1Schema = z.object({ showTenFrames: z.boolean(), showTenFramesForAll: z.boolean(), fontSize: z.number().int().min(8).max(32), -}); +}) -export type AdditionConfigV1 = z.infer; +export type AdditionConfigV1 = z.infer /** * Addition worksheet config - Version 2 @@ -58,7 +58,7 @@ export const additionConfigV2Schema = z.object({ problemsPerPage: z.number().int().min(1).max(100), cols: z.number().int().min(1).max(10), pages: z.number().int().min(1).max(20), - orientation: z.enum(["portrait", "landscape"]), + orientation: z.enum(['portrait', 'landscape']), name: z.string(), pAnyStart: z.number().min(0).max(1), pAllStart: z.number().min(0).max(1), @@ -67,46 +67,46 @@ export const additionConfigV2Schema = z.object({ // V2: Display rules replace individual booleans displayRules: z.object({ carryBoxes: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), answerBoxes: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), placeValueColors: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), tenFrames: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), problemNumbers: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), cellBorders: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), }), @@ -116,9 +116,9 @@ export const additionConfigV2Schema = z.object({ // V2: Keep fontSize and showTenFramesForAll for now (may refactor later) fontSize: z.number().int().min(8).max(32), showTenFramesForAll: z.boolean(), -}); +}) -export type AdditionConfigV2 = z.infer; +export type AdditionConfigV2 = z.infer /** * Addition worksheet config - Version 3 @@ -133,7 +133,7 @@ const additionConfigV3BaseSchema = z.object({ problemsPerPage: z.number().int().min(1).max(100), cols: z.number().int().min(1).max(10), pages: z.number().int().min(1).max(20), - orientation: z.enum(["portrait", "landscape"]), + orientation: z.enum(['portrait', 'landscape']), name: z.string(), fontSize: z.number().int().min(8).max(32), @@ -141,55 +141,55 @@ const additionConfigV3BaseSchema = z.object({ pAnyStart: z.number().min(0).max(1), pAllStart: z.number().min(0).max(1), interpolate: z.boolean(), -}); +}) // Smart Difficulty Mode const additionConfigV3SmartSchema = additionConfigV3BaseSchema.extend({ - mode: z.literal("smart"), + mode: z.literal('smart'), // Conditional display rules displayRules: z.object({ carryBoxes: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), answerBoxes: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), placeValueColors: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), tenFrames: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), problemNumbers: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), cellBorders: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), }), @@ -198,11 +198,11 @@ const additionConfigV3SmartSchema = additionConfigV3BaseSchema.extend({ // showTenFramesForAll is deprecated in V3 smart mode // (controlled by displayRules.tenFrames) -}); +}) // Manual Control Mode const additionConfigV3ManualSchema = additionConfigV3BaseSchema.extend({ - mode: z.literal("manual"), + mode: z.literal('manual'), // Simple boolean toggles showCarryBoxes: z.boolean(), @@ -215,19 +215,17 @@ const additionConfigV3ManualSchema = additionConfigV3BaseSchema.extend({ // Optional: Which manual preset is selected manualPreset: z.string().optional(), -}); +}) // V3 uses discriminated union on 'mode' -export const additionConfigV3Schema = z.discriminatedUnion("mode", [ +export const additionConfigV3Schema = z.discriminatedUnion('mode', [ additionConfigV3SmartSchema, additionConfigV3ManualSchema, -]); +]) -export type AdditionConfigV3 = z.infer; -export type AdditionConfigV3Smart = z.infer; -export type AdditionConfigV3Manual = z.infer< - typeof additionConfigV3ManualSchema ->; +export type AdditionConfigV3 = z.infer +export type AdditionConfigV3Smart = z.infer +export type AdditionConfigV3Manual = z.infer /** * Addition worksheet config - Version 4 @@ -242,7 +240,7 @@ const additionConfigV4BaseSchema = z.object({ problemsPerPage: z.number().int().min(1).max(100), cols: z.number().int().min(1).max(10), pages: z.number().int().min(1).max(20), - orientation: z.enum(["portrait", "landscape"]), + orientation: z.enum(['portrait', 'landscape']), name: z.string(), fontSize: z.number().int().min(8).max(32), @@ -253,75 +251,75 @@ const additionConfigV4BaseSchema = z.object({ max: z.number().int().min(1).max(5), }) .refine((data) => data.min <= data.max, { - message: "min must be less than or equal to max", + message: 'min must be less than or equal to max', }), // V4: Operator selection (addition, subtraction, or mixed) - operator: z.enum(["addition", "subtraction", "mixed"]).default("addition"), + operator: z.enum(['addition', 'subtraction', 'mixed']).default('addition'), // Regrouping probabilities (shared between modes) pAnyStart: z.number().min(0).max(1), pAllStart: z.number().min(0).max(1), interpolate: z.boolean(), -}); +}) // Smart Difficulty Mode for V4 const additionConfigV4SmartSchema = additionConfigV4BaseSchema.extend({ - mode: z.literal("smart"), + mode: z.literal('smart'), // Conditional display rules displayRules: z.object({ carryBoxes: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), answerBoxes: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), placeValueColors: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), tenFrames: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), problemNumbers: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), cellBorders: z.enum([ - "always", - "never", - "whenRegrouping", - "whenMultipleRegroups", - "when3PlusDigits", + 'always', + 'never', + 'whenRegrouping', + 'whenMultipleRegroups', + 'when3PlusDigits', ]), }), // Optional: Which smart difficulty profile is selected difficultyProfile: z.string().optional(), -}); +}) // Manual Control Mode for V4 const additionConfigV4ManualSchema = additionConfigV4BaseSchema.extend({ - mode: z.literal("manual"), + mode: z.literal('manual'), // Simple boolean toggles showCarryBoxes: z.boolean(), @@ -336,57 +334,55 @@ const additionConfigV4ManualSchema = additionConfigV4BaseSchema.extend({ // Optional: Which manual preset is selected manualPreset: z.string().optional(), -}); +}) // V4 uses discriminated union on 'mode' -export const additionConfigV4Schema = z.discriminatedUnion("mode", [ +export const additionConfigV4Schema = z.discriminatedUnion('mode', [ additionConfigV4SmartSchema, additionConfigV4ManualSchema, -]); +]) -export type AdditionConfigV4 = z.infer; -export type AdditionConfigV4Smart = z.infer; -export type AdditionConfigV4Manual = z.infer< - typeof additionConfigV4ManualSchema ->; +export type AdditionConfigV4 = z.infer +export type AdditionConfigV4Smart = z.infer +export type AdditionConfigV4Manual = z.infer /** Union of all addition config versions (add new versions here) */ -export const additionConfigSchema = z.discriminatedUnion("version", [ +export const additionConfigSchema = z.discriminatedUnion('version', [ additionConfigV1Schema, additionConfigV2Schema, additionConfigV3Schema, additionConfigV4Schema, -]); +]) -export type AdditionConfig = z.infer; +export type AdditionConfig = z.infer /** * Default addition config (always latest version - V4 Smart Mode) */ export const defaultAdditionConfig: AdditionConfigV4Smart = { version: 4, - mode: "smart", + mode: 'smart', problemsPerPage: 20, cols: 5, pages: 1, - orientation: "landscape", - name: "", + orientation: 'landscape', + name: '', digitRange: { min: 2, max: 2 }, // V4: Default to 2-digit problems (backward compatible) - operator: "addition", + operator: 'addition', pAnyStart: 0.25, pAllStart: 0, interpolate: true, displayRules: { - carryBoxes: "whenRegrouping", - answerBoxes: "always", - placeValueColors: "always", - tenFrames: "whenRegrouping", - problemNumbers: "always", - cellBorders: "always", + carryBoxes: 'whenRegrouping', + answerBoxes: 'always', + placeValueColors: 'always', + tenFrames: 'whenRegrouping', + problemNumbers: 'always', + cellBorders: 'always', }, - difficultyProfile: "earlyLearner", + difficultyProfile: 'earlyLearner', fontSize: 16, -}; +} /** * Migrate V1 config to V2 @@ -395,21 +391,17 @@ export const defaultAdditionConfig: AdditionConfigV4Smart = { function migrateAdditionV1toV2(v1: AdditionConfigV1): AdditionConfigV2 { // Convert V1 boolean flags to V2 rule modes // V1 booleans were always on/off, so map true→'always', false→'never' - const displayRules: AdditionConfigV2["displayRules"] = { - carryBoxes: v1.showCarryBoxes ? "always" : "never", - answerBoxes: v1.showAnswerBoxes ? "always" : "never", - placeValueColors: v1.showPlaceValueColors ? "always" : "never", - tenFrames: v1.showTenFrames ? "always" : "never", - problemNumbers: v1.showProblemNumbers ? "always" : "never", - cellBorders: v1.showCellBorder ? "always" : "never", - }; + const displayRules: AdditionConfigV2['displayRules'] = { + carryBoxes: v1.showCarryBoxes ? 'always' : 'never', + answerBoxes: v1.showAnswerBoxes ? 'always' : 'never', + placeValueColors: v1.showPlaceValueColors ? 'always' : 'never', + tenFrames: v1.showTenFrames ? 'always' : 'never', + problemNumbers: v1.showProblemNumbers ? 'always' : 'never', + cellBorders: v1.showCellBorder ? 'always' : 'never', + } // Try to match config to a known profile - const profileName = getProfileFromConfig( - v1.pAllStart, - v1.pAnyStart, - displayRules, - ); + const profileName = getProfileFromConfig(v1.pAllStart, v1.pAnyStart, displayRules) return { version: 2, @@ -422,10 +414,10 @@ function migrateAdditionV1toV2(v1: AdditionConfigV1): AdditionConfigV2 { pAllStart: v1.pAllStart, interpolate: v1.interpolate, displayRules, - difficultyProfile: profileName === "custom" ? undefined : profileName, + difficultyProfile: profileName === 'custom' ? undefined : profileName, showTenFramesForAll: v1.showTenFramesForAll, fontSize: v1.fontSize, - }; + } } /** @@ -437,7 +429,7 @@ function migrateAdditionV2toV3(v2: AdditionConfigV2): AdditionConfigV3 { if (v2.difficultyProfile) { return { version: 3, - mode: "smart", + mode: 'smart', problemsPerPage: v2.problemsPerPage, cols: v2.cols, pages: v2.pages, @@ -449,14 +441,14 @@ function migrateAdditionV2toV3(v2: AdditionConfigV2): AdditionConfigV3 { interpolate: v2.interpolate, displayRules: v2.displayRules, difficultyProfile: v2.difficultyProfile, - }; + } } // No preset → Manual mode // Convert displayRules to boolean flags return { version: 3, - mode: "manual", + mode: 'manual', problemsPerPage: v2.problemsPerPage, cols: v2.cols, pages: v2.pages, @@ -466,14 +458,14 @@ function migrateAdditionV2toV3(v2: AdditionConfigV2): AdditionConfigV3 { pAnyStart: v2.pAnyStart, pAllStart: v2.pAllStart, interpolate: v2.interpolate, - showCarryBoxes: v2.displayRules.carryBoxes === "always", - showAnswerBoxes: v2.displayRules.answerBoxes === "always", - showPlaceValueColors: v2.displayRules.placeValueColors === "always", - showTenFrames: v2.displayRules.tenFrames === "always", - showProblemNumbers: v2.displayRules.problemNumbers === "always", - showCellBorder: v2.displayRules.cellBorders === "always", + showCarryBoxes: v2.displayRules.carryBoxes === 'always', + showAnswerBoxes: v2.displayRules.answerBoxes === 'always', + showPlaceValueColors: v2.displayRules.placeValueColors === 'always', + showTenFrames: v2.displayRules.tenFrames === 'always', + showProblemNumbers: v2.displayRules.problemNumbers === 'always', + showCellBorder: v2.displayRules.cellBorders === 'always', showTenFramesForAll: v2.showTenFramesForAll, - }; + } } /** @@ -491,23 +483,23 @@ function migrateAdditionV3toV4(v3: AdditionConfigV3): AdditionConfigV4 { name: v3.name, fontSize: v3.fontSize, digitRange: { min: 2, max: 2 }, // V4: Default to 2-digit for backward compatibility - operator: "addition" as const, // V4: Default to addition for backward compatibility + operator: 'addition' as const, // V4: Default to addition for backward compatibility pAnyStart: v3.pAnyStart, pAllStart: v3.pAllStart, interpolate: v3.interpolate, - }; + } - if (v3.mode === "smart") { + if (v3.mode === 'smart') { return { ...baseFields, - mode: "smart", + mode: 'smart', displayRules: v3.displayRules, difficultyProfile: v3.difficultyProfile, - }; + } } else { return { ...baseFields, - mode: "manual", + mode: 'manual', showCarryBoxes: v3.showCarryBoxes, showAnswerBoxes: v3.showAnswerBoxes, showPlaceValueColors: v3.showPlaceValueColors, @@ -518,7 +510,7 @@ function migrateAdditionV3toV4(v3: AdditionConfigV3): AdditionConfigV4 { showBorrowNotation: true, // V4: Default to true for backward compatibility showBorrowingHints: false, // V4: Default to false for backward compatibility manualPreset: v3.manualPreset, - }; + } } } @@ -528,45 +520,38 @@ function migrateAdditionV3toV4(v3: AdditionConfigV3): AdditionConfigV4 { */ export function migrateAdditionConfig(rawConfig: unknown): AdditionConfigV4 { // First, try to parse as any known version - const parsed = additionConfigSchema.safeParse(rawConfig); + const parsed = additionConfigSchema.safeParse(rawConfig) if (!parsed.success) { // If parsing fails completely, return defaults - console.warn( - "Failed to parse addition config, using defaults:", - parsed.error, - ); - return defaultAdditionConfig; + console.warn('Failed to parse addition config, using defaults:', parsed.error) + return defaultAdditionConfig } - const config = parsed.data; + const config = parsed.data // Migrate to latest version switch (config.version) { case 1: // Migrate V1 → V2 → V3 → V4 - return migrateAdditionV3toV4( - migrateAdditionV2toV3(migrateAdditionV1toV2(config)), - ); + return migrateAdditionV3toV4(migrateAdditionV2toV3(migrateAdditionV1toV2(config))) case 2: // Migrate V2 → V3 → V4 - return migrateAdditionV3toV4(migrateAdditionV2toV3(config)); + return migrateAdditionV3toV4(migrateAdditionV2toV3(config)) case 3: // Migrate V3 → V4 - return migrateAdditionV3toV4(config); + return migrateAdditionV3toV4(config) case 4: // Already latest version - return config; + return config default: // Unknown version, return defaults - console.warn( - `Unknown addition config version: ${(config as any).version}`, - ); - return defaultAdditionConfig; + console.warn(`Unknown addition config version: ${(config as any).version}`) + return defaultAdditionConfig } } @@ -576,11 +561,11 @@ export function migrateAdditionConfig(rawConfig: unknown): AdditionConfigV4 { */ export function parseAdditionConfig(jsonString: string): AdditionConfigV4 { try { - const raw = JSON.parse(jsonString); - return migrateAdditionConfig(raw); + const raw = JSON.parse(jsonString) + return migrateAdditionConfig(raw) } catch (error) { - console.error("Failed to parse addition config JSON:", error); - return defaultAdditionConfig; + console.error('Failed to parse addition config JSON:', error) + return defaultAdditionConfig } } @@ -588,14 +573,12 @@ export function parseAdditionConfig(jsonString: string): AdditionConfigV4 { * Serialize addition config to JSON string * Ensures version field is set to current version (V4) */ -export function serializeAdditionConfig( - config: Omit, -): string { +export function serializeAdditionConfig(config: Omit): string { const versioned: AdditionConfigV4 = { ...config, version: ADDITION_CURRENT_VERSION, - } as AdditionConfigV4; - return JSON.stringify(versioned); + } as AdditionConfigV4 + return JSON.stringify(versioned) } // ============================================================================= diff --git a/apps/web/src/app/games/page.tsx b/apps/web/src/app/games/page.tsx index 7fb5e62a..c6012fa7 100644 --- a/apps/web/src/app/games/page.tsx +++ b/apps/web/src/app/games/page.tsx @@ -1,65 +1,62 @@ -"use client"; +'use client' -import Autoplay from "embla-carousel-autoplay"; -import useEmblaCarousel from "embla-carousel-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { PageWithNav } from "@/components/PageWithNav"; -import { GamePreview } from "@/components/GamePreview"; -import { getAvailableGames } from "@/lib/arcade/game-registry"; -import { css } from "../../../styled-system/css"; -import { useFullscreen } from "../../contexts/FullscreenContext"; -import { useGameMode } from "../../contexts/GameModeContext"; -import { useUserProfile } from "../../contexts/UserProfileContext"; -import { useAllPlayerStats } from "@/hooks/usePlayerStats"; +import Autoplay from 'embla-carousel-autoplay' +import useEmblaCarousel from 'embla-carousel-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { PageWithNav } from '@/components/PageWithNav' +import { GamePreview } from '@/components/GamePreview' +import { getAvailableGames } from '@/lib/arcade/game-registry' +import { css } from '../../../styled-system/css' +import { useFullscreen } from '../../contexts/FullscreenContext' +import { useGameMode } from '../../contexts/GameModeContext' +import { useUserProfile } from '../../contexts/UserProfileContext' +import { useAllPlayerStats } from '@/hooks/usePlayerStats' function GamesPageContent() { - const t = useTranslations("games"); - const { profile } = useUserProfile(); - const { gameMode, getAllPlayers } = useGameMode(); - const { enterFullscreen } = useFullscreen(); - const router = useRouter(); + const t = useTranslations('games') + const { profile } = useUserProfile() + const { gameMode, getAllPlayers } = useGameMode() + const { enterFullscreen } = useFullscreen() + const router = useRouter() // Get all players sorted by creation time const allPlayers = getAllPlayers().sort((a, b) => { - const aTime = - a.createdAt instanceof Date ? a.createdAt.getTime() : a.createdAt; - const bTime = - b.createdAt instanceof Date ? b.createdAt.getTime() : b.createdAt; - return aTime - bTime; - }); + const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : a.createdAt + const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : b.createdAt + return aTime - bTime + }) // Fetch per-player stats - const { data: playerStatsArray, isLoading: statsLoading } = - useAllPlayerStats(); + const { data: playerStatsArray, isLoading: statsLoading } = useAllPlayerStats() // Create a map of playerId -> stats for easy lookup const playerStatsMap = useMemo(() => { - const map = new Map(); + const map = new Map() if (playerStatsArray) { for (const stats of playerStatsArray) { - map.set(stats.playerId, stats); + map.set(stats.playerId, stats) } } - return map; - }, [playerStatsArray]); + return map + }, [playerStatsArray]) // Get available games - const availableGames = getAvailableGames(); + const availableGames = getAvailableGames() // Check if user has any stats to show (check if ANY player has stats) const hasStats = playerStatsArray && playerStatsArray.length > 0 && - playerStatsArray.some((s) => s.gamesPlayed > 0); + playerStatsArray.some((s) => s.gamesPlayed > 0) // Embla carousel setup for games hero carousel with autoplay const [gamesEmblaRef, gamesEmblaApi] = useEmblaCarousel( { loop: true, - align: "center", + align: 'center', slidesToScroll: 1, skipSnaps: false, }, @@ -69,152 +66,150 @@ function GamesPageContent() { stopOnInteraction: true, stopOnMouseEnter: true, }), - ], - ); - const [gamesSelectedIndex, setGamesSelectedIndex] = useState(0); + ] + ) + const [gamesSelectedIndex, setGamesSelectedIndex] = useState(0) // Embla carousel setup for player carousel const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, - align: "center", - containScroll: "trimSnaps", - }); - const [selectedIndex, setSelectedIndex] = useState(0); + align: 'center', + containScroll: 'trimSnaps', + }) + const [selectedIndex, setSelectedIndex] = useState(0) // Games carousel callbacks const onGamesSelect = useCallback(() => { - if (!gamesEmblaApi) return; - setGamesSelectedIndex(gamesEmblaApi.selectedScrollSnap()); - }, [gamesEmblaApi]); + if (!gamesEmblaApi) return + setGamesSelectedIndex(gamesEmblaApi.selectedScrollSnap()) + }, [gamesEmblaApi]) useEffect(() => { - if (!gamesEmblaApi) return; - onGamesSelect(); - gamesEmblaApi.on("select", onGamesSelect); - gamesEmblaApi.on("reInit", onGamesSelect); + if (!gamesEmblaApi) return + onGamesSelect() + gamesEmblaApi.on('select', onGamesSelect) + gamesEmblaApi.on('reInit', onGamesSelect) return () => { - gamesEmblaApi.off("select", onGamesSelect); - gamesEmblaApi.off("reInit", onGamesSelect); - }; - }, [gamesEmblaApi, onGamesSelect]); + gamesEmblaApi.off('select', onGamesSelect) + gamesEmblaApi.off('reInit', onGamesSelect) + } + }, [gamesEmblaApi, onGamesSelect]) // Player carousel callbacks const onSelect = useCallback(() => { - if (!emblaApi) return; - setSelectedIndex(emblaApi.selectedScrollSnap()); - }, [emblaApi]); + if (!emblaApi) return + setSelectedIndex(emblaApi.selectedScrollSnap()) + }, [emblaApi]) useEffect(() => { - if (!emblaApi) return; - onSelect(); - emblaApi.on("select", onSelect); - emblaApi.on("reInit", onSelect); + if (!emblaApi) return + onSelect() + emblaApi.on('select', onSelect) + emblaApi.on('reInit', onSelect) return () => { - emblaApi.off("select", onSelect); - emblaApi.off("reInit", onSelect); - }; - }, [emblaApi, onSelect]); + emblaApi.off('select', onSelect) + emblaApi.off('reInit', onSelect) + } + }, [emblaApi, onSelect]) return (
{/* Subtle background pattern */}
{/* Enter Arcade Button */}
@@ -222,61 +217,61 @@ function GamesPageContent() { {/* Games Hero Carousel - Full Width */}
{/* Dynamic Game Cards */} {availableGames.map((game, index) => { - const isActive = index === gamesSelectedIndex; - const manifest = game.manifest; - const GameComp = game.GameComponent; - const Provider = game.Provider; + const isActive = index === gamesSelectedIndex + const manifest = game.manifest + const GameComp = game.GameComponent + const Provider = game.Provider return (
{/* Game Icon */}
{manifest.icon} @@ -334,12 +329,12 @@ function GamesPageContent() { {/* Game Title */}

{manifest.displayName} @@ -348,41 +343,41 @@ function GamesPageContent() { {/* Game Info Badges */}
{manifest.difficulty} {manifest.maxPlayers === 1 - ? "1 Player" + ? '1 Player' : `1-${manifest.maxPlayers} Players`}
@@ -390,18 +385,18 @@ function GamesPageContent() { {/* Description */}

{manifest.description}

- ); + ) })}
@@ -409,10 +404,10 @@ function GamesPageContent() { {/* Navigation Dots with Game Icons */}
@@ -421,26 +416,26 @@ function GamesPageContent() { key={game.manifest.name} onClick={() => gamesEmblaApi?.scrollTo(index)} className={css({ - rounded: "full", - border: "none", - cursor: "pointer", - transition: "all 0.3s ease", - minH: "44px", - minW: "44px", - w: "12", - h: "12", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: index === gamesSelectedIndex ? "2xl" : "lg", + rounded: 'full', + border: 'none', + cursor: 'pointer', + transition: 'all 0.3s ease', + minH: '44px', + minW: '44px', + w: '12', + h: '12', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: index === gamesSelectedIndex ? '2xl' : 'lg', opacity: index === gamesSelectedIndex ? 1 : 0.5, background: index === gamesSelectedIndex - ? "rgba(255, 255, 255, 0.9)" - : "rgba(255, 255, 255, 0.3)", + ? 'rgba(255, 255, 255, 0.9)' + : 'rgba(255, 255, 255, 0.3)', _hover: { opacity: 1, - transform: "scale(1.1)", + transform: 'scale(1.1)', }, })} aria-label={`Go to ${game.manifest.displayName}`} @@ -459,131 +454,131 @@ function GamesPageContent() { {/* Character Showcase Header */}

- {t("champions.title")} + {t('champions.title')}

- {t("champions.subtitle")} + {t('champions.subtitle')}

{/* Player Carousel */}
{/* Dynamic Player Character Cards */} {allPlayers.map((player, index) => { - const isActive = index === selectedIndex; + const isActive = index === selectedIndex // Rotate through different color schemes for visual variety const colorSchemes = [ { - border: "rgba(59, 130, 246, 0.3)", - shadow: "rgba(59, 130, 246, 0.1)", - gradient: "linear-gradient(90deg, #3b82f6, #1d4ed8)", - statBg: "linear-gradient(135deg, #dbeafe, #bfdbfe)", - statBorder: "blue.200", - statColor: "blue.800", - levelColor: "blue.700", + border: 'rgba(59, 130, 246, 0.3)', + shadow: 'rgba(59, 130, 246, 0.1)', + gradient: 'linear-gradient(90deg, #3b82f6, #1d4ed8)', + statBg: 'linear-gradient(135deg, #dbeafe, #bfdbfe)', + statBorder: 'blue.200', + statColor: 'blue.800', + levelColor: 'blue.700', }, { - border: "rgba(139, 92, 246, 0.3)", - shadow: "rgba(139, 92, 246, 0.1)", - gradient: "linear-gradient(90deg, #8b5cf6, #7c3aed)", - statBg: "linear-gradient(135deg, #e9d5ff, #ddd6fe)", - statBorder: "purple.200", - statColor: "purple.800", - levelColor: "purple.700", + border: 'rgba(139, 92, 246, 0.3)', + shadow: 'rgba(139, 92, 246, 0.1)', + gradient: 'linear-gradient(90deg, #8b5cf6, #7c3aed)', + statBg: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)', + statBorder: 'purple.200', + statColor: 'purple.800', + levelColor: 'purple.700', }, { - border: "rgba(16, 185, 129, 0.3)", - shadow: "rgba(16, 185, 129, 0.1)", - gradient: "linear-gradient(90deg, #10b981, #059669)", - statBg: "linear-gradient(135deg, #d1fae5, #a7f3d0)", - statBorder: "green.200", - statColor: "green.800", - levelColor: "green.700", + border: 'rgba(16, 185, 129, 0.3)', + shadow: 'rgba(16, 185, 129, 0.1)', + gradient: 'linear-gradient(90deg, #10b981, #059669)', + statBg: 'linear-gradient(135deg, #d1fae5, #a7f3d0)', + statBorder: 'green.200', + statColor: 'green.800', + levelColor: 'green.700', }, { - border: "rgba(245, 158, 11, 0.3)", - shadow: "rgba(245, 158, 11, 0.1)", - gradient: "linear-gradient(90deg, #f59e0b, #d97706)", - statBg: "linear-gradient(135deg, #fef3c7, #fde68a)", - statBorder: "yellow.200", - statColor: "yellow.800", - levelColor: "yellow.700", + border: 'rgba(245, 158, 11, 0.3)', + shadow: 'rgba(245, 158, 11, 0.1)', + gradient: 'linear-gradient(90deg, #f59e0b, #d97706)', + statBg: 'linear-gradient(135deg, #fef3c7, #fde68a)', + statBorder: 'yellow.200', + statColor: 'yellow.800', + levelColor: 'yellow.700', }, - ]; - const theme = colorSchemes[index % colorSchemes.length]; + ] + const theme = colorSchemes[index % colorSchemes.length] // Get per-player stats - const playerStats = playerStatsMap.get(player.id); - const gamesPlayed = playerStats?.gamesPlayed || 0; - const totalWins = playerStats?.totalWins || 0; + const playerStats = playerStatsMap.get(player.id) + const gamesPlayed = playerStats?.gamesPlayed || 0 + const totalWins = playerStats?.totalWins || 0 return (
@@ -610,23 +605,23 @@ function GamesPageContent() { {/* Character Display */}
{player.emoji}

@@ -637,17 +632,17 @@ function GamesPageContent() { {/* Stats */}
@@ -665,43 +660,42 @@ function GamesPageContent() {
- {t("champions.stats.gamesPlayed")} + {t('champions.stats.gamesPlayed')}
{totalWins}
- {t("champions.stats.victories")} + {t('champions.stats.victories')}
@@ -709,35 +703,35 @@ function GamesPageContent() { {/* Level Progress */}
- {t("champions.stats.level", { + {t('champions.stats.level', { level: Math.floor(gamesPlayed / 5) + 1, })} - {t("champions.stats.xp", { + {t('champions.stats.xp', { current: gamesPlayed % 5, total: 5, })} @@ -745,18 +739,18 @@ function GamesPageContent() {
⚙️
- ); + ) })}
@@ -806,10 +798,10 @@ function GamesPageContent() { {/* Navigation Dots */}
@@ -818,22 +810,22 @@ function GamesPageContent() { key={player.id} onClick={() => emblaApi?.scrollTo(index)} className={css({ - rounded: "full", - border: "none", - cursor: "pointer", - transition: "all 0.3s ease", - minH: "44px", - minW: "44px", - w: "12", - h: "12", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: index === selectedIndex ? "2xl" : "lg", + rounded: 'full', + border: 'none', + cursor: 'pointer', + transition: 'all 0.3s ease', + minH: '44px', + minW: '44px', + w: '12', + h: '12', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: index === selectedIndex ? '2xl' : 'lg', opacity: index === selectedIndex ? 1 : 0.5, _hover: { opacity: 1, - transform: "scale(1.1)", + transform: 'scale(1.1)', }, })} style={{ @@ -858,96 +850,96 @@ function GamesPageContent() { {hasStats && (
{/* Head-to-Head Stats */}

- {t("dashboard.headToHead.title")} + {t('dashboard.headToHead.title')}

- {t("dashboard.headToHead.subtitle")} + {t('dashboard.headToHead.subtitle')}

{allPlayers.slice(0, 2).map((player, idx) => { - const playerStats = playerStatsMap.get(player.id); - const totalWins = playerStats?.totalWins || 0; + const playerStats = playerStatsMap.get(player.id) + const totalWins = playerStats?.totalWins || 0 return (
{player.emoji}
@@ -955,142 +947,140 @@ function GamesPageContent() {
- {t("dashboard.headToHead.wins")} + {t('dashboard.headToHead.wins')}
{idx === 0 && allPlayers.length > 1 && (
- {t("dashboard.headToHead.vs")} + {t('dashboard.headToHead.vs')}
)}
- ); + ) })}
- {t("dashboard.headToHead.lastPlayed")} + {t('dashboard.headToHead.lastPlayed')}
{/* Recent Achievements */}

- {t("dashboard.achievements.title")} + {t('dashboard.achievements.title')}

- {t("dashboard.achievements.subtitle")} + {t('dashboard.achievements.subtitle')}

{allPlayers.slice(0, 2).map((player, idx) => (
- - {player.emoji} - + {player.emoji}
{idx === 0 - ? t("dashboard.achievements.firstWin.title") - : t("dashboard.achievements.speedDemon.title")} + ? t('dashboard.achievements.firstWin.title') + : t('dashboard.achievements.speedDemon.title')}
{idx === 0 - ? t("dashboard.achievements.firstWin.description") - : t("dashboard.achievements.speedDemon.description")} + ? t('dashboard.achievements.firstWin.description') + : t('dashboard.achievements.speedDemon.description')}
@@ -1101,130 +1091,126 @@ function GamesPageContent() { {/* Challenge System */}

- {t("dashboard.challenges.title")} + {t('dashboard.challenges.title')}

- {t("dashboard.challenges.subtitle")} + {t('dashboard.challenges.subtitle')}

{allPlayers.length >= 2 && (
- - {allPlayers[0].emoji} - + {allPlayers[0].emoji} - {t("dashboard.challenges.challengesText")} - - - {allPlayers[1].emoji} + {t('dashboard.challenges.challengesText')} + {allPlayers[1].emoji}
- "{t("dashboard.challenges.exampleChallenge")}" + "{t('dashboard.challenges.exampleChallenge')}"
- {t("dashboard.challenges.currentBest", { score: 850 })} + {t('dashboard.challenges.currentBest', { score: 850 })}
)}
)}
- ); + ) } // Refined animations for the sweet spot design @@ -1328,23 +1314,20 @@ const globalAnimations = ` transform: translateY(0) scale(1); } } -`; +` export default function GamesPage() { return ( - ); + ) } // Inject refined animations into the page -if ( - typeof document !== "undefined" && - !document.getElementById("games-page-animations") -) { - const style = document.createElement("style"); - style.id = "games-page-animations"; - style.textContent = globalAnimations; - document.head.appendChild(style); +if (typeof document !== 'undefined' && !document.getElementById('games-page-animations')) { + const style = document.createElement('style') + style.id = 'games-page-animations' + style.textContent = globalAnimations + document.head.appendChild(style) } diff --git a/apps/web/src/app/guide/components/ArithmeticOperationsGuide.tsx b/apps/web/src/app/guide/components/ArithmeticOperationsGuide.tsx index ea4be9ff..3b6c55d2 100644 --- a/apps/web/src/app/guide/components/ArithmeticOperationsGuide.tsx +++ b/apps/web/src/app/guide/components/ArithmeticOperationsGuide.tsx @@ -1,101 +1,99 @@ -"use client"; +'use client' -import { AbacusReact, useAbacusConfig } from "@soroban/abacus-react"; -import Link from "next/link"; -import { useMessages, useTranslations } from "next-intl"; -import { TutorialPlayer } from "@/components/tutorial/TutorialPlayer"; -import { getTutorialForEditor } from "@/utils/tutorialConverter"; -import { css } from "../../../../styled-system/css"; -import { grid } from "../../../../styled-system/patterns"; +import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react' +import Link from 'next/link' +import { useMessages, useTranslations } from 'next-intl' +import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer' +import { getTutorialForEditor } from '@/utils/tutorialConverter' +import { css } from '../../../../styled-system/css' +import { grid } from '../../../../styled-system/patterns' export function ArithmeticOperationsGuide() { - const appConfig = useAbacusConfig(); - const messages = useMessages() as any; - const t = useTranslations("guide.arithmetic"); + const appConfig = useAbacusConfig() + const messages = useMessages() as any + const t = useTranslations('guide.arithmetic') return ( -
+

- {t("title")} + {t('title')}

- {t("subtitle")} + {t('subtitle')}

{/* Addition Section */}

- {t("addition.title")} + {t('addition.title')}

-

- {t("addition.description")} -

+

{t('addition.description')}

-
+

- {t("addition.basicSteps.title")} + {t('addition.basicSteps.title')}

    - {(t.raw("addition.basicSteps.steps") as string[]).map((step, i) => ( -
  1. + {(t.raw('addition.basicSteps.steps') as string[]).map((step, i) => ( +
  2. {i + 1}. {step}
  3. ))} @@ -104,44 +102,42 @@ export function ArithmeticOperationsGuide() {
    - {t("addition.example.title")} + {t('addition.example.title')}
    -
    -
    -

    - {t("addition.example.start")} +

    +
    +

    + {t('addition.example.start')}

    -
    - + -
    -
    -

    - {t("addition.example.result")} +

    +
    +
    +

    + {t('addition.example.result')}

    - {t("guidedTutorial.title")} + {t('guidedTutorial.title')}

    -

    - {t("guidedTutorial.description")} +

    + {t('guidedTutorial.description')}

    ✏️ - {t("guidedTutorial.editableNote")} + {t('guidedTutorial.editableNote')}

    -

    - {t("guidedTutorial.editableDesc")}{" "} +

    + {t('guidedTutorial.editableDesc')}{' '} - {t("guidedTutorial.editableLink")} + {t('guidedTutorial.editableLink')}

    @@ -277,99 +269,95 @@ export function ArithmeticOperationsGuide() { {/* Subtraction Section */}

    - {t("subtraction.title")} + {t('subtraction.title')}

    -

    - {t("subtraction.description")} -

    +

    {t('subtraction.description')}

    -
    +

    - {t("subtraction.basicSteps.title")} + {t('subtraction.basicSteps.title')}

      - {(t.raw("subtraction.basicSteps.steps") as string[]).map( - (step, i) => ( -
    1. - {i + 1}. {step} -
    2. - ), - )} + {(t.raw('subtraction.basicSteps.steps') as string[]).map((step, i) => ( +
    3. + {i + 1}. {step} +
    4. + ))}
    - {t("subtraction.example.title")} + {t('subtraction.example.title')}
    -
    -
    -

    - {t("subtraction.example.start")} +

    +
    +

    + {t('subtraction.example.start')}

    -
    - - -
    -
    -

    - {t("subtraction.example.result")} +

    -
    +
    +

    + {t('subtraction.example.result')}

    - {t("multiplicationDivision.title")} + {t('multiplicationDivision.title')}

    -

    - {t("multiplicationDivision.description")} +

    + {t('multiplicationDivision.description')}

    -
    +

    - {t("multiplicationDivision.multiplication.title")} + {t('multiplicationDivision.multiplication.title')}

      - {( - t.raw( - "multiplicationDivision.multiplication.points", - ) as string[] - ).map((point, i) => ( -
    • - • {point} -
    • - ))} + {(t.raw('multiplicationDivision.multiplication.points') as string[]).map( + (point, i) => ( +
    • + • {point} +
    • + ) + )}

    - {t("multiplicationDivision.division.title")} + {t('multiplicationDivision.division.title')}

      - {( - t.raw("multiplicationDivision.division.points") as string[] - ).map((point, i) => ( -
    • + {(t.raw('multiplicationDivision.division.points') as string[]).map((point, i) => ( +
    • • {point}
    • ))} @@ -535,50 +517,50 @@ export function ArithmeticOperationsGuide() { {/* Practice Tips */}

      - {t("practiceTips.title")} + {t('practiceTips.title')}

      - {t("practiceTips.description")} + {t('practiceTips.description')}

      - {t("practiceTips.button")} + {t('practiceTips.button')}
    - ); + ) } diff --git a/apps/web/src/app/guide/components/ReadingNumbersGuide.tsx b/apps/web/src/app/guide/components/ReadingNumbersGuide.tsx index dd61c7d8..acdf8d47 100644 --- a/apps/web/src/app/guide/components/ReadingNumbersGuide.tsx +++ b/apps/web/src/app/guide/components/ReadingNumbersGuide.tsx @@ -1,185 +1,181 @@ -"use client"; +'use client' -import { AbacusReact, useAbacusConfig } from "@soroban/abacus-react"; -import Link from "next/link"; -import { useTranslations } from "next-intl"; -import { css } from "../../../../styled-system/css"; -import { grid, hstack, stack } from "../../../../styled-system/patterns"; +import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react' +import Link from 'next/link' +import { useTranslations } from 'next-intl' +import { css } from '../../../../styled-system/css' +import { grid, hstack, stack } from '../../../../styled-system/patterns' export function ReadingNumbersGuide() { - const appConfig = useAbacusConfig(); - const t = useTranslations("guide.reading"); + const appConfig = useAbacusConfig() + const t = useTranslations('guide.reading') return ( -
    +
    {/* Section Introduction */} -
    +

    - {t("title")} + {t('title')}

    - {t("subtitle")} + {t('subtitle')}

    {/* Step 1: Basic Structure */}
    -
    -
    +
    +
    - {t("structure.number")} + {t('structure.number')}

    - {t("structure.title")} + {t('structure.title')}

    -
    +

    - {t("structure.description")} + {t('structure.description')}

    -
    +

    - {t("structure.heaven.title")} + {t('structure.heaven.title')}

      - {(t.raw("structure.heaven.points") as string[]).map( - (point, i) => ( -
    • - • {point} -
    • - ), - )} + {(t.raw('structure.heaven.points') as string[]).map((point, i) => ( +
    • + • {point} +
    • + ))}

    - {t("structure.earth.title")} + {t('structure.earth.title')}

      - {(t.raw("structure.earth.points") as string[]).map( - (point, i) => ( -
    • - • {point} -
    • - ), - )} + {(t.raw('structure.earth.points') as string[]).map((point, i) => ( +
    • + • {point} +
    • + ))}

    - {t("structure.keyConcept")} + {t('structure.keyConcept')}

    @@ -189,79 +185,79 @@ export function ReadingNumbersGuide() { {/* Step 2: Single Digits */}
    -
    -
    +
    +
    - {t("singleDigits.number")} + {t('singleDigits.number')}

    - {t("singleDigits.title")} + {t('singleDigits.title')}

    - {t("singleDigits.description")} + {t('singleDigits.description')}

    -
    +
    {[ - { num: 0, descKey: "0" }, - { num: 1, descKey: "1" }, - { num: 3, descKey: "3" }, - { num: 5, descKey: "5" }, - { num: 7, descKey: "7" }, + { num: 0, descKey: '0' }, + { num: 1, descKey: '1' }, + { num: 3, descKey: '3' }, + { num: 5, descKey: '5' }, + { num: 7, descKey: '7' }, ].map((example) => (
    {example.num} @@ -269,11 +265,11 @@ export function ReadingNumbersGuide() {
    {t(`singleDigits.examples.${example.descKey}`)} @@ -309,126 +305,122 @@ export function ReadingNumbersGuide() { {/* Step 3: Multi-digit Numbers */}
    -
    -
    +
    +
    - {t("multiDigit.number")} + {t('multiDigit.number')}

    - {t("multiDigit.title")} + {t('multiDigit.title')}

    - {t("multiDigit.description")} + {t('multiDigit.description')}

    - {t("multiDigit.readingDirection.title")} + {t('multiDigit.readingDirection.title')}

    -
    +
    - {t("multiDigit.readingDirection.readingOrder.title")} + {t('multiDigit.readingDirection.readingOrder.title')}
      - {( - t.raw( - "multiDigit.readingDirection.readingOrder.points", - ) as string[] - ).map((point, i) => ( -
    • - • {point} -
    • - ))} + {(t.raw('multiDigit.readingDirection.readingOrder.points') as string[]).map( + (point, i) => ( +
    • + • {point} +
    • + ) + )}
    - {t("multiDigit.readingDirection.placeValues.title")} + {t('multiDigit.readingDirection.placeValues.title')}
      - {( - t.raw( - "multiDigit.readingDirection.placeValues.points", - ) as string[] - ).map((point, i) => ( -
    • - • {point} -
    • - ))} + {(t.raw('multiDigit.readingDirection.placeValues.points') as string[]).map( + (point, i) => ( +
    • + • {point} +
    • + ) + )}
    @@ -437,51 +429,51 @@ export function ReadingNumbersGuide() { {/* Multi-digit Examples */}

    - {t("multiDigit.examples.title")} + {t('multiDigit.examples.title')}

    -
    +
    {[ - { num: 23, descKey: "23" }, - { num: 58, descKey: "58" }, - { num: 147, descKey: "147" }, + { num: 23, descKey: '23' }, + { num: 58, descKey: '58' }, + { num: 147, descKey: '147' }, ].map((example) => (
    {example.num} @@ -489,16 +481,16 @@ export function ReadingNumbersGuide() {
    {t(`multiDigit.examples.${example.descKey}`)} @@ -530,159 +522,155 @@ export function ReadingNumbersGuide() { {/* Step 4: Practice Tips */}
    -
    -
    +
    +
    - {t("practice.number")} + {t('practice.number')}

    - {t("practice.title")} + {t('practice.title')}

    -
    +

    - {t("practice.learningTips.title")} + {t('practice.learningTips.title')}

      - {(t.raw("practice.learningTips.points") as string[]).map( - (point, i) => ( -
    • - • {point} -
    • - ), - )} + {(t.raw('practice.learningTips.points') as string[]).map((point, i) => ( +
    • + • {point} +
    • + ))}

    - {t("practice.quickRecognition.title")} + {t('practice.quickRecognition.title')}

      - {(t.raw("practice.quickRecognition.points") as string[]).map( - (point, i) => ( -
    • - • {point} -
    • - ), - )} + {(t.raw('practice.quickRecognition.points') as string[]).map((point, i) => ( +
    • + • {point} +
    • + ))}

    - {t("practice.readyToPractice.title")} + {t('practice.readyToPractice.title')}

    - {t("practice.readyToPractice.description")} + {t('practice.readyToPractice.description')}

    - {t("practice.readyToPractice.button")} + {t('practice.readyToPractice.button')}
    @@ -691,93 +679,91 @@ export function ReadingNumbersGuide() { {/* Step 5: Interactive Practice */}
    -
    -
    +
    +
    - {t("interactive.number")} + {t('interactive.number')}

    - {t("interactive.title")} + {t('interactive.title')}

    - {t("interactive.description")} + {t('interactive.description')}

    - {t("interactive.howToUse.title")} + {t('interactive.howToUse.title')}

    -
    +
    - {t("interactive.howToUse.heaven.title")} + {t('interactive.howToUse.heaven.title')}
      - {( - t.raw("interactive.howToUse.heaven.points") as string[] - ).map((point, i) => ( -
    • + {(t.raw('interactive.howToUse.heaven.points') as string[]).map((point, i) => ( +
    • • {point}
    • ))} @@ -786,27 +772,25 @@ export function ReadingNumbersGuide() {
      - {t("interactive.howToUse.earth.title")} + {t('interactive.howToUse.earth.title')}
        - {(t.raw("interactive.howToUse.earth.points") as string[]).map( - (point, i) => ( -
      • - • {point} -
      • - ), - )} + {(t.raw('interactive.howToUse.earth.points') as string[]).map((point, i) => ( +
      • + • {point} +
      • + ))}
    @@ -815,15 +799,15 @@ export function ReadingNumbersGuide() { {/* Interactive Abacus Component */}

    - {t("interactive.readyToPractice.title")} + {t('interactive.readyToPractice.title')}

    - {t("interactive.readyToPractice.description")} + {t('interactive.readyToPractice.description')}

    - {t("interactive.readyToPractice.button")} + {t('interactive.readyToPractice.button')}
    - ); + ) } diff --git a/apps/web/src/app/guide/page.tsx b/apps/web/src/app/guide/page.tsx index 07b45153..79c36255 100644 --- a/apps/web/src/app/guide/page.tsx +++ b/apps/web/src/app/guide/page.tsx @@ -1,54 +1,52 @@ -"use client"; +'use client' -import { useState } from "react"; -import { useTranslations } from "next-intl"; -import { PageWithNav } from "@/components/PageWithNav"; -import { css } from "../../../styled-system/css"; -import { container, hstack } from "../../../styled-system/patterns"; -import { ArithmeticOperationsGuide } from "./components/ArithmeticOperationsGuide"; -import { ReadingNumbersGuide } from "./components/ReadingNumbersGuide"; +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { PageWithNav } from '@/components/PageWithNav' +import { css } from '../../../styled-system/css' +import { container, hstack } from '../../../styled-system/patterns' +import { ArithmeticOperationsGuide } from './components/ArithmeticOperationsGuide' +import { ReadingNumbersGuide } from './components/ReadingNumbersGuide' -type TabType = "reading" | "arithmetic"; +type TabType = 'reading' | 'arithmetic' export default function GuidePage() { - const t = useTranslations("guide.page"); - const [activeTab, setActiveTab] = useState("reading"); + const t = useTranslations('guide.page') + const [activeTab, setActiveTab] = useState('reading') return ( - -
    + +
    {/* Hero Section */}
    -
    +

    - {t("hero.title")} + {t('hero.title')}

    - {t("hero.subtitle")} + {t('hero.subtitle')}

    @@ -56,95 +54,71 @@ export default function GuidePage() { {/* Navigation Tabs */}
    -
    -
    +
    +
    {/* Main Content */} -
    +
    - {activeTab === "reading" ? ( - - ) : ( - - )} + {activeTab === 'reading' ? : }
    - ); + ) } diff --git a/apps/web/src/app/icon/route.tsx b/apps/web/src/app/icon/route.tsx index 6876d2f3..4900bc42 100644 --- a/apps/web/src/app/icon/route.tsx +++ b/apps/web/src/app/icon/route.tsx @@ -1,64 +1,62 @@ -import { readFileSync } from "fs"; -import { join } from "path"; +import { readFileSync } from 'fs' +import { join } from 'path' -export const runtime = "nodejs"; +export const runtime = 'nodejs' // In-memory cache: { day: svg } -const iconCache = new Map(); +const iconCache = new Map() // Get current day of month in US Central Time function getDayOfMonth(): number { - const now = new Date(); + const now = new Date() // Get date in America/Chicago timezone - const centralDate = new Date( - now.toLocaleString("en-US", { timeZone: "America/Chicago" }), - ); - return centralDate.getDate(); + const centralDate = new Date(now.toLocaleString('en-US', { timeZone: 'America/Chicago' })) + return centralDate.getDate() } // Load pre-generated day icon from public/icons/ function loadDayIcon(day: number): string { // Read pre-generated icon from public/icons/ // Icons are generated at build time by scripts/generateAllDayIcons.tsx - const filename = `icon-day-${day.toString().padStart(2, "0")}.svg`; - const filepath = join(process.cwd(), "public", "icons", filename); - return readFileSync(filepath, "utf-8"); + const filename = `icon-day-${day.toString().padStart(2, '0')}.svg` + const filepath = join(process.cwd(), 'public', 'icons', filename) + return readFileSync(filepath, 'utf-8') } export async function GET(request: Request) { // Parse query parameters for testing - const { searchParams } = new URL(request.url); - const dayParam = searchParams.get("day"); + const { searchParams } = new URL(request.url) + const dayParam = searchParams.get('day') // Use override day if provided (for testing), otherwise use current day - let dayOfMonth: number; + let dayOfMonth: number if (dayParam) { - const parsedDay = parseInt(dayParam, 10); + const parsedDay = parseInt(dayParam, 10) // Validate day is 1-31 if (parsedDay >= 1 && parsedDay <= 31) { - dayOfMonth = parsedDay; + dayOfMonth = parsedDay } else { - return new Response("Invalid day parameter. Must be 1-31.", { + return new Response('Invalid day parameter. Must be 1-31.', { status: 400, - }); + }) } } else { - dayOfMonth = getDayOfMonth(); + dayOfMonth = getDayOfMonth() } // Check cache first - let svg = iconCache.get(dayOfMonth); + let svg = iconCache.get(dayOfMonth) if (!svg) { // Load pre-generated icon and cache - svg = loadDayIcon(dayOfMonth); - iconCache.set(dayOfMonth, svg); + svg = loadDayIcon(dayOfMonth) + iconCache.set(dayOfMonth, svg) // Clear old cache entries (keep only current day, unless testing with override) if (!dayParam) { for (const [cachedDay] of iconCache) { if (cachedDay !== dayOfMonth) { - iconCache.delete(cachedDay); + iconCache.delete(cachedDay) } } } @@ -66,11 +64,11 @@ export async function GET(request: Request) { return new Response(svg, { headers: { - "Content-Type": "image/svg+xml", + 'Content-Type': 'image/svg+xml', // Cache for 1 hour for current day, shorter cache for test overrides - "Cache-Control": dayParam - ? "public, max-age=60, s-maxage=60" - : "public, max-age=3600, s-maxage=3600", + 'Cache-Control': dayParam + ? 'public, max-age=60, s-maxage=60' + : 'public, max-age=3600, s-maxage=3600', }, - }); + }) } diff --git a/apps/web/src/app/join/[code]/page.tsx b/apps/web/src/app/join/[code]/page.tsx index 8d84046b..5f8f5940 100644 --- a/apps/web/src/app/join/[code]/page.tsx +++ b/apps/web/src/app/join/[code]/page.tsx @@ -1,20 +1,16 @@ -"use client"; +'use client' -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; -import { io } from "socket.io-client"; -import { - useGetRoomByCode, - useJoinRoom, - useRoomData, -} from "@/hooks/useRoomData"; -import { getRoomDisplayWithEmoji } from "@/utils/room-display"; +import { useRouter } from 'next/navigation' +import { useCallback, useEffect, useState } from 'react' +import { io } from 'socket.io-client' +import { useGetRoomByCode, useJoinRoom, useRoomData } from '@/hooks/useRoomData' +import { getRoomDisplayWithEmoji } from '@/utils/room-display' interface RoomSwitchConfirmationProps { - currentRoom: { name: string | null; code: string; gameName: string }; - targetRoom: { name: string | null; code: string; gameName: string }; - onConfirm: () => void; - onCancel: () => void; + currentRoom: { name: string | null; code: string; gameName: string } + targetRoom: { name: string | null; code: string; gameName: string } + onConfirm: () => void + onCancel: () => void } function RoomSwitchConfirmation({ @@ -26,46 +22,45 @@ function RoomSwitchConfirmation({ return (

    Switch Rooms?

    You are currently in another room. Would you like to switch? @@ -73,24 +68,24 @@ function RoomSwitchConfirmation({

    -
    +
    Current Room
    -
    +
    {getRoomDisplayWithEmoji({ name: currentRoom.name, code: currentRoom.code, @@ -99,9 +94,9 @@ function RoomSwitchConfirmation({
    Code: {currentRoom.code} @@ -110,23 +105,23 @@ function RoomSwitchConfirmation({
    New Room
    -
    +
    {getRoomDisplayWithEmoji({ name: targetRoom.name, code: targetRoom.code, @@ -135,9 +130,9 @@ function RoomSwitchConfirmation({
    Code: {targetRoom.code} @@ -145,27 +140,27 @@ function RoomSwitchConfirmation({
    -
    +
    - ); + ) } export default function JoinRoomPage({ params }: { params: { code: string } }) { - const router = useRouter(); - const { roomData } = useRoomData(); - const { mutateAsync: getRoomByCode } = useGetRoomByCode(); - const { mutateAsync: joinRoom } = useJoinRoom(); + const router = useRouter() + const { roomData } = useRoomData() + const { mutateAsync: getRoomByCode } = useGetRoomByCode() + const { mutateAsync: joinRoom } = useJoinRoom() const [targetRoomData, setTargetRoomData] = useState<{ - id: string; - name: string | null; - code: string; - gameName: string; - accessMode: string; - } | null>(null); - const [showConfirmation, setShowConfirmation] = useState(false); - const [showPasswordPrompt, setShowPasswordPrompt] = useState(false); - const [showApprovalPrompt, setShowApprovalPrompt] = useState(false); - const [approvalRequested, setApprovalRequested] = useState(false); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [isJoining, setIsJoining] = useState(false); - const code = params.code.toUpperCase(); + id: string + name: string | null + code: string + gameName: string + accessMode: string + } | null>(null) + const [showConfirmation, setShowConfirmation] = useState(false) + const [showPasswordPrompt, setShowPasswordPrompt] = useState(false) + const [showApprovalPrompt, setShowApprovalPrompt] = useState(false) + const [approvalRequested, setApprovalRequested] = useState(false) + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [isJoining, setIsJoining] = useState(false) + const code = params.code.toUpperCase() const handleJoin = useCallback( async (targetRoomId: string, roomPassword?: string) => { - setIsJoining(true); - setError(null); + setIsJoining(true) + setError(null) try { await joinRoom({ roomId: targetRoomId, - displayName: "Player", + displayName: 'Player', password: roomPassword, - }); + }) // Navigate to the game - router.push("/arcade"); + router.push('/arcade') } catch (err) { - setError(err instanceof Error ? err.message : "Failed to join room"); - setIsJoining(false); + setError(err instanceof Error ? err.message : 'Failed to join room') + setIsJoining(false) } }, - [joinRoom, router], - ); + [joinRoom, router] + ) // Fetch target room data and handle join logic useEffect(() => { - if (!code) return; + if (!code) return - let mounted = true; + let mounted = true // Look up room by code getRoomByCode(code) .then((room) => { - if (!mounted) return; + if (!mounted) return setTargetRoomData({ id: room.id, @@ -262,205 +257,194 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) { code: room.code, gameName: room.gameName, accessMode: room.accessMode, - }); + }) // If user is already in this exact room, just navigate to game if (roomData && roomData.id === room.id) { - router.push("/arcade"); - return; + router.push('/arcade') + return } // Check if room needs password - if (room.accessMode === "password") { - setShowPasswordPrompt(true); - return; + if (room.accessMode === 'password') { + setShowPasswordPrompt(true) + return } // Check for other access modes - if (room.accessMode === "locked" || room.accessMode === "retired") { - setError("This room is no longer accepting new members"); - return; + if (room.accessMode === 'locked' || room.accessMode === 'retired') { + setError('This room is no longer accepting new members') + return } - if (room.accessMode === "approval-only") { - setShowApprovalPrompt(true); - return; + if (room.accessMode === 'approval-only') { + setShowApprovalPrompt(true) + return } // For restricted rooms, try to join - the API will check for invitation // If user is in a different room, show confirmation if (roomData) { - setShowConfirmation(true); + setShowConfirmation(true) } else { // Otherwise, auto-join (for open rooms and restricted rooms with invitation) - handleJoin(room.id); + handleJoin(room.id) } }) .catch((err) => { - if (!mounted) return; - setError(err instanceof Error ? err.message : "Failed to load room"); - }); + if (!mounted) return + setError(err instanceof Error ? err.message : 'Failed to load room') + }) return () => { - mounted = false; - }; - }, [code, roomData, handleJoin, router, getRoomByCode]); + mounted = false + } + }, [code, roomData, handleJoin, router, getRoomByCode]) const handleConfirm = () => { if (targetRoomData) { - if (targetRoomData.accessMode === "password") { - setShowConfirmation(false); - setShowPasswordPrompt(true); + if (targetRoomData.accessMode === 'password') { + setShowConfirmation(false) + setShowPasswordPrompt(true) } else { - handleJoin(targetRoomData.id); + handleJoin(targetRoomData.id) } } - }; + } const handleCancel = () => { - router.push("/arcade"); // Stay in current room - }; + router.push('/arcade') // Stay in current room + } const handlePasswordSubmit = () => { if (targetRoomData && password) { - handleJoin(targetRoomData.id, password); + handleJoin(targetRoomData.id, password) } - }; + } const handleRequestApproval = async () => { - if (!targetRoomData) return; + if (!targetRoomData) return - setIsJoining(true); - setError(null); + setIsJoining(true) + setError(null) try { - const res = await fetch( - `/api/arcade/rooms/${targetRoomData.id}/join-requests`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - }, - ); + const res = await fetch(`/api/arcade/rooms/${targetRoomData.id}/join-requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorData.error || "Failed to request approval"); + const errorData = await res.json() + throw new Error(errorData.error || 'Failed to request approval') } // Request sent successfully - show waiting state - setApprovalRequested(true); - setIsJoining(false); + setApprovalRequested(true) + setIsJoining(false) } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to request approval", - ); - setIsJoining(false); + setError(err instanceof Error ? err.message : 'Failed to request approval') + setIsJoining(false) } - }; + } // Socket listener for approval notifications useEffect(() => { - if (!approvalRequested || !targetRoomData) return; + if (!approvalRequested || !targetRoomData) return - console.log( - "[Join Page] Setting up approval listener for room:", - targetRoomData.id, - ); + console.log('[Join Page] Setting up approval listener for room:', targetRoomData.id) - let socket: ReturnType | null = null; + let socket: ReturnType | null = null // Fetch viewer ID and set up socket const setupSocket = async () => { try { // Get current user's viewer ID - const res = await fetch("/api/viewer"); + const res = await fetch('/api/viewer') if (!res.ok) { - console.error("[Join Page] Failed to get viewer ID"); - return; + console.error('[Join Page] Failed to get viewer ID') + return } - const { viewerId } = await res.json(); - console.log("[Join Page] Got viewer ID:", viewerId); + const { viewerId } = await res.json() + console.log('[Join Page] Got viewer ID:', viewerId) // Connect socket - socket = io({ path: "/api/socket" }); + socket = io({ path: '/api/socket' }) - socket.on("connect", () => { - console.log("[Join Page] Socket connected, joining user channel"); + socket.on('connect', () => { + console.log('[Join Page] Socket connected, joining user channel') // Join user-specific channel to receive moderation events - socket?.emit("join-user-channel", { userId: viewerId }); - }); + socket?.emit('join-user-channel', { userId: viewerId }) + }) - socket.on( - "join-request-approved", - (data: { roomId: string; requestId: string }) => { - console.log("[Join Page] Request approved via socket!", data); - if (data.roomId === targetRoomData.id) { - console.log("[Join Page] Joining room automatically..."); - handleJoin(targetRoomData.id); - } - }, - ); + socket.on('join-request-approved', (data: { roomId: string; requestId: string }) => { + console.log('[Join Page] Request approved via socket!', data) + if (data.roomId === targetRoomData.id) { + console.log('[Join Page] Joining room automatically...') + handleJoin(targetRoomData.id) + } + }) - socket.on("connect_error", (error) => { - console.error("[Join Page] Socket connection error:", error); - }); + socket.on('connect_error', (error) => { + console.error('[Join Page] Socket connection error:', error) + }) } catch (error) { - console.error("[Join Page] Error setting up socket:", error); + console.error('[Join Page] Error setting up socket:', error) } - }; + } - setupSocket(); + setupSocket() return () => { - console.log("[Join Page] Cleaning up approval listener"); - socket?.disconnect(); - }; - }, [approvalRequested, targetRoomData, handleJoin]); + console.log('[Join Page] Cleaning up approval listener') + socket?.disconnect() + } + }, [approvalRequested, targetRoomData, handleJoin]) // Only show error page for non-password and non-approval errors if (error && !showPasswordPrompt && !showApprovalPrompt) { return (
    -
    {error}
    +
    {error}
    Go to Champion Arena
    - ); + ) } if (isJoining || !targetRoomData) { return (
    - {isJoining ? "Joining room..." : "Loading..."} + {isJoining ? 'Joining room...' : 'Loading...'}
    - ); + ) } if (showConfirmation && roomData && targetRoomData) { @@ -479,48 +463,46 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) { onConfirm={handleConfirm} onCancel={handleCancel} /> - ); + ) } if (showPasswordPrompt && targetRoomData) { return (

    🔑 Password Required

    This room is password protected. Enter the password to join. @@ -528,18 +510,18 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {

    {getRoomDisplayWithEmoji({ @@ -550,10 +532,10 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
    Code: {targetRoomData.code} @@ -564,59 +546,59 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) { type="password" value={password} onChange={(e) => { - setPassword(e.target.value); - setError(null); // Clear error when user starts typing + setPassword(e.target.value) + setError(null) // Clear error when user starts typing }} onKeyDown={(e) => { - if (e.key === "Enter" && password) { - handlePasswordSubmit(); + if (e.key === 'Enter' && password) { + handlePasswordSubmit() } }} placeholder="Enter password" disabled={isJoining} style={{ - width: "100%", - padding: "12px 16px", - border: "2px solid rgba(251, 191, 36, 0.4)", - borderRadius: "10px", - background: "rgba(255, 255, 255, 0.05)", - color: "rgba(251, 191, 36, 1)", - fontSize: "16px", - outline: "none", - marginBottom: "8px", + width: '100%', + padding: '12px 16px', + border: '2px solid rgba(251, 191, 36, 0.4)', + borderRadius: '10px', + background: 'rgba(255, 255, 255, 0.05)', + color: 'rgba(251, 191, 36, 1)', + fontSize: '16px', + outline: 'none', + marginBottom: '8px', }} /> {error && (

    {error}

    )} -
    +
    - ); + ) } if (showApprovalPrompt && targetRoomData) { return (
    {approvalRequested ? ( // Waiting for approval state <> -
    -
    +
    +

    Waiting for Approval

    Your request has been sent to the room moderator. @@ -707,18 +684,18 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {

    {getRoomDisplayWithEmoji({ @@ -729,10 +706,10 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
    Code: {targetRoomData.code} @@ -741,36 +718,36 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {

    - You'll be able to join once the host approves your request. You - can close this page and check back later. + You'll be able to join once the host approves your request. You can close this page + and check back later.

    )}
    - ); + ) } - return null; + return null } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7323b682..762ae771 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,92 +1,87 @@ -import type { Metadata, Viewport } from "next"; -import "./globals.css"; -import { ClientProviders } from "@/components/ClientProviders"; -import { getRequestLocale } from "@/i18n/request"; -import { getMessages } from "@/i18n/messages"; +import type { Metadata, Viewport } from 'next' +import './globals.css' +import { ClientProviders } from '@/components/ClientProviders' +import { getRequestLocale } from '@/i18n/request' +import { getMessages } from '@/i18n/messages' export const metadata: Metadata = { - metadataBase: new URL("https://abaci.one"), + metadataBase: new URL('https://abaci.one'), title: { - default: "Abaci.One - Interactive Soroban Learning", - template: "%s | Abaci.One", + default: 'Abaci.One - Interactive Soroban Learning', + template: '%s | Abaci.One', }, description: - "Master the Japanese abacus (soroban) with interactive tutorials, arcade-style math games, and beautiful flashcards. Learn arithmetic through play with Rithmomachia, Complement Race, and more.", + 'Master the Japanese abacus (soroban) with interactive tutorials, arcade-style math games, and beautiful flashcards. Learn arithmetic through play with Rithmomachia, Complement Race, and more.', keywords: [ - "soroban", - "abacus", - "Japanese abacus", - "mental arithmetic", - "math games", - "abacus tutorial", - "soroban learning", - "arithmetic practice", - "educational games", - "Rithmomachia", - "number bonds", - "complement training", + 'soroban', + 'abacus', + 'Japanese abacus', + 'mental arithmetic', + 'math games', + 'abacus tutorial', + 'soroban learning', + 'arithmetic practice', + 'educational games', + 'Rithmomachia', + 'number bonds', + 'complement training', ], - authors: [{ name: "Abaci.One" }], - creator: "Abaci.One", - publisher: "Abaci.One", + authors: [{ name: 'Abaci.One' }], + creator: 'Abaci.One', + publisher: 'Abaci.One', // Open Graph openGraph: { - type: "website", - locale: "en_US", - alternateLocale: ["de_DE", "ja_JP", "hi_IN", "es_ES", "la"], - url: "https://abaci.one", - title: "Abaci.One - Interactive Soroban Learning", - description: - "Master the Japanese abacus through interactive games, tutorials, and practice", - siteName: "Abaci.One", + type: 'website', + locale: 'en_US', + alternateLocale: ['de_DE', 'ja_JP', 'hi_IN', 'es_ES', 'la'], + url: 'https://abaci.one', + title: 'Abaci.One - Interactive Soroban Learning', + description: 'Master the Japanese abacus through interactive games, tutorials, and practice', + siteName: 'Abaci.One', }, // Twitter twitter: { - card: "summary_large_image", - title: "Abaci.One - Interactive Soroban Learning", - description: "Master the Japanese abacus through games and practice", + card: 'summary_large_image', + title: 'Abaci.One - Interactive Soroban Learning', + description: 'Master the Japanese abacus through games and practice', }, // Icons icons: { icon: [ - { url: "/favicon.ico", sizes: "any" }, - { url: "/icon", type: "image/svg+xml" }, + { url: '/favicon.ico', sizes: 'any' }, + { url: '/icon', type: 'image/svg+xml' }, ], - apple: "/apple-touch-icon.png", + apple: '/apple-touch-icon.png', }, // Manifest - manifest: "/manifest.json", + manifest: '/manifest.json', // App-specific - applicationName: "Abaci.One", + applicationName: 'Abaci.One', appleWebApp: { capable: true, - statusBarStyle: "default", - title: "Abaci.One", + statusBarStyle: 'default', + title: 'Abaci.One', }, // Category - category: "education", -}; + category: 'education', +} export const viewport: Viewport = { - width: "device-width", + width: 'device-width', initialScale: 1, maximumScale: 1, userScalable: false, -}; +} -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - const locale = await getRequestLocale(); - const messages = await getMessages(locale); +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const locale = await getRequestLocale() + const messages = await getMessages(locale) return ( @@ -96,5 +91,5 @@ export default async function RootLayout({ - ); + ) } diff --git a/apps/web/src/app/levels/page.tsx b/apps/web/src/app/levels/page.tsx index 1bdd6ca0..ab6e6a22 100644 --- a/apps/web/src/app/levels/page.tsx +++ b/apps/web/src/app/levels/page.tsx @@ -1,56 +1,53 @@ -"use client"; +'use client' -import { PageWithNav } from "@/components/PageWithNav"; -import { LevelSliderDisplay } from "@/components/LevelSliderDisplay"; -import { css } from "../../../styled-system/css"; -import { container, stack } from "../../../styled-system/patterns"; +import { PageWithNav } from '@/components/PageWithNav' +import { LevelSliderDisplay } from '@/components/LevelSliderDisplay' +import { css } from '../../../styled-system/css' +import { container, stack } from '../../../styled-system/patterns' export default function LevelsPage() { return ( -
    +
    {/* Hero Section */}
    -
    +

    Understanding Kyu & Dan Levels @@ -58,12 +55,12 @@ export default function LevelsPage() {

    Slide through the complete progression from beginner to master @@ -73,46 +70,41 @@ export default function LevelsPage() {

    {/* Main content */} -
    -
    +
    +
    {/* Info Section */}

    About This Ranking System

    -
    -

    - This ranking system is based on the official examination - structure used by the{" "} - - Japan Abacus Federation - - . It represents a standardized progression from beginner (10th - Kyu) to master level (10th Dan), used internationally for - soroban proficiency assessment. +

    +

    + This ranking system is based on the official examination structure used by the{' '} + Japan Abacus Federation. It + represents a standardized progression from beginner (10th Kyu) to master level + (10th Dan), used internationally for soroban proficiency assessment.

    -

    - The system is designed to gradually increase in difficulty. - Kyu levels progress from 2-digit calculations at 10th Kyu to - 10-digit calculations at 1st Kyu. Dan levels all require - mastery of 30-digit calculations, with ranks awarded based on +

    + The system is designed to gradually increase in difficulty. Kyu levels progress + from 2-digit calculations at 10th Kyu to 10-digit calculations at 1st Kyu. Dan + levels all require mastery of 30-digit calculations, with ranks awarded based on exam scores.

    @@ -121,5 +113,5 @@ export default function LevelsPage() {
    - ); + ) } diff --git a/apps/web/src/app/opengraph-image.tsx b/apps/web/src/app/opengraph-image.tsx index 4554afcf..ddc94128 100644 --- a/apps/web/src/app/opengraph-image.tsx +++ b/apps/web/src/app/opengraph-image.tsx @@ -1,124 +1,116 @@ -import { ImageResponse } from "next/og"; -import { readFileSync } from "fs"; -import { join } from "path"; +import { ImageResponse } from 'next/og' +import { readFileSync } from 'fs' +import { join } from 'path' // Route segment config -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' // Image metadata -export const alt = "Abaci.One - Interactive Soroban Learning Platform"; +export const alt = 'Abaci.One - Interactive Soroban Learning Platform' export const size = { width: 1200, height: 630, -}; -export const contentType = "image/png"; +} +export const contentType = 'image/png' // Extract just the abacus SVG content from the pre-generated og-image.svg // This SVG is generated by scripts/generateAbacusIcons.tsx using AbacusReact function getAbacusSVGContent(): string { - const svgPath = join(process.cwd(), "public", "og-image.svg"); - const svgContent = readFileSync(svgPath, "utf-8"); + const svgPath = join(process.cwd(), 'public', 'og-image.svg') + const svgContent = readFileSync(svgPath, 'utf-8') // Extract just the abacus element (contains the AbacusReact output) const abacusMatch = svgContent.match( - /\s*]*>([\s\S]*?)<\/g>/, - ); + /\s*]*>([\s\S]*?)<\/g>/ + ) if (!abacusMatch) { - throw new Error("Could not extract abacus content from og-image.svg"); + throw new Error('Could not extract abacus content from og-image.svg') } - return abacusMatch[0]; // Return the full ... block with AbacusReact output + return abacusMatch[0] // Return the full ... block with AbacusReact output } // Image generation // Note: Uses pre-generated SVG from og-image.svg which is rendered by AbacusReact // This avoids importing react-dom/server in this file (Next.js restriction) export default async function Image() { - const abacusSVG = getAbacusSVGContent(); + const abacusSVG = getAbacusSVGContent() return new ImageResponse( - ( +
    + {/* Left side - Abacus from pre-generated og-image.svg (AbacusReact output) */}
    + + {/* Right side - Text content */} +
    - {/* Left side - Abacus from pre-generated og-image.svg (AbacusReact output) */} -
    - - {/* Right side - Text content */} -
    -

    - Abaci.One -

    + Abaci.One +

-

- Learn Soroban Through Play -

+

+ Learn Soroban Through Play +

-
-
- • Interactive Games -
-
- • Tutorials -
-
- • Practice Tools -
-
+
+
• Interactive Games
+
• Tutorials
+
• Practice Tools
- ), +
, { ...size, - }, - ); + } + ) } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 88c92696..2d346159 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,102 +1,102 @@ -"use client"; +'use client' -import Link from "next/link"; -import { useEffect, useState, useRef } from "react"; -import { useTranslations, useMessages } from "next-intl"; -import { AbacusReact, useAbacusConfig } from "@soroban/abacus-react"; -import { useHomeHero } from "@/contexts/HomeHeroContext"; -import { PageWithNav } from "@/components/PageWithNav"; -import { TutorialPlayer } from "@/components/tutorial/TutorialPlayer"; -import { getTutorialForEditor } from "@/utils/tutorialConverter"; -import { getAvailableGames } from "@/lib/arcade/game-registry"; -import { InteractiveFlashcards } from "@/components/InteractiveFlashcards"; -import { LevelSliderDisplay } from "@/components/LevelSliderDisplay"; -import { HomeBlogSection } from "@/components/HomeBlogSection"; -import { css } from "../../styled-system/css"; -import { container, grid, hstack, stack } from "../../styled-system/patterns"; +import Link from 'next/link' +import { useEffect, useState, useRef } from 'react' +import { useTranslations, useMessages } from 'next-intl' +import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react' +import { useHomeHero } from '@/contexts/HomeHeroContext' +import { PageWithNav } from '@/components/PageWithNav' +import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer' +import { getTutorialForEditor } from '@/utils/tutorialConverter' +import { getAvailableGames } from '@/lib/arcade/game-registry' +import { InteractiveFlashcards } from '@/components/InteractiveFlashcards' +import { LevelSliderDisplay } from '@/components/LevelSliderDisplay' +import { HomeBlogSection } from '@/components/HomeBlogSection' +import { css } from '../../styled-system/css' +import { container, grid, hstack, stack } from '../../styled-system/patterns' // Hero section placeholder - the actual abacus is rendered by MyAbacus component function HeroSection() { - const { subtitle, setIsHeroVisible, isSubtitleLoaded } = useHomeHero(); - const heroRef = useRef(null); + const { subtitle, setIsHeroVisible, isSubtitleLoaded } = useHomeHero() + const heroRef = useRef(null) // Detect when hero scrolls out of view useEffect(() => { - if (!heroRef.current) return; + if (!heroRef.current) return const observer = new IntersectionObserver( ([entry]) => { - setIsHeroVisible(entry.intersectionRatio > 0.2); + setIsHeroVisible(entry.intersectionRatio > 0.2) }, { threshold: [0, 0.2, 0.5, 1], - }, - ); + } + ) - observer.observe(heroRef.current); - return () => observer.disconnect(); - }, [setIsHeroVisible]); + observer.observe(heroRef.current) + return () => observer.disconnect() + }, [setIsHeroVisible]) return (
{/* Background pattern */}
{/* Title and Subtitle */}

Abaci One

{subtitle.text} @@ -109,14 +109,14 @@ function HeroSection() { {/* Scroll hint */}

@@ -142,7 +142,7 @@ function HeroSection() { }} />
- ); + ) } // Mini abacus that cycles through a sequence of values @@ -151,51 +151,51 @@ function MiniAbacus({ columns = 3, interval = 2500, }: { - values: number[]; - columns?: number; - interval?: number; + values: number[] + columns?: number + interval?: number }) { - const [currentIndex, setCurrentIndex] = useState(0); - const appConfig = useAbacusConfig(); + const [currentIndex, setCurrentIndex] = useState(0) + const appConfig = useAbacusConfig() useEffect(() => { - if (values.length === 0) return; + if (values.length === 0) return const timer = setInterval(() => { - setCurrentIndex((prev) => (prev + 1) % values.length); - }, interval); + setCurrentIndex((prev) => (prev + 1) % values.length) + }, interval) - return () => clearInterval(timer); - }, [values, interval]); + return () => clearInterval(timer) + }, [values, interval]) // Dark theme styles for the abacus const darkStyles = { columnPosts: { - fill: "rgba(255, 255, 255, 0.3)", - stroke: "rgba(255, 255, 255, 0.2)", + fill: 'rgba(255, 255, 255, 0.3)', + stroke: 'rgba(255, 255, 255, 0.2)', strokeWidth: 2, }, reckoningBar: { - fill: "rgba(255, 255, 255, 0.4)", - stroke: "rgba(255, 255, 255, 0.25)", + fill: 'rgba(255, 255, 255, 0.4)', + stroke: 'rgba(255, 255, 255, 0.25)', strokeWidth: 3, }, - }; + } return (
- ); + ) } export default function HomePage() { - const t = useTranslations("home"); - const messages = useMessages() as any; - const [selectedSkillIndex, setSelectedSkillIndex] = useState(1); // Default to "Friends techniques" - const fullTutorial = getTutorialForEditor(messages.tutorial || {}); + const t = useTranslations('home') + const messages = useMessages() as any + const [selectedSkillIndex, setSelectedSkillIndex] = useState(1) // Default to "Friends techniques" + const fullTutorial = getTutorialForEditor(messages.tutorial || {}) // Create different tutorials for each skill level const skillTutorials = [ // Skill 0: Read and set numbers (0-9999) { ...fullTutorial, - id: "read-numbers-demo", - title: t("skills.readNumbers.tutorialTitle"), - description: t("skills.readNumbers.tutorialDesc"), - steps: fullTutorial.steps.filter((step) => step.id.startsWith("basic-")), + id: 'read-numbers-demo', + title: t('skills.readNumbers.tutorialTitle'), + description: t('skills.readNumbers.tutorialDesc'), + 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"), - steps: fullTutorial.steps.filter((step) => step.id === "complement-2"), + id: 'friends-of-5-demo', + title: t('skills.friends.tutorialTitle'), + description: t('skills.friends.tutorialDesc'), + 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"), - steps: fullTutorial.steps - .filter((step) => step.id.includes("complement")) - .slice(0, 3), + id: 'multiply-demo', + title: t('skills.multiply.tutorialTitle'), + description: t('skills.multiply.tutorialDesc'), + 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"), + id: 'mental-calc-demo', + title: t('skills.mental.tutorialTitle'), + description: t('skills.mental.tutorialDesc'), steps: fullTutorial.steps.slice(-3), }, - ]; + ] - const selectedTutorial = skillTutorials[selectedSkillIndex]; + const selectedTutorial = skillTutorials[selectedSkillIndex] return ( -
+
{/* Hero Section - abacus rendered by MyAbacus in hero mode */} @@ -265,13 +263,13 @@ export default function HomePage() {
{/* Blog Section (Left Column) */} @@ -280,35 +278,29 @@ export default function HomePage() {
{/* Learn by Doing Section (Right Column) */} -
-
+
+

- {t("learnByDoing.title")} + {t('learnByDoing.title')}

- {t("learnByDoing.subtitle")} + {t('learnByDoing.subtitle')}

@@ -316,32 +308,32 @@ export default function HomePage() {
{/* Tutorial on the left */}

- {t("whatYouLearn.title")} + {t('whatYouLearn.title')}

{[ { - title: t("skills.readNumbers.title"), - desc: t("skills.readNumbers.desc"), - example: t("skills.readNumbers.example"), - badge: t("skills.readNumbers.badge"), + title: t('skills.readNumbers.title'), + desc: t('skills.readNumbers.desc'), + example: t('skills.readNumbers.example'), + badge: t('skills.readNumbers.badge'), values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999], columns: 3, }, { - title: t("skills.friends.title"), - desc: t("skills.friends.desc"), - example: t("skills.friends.example"), - badge: t("skills.friends.badge"), + title: t('skills.friends.title'), + desc: t('skills.friends.desc'), + example: t('skills.friends.example'), + badge: t('skills.friends.badge'), values: [2, 5, 3], columns: 1, }, { - title: t("skills.multiply.title"), - desc: t("skills.multiply.desc"), - example: t("skills.multiply.example"), - badge: t("skills.multiply.badge"), + title: t('skills.multiply.title'), + desc: t('skills.multiply.desc'), + example: t('skills.multiply.example'), + badge: t('skills.multiply.badge'), values: [12, 24, 36, 48], columns: 2, }, { - title: t("skills.mental.title"), - desc: t("skills.mental.desc"), - example: t("skills.mental.example"), - badge: t("skills.mental.badge"), + title: t('skills.mental.title'), + desc: t('skills.mental.desc'), + example: t('skills.mental.example'), + badge: t('skills.mental.badge'), values: [7, 14, 21, 28, 35], columns: 2, }, ].map((skill, i) => { - const isSelected = i === selectedSkillIndex; - const skillNames = [ - "read-numbers", - "friends", - "multiply", - "mental", - ]; + const isSelected = i === selectedSkillIndex + const skillNames = ['read-numbers', 'friends', 'multiply', 'mental'] return (
setSelectedSkillIndex(i)} className={css({ - bg: isSelected ? "accent.subtle" : "bg.surface", - borderRadius: "xl", - p: { base: "4", lg: "5" }, - border: "1px solid", - borderColor: isSelected - ? "accent.default" - : "border.default", + bg: isSelected ? 'accent.subtle' : 'bg.surface', + borderRadius: 'xl', + p: { base: '4', lg: '5' }, + border: '1px solid', + borderColor: isSelected ? 'accent.default' : 'border.default', boxShadow: isSelected - ? "0 6px 16px token(colors.accent.muted)" - : "0 4px 12px token(colors.bg.muted)", - transition: "all 0.2s", - cursor: "pointer", + ? '0 6px 16px token(colors.accent.muted)' + : '0 4px 12px token(colors.bg.muted)', + transition: 'all 0.2s', + cursor: 'pointer', _hover: { - bg: isSelected - ? "accent.muted" - : "interactive.hover", - borderColor: isSelected - ? "accent.emphasis" - : "border.emphasis", - transform: "translateY(-2px)", + bg: isSelected ? 'accent.muted' : 'interactive.hover', + borderColor: isSelected ? 'accent.emphasis' : 'border.emphasis', + transform: 'translateY(-2px)', boxShadow: isSelected - ? "0 8px 20px token(colors.accent.muted)" - : "0 6px 16px token(colors.bg.muted)", + ? '0 8px 20px token(colors.accent.muted)' + : '0 6px 16px token(colors.bg.muted)', }, })} >
- +
{skill.title} @@ -511,13 +489,13 @@ export default function HomePage() {
{skill.badge} @@ -526,9 +504,9 @@ export default function HomePage() {
{skill.desc} @@ -536,16 +514,16 @@ export default function HomePage() {
{skill.example} @@ -553,7 +531,7 @@ export default function HomePage() {
- ); + ) })}
@@ -563,36 +541,34 @@ export default function HomePage() {
{/* Main content container */} -
+
{/* Current Offerings Section */} -
-
+
+

- {t("arcade.title")} + {t('arcade.title')}

-

- {t("arcade.subtitle")} +

+ {t('arcade.subtitle')}

-
+
{getAvailableGames().map((game) => { const playersText = game.manifest.maxPlayers === 1 - ? t("arcade.soloChallenge") - : t("arcade.playersCount", { + ? t('arcade.soloChallenge') + : t('arcade.playersCount', { min: 1, max: game.manifest.maxPlayers, - }); + }) return ( - ); + ) })}
{/* Progression Visualization */} -
-
+
+

- {t("journey.title")} + {t('journey.title')}

-

- {t("journey.subtitle")} +

+ {t('journey.subtitle')}

@@ -633,45 +607,45 @@ export default function HomePage() {
{/* Flashcard Generator Section */} -
-
+
+

- {t("flashcards.title")} + {t('flashcards.title')}

- {t("flashcards.subtitle")} + {t('flashcards.subtitle')}

{/* Combined interactive display and CTA */}
{/* Interactive Flashcards Display */} -
+
@@ -679,77 +653,73 @@ export default function HomePage() {
{[ { - icon: t("flashcards.features.formats.icon"), - title: t("flashcards.features.formats.title"), - desc: t("flashcards.features.formats.desc"), + icon: t('flashcards.features.formats.icon'), + title: t('flashcards.features.formats.title'), + desc: t('flashcards.features.formats.desc'), }, { - icon: t("flashcards.features.customizable.icon"), - title: t("flashcards.features.customizable.title"), - desc: t("flashcards.features.customizable.desc"), + icon: t('flashcards.features.customizable.icon'), + title: t('flashcards.features.customizable.title'), + desc: t('flashcards.features.customizable.desc'), }, { - icon: t("flashcards.features.paperSizes.icon"), - title: t("flashcards.features.paperSizes.title"), - desc: t("flashcards.features.paperSizes.desc"), + icon: t('flashcards.features.paperSizes.icon'), + title: t('flashcards.features.paperSizes.title'), + desc: t('flashcards.features.paperSizes.desc'), }, ].map((feature, i) => (
-
- {feature.icon} -
+
{feature.icon}
{feature.title}
-
- {feature.desc} -
+
{feature.desc}
))}
{/* CTA Button */} -
+
- {t("flashcards.cta")} + {t('flashcards.cta')}
@@ -758,7 +728,7 @@ export default function HomePage() {
- ); + ) } function GameCard({ @@ -770,31 +740,31 @@ function GameCard({ gradient, href, }: { - icon: string; - title: string; - description: string; - players: string; - tags: string[]; - gradient: string; - href: string; + icon: string + title: string + description: string + players: string + tags: string[] + gradient: string + href: string }) { return ( - +
@@ -802,7 +772,7 @@ function GameCard({
{/* Content */}
{icon}

{title}

{description}

{players}

-
+
{tags.map((tag) => ( {tag} @@ -889,7 +858,7 @@ function GameCard({
- ); + ) } function FeaturePanel({ @@ -900,49 +869,44 @@ function FeaturePanel({ ctaText, ctaHref, }: { - icon: string; - title: string; - features: string[]; - accentColor: "purple" | "blue"; - ctaText?: string; - ctaHref?: string; + icon: string + title: string + features: string[] + accentColor: 'purple' | 'blue' + ctaText?: string + ctaHref?: string }) { - const borderColor = - accentColor === "purple" ? "purple.500/30" : "blue.500/30"; - const bgColor = accentColor === "purple" ? "purple.500/10" : "blue.500/10"; - const hoverBg = accentColor === "purple" ? "purple.500/20" : "blue.500/20"; + const borderColor = accentColor === 'purple' ? 'purple.500/30' : 'blue.500/30' + const bgColor = accentColor === 'purple' ? 'purple.500/10' : 'blue.500/10' + const hoverBg = accentColor === 'purple' ? 'purple.500/20' : 'blue.500/20' return (
-
- {icon} +
+ {icon}

{title}

-
+
{features.map((feature, i) => ( -
- - ✓ - - - {feature} - +
+ + {feature}
))}
@@ -950,23 +914,23 @@ function FeaturePanel({ {ctaText} )}
- ); + ) } diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts index 20df273a..e83627de 100644 --- a/apps/web/src/app/robots.ts +++ b/apps/web/src/app/robots.ts @@ -1,12 +1,12 @@ -import type { MetadataRoute } from "next"; +import type { MetadataRoute } from 'next' export default function robots(): MetadataRoute.Robots { return { rules: { - userAgent: "*", - allow: "/", - disallow: ["/api/", "/test/", "/_next/"], + userAgent: '*', + allow: '/', + disallow: ['/api/', '/test/', '/_next/'], }, - sitemap: "https://abaci.one/sitemap.xml", - }; + sitemap: 'https://abaci.one/sitemap.xml', + } } diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index bae5b92d..f7d43084 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -1,39 +1,37 @@ -import type { MetadataRoute } from "next"; +import type { MetadataRoute } from 'next' export default function sitemap(): MetadataRoute.Sitemap { - const baseUrl = "https://abaci.one"; + const baseUrl = 'https://abaci.one' // Main pages - const routes = ["", "/arcade", "/create", "/guide", "/about"].map( - (route) => ({ - url: `${baseUrl}${route}`, - lastModified: new Date(), - changeFrequency: "weekly" as const, - priority: route === "" ? 1 : 0.8, - }), - ); + const routes = ['', '/arcade', '/create', '/guide', '/about'].map((route) => ({ + url: `${baseUrl}${route}`, + lastModified: new Date(), + changeFrequency: 'weekly' as const, + priority: route === '' ? 1 : 0.8, + })) // Arcade games const games = [ - "/arcade/rithmomachia", - "/arcade/complement-race", - "/arcade/matching", - "/arcade/memory-quiz", - "/arcade/card-sorting", + '/arcade/rithmomachia', + '/arcade/complement-race', + '/arcade/matching', + '/arcade/memory-quiz', + '/arcade/card-sorting', ].map((route) => ({ url: `${baseUrl}${route}`, lastModified: new Date(), - changeFrequency: "monthly" as const, + changeFrequency: 'monthly' as const, priority: 0.6, - })); + })) // Guide pages - const guides = ["/arcade/rithmomachia/guide"].map((route) => ({ + const guides = ['/arcade/rithmomachia/guide'].map((route) => ({ url: `${baseUrl}${route}`, lastModified: new Date(), - changeFrequency: "monthly" as const, + changeFrequency: 'monthly' as const, priority: 0.5, - })); + })) - return [...routes, ...games, ...guides]; + return [...routes, ...games, ...guides] } diff --git a/apps/web/src/app/test-arcade/page.tsx b/apps/web/src/app/test-arcade/page.tsx index a93ab709..65352171 100644 --- a/apps/web/src/app/test-arcade/page.tsx +++ b/apps/web/src/app/test-arcade/page.tsx @@ -1,133 +1,130 @@ -"use client"; +'use client' -import { useEffect, useState } from "react"; -import { io, type Socket } from "socket.io-client"; +import { useEffect, useState } from 'react' +import { io, type Socket } from 'socket.io-client' export default function TestArcadePage() { - const [socket, setSocket] = useState(null); - const [connected, setConnected] = useState(false); - const [userId] = useState("evybhb5o0v4t76e7qnrx3x1t"); // Use real user ID from DB - const [logs, setLogs] = useState([]); + const [socket, setSocket] = useState(null) + const [connected, setConnected] = useState(false) + const [userId] = useState('evybhb5o0v4t76e7qnrx3x1t') // Use real user ID from DB + const [logs, setLogs] = useState([]) const addLog = (message: string) => { - setLogs((prev) => [ - ...prev, - `${new Date().toLocaleTimeString()}: ${message}`, - ]); - }; + setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]) + } useEffect(() => { // Initialize socket connection const socketInstance = io({ - path: "/api/socket", - }); + path: '/api/socket', + }) - socketInstance.on("connect", () => { - setConnected(true); - addLog("✅ Connected to Socket.IO"); - }); + socketInstance.on('connect', () => { + setConnected(true) + addLog('✅ Connected to Socket.IO') + }) - socketInstance.on("disconnect", () => { - setConnected(false); - addLog("❌ Disconnected from Socket.IO"); - }); + socketInstance.on('disconnect', () => { + setConnected(false) + addLog('❌ Disconnected from Socket.IO') + }) - socketInstance.on("session-state", (data) => { - addLog(`📦 Received session state: ${JSON.stringify(data, null, 2)}`); - }); + socketInstance.on('session-state', (data) => { + addLog(`📦 Received session state: ${JSON.stringify(data, null, 2)}`) + }) - socketInstance.on("no-active-session", () => { - addLog("ℹ️ No active session found"); - }); + socketInstance.on('no-active-session', () => { + addLog('ℹ️ No active session found') + }) - socketInstance.on("move-accepted", (data) => { - addLog(`✅ Move accepted: ${JSON.stringify(data, null, 2)}`); - }); + socketInstance.on('move-accepted', (data) => { + addLog(`✅ Move accepted: ${JSON.stringify(data, null, 2)}`) + }) - socketInstance.on("move-rejected", (data) => { - addLog(`❌ Move rejected: ${JSON.stringify(data, null, 2)}`); - }); + socketInstance.on('move-rejected', (data) => { + addLog(`❌ Move rejected: ${JSON.stringify(data, null, 2)}`) + }) - socketInstance.on("session-ended", () => { - addLog("🚪 Session ended"); - }); + socketInstance.on('session-ended', () => { + addLog('🚪 Session ended') + }) - socketInstance.on("session-error", (data) => { - addLog(`⚠️ Session error: ${data.error}`); - }); + socketInstance.on('session-error', (data) => { + addLog(`⚠️ Session error: ${data.error}`) + }) - setSocket(socketInstance); + setSocket(socketInstance) return () => { - socketInstance.disconnect(); - }; - }, [addLog]); + socketInstance.disconnect() + } + }, [addLog]) const joinSession = () => { - if (!socket) return; - addLog(`Joining session as user: ${userId}`); - socket.emit("join-arcade-session", { userId }); - }; + if (!socket) return + addLog(`Joining session as user: ${userId}`) + socket.emit('join-arcade-session', { userId }) + } const startGame = () => { - if (!socket) return; + if (!socket) return const move = { - type: "START_GAME", + type: 'START_GAME', playerId: userId, timestamp: Date.now(), data: { activePlayers: [1], }, - }; - addLog(`Sending START_GAME move: ${JSON.stringify(move)}`); - socket.emit("game-move", { userId, move }); - }; + } + addLog(`Sending START_GAME move: ${JSON.stringify(move)}`) + socket.emit('game-move', { userId, move }) + } const testFlipCard = () => { - if (!socket) return; + if (!socket) return const move = { - type: "FLIP_CARD", + type: 'FLIP_CARD', playerId: userId, timestamp: Date.now(), data: { - cardId: "test-card-1", + cardId: 'test-card-1', }, - }; - addLog(`Sending move: ${JSON.stringify(move)}`); - socket.emit("game-move", { userId, move }); - }; + } + addLog(`Sending move: ${JSON.stringify(move)}`) + socket.emit('game-move', { userId, move }) + } const createSession = async () => { - addLog("Creating test session via API..."); + addLog('Creating test session via API...') // First, delete any existing session - addLog("Deleting any existing session first..."); + addLog('Deleting any existing session first...') try { await fetch(`/api/arcade-session?userId=${userId}`, { - method: "DELETE", - }); - addLog("✅ Old session deleted (if existed)"); + method: 'DELETE', + }) + addLog('✅ Old session deleted (if existed)') } catch (error) { - addLog(`⚠️ Could not delete old session: ${error}`); + addLog(`⚠️ Could not delete old session: ${error}`) } // Now create new session try { - const response = await fetch("/api/arcade-session", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/arcade-session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId, - gameName: "matching", - gameUrl: "/arcade/matching", + gameName: 'matching', + gameUrl: '/arcade/matching', initialState: { cards: [], gameCards: [], flippedCards: [], - gameType: "abacus-numeral", + gameType: 'abacus-numeral', difficulty: 6, turnTimer: 30, - gamePhase: "setup", + gamePhase: 'setup', currentPlayer: 1, matchedPairs: 0, totalPairs: 6, @@ -146,77 +143,74 @@ export default function TestArcadePage() { }, activePlayers: [1], }), - }); - const data = await response.json(); + }) + const data = await response.json() if (response.ok) { - addLog(`✅ Session created successfully`); + addLog(`✅ Session created successfully`) } else { - addLog(`❌ Failed to create session: ${data.error}`); + addLog(`❌ Failed to create session: ${data.error}`) } } catch (error) { - addLog(`❌ Error creating session: ${error}`); + addLog(`❌ Error creating session: ${error}`) } - }; + } const exitSession = () => { - if (!socket) return; - addLog(`Exiting session for user: ${userId}`); - socket.emit("exit-arcade-session", { userId }); - }; + if (!socket) return + addLog(`Exiting session for user: ${userId}`) + socket.emit('exit-arcade-session', { userId }) + } return ( -
+

Arcade Session Test

Test Cross-Tab Sync: -
    +
    1. Open this page in TWO browser tabs
    2. In Tab 1: Click buttons 1 → 2
    3. In Tab 2: Click button 2 only (session already exists)
    4. In Tab 1: Click button 3 (Start Game)
    5. -
    6. - Watch Tab 2's event log - it should show "✅ Move accepted" - instantly! -
    7. +
    8. Watch Tab 2's event log - it should show "✅ Move accepted" instantly!
-
- Connection Status:{" "} - - {connected ? "🟢 Connected" : "🔴 Disconnected"} +
+ Connection Status:{' '} + + {connected ? '🟢 Connected' : '🔴 Disconnected'}
-
+
User ID: {userId}
@@ -159,68 +155,64 @@ export default function TestGuardPage() { <>
-

- Step 2: Test the Guard -

-
    +

    Step 2: Test the Guard

    +
    1. Open /arcade/matching - {" "} + {' '} (should load normally)
    2. Open /arcade/memory-quiz - {" "} + {' '} (should redirect to /arcade/matching)
    3. Open /arcade/complement-race - {" "} + {' '} (should redirect to /arcade/matching)
-
-

- Step 3: Clean Up -

+
+

Step 3: Clean Up

- ); + ) } diff --git a/apps/web/src/app/test-static-abacus/page.tsx b/apps/web/src/app/test-static-abacus/page.tsx index e455a483..c647f2a4 100644 --- a/apps/web/src/app/test-static-abacus/page.tsx +++ b/apps/web/src/app/test-static-abacus/page.tsx @@ -5,74 +5,61 @@ * Note: Uses /static import path to avoid client-side code */ -import { AbacusStatic } from "@soroban/abacus-react/static"; +import { AbacusStatic } from '@soroban/abacus-react/static' export default function TestStaticAbacusPage() { - const numbers = [1, 2, 3, 4, 5, 10, 25, 50, 100, 123, 456, 789]; + const numbers = [1, 2, 3, 4, 5, 10, 25, 50, 100, 123, 456, 789] return ( -
-

- AbacusStatic Test (Server Component) -

-

- This page is a React Server Component - no "use client" - directive! All abacus displays below are rendered on the server with - zero client-side JavaScript. +

+

AbacusStatic Test (Server Component)

+

+ This page is a React Server Component - no "use client" directive! All abacus + displays below are rendered on the server with zero client-side JavaScript.

{numbers.map((num) => (
- - - {num} - + + {num}
))}
-

✅ Success!

-

- If you can see the abacus displays above, then AbacusStatic is working - correctly in React Server Components. Check the page source - - you'll see pure HTML/SVG with no client-side hydration markers! +

✅ Success!

+

+ If you can see the abacus displays above, then AbacusStatic is working correctly in React + Server Components. Check the page source - you'll see pure HTML/SVG with no + client-side hydration markers!

- ); + ) } diff --git a/apps/web/src/app/test/arcade-rooms/page.tsx b/apps/web/src/app/test/arcade-rooms/page.tsx index a025710b..9712881d 100644 --- a/apps/web/src/app/test/arcade-rooms/page.tsx +++ b/apps/web/src/app/test/arcade-rooms/page.tsx @@ -1,374 +1,368 @@ -"use client"; +'use client' -import { useState } from "react"; -import { io, type Socket } from "socket.io-client"; +import { useState } from 'react' +import { io, type Socket } from 'socket.io-client' interface TestResult { - name: string; - status: "pending" | "running" | "success" | "error"; - message?: string; - data?: any; + name: string + status: 'pending' | 'running' | 'success' | 'error' + message?: string + data?: any } export default function ArcadeRoomsTestPage() { - const [results, setResults] = useState([]); - const [roomId, setRoomId] = useState(""); - const [socket1, setSocket1] = useState(null); - const [socket2, setSocket2] = useState(null); + const [results, setResults] = useState([]) + const [roomId, setRoomId] = useState('') + const [socket1, setSocket1] = useState(null) + const [socket2, setSocket2] = useState(null) const updateResult = (name: string, updates: Partial) => { setResults((prev) => { - const existing = prev.find((r) => r.name === name); + const existing = prev.find((r) => r.name === name) if (existing) { - return prev.map((r) => (r.name === name ? { ...r, ...updates } : r)); + return prev.map((r) => (r.name === name ? { ...r, ...updates } : r)) } - return [...prev, { name, status: "pending", ...updates }]; - }); - }; + return [...prev, { name, status: 'pending', ...updates }] + }) + } const log = (name: string, message: string, data?: any) => { - console.log(`[${name}]`, message, data); - updateResult(name, { message, data }); - }; + console.log(`[${name}]`, message, data) + updateResult(name, { message, data }) + } const clearResults = () => { - setResults([]); - socket1?.disconnect(); - socket2?.disconnect(); - setSocket1(null); - setSocket2(null); - }; + setResults([]) + socket1?.disconnect() + socket2?.disconnect() + setSocket1(null) + setSocket2(null) + } // Test 1: Create Room const testCreateRoom = async () => { - const testName = "Create Room"; - updateResult(testName, { status: "running" }); + const testName = 'Create Room' + updateResult(testName, { status: 'running' }) try { - const response = await fetch("/api/arcade/rooms", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/arcade/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `Test Room ${Date.now()}`, - createdBy: "test-user-1", - creatorName: "Test User 1", - gameName: "matching", + createdBy: 'test-user-1', + creatorName: 'Test User 1', + gameName: 'matching', gameConfig: { difficulty: 6 }, }), - }); + }) if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + throw new Error(`HTTP ${response.status}`) } - const data = await response.json(); - setRoomId(data.room.id); - log(testName, `✅ Room created: ${data.room.code}`, data.room); - updateResult(testName, { status: "success" }); + const data = await response.json() + setRoomId(data.room.id) + log(testName, `✅ Room created: ${data.room.code}`, data.room) + updateResult(testName, { status: 'success' }) } catch (error) { - log(testName, `❌ ${error}`); - updateResult(testName, { status: "error" }); + log(testName, `❌ ${error}`) + updateResult(testName, { status: 'error' }) } - }; + } // Test 2: Join Room (Socket) const testJoinRoom = async (testRoomId?: string) => { - const testName = "Join Room"; - const activeRoomId = testRoomId || roomId; + const testName = 'Join Room' + const activeRoomId = testRoomId || roomId if (!activeRoomId) { - log(testName, "❌ No room ID - create room first"); - updateResult(testName, { status: "error" }); - return; + log(testName, '❌ No room ID - create room first') + updateResult(testName, { status: 'error' }) + return } - updateResult(testName, { status: "running" }); + updateResult(testName, { status: 'running' }) - const sock = io({ path: "/api/socket" }); - setSocket1(sock); + const sock = io({ path: '/api/socket' }) + setSocket1(sock) - sock.on("connect", () => { - log(testName, `Connected: ${sock.id}`); - sock.emit("join-room", { roomId: activeRoomId, userId: "test-user-1" }); - }); + sock.on('connect', () => { + log(testName, `Connected: ${sock.id}`) + sock.emit('join-room', { roomId: activeRoomId, userId: 'test-user-1' }) + }) - sock.on("room-joined", (data) => { - log(testName, `✅ Joined room`, data); - updateResult(testName, { status: "success" }); - }); + sock.on('room-joined', (data) => { + log(testName, `✅ Joined room`, data) + updateResult(testName, { status: 'success' }) + }) - sock.on("room-error", (error) => { - log(testName, `❌ ${error.error}`); - updateResult(testName, { status: "error" }); - }); - }; + sock.on('room-error', (error) => { + log(testName, `❌ ${error.error}`) + updateResult(testName, { status: 'error' }) + }) + } // Test 3: Multi-User Join const testMultiUserJoin = async (testRoomId?: string) => { - const testName = "Multi-User Join"; - const activeRoomId = testRoomId || roomId; + const testName = 'Multi-User Join' + const activeRoomId = testRoomId || roomId if (!activeRoomId || !socket1) { - log(testName, "❌ Join room first (Test 2)"); - updateResult(testName, { status: "error" }); - return; + log(testName, '❌ Join room first (Test 2)') + updateResult(testName, { status: 'error' }) + return } - updateResult(testName, { status: "running" }); + updateResult(testName, { status: 'running' }) // Listen on socket1 for member-joined - socket1.once("member-joined", (data) => { - log( - testName, - `✅ User 2 joined, ${data.onlineMembers.length} online`, - data, - ); - updateResult(testName, { status: "success" }); - }); + socket1.once('member-joined', (data) => { + log(testName, `✅ User 2 joined, ${data.onlineMembers.length} online`, data) + updateResult(testName, { status: 'success' }) + }) // Connect socket2 - const sock2 = io({ path: "/api/socket" }); - setSocket2(sock2); + const sock2 = io({ path: '/api/socket' }) + setSocket2(sock2) - sock2.on("connect", () => { - log(testName, `User 2 connected: ${sock2.id}`); - sock2.emit("join-room", { roomId: activeRoomId, userId: "test-user-2" }); - }); + sock2.on('connect', () => { + log(testName, `User 2 connected: ${sock2.id}`) + sock2.emit('join-room', { roomId: activeRoomId, userId: 'test-user-2' }) + }) - sock2.on("room-error", (error) => { - log(testName, `❌ ${error.error}`); - updateResult(testName, { status: "error" }); - }); - }; + sock2.on('room-error', (error) => { + log(testName, `❌ ${error.error}`) + updateResult(testName, { status: 'error' }) + }) + } // Test 4: Game Move Broadcast const testGameMoveBroadcast = async (testRoomId?: string) => { - const testName = "Game Move Broadcast"; - const activeRoomId = testRoomId || roomId; + const testName = 'Game Move Broadcast' + const activeRoomId = testRoomId || roomId if (!activeRoomId || !socket1 || !socket2) { - log(testName, "❌ Run Multi-User Join first (Test 3)"); - updateResult(testName, { status: "error" }); - return; + log(testName, '❌ Run Multi-User Join first (Test 3)') + updateResult(testName, { status: 'error' }) + return } - updateResult(testName, { status: "running" }); + updateResult(testName, { status: 'running' }) - let socket1Received = false; - let socket2Received = false; + let socket1Received = false + let socket2Received = false const checkComplete = () => { if (socket1Received && socket2Received) { - log(testName, "✅ Both sockets received move"); - updateResult(testName, { status: "success" }); + log(testName, '✅ Both sockets received move') + updateResult(testName, { status: 'success' }) } - }; + } - socket1.once("room-move-accepted", (data) => { - log(testName, "Socket 1 received move", data.move.type); - socket1Received = true; - checkComplete(); - }); + socket1.once('room-move-accepted', (data) => { + log(testName, 'Socket 1 received move', data.move.type) + socket1Received = true + checkComplete() + }) - socket2.once("room-move-accepted", (data) => { - log(testName, "Socket 2 received move", data.move.type); - socket2Received = true; - checkComplete(); - }); + socket2.once('room-move-accepted', (data) => { + log(testName, 'Socket 2 received move', data.move.type) + socket2Received = true + checkComplete() + }) // Send move from socket1 - socket1.emit("room-game-move", { + socket1.emit('room-game-move', { roomId: activeRoomId, - userId: "test-guest-1", + userId: 'test-guest-1', move: { - type: "START_GAME", - playerId: "player-1", + type: 'START_GAME', + playerId: 'player-1', timestamp: Date.now(), - data: { activePlayers: ["player-1"] }, + data: { activePlayers: ['player-1'] }, }, - }); - }; + }) + } // Test 5: Solo Play (Backward Compatibility) const testSoloPlay = async () => { - const testName = "Solo Play"; - updateResult(testName, { status: "running" }); + const testName = 'Solo Play' + updateResult(testName, { status: 'running' }) - const soloSocket = io({ path: "/api/socket" }); + const soloSocket = io({ path: '/api/socket' }) - soloSocket.on("connect", () => { - log(testName, `Solo connected: ${soloSocket.id}`); - soloSocket.emit("join-arcade-session", { + soloSocket.on('connect', () => { + log(testName, `Solo connected: ${soloSocket.id}`) + soloSocket.emit('join-arcade-session', { userId: `solo-guest-${Date.now()}`, - }); - }); + }) + }) - soloSocket.on("no-active-session", () => { - log(testName, "No active session (expected)"); + soloSocket.on('no-active-session', () => { + log(testName, 'No active session (expected)') // Send START_GAME - soloSocket.emit("game-move", { + soloSocket.emit('game-move', { userId: `solo-guest-${Date.now()}`, move: { - type: "START_GAME", - playerId: "player-1", + type: 'START_GAME', + playerId: 'player-1', timestamp: Date.now(), - data: { activePlayers: ["player-1"] }, + data: { activePlayers: ['player-1'] }, }, - }); - }); + }) + }) - soloSocket.on("move-accepted", (data) => { - log(testName, "✅ Solo move accepted", data); - updateResult(testName, { status: "success" }); - soloSocket.disconnect(); - }); + soloSocket.on('move-accepted', (data) => { + log(testName, '✅ Solo move accepted', data) + updateResult(testName, { status: 'success' }) + soloSocket.disconnect() + }) - soloSocket.on("move-rejected", (error) => { - log(testName, `❌ ${error.error}`); - updateResult(testName, { status: "error" }); - soloSocket.disconnect(); - }); - }; + soloSocket.on('move-rejected', (error) => { + log(testName, `❌ ${error.error}`) + updateResult(testName, { status: 'error' }) + soloSocket.disconnect() + }) + } // Test 6: Room Isolation (ensure solo doesn't leak to room) const testRoomIsolation = async () => { - const testName = "Room Isolation"; + const testName = 'Room Isolation' if (!socket1) { - log(testName, "❌ Join room first (Test 2)"); - updateResult(testName, { status: "error" }); - return; + log(testName, '❌ Join room first (Test 2)') + updateResult(testName, { status: 'error' }) + return } - updateResult(testName, { status: "running" }); + updateResult(testName, { status: 'running' }) - let receivedSoloMove = false; + let receivedSoloMove = false - socket1.once("room-move-accepted", (data) => { - if (data.userId.includes("solo-guest")) { - receivedSoloMove = true; - log(testName, "❌ LEAK: Received solo move in room!"); - updateResult(testName, { status: "error" }); + socket1.once('room-move-accepted', (data) => { + if (data.userId.includes('solo-guest')) { + receivedSoloMove = true + log(testName, '❌ LEAK: Received solo move in room!') + updateResult(testName, { status: 'error' }) } - }); + }) // Create solo session - const soloSocket = io({ path: "/api/socket" }); - soloSocket.on("connect", () => { - const soloUserId = `solo-guest-${Date.now()}`; - soloSocket.emit("join-arcade-session", { userId: soloUserId }); + const soloSocket = io({ path: '/api/socket' }) + soloSocket.on('connect', () => { + const soloUserId = `solo-guest-${Date.now()}` + soloSocket.emit('join-arcade-session', { userId: soloUserId }) setTimeout(() => { - soloSocket.emit("game-move", { + soloSocket.emit('game-move', { userId: soloUserId, move: { - type: "START_GAME", - playerId: "player-1", + type: 'START_GAME', + playerId: 'player-1', timestamp: Date.now(), - data: { activePlayers: ["player-1"] }, + data: { activePlayers: ['player-1'] }, }, - }); - }, 500); - }); + }) + }, 500) + }) - soloSocket.on("move-accepted", () => { + soloSocket.on('move-accepted', () => { // Wait to see if room received it setTimeout(() => { if (!receivedSoloMove) { - log(testName, "✅ Solo move did not leak to room"); - updateResult(testName, { status: "success" }); + log(testName, '✅ Solo move did not leak to room') + updateResult(testName, { status: 'success' }) } - soloSocket.disconnect(); - }, 1000); - }); - }; + soloSocket.disconnect() + }, 1000) + }) + } const runAllTests = async () => { - clearResults(); - setRoomId(""); // Reset room ID - setSocket1(null); - setSocket2(null); + clearResults() + setRoomId('') // Reset room ID + setSocket1(null) + setSocket2(null) // Test 1: Create Room - updateResult("Run All Tests", { - status: "running", - message: "Creating room...", - }); + updateResult('Run All Tests', { + status: 'running', + message: 'Creating room...', + }) try { - const response = await fetch("/api/arcade/rooms", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/arcade/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `Test Room ${Date.now()}`, - createdBy: "test-user-1", - creatorName: "Test User 1", - gameName: "matching", + createdBy: 'test-user-1', + creatorName: 'Test User 1', + gameName: 'matching', gameConfig: { difficulty: 6 }, }), - }); + }) if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + throw new Error(`HTTP ${response.status}`) } - const data = await response.json(); - const createdRoomId = data.room.id; - setRoomId(createdRoomId); + const data = await response.json() + const createdRoomId = data.room.id + setRoomId(createdRoomId) - log("Create Room", `✅ Room created: ${data.room.code}`, data.room); - updateResult("Create Room", { status: "success" }); + log('Create Room', `✅ Room created: ${data.room.code}`, data.room) + updateResult('Create Room', { status: 'success' }) - updateResult("Run All Tests", { - message: "Room created, running tests...", - }); - await new Promise((r) => setTimeout(r, 1000)); + updateResult('Run All Tests', { + message: 'Room created, running tests...', + }) + await new Promise((r) => setTimeout(r, 1000)) // Now run tests with the roomId we just got - await runTestsWithRoom(createdRoomId); + await runTestsWithRoom(createdRoomId) } catch (error) { - log("Create Room", `❌ ${error}`); - updateResult("Create Room", { status: "error" }); - updateResult("Run All Tests", { - status: "error", - message: "Room creation failed", - }); + log('Create Room', `❌ ${error}`) + updateResult('Create Room', { status: 'error' }) + updateResult('Run All Tests', { + status: 'error', + message: 'Room creation failed', + }) } - }; + } const runTestsWithRoom = async (testRoomId: string) => { // Pass testRoomId to each test to avoid state closure issues // Test 2: Join Room - await testJoinRoom(testRoomId); - await new Promise((r) => setTimeout(r, 2000)); + await testJoinRoom(testRoomId) + await new Promise((r) => setTimeout(r, 2000)) // Test 3: Multi-User Join - await testMultiUserJoin(testRoomId); - await new Promise((r) => setTimeout(r, 2000)); + await testMultiUserJoin(testRoomId) + await new Promise((r) => setTimeout(r, 2000)) // Test 4: Game Move Broadcast - await testGameMoveBroadcast(testRoomId); - await new Promise((r) => setTimeout(r, 2000)); + await testGameMoveBroadcast(testRoomId) + await new Promise((r) => setTimeout(r, 2000)) // Test 5: Solo Play (doesn't need roomId) - await testSoloPlay(); - await new Promise((r) => setTimeout(r, 2000)); + await testSoloPlay() + await new Promise((r) => setTimeout(r, 2000)) // Test 6: Room Isolation (doesn't need roomId parameter since it uses socket1) - await testRoomIsolation(); - await new Promise((r) => setTimeout(r, 1000)); + await testRoomIsolation() + await new Promise((r) => setTimeout(r, 1000)) - updateResult("Run All Tests", { - status: "success", - message: "✅ All tests completed", - }); - }; + updateResult('Run All Tests', { + status: 'success', + message: '✅ All tests completed', + }) + } return (

Arcade Rooms Manual Testing

-

- Phase 1 & 2: Room CRUD, Socket.IO Integration -

+

Phase 1 & 2: Room CRUD, Socket.IO Integration

Test Controls

@@ -430,8 +424,7 @@ export default function ArcadeRoomsTestPage() { {roomId && (

- Room ID:{" "} - {roomId} + Room ID: {roomId}

)} @@ -441,9 +434,7 @@ export default function ArcadeRoomsTestPage() {

Test Results

{results.length === 0 && ( -

- No tests run yet. Click a button above to start. -

+

No tests run yet. Click a button above to start.

)}
@@ -451,13 +442,13 @@ export default function ArcadeRoomsTestPage() {
@@ -466,22 +457,20 @@ export default function ArcadeRoomsTestPage() { {result.name} {result.status}
{result.message && ( -

- {result.message} -

+

{result.message}

)} {result.data && (
@@ -512,5 +501,5 @@ export default function ArcadeRoomsTestPage() {
- ); + ) } diff --git a/apps/web/src/app/tutorial-editor/__tests__/page.integration.test.tsx b/apps/web/src/app/tutorial-editor/__tests__/page.integration.test.tsx index 1d4351f3..9aa477b5 100644 --- a/apps/web/src/app/tutorial-editor/__tests__/page.integration.test.tsx +++ b/apps/web/src/app/tutorial-editor/__tests__/page.integration.test.tsx @@ -1,9 +1,9 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import TutorialEditorPage from "../page"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TutorialEditorPage from '../page' // Mock Next.js router -vi.mock("next/navigation", () => ({ +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), back: vi.fn(), @@ -11,505 +11,495 @@ vi.mock("next/navigation", () => ({ refresh: vi.fn(), }), useSearchParams: () => new URLSearchParams(), - usePathname: () => "/tutorial-editor", -})); + usePathname: () => '/tutorial-editor', +})) // Mock the AbacusReact component -vi.mock("@soroban/abacus-react", () => ({ +vi.mock('@soroban/abacus-react', () => ({ AbacusReact: ({ value, onValueChange, callbacks }: any) => (
{value}
), -})); +})) -describe("Tutorial Editor Page Integration Tests", () => { +describe('Tutorial Editor Page Integration Tests', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("Page Structure and Navigation", () => { - it("renders the complete tutorial editor page with all components", () => { - render(); + describe('Page Structure and Navigation', () => { + it('renders the complete tutorial editor page with all components', () => { + render() // Check main page elements - expect( - screen.getByText("Tutorial Editor & Debugger"), - ).toBeInTheDocument(); - expect(screen.getByText(/Guided Addition Tutorial/)).toBeInTheDocument(); + expect(screen.getByText('Tutorial Editor & Debugger')).toBeInTheDocument() + expect(screen.getByText(/Guided Addition Tutorial/)).toBeInTheDocument() // Check mode selector buttons - expect(screen.getByText("Editor")).toBeInTheDocument(); - expect(screen.getByText("Player")).toBeInTheDocument(); - expect(screen.getByText("Split")).toBeInTheDocument(); + expect(screen.getByText('Editor')).toBeInTheDocument() + expect(screen.getByText('Player')).toBeInTheDocument() + expect(screen.getByText('Split')).toBeInTheDocument() // Check options - expect(screen.getByText("Debug Info")).toBeInTheDocument(); - expect(screen.getByText("Auto Save")).toBeInTheDocument(); + expect(screen.getByText('Debug Info')).toBeInTheDocument() + expect(screen.getByText('Auto Save')).toBeInTheDocument() // Check export functionality - expect(screen.getByText("Export Debug")).toBeInTheDocument(); - }); + expect(screen.getByText('Export Debug')).toBeInTheDocument() + }) - it("switches between editor, player, and split modes correctly", async () => { - render(); + it('switches between editor, player, and split modes correctly', async () => { + render() // Default should be editor mode - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() // Switch to player mode - fireEvent.click(screen.getByText("Player")); + fireEvent.click(screen.getByText('Player')) await waitFor(() => { - expect(screen.queryByText("Edit Tutorial")).not.toBeInTheDocument(); - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - expect(screen.getByText(/Step 1 of/)).toBeInTheDocument(); - }); + expect(screen.queryByText('Edit Tutorial')).not.toBeInTheDocument() + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + expect(screen.getByText(/Step 1 of/)).toBeInTheDocument() + }) // Switch to split mode - fireEvent.click(screen.getByText("Split")); + fireEvent.click(screen.getByText('Split')) await waitFor(() => { // Should show both editor and player - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - }); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + }) // Switch back to editor mode - fireEvent.click(screen.getByText("Editor")); + fireEvent.click(screen.getByText('Editor')) await waitFor(() => { - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - expect(screen.queryByTestId("mock-abacus")).not.toBeInTheDocument(); - }); - }); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + expect(screen.queryByTestId('mock-abacus')).not.toBeInTheDocument() + }) + }) - it("toggles debug information display", () => { - render(); + it('toggles debug information display', () => { + render() - const debugInfoCheckbox = screen.getByLabelText("Debug Info"); - expect(debugInfoCheckbox).toBeChecked(); // Should be checked by default + const debugInfoCheckbox = screen.getByLabelText('Debug Info') + expect(debugInfoCheckbox).toBeChecked() // Should be checked by default // Should show validation status when debug info is enabled - expect( - screen.getByText("Tutorial validation passed ✓"), - ).toBeInTheDocument(); + expect(screen.getByText('Tutorial validation passed ✓')).toBeInTheDocument() // Toggle debug info off - fireEvent.click(debugInfoCheckbox); - expect(debugInfoCheckbox).not.toBeChecked(); + fireEvent.click(debugInfoCheckbox) + expect(debugInfoCheckbox).not.toBeChecked() // Validation status should be hidden - expect( - screen.queryByText("Tutorial validation passed ✓"), - ).not.toBeInTheDocument(); - }); + expect(screen.queryByText('Tutorial validation passed ✓')).not.toBeInTheDocument() + }) - it("toggles auto save option", () => { - render(); + it('toggles auto save option', () => { + render() - const autoSaveCheckbox = screen.getByLabelText("Auto Save"); - expect(autoSaveCheckbox).not.toBeChecked(); // Should be unchecked by default + const autoSaveCheckbox = screen.getByLabelText('Auto Save') + expect(autoSaveCheckbox).not.toBeChecked() // Should be unchecked by default - fireEvent.click(autoSaveCheckbox); - expect(autoSaveCheckbox).toBeChecked(); - }); - }); + fireEvent.click(autoSaveCheckbox) + expect(autoSaveCheckbox).toBeChecked() + }) + }) - describe("Editor Mode Functionality", () => { - it("supports complete tutorial editing workflow in editor mode", async () => { - render(); + describe('Editor Mode Functionality', () => { + it('supports complete tutorial editing workflow in editor mode', async () => { + render() // Start in editor mode - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() // Enter edit mode - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) await waitFor(() => { - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + }) // Edit tutorial metadata - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') fireEvent.change(titleInput, { - target: { value: "Advanced Addition Tutorial" }, - }); + target: { value: 'Advanced Addition Tutorial' }, + }) // Save changes - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Save Changes')) // Should show saving status await waitFor(() => { - expect(screen.getByText("Saving...")).toBeInTheDocument(); - }); + expect(screen.getByText('Saving...')).toBeInTheDocument() + }) // Should show saved status await waitFor( () => { - expect(screen.getByText("Saved!")).toBeInTheDocument(); + expect(screen.getByText('Saved!')).toBeInTheDocument() }, - { timeout: 2000 }, - ); - }); + { timeout: 2000 } + ) + }) - it("handles validation errors and displays them appropriately", async () => { - render(); + it('handles validation errors and displays them appropriately', async () => { + render() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) await waitFor(() => { - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + }) // Clear the title to trigger validation error - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); - fireEvent.change(titleInput, { target: { value: "" } }); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') + fireEvent.change(titleInput, { target: { value: '' } }) // Try to save - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Save Changes')) // Should show validation error await waitFor(() => { - expect(screen.getByText(/validation error/)).toBeInTheDocument(); - }); - }); + expect(screen.getByText(/validation error/)).toBeInTheDocument() + }) + }) - it("integrates preview functionality with player mode", async () => { - render(); + it('integrates preview functionality with player mode', async () => { + render() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) await waitFor(() => { - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + }) // Look for preview buttons in the editor - const previewButtons = screen.getAllByText(/Preview/); + const previewButtons = screen.getAllByText(/Preview/) if (previewButtons.length > 0) { - fireEvent.click(previewButtons[0]); + fireEvent.click(previewButtons[0]) // Should switch to player mode for preview await waitFor(() => { - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - }); + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + }) } - }); - }); + }) + }) - describe("Player Mode Functionality", () => { - it("supports interactive tutorial playthrough in player mode", async () => { - render(); + describe('Player Mode Functionality', () => { + it('supports interactive tutorial playthrough in player mode', async () => { + render() // Switch to player mode - fireEvent.click(screen.getByText("Player")); + fireEvent.click(screen.getByText('Player')) await waitFor(() => { - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - expect(screen.getByText(/Step 1 of/)).toBeInTheDocument(); - }); + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + expect(screen.getByText(/Step 1 of/)).toBeInTheDocument() + }) // Should show debug controls in player mode - expect(screen.getByText("Debug")).toBeInTheDocument(); - expect(screen.getByText("Steps")).toBeInTheDocument(); + expect(screen.getByText('Debug')).toBeInTheDocument() + expect(screen.getByText('Steps')).toBeInTheDocument() // Interact with the abacus - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) // Should update abacus value await waitFor(() => { - const abacusValue = screen.getByTestId("abacus-value"); - expect(abacusValue).toHaveTextContent("1"); - }); + const abacusValue = screen.getByTestId('abacus-value') + expect(abacusValue).toHaveTextContent('1') + }) // Should show step completion feedback await waitFor(() => { - const successMessage = screen.queryByText(/Great! You completed/); + const successMessage = screen.queryByText(/Great! You completed/) if (successMessage) { - expect(successMessage).toBeInTheDocument(); + expect(successMessage).toBeInTheDocument() } - }); - }); + }) + }) - it("tracks and displays debug events in player mode", async () => { - render(); + it('tracks and displays debug events in player mode', async () => { + render() // Switch to player mode - fireEvent.click(screen.getByText("Player")); + fireEvent.click(screen.getByText('Player')) await waitFor(() => { - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - }); + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + }) // Interact with abacus to generate events - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) // Should show debug events panel at bottom await waitFor(() => { - const debugEventsPanel = screen.queryByText("Debug Events"); + const debugEventsPanel = screen.queryByText('Debug Events') if (debugEventsPanel) { - expect(debugEventsPanel).toBeInTheDocument(); + expect(debugEventsPanel).toBeInTheDocument() } - }); - }); + }) + }) - it("supports step navigation and debugging features", async () => { - render(); + it('supports step navigation and debugging features', async () => { + render() - fireEvent.click(screen.getByText("Player")); + fireEvent.click(screen.getByText('Player')) await waitFor(() => { - expect(screen.getByText("Steps")).toBeInTheDocument(); - }); + expect(screen.getByText('Steps')).toBeInTheDocument() + }) // Open step list - fireEvent.click(screen.getByText("Steps")); + fireEvent.click(screen.getByText('Steps')) await waitFor(() => { - expect(screen.getByText("Tutorial Steps")).toBeInTheDocument(); - }); + expect(screen.getByText('Tutorial Steps')).toBeInTheDocument() + }) // Should show step list - const stepItems = screen.getAllByText(/^\d+\./); - expect(stepItems.length).toBeGreaterThan(0); + const stepItems = screen.getAllByText(/^\d+\./) + expect(stepItems.length).toBeGreaterThan(0) // Test auto-advance feature - const autoAdvanceCheckbox = screen.getByLabelText("Auto-advance"); - fireEvent.click(autoAdvanceCheckbox); - expect(autoAdvanceCheckbox).toBeChecked(); - }); - }); + const autoAdvanceCheckbox = screen.getByLabelText('Auto-advance') + fireEvent.click(autoAdvanceCheckbox) + expect(autoAdvanceCheckbox).toBeChecked() + }) + }) - describe("Split Mode Functionality", () => { - it("displays both editor and player simultaneously in split mode", async () => { - render(); + describe('Split Mode Functionality', () => { + it('displays both editor and player simultaneously in split mode', async () => { + render() // Switch to split mode - fireEvent.click(screen.getByText("Split")); + fireEvent.click(screen.getByText('Split')) await waitFor(() => { // Should show both editor and player components - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - expect(screen.getByText(/Step 1 of/)).toBeInTheDocument(); - }); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + expect(screen.getByText(/Step 1 of/)).toBeInTheDocument() + }) // Should be able to interact with both sides - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) await waitFor(() => { - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + }) // Player side should still be functional - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) await waitFor(() => { - const abacusValue = screen.getByTestId("abacus-value"); - expect(abacusValue).toHaveTextContent("1"); - }); - }); + const abacusValue = screen.getByTestId('abacus-value') + expect(abacusValue).toHaveTextContent('1') + }) + }) - it("synchronizes changes between editor and player in split mode", async () => { - render(); + it('synchronizes changes between editor and player in split mode', async () => { + render() - fireEvent.click(screen.getByText("Split")); + fireEvent.click(screen.getByText('Split')) await waitFor(() => { - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - }); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + }) // Edit tutorial in editor side - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) await waitFor(() => { - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + }) - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); - fireEvent.change(titleInput, { target: { value: "Modified Tutorial" } }); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') + fireEvent.change(titleInput, { target: { value: 'Modified Tutorial' } }) // The changes should be reflected in the tutorial state // (This tests that both sides work with the same tutorial state) - expect(titleInput).toHaveValue("Modified Tutorial"); - }); - }); + expect(titleInput).toHaveValue('Modified Tutorial') + }) + }) - describe("Debug and Export Features", () => { - it("exports debug data correctly", () => { - render(); + describe('Debug and Export Features', () => { + it('exports debug data correctly', () => { + render() // Mock URL.createObjectURL and related methods - const mockCreateObjectURL = vi.fn(() => "mock-url"); - const mockRevokeObjectURL = vi.fn(); - const mockClick = vi.fn(); + const mockCreateObjectURL = vi.fn(() => 'mock-url') + const mockRevokeObjectURL = vi.fn() + const mockClick = vi.fn() - global.URL.createObjectURL = mockCreateObjectURL; - global.URL.revokeObjectURL = mockRevokeObjectURL; + global.URL.createObjectURL = mockCreateObjectURL + global.URL.revokeObjectURL = mockRevokeObjectURL // Mock document.createElement to return a mock anchor element - const originalCreateElement = document.createElement; + const originalCreateElement = document.createElement document.createElement = vi.fn((tagName) => { - if (tagName === "a") { + if (tagName === 'a') { return { - href: "", - download: "", + href: '', + download: '', click: mockClick, - } as any; + } as any } - return originalCreateElement.call(document, tagName); - }); + return originalCreateElement.call(document, tagName) + }) // Click export button - fireEvent.click(screen.getByText("Export Debug")); + fireEvent.click(screen.getByText('Export Debug')) // Should create blob and trigger download - expect(mockCreateObjectURL).toHaveBeenCalled(); - expect(mockClick).toHaveBeenCalled(); + expect(mockCreateObjectURL).toHaveBeenCalled() + expect(mockClick).toHaveBeenCalled() // Restore original methods - document.createElement = originalCreateElement; - }); + document.createElement = originalCreateElement + }) - it("tracks events and displays them in debug panel", async () => { - render(); + it('tracks events and displays them in debug panel', async () => { + render() // Switch to player mode to generate events - fireEvent.click(screen.getByText("Player")); + fireEvent.click(screen.getByText('Player')) await waitFor(() => { - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - }); + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + }) // Generate some events - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) + fireEvent.click(bead) // Should show debug events await waitFor(() => { - const debugEventsPanel = screen.queryByText("Debug Events"); + const debugEventsPanel = screen.queryByText('Debug Events') if (debugEventsPanel) { - expect(debugEventsPanel).toBeInTheDocument(); + expect(debugEventsPanel).toBeInTheDocument() // Should show event entries - const eventEntries = screen.getAllByText("VALUE_CHANGED"); - expect(eventEntries.length).toBeGreaterThan(0); + const eventEntries = screen.getAllByText('VALUE_CHANGED') + expect(eventEntries.length).toBeGreaterThan(0) } - }); - }); + }) + }) - it("displays validation status correctly in debug mode", async () => { - render(); + it('displays validation status correctly in debug mode', async () => { + render() // Debug info should be enabled by default - expect( - screen.getByText("Tutorial validation passed ✓"), - ).toBeInTheDocument(); + expect(screen.getByText('Tutorial validation passed ✓')).toBeInTheDocument() // Switch to editor and make invalid changes - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) await waitFor(() => { - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + }) // Clear title to trigger validation error - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); - fireEvent.change(titleInput, { target: { value: "" } }); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') + fireEvent.change(titleInput, { target: { value: '' } }) // Try to save to trigger validation - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Save Changes')) // Should show validation error status await waitFor(() => { - expect(screen.getByText(/validation error/)).toBeInTheDocument(); - }); - }); - }); + expect(screen.getByText(/validation error/)).toBeInTheDocument() + }) + }) + }) - describe("Error Handling and Edge Cases", () => { - it("handles errors gracefully and maintains application stability", async () => { - render(); + describe('Error Handling and Edge Cases', () => { + it('handles errors gracefully and maintains application stability', async () => { + render() // Test rapid mode switching - fireEvent.click(screen.getByText("Player")); - fireEvent.click(screen.getByText("Split")); - fireEvent.click(screen.getByText("Editor")); + fireEvent.click(screen.getByText('Player')) + fireEvent.click(screen.getByText('Split')) + fireEvent.click(screen.getByText('Editor')) await waitFor(() => { - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - }); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + }) // Test that the application remains functional - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) await waitFor(() => { - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + }) + }) - it("preserves state when switching between modes", async () => { - render(); + it('preserves state when switching between modes', async () => { + render() // Make changes in editor mode - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) await waitFor(() => { - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + }) - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); - fireEvent.change(titleInput, { target: { value: "Temporary Change" } }); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') + fireEvent.change(titleInput, { target: { value: 'Temporary Change' } }) // Switch to player mode - fireEvent.click(screen.getByText("Player")); + fireEvent.click(screen.getByText('Player')) await waitFor(() => { - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - }); + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + }) // Switch back to editor mode - fireEvent.click(screen.getByText("Editor")); + fireEvent.click(screen.getByText('Editor')) await waitFor(() => { - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - }); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + }) // Changes should be preserved in the component state // (Though they're not saved until explicitly saved) - }); + }) - it("handles window resize and responsive behavior", () => { - render(); + it('handles window resize and responsive behavior', () => { + render() // Test that the application renders without errors - expect( - screen.getByText("Tutorial Editor & Debugger"), - ).toBeInTheDocument(); + expect(screen.getByText('Tutorial Editor & Debugger')).toBeInTheDocument() // Switch to split mode which tests layout handling - fireEvent.click(screen.getByText("Split")); + fireEvent.click(screen.getByText('Split')) // Should render both panels - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - }); - }); -}); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + }) + }) +}) diff --git a/apps/web/src/app/tutorial-editor/page.tsx b/apps/web/src/app/tutorial-editor/page.tsx index caae438d..b974ef9e 100644 --- a/apps/web/src/app/tutorial-editor/page.tsx +++ b/apps/web/src/app/tutorial-editor/page.tsx @@ -1,113 +1,104 @@ -"use client"; +'use client' -import { useCallback, useState } from "react"; -import Resizable from "react-resizable-layout"; -import { TutorialEditor } from "@/components/tutorial/TutorialEditor"; -import { TutorialPlayer } from "@/components/tutorial/TutorialPlayer"; -import { DevAccessProvider, EditorProtected } from "@/hooks/useAccessControl"; +import { useCallback, useState } from 'react' +import Resizable from 'react-resizable-layout' +import { TutorialEditor } from '@/components/tutorial/TutorialEditor' +import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer' +import { DevAccessProvider, EditorProtected } from '@/hooks/useAccessControl' import type { StepValidationError, Tutorial, TutorialEvent, TutorialValidation, -} from "@/types/tutorial"; -import { - getTutorialForEditor, - validateTutorialConversion, -} from "@/utils/tutorialConverter"; -import { css } from "../../../styled-system/css"; -import { hstack, vstack } from "../../../styled-system/patterns"; +} from '@/types/tutorial' +import { getTutorialForEditor, validateTutorialConversion } from '@/utils/tutorialConverter' +import { css } from '../../../styled-system/css' +import { hstack, vstack } from '../../../styled-system/patterns' interface EditorMode { - mode: "editor" | "player" | "split"; - showDebugInfo: boolean; - autoSave: boolean; - editingTitle: boolean; + mode: 'editor' | 'player' | 'split' + showDebugInfo: boolean + autoSave: boolean + editingTitle: boolean } export default function TutorialEditorPage() { - const [tutorial, setTutorial] = useState(() => - getTutorialForEditor(), - ); + const [tutorial, setTutorial] = useState(() => getTutorialForEditor()) const [editorMode, setEditorMode] = useState({ - mode: "editor", + mode: 'editor', showDebugInfo: true, autoSave: false, editingTitle: false, - }); - const [saveStatus, setSaveStatus] = useState< - "idle" | "saving" | "saved" | "error" - >("idle"); - const [validationResult, setValidationResult] = useState( - () => { - const result = validateTutorialConversion(); - return { - isValid: result.isValid, - errors: result.errors.map((error) => ({ - stepId: "", - field: "general", - message: error, - severity: "error" as const, - })), - warnings: [], - }; - }, - ); - const [debugEvents, setDebugEvents] = useState([]); + }) + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') + const [validationResult, setValidationResult] = useState(() => { + const result = validateTutorialConversion() + return { + isValid: result.isValid, + errors: result.errors.map((error) => ({ + stepId: '', + field: 'general', + message: error, + severity: 'error' as const, + })), + warnings: [], + } + }) + const [debugEvents, setDebugEvents] = useState([]) // Save tutorial (placeholder - would connect to actual backend) const handleSave = useCallback(async (updatedTutorial: Tutorial) => { - setSaveStatus("saving"); + setSaveStatus('saving') try { // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)) // In real implementation, this would save to backend - console.log("Saving tutorial:", updatedTutorial); + console.log('Saving tutorial:', updatedTutorial) - setTutorial(updatedTutorial); - setSaveStatus("saved"); + setTutorial(updatedTutorial) + setSaveStatus('saved') // Reset status after 2 seconds - setTimeout(() => setSaveStatus("idle"), 2000); + setTimeout(() => setSaveStatus('idle'), 2000) } catch (error) { - console.error("Failed to save tutorial:", error); - setSaveStatus("error"); + console.error('Failed to save tutorial:', error) + setSaveStatus('error') } - }, []); + }, []) // Validate tutorial (enhanced validation) const handleValidate = useCallback( async (tutorialToValidate: Tutorial): Promise => { - const errors: StepValidationError[] = []; - const warnings: StepValidationError[] = []; + const errors: StepValidationError[] = [] + const warnings: StepValidationError[] = [] // Validate tutorial metadata if (!tutorialToValidate.title.trim()) { errors.push({ - stepId: "", - field: "title", - message: "Tutorial title is required", - severity: "error", - }); + stepId: '', + field: 'title', + message: 'Tutorial title is required', + severity: 'error', + }) } if (!tutorialToValidate.description.trim()) { warnings.push({ - stepId: "", - field: "description", - message: "Tutorial description is recommended", - severity: "warning", - }); + stepId: '', + field: 'description', + message: 'Tutorial description is recommended', + severity: 'warning', + }) } if (tutorialToValidate.steps.length === 0) { errors.push({ - stepId: "", - field: "steps", - message: "Tutorial must have at least one step", - severity: "error", - }); + stepId: '', + field: 'steps', + message: 'Tutorial must have at least one step', + severity: 'error', + }) } // Validate each step @@ -116,47 +107,47 @@ export default function TutorialEditorPage() { if (!step.title.trim()) { errors.push({ stepId: step.id, - field: "title", + field: 'title', message: `Step ${index + 1}: Title is required`, - severity: "error", - }); + severity: 'error', + }) } if (!step.problem.trim()) { errors.push({ stepId: step.id, - field: "problem", + field: 'problem', message: `Step ${index + 1}: Problem is required`, - severity: "error", - }); + severity: 'error', + }) } if (!step.description.trim()) { warnings.push({ stepId: step.id, - field: "description", + field: 'description', message: `Step ${index + 1}: Description is recommended`, - severity: "warning", - }); + severity: 'warning', + }) } // Value validation if (step.startValue < 0 || step.targetValue < 0) { errors.push({ stepId: step.id, - field: "values", + field: 'values', message: `Step ${index + 1}: Values cannot be negative`, - severity: "error", - }); + severity: 'error', + }) } if (step.startValue === step.targetValue) { warnings.push({ stepId: step.id, - field: "values", + field: 'values', message: `Step ${index + 1}: Start and target values are the same`, - severity: "warning", - }); + severity: 'warning', + }) } // Highlight beads validation @@ -165,40 +156,34 @@ export default function TutorialEditorPage() { if (highlight.placeValue < 0 || highlight.placeValue > 4) { errors.push({ stepId: step.id, - field: "highlightBeads", + field: 'highlightBeads', message: `Step ${index + 1}: Highlight bead ${bIndex + 1} has invalid place value`, - severity: "error", - }); + severity: 'error', + }) } - if ( - highlight.beadType === "earth" && - highlight.position !== undefined - ) { + if (highlight.beadType === 'earth' && highlight.position !== undefined) { if (highlight.position < 0 || highlight.position > 3) { errors.push({ stepId: step.id, - field: "highlightBeads", + field: 'highlightBeads', message: `Step ${index + 1}: Earth bead position must be 0-3`, - severity: "error", - }); + severity: 'error', + }) } } - }); + }) } // Multi-step validation - if (step.expectedAction === "multi-step") { - if ( - !step.multiStepInstructions || - step.multiStepInstructions.length === 0 - ) { + if (step.expectedAction === 'multi-step') { + if (!step.multiStepInstructions || step.multiStepInstructions.length === 0) { errors.push({ stepId: step.id, - field: "multiStepInstructions", + field: 'multiStepInstructions', message: `Step ${index + 1}: Multi-step actions require instructions`, - severity: "error", - }); + severity: 'error', + }) } } @@ -206,60 +191,57 @@ export default function TutorialEditorPage() { if (!step.tooltip.content.trim() || !step.tooltip.explanation.trim()) { warnings.push({ stepId: step.id, - field: "tooltip", + field: 'tooltip', message: `Step ${index + 1}: Tooltip content should be complete`, - severity: "warning", - }); + severity: 'warning', + }) } // Error messages validation removed - errorMessages property no longer exists // Bead diff tooltip provides better guidance instead - }); + }) const validation: TutorialValidation = { isValid: errors.length === 0, errors, warnings, - }; + } - setValidationResult(validation); - return validation; + setValidationResult(validation) + return validation }, - [], - ); + [] + ) // Preview step in player mode - const handlePreview = useCallback( - (tutorialToPreview: Tutorial, _stepIndex: number) => { - setTutorial(tutorialToPreview); - setEditorMode((prev) => ({ ...prev, mode: "player" })); - // The TutorialPlayer will handle jumping to the specific step - }, - [], - ); + const handlePreview = useCallback((tutorialToPreview: Tutorial, _stepIndex: number) => { + setTutorial(tutorialToPreview) + setEditorMode((prev) => ({ ...prev, mode: 'player' })) + // The TutorialPlayer will handle jumping to the specific step + }, []) // Handle debug events from player const handleDebugEvent = useCallback((event: TutorialEvent) => { - setDebugEvents((prev) => [...prev.slice(-50), event]); // Keep last 50 events - }, []); + setDebugEvents((prev) => [...prev.slice(-50), event]) // Keep last 50 events + }, []) // Mode switching - const switchMode = useCallback((mode: EditorMode["mode"]) => { - setEditorMode((prev) => ({ ...prev, mode })); - }, []); + const switchMode = useCallback((mode: EditorMode['mode']) => { + setEditorMode((prev) => ({ ...prev, mode })) + }, []) const toggleDebugInfo = useCallback(() => { - setEditorMode((prev) => ({ ...prev, showDebugInfo: !prev.showDebugInfo })); - }, []); + setEditorMode((prev) => ({ ...prev, showDebugInfo: !prev.showDebugInfo })) + }, []) const toggleAutoSave = useCallback(() => { - setEditorMode((prev) => ({ ...prev, autoSave: !prev.autoSave })); - }, []); + setEditorMode((prev) => ({ ...prev, autoSave: !prev.autoSave })) + }, []) // Tutorial metadata update const updateTutorialTitle = useCallback((title: string) => { - setTutorial((prev) => ({ ...prev, title, updatedAt: new Date() })); - }, []); + setTutorial((prev) => ({ ...prev, title, updatedAt: new Date() })) + }, []) // Export tutorial data for debugging const exportTutorialData = useCallback(() => { @@ -268,18 +250,18 @@ export default function TutorialEditorPage() { validation: validationResult, debugEvents: debugEvents.slice(-20), timestamp: new Date().toISOString(), - }; + } const blob = new Blob([JSON.stringify(data, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `tutorial-debug-${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); - }, [tutorial, validationResult, debugEvents]); + type: 'application/json', + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `tutorial-debug-${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + }, [tutorial, validationResult, debugEvents]) return ( @@ -287,23 +269,21 @@ export default function TutorialEditorPage() { fallback={
-

+

Access Restricted

-

+

Tutorial editor requires administrative privileges.

-

+

In development mode, this would check your actual permissions.

@@ -312,46 +292,43 @@ export default function TutorialEditorPage() { >
- {({ - position: headerHeight, - separatorProps: headerSeparatorProps, - }) => ( + {({ position: headerHeight, separatorProps: headerSeparatorProps }) => (
{/* Header controls - Fixed height */}

@@ -359,8 +336,8 @@ export default function TutorialEditorPage() {

@@ -368,9 +345,7 @@ export default function TutorialEditorPage() { - updateTutorialTitle(e.target.value) - } + onChange={(e) => updateTutorialTitle(e.target.value)} onBlur={() => setEditorMode((prev) => ({ ...prev, @@ -378,22 +353,22 @@ export default function TutorialEditorPage() { })) } onKeyDown={(e) => { - if (e.key === "Enter" || e.key === "Escape") { + if (e.key === 'Enter' || e.key === 'Escape') { setEditorMode((prev) => ({ ...prev, editingTitle: false, - })); + })) } }} className={css({ - fontSize: "lg", - fontWeight: "medium", + fontSize: 'lg', + fontWeight: 'medium', p: 1, - border: "1px solid", - borderColor: "blue.300", - borderRadius: "sm", - bg: "white", - minWidth: "300px", + border: '1px solid', + borderColor: 'blue.300', + borderRadius: 'sm', + bg: 'white', + minWidth: '300px', })} /> ) : ( @@ -405,20 +380,18 @@ export default function TutorialEditorPage() { })) } className={css({ - fontSize: "lg", - fontWeight: "medium", - cursor: "pointer", + fontSize: 'lg', + fontWeight: 'medium', + cursor: 'pointer', p: 1, - borderRadius: "sm", - _hover: { bg: "gray.50" }, + borderRadius: 'sm', + _hover: { bg: 'gray.50' }, })} > {tutorial.title} )} - + - {tutorial.steps.length} steps
@@ -427,48 +400,34 @@ export default function TutorialEditorPage() {
{/* Mode selector */}
- {(["editor", "player", "split"] as const).map( - (mode) => ( - - ), - )} + {(['editor', 'player', 'split'] as const).map((mode) => ( + + ))}
{/* Options */}
-
@@ -545,53 +504,46 @@ export default function TutorialEditorPage() {
- - {validationResult.errors?.length || 0} validation - error(s) + + {validationResult.errors?.length || 0} validation error(s) - {validationResult.warnings && - validationResult.warnings.length > 0 && ( - - and {validationResult.warnings.length}{" "} - warning(s) - - )} + {validationResult.warnings && validationResult.warnings.length > 0 && ( + + and {validationResult.warnings.length} warning(s) + + )}
- ) : validationResult.warnings && - validationResult.warnings.length > 0 ? ( + ) : validationResult.warnings && validationResult.warnings.length > 0 ? (
- Tutorial is valid with{" "} - {validationResult.warnings?.length || 0} warning(s) + Tutorial is valid with {validationResult.warnings?.length || 0} warning(s)
) : (
Tutorial validation passed ✓ @@ -605,13 +557,13 @@ export default function TutorialEditorPage() {
@@ -620,11 +572,11 @@ export default function TutorialEditorPage() {
- {editorMode.mode === "editor" && ( + {editorMode.mode === 'editor' && ( )} - {editorMode.mode === "player" && ( + {editorMode.mode === 'player' && ( { - console.log("Tutorial completed:", { + console.log('Tutorial completed:', { score, timeSpent, - }); + }) }} /> )} - {editorMode.mode === "split" && ( - - {({ - position: splitPosition, - separatorProps: splitSeparatorProps, - }) => ( + {editorMode.mode === 'split' && ( + + {({ position: splitPosition, separatorProps: splitSeparatorProps }) => (
@@ -688,13 +631,13 @@ export default function TutorialEditorPage() {
@@ -702,7 +645,7 @@ export default function TutorialEditorPage() {
0 && (
-

+

Debug Events ({debugEvents.length})

-
+
{debugEvents .slice(-10) .reverse() .map((event, index) => ( -
- +
+ {event.timestamp.toLocaleTimeString()} - {" "} - - {event.type} - {" "} - {event.type === "VALUE_CHANGED" && ( + {' '} + {event.type}{' '} + {event.type === 'VALUE_CHANGED' && ( {event.oldValue} → {event.newValue} )} - {event.type === "STEP_COMPLETED" && ( + {event.type === 'STEP_COMPLETED' && ( - {event.success ? "SUCCESS" : "FAILED"} + {event.success ? 'SUCCESS' : 'FAILED'} )} - {event.type === "ERROR_OCCURRED" && ( - - {event.error} - + {event.type === 'ERROR_OCCURRED' && ( + {event.error} )}
))} @@ -784,5 +720,5 @@ export default function TutorialEditorPage() {
- ); + ) } diff --git a/apps/web/src/arcade-games/card-sorting/Provider.tsx b/apps/web/src/arcade-games/card-sorting/Provider.tsx index 4c83f620..75c1be8b 100644 --- a/apps/web/src/arcade-games/card-sorting/Provider.tsx +++ b/apps/web/src/arcade-games/card-sorting/Provider.tsx @@ -1,78 +1,66 @@ -"use client"; +'use client' -import { - type ReactNode, - useCallback, - useMemo, - createContext, - useContext, - useState, -} from "react"; -import { useArcadeSession } from "@/hooks/useArcadeSession"; -import { useRoomData, useUpdateGameConfig } from "@/hooks/useRoomData"; -import { useViewerId } from "@/hooks/useViewerId"; -import { buildPlayerMetadata as buildPlayerMetadataUtil } from "@/lib/arcade/player-ownership.client"; -import type { GameMove } from "@/lib/arcade/validation"; -import { useGameMode } from "@/contexts/GameModeContext"; -import { generateRandomCards, shuffleCards } from "./utils/cardGeneration"; +import { type ReactNode, useCallback, useMemo, createContext, useContext, useState } from 'react' +import { useArcadeSession } from '@/hooks/useArcadeSession' +import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData' +import { useViewerId } from '@/hooks/useViewerId' +import { buildPlayerMetadata as buildPlayerMetadataUtil } from '@/lib/arcade/player-ownership.client' +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"; +} from './types' // Context value interface interface CardSortingContextValue { - state: CardSortingState; + state: CardSortingState // Actions - startGame: () => void; - placeCard: (cardId: string, position: number) => void; - insertCard: (cardId: string, insertPosition: number) => void; - removeCard: (position: number) => void; - checkSolution: (finalSequence?: SortingCard[]) => void; - goToSetup: () => void; - resumeGame: () => void; - setConfig: ( - field: "cardCount" | "timeLimit" | "gameMode", - value: unknown, - ) => void; - updateCardPositions: (positions: CardPosition[]) => void; - exitSession: () => void; + startGame: () => void + placeCard: (cardId: string, position: number) => void + insertCard: (cardId: string, insertPosition: number) => void + removeCard: (position: number) => void + checkSolution: (finalSequence?: SortingCard[]) => void + goToSetup: () => void + resumeGame: () => void + setConfig: (field: 'cardCount' | 'timeLimit' | 'gameMode', value: unknown) => void + updateCardPositions: (positions: CardPosition[]) => void + exitSession: () => void // Computed - canCheckSolution: boolean; - placedCount: number; - elapsedTime: number; - hasConfigChanged: boolean; - canResumeGame: boolean; + canCheckSolution: boolean + placedCount: number + elapsedTime: number + hasConfigChanged: boolean + canResumeGame: boolean // UI state - selectedCardId: string | null; - selectCard: (cardId: string | null) => void; + selectedCardId: string | null + selectCard: (cardId: string | null) => void // Spectator mode - localPlayerId: string | undefined; - isSpectating: boolean; + localPlayerId: string | undefined + isSpectating: boolean // Multiplayer - players: Map; // All room players + players: Map // All room players } // Create context -const CardSortingContext = createContext(null); +const CardSortingContext = createContext(null) // Initial state matching validator's getInitialState -const createInitialState = ( - config: Partial, -): CardSortingState => ({ +const createInitialState = (config: Partial): CardSortingState => ({ cardCount: config.cardCount ?? 8, timeLimit: config.timeLimit ?? null, - gameMode: config.gameMode ?? "solo", - gamePhase: "setup", - playerId: "", + gameMode: config.gameMode ?? 'solo', + gamePhase: 'setup', + playerId: '', playerMetadata: { - id: "", - name: "", - emoji: "", - userId: "", + id: '', + name: '', + emoji: '', + userId: '', }, activePlayers: [], allPlayerMetadata: new Map(), @@ -86,33 +74,26 @@ const createInitialState = ( cursorPositions: new Map(), selectedCardId: null, scoreBreakdown: null, -}); +}) /** * Optimistic move application (client-side prediction) */ -function applyMoveOptimistically( - state: CardSortingState, - move: GameMove, -): CardSortingState { - const typedMove = move as CardSortingMove; +function applyMoveOptimistically(state: CardSortingState, move: GameMove): CardSortingState { + const typedMove = move as CardSortingMove switch (typedMove.type) { - case "START_GAME": { - const selectedCards = typedMove.data.selectedCards as SortingCard[]; - const correctOrder = [...selectedCards].sort( - (a, b) => a.number - b.number, - ); + case 'START_GAME': { + const selectedCards = typedMove.data.selectedCards as SortingCard[] + const correctOrder = [...selectedCards].sort((a, b) => a.number - b.number) return { ...state, - gamePhase: "playing", + gamePhase: 'playing', playerId: typedMove.playerId, playerMetadata: typedMove.data.playerMetadata, activePlayers: [typedMove.playerId], - allPlayerMetadata: new Map([ - [typedMove.playerId, typedMove.data.playerMetadata], - ]), + allPlayerMetadata: new Map([[typedMove.playerId, typedMove.data.playerMetadata]]), gameStartTime: Date.now(), selectedCards, correctOrder, @@ -127,113 +108,113 @@ function applyMoveOptimistically( }, pausedGamePhase: undefined, pausedGameState: undefined, - }; + } } - case "PLACE_CARD": { - const { cardId, position } = typedMove.data; - const card = state.availableCards.find((c) => c.id === cardId); - if (!card) return state; + case 'PLACE_CARD': { + const { cardId, position } = typedMove.data + const card = state.availableCards.find((c) => c.id === cardId) + if (!card) return state // Simple replacement (can leave gaps) - const newPlaced = [...state.placedCards]; - const replacedCard = newPlaced[position]; - newPlaced[position] = card; + const newPlaced = [...state.placedCards] + const replacedCard = newPlaced[position] + newPlaced[position] = card // Remove card from available - let newAvailable = state.availableCards.filter((c) => c.id !== cardId); + let newAvailable = state.availableCards.filter((c) => c.id !== cardId) // If slot was occupied, add replaced card back to available if (replacedCard) { - newAvailable = [...newAvailable, replacedCard]; + newAvailable = [...newAvailable, replacedCard] } return { ...state, availableCards: newAvailable, placedCards: newPlaced, - }; + } } - case "INSERT_CARD": { - const { cardId, insertPosition } = typedMove.data; - const card = state.availableCards.find((c) => c.id === cardId); + case 'INSERT_CARD': { + const { cardId, insertPosition } = typedMove.data + const card = state.availableCards.find((c) => c.id === cardId) if (!card) { - return state; + return state } // Insert with shift and compact (no gaps) - const newPlaced = new Array(state.cardCount).fill(null); + const newPlaced = new Array(state.cardCount).fill(null) // Copy existing cards, shifting those at/after insert position for (let i = 0; i < state.placedCards.length; i++) { if (state.placedCards[i] !== null) { if (i < insertPosition) { - newPlaced[i] = state.placedCards[i]; + newPlaced[i] = state.placedCards[i] } else { // Cards at or after insert position shift right by 1 // Card will be collected during compaction if it falls off the end - newPlaced[i + 1] = state.placedCards[i]; + newPlaced[i + 1] = state.placedCards[i] } } } // Place new card at insert position - newPlaced[insertPosition] = card; + newPlaced[insertPosition] = card // Compact to remove gaps - const compacted: SortingCard[] = []; + const compacted: SortingCard[] = [] for (const c of newPlaced) { if (c !== null) { - compacted.push(c); + compacted.push(c) } } // Fill final array (no gaps) - const finalPlaced = new Array(state.cardCount).fill(null); + const finalPlaced = new Array(state.cardCount).fill(null) for (let i = 0; i < Math.min(compacted.length, state.cardCount); i++) { - finalPlaced[i] = compacted[i]; + finalPlaced[i] = compacted[i] } // Remove from available - let newAvailable = state.availableCards.filter((c) => c.id !== cardId); + let newAvailable = state.availableCards.filter((c) => c.id !== cardId) // Any excess cards go back to available if (compacted.length > state.cardCount) { - const excess = compacted.slice(state.cardCount); - newAvailable = [...newAvailable, ...excess]; + const excess = compacted.slice(state.cardCount) + newAvailable = [...newAvailable, ...excess] } return { ...state, availableCards: newAvailable, placedCards: finalPlaced, - }; + } } - case "REMOVE_CARD": { - const { position } = typedMove.data; - const card = state.placedCards[position]; - if (!card) return state; + case 'REMOVE_CARD': { + const { position } = typedMove.data + const card = state.placedCards[position] + if (!card) return state - const newPlaced = [...state.placedCards]; - newPlaced[position] = null; - const newAvailable = [...state.availableCards, card]; + const newPlaced = [...state.placedCards] + newPlaced[position] = null + const newAvailable = [...state.availableCards, card] return { ...state, availableCards: newAvailable, placedCards: newPlaced, - }; + } } - case "CHECK_SOLUTION": { + case 'CHECK_SOLUTION': { // Don't apply optimistic update - wait for server to calculate and return score - return state; + return state } - case "GO_TO_SETUP": { - const isPausingGame = state.gamePhase === "playing"; + case 'GO_TO_SETUP': { + const isPausingGame = state.gamePhase === 'playing' return { ...createInitialState({ @@ -243,7 +224,7 @@ function applyMoveOptimistically( }), // Save paused state if coming from active game originalConfig: state.originalConfig, - pausedGamePhase: isPausingGame ? "playing" : undefined, + pausedGamePhase: isPausingGame ? 'playing' : undefined, pausedGameState: isPausingGame ? { selectedCards: state.selectedCards, @@ -253,20 +234,18 @@ function applyMoveOptimistically( gameStartTime: state.gameStartTime || Date.now(), } : undefined, - }; + } } - case "SET_CONFIG": { - const { field, value } = typedMove.data; - const clearPausedGame = !!state.pausedGamePhase; + case 'SET_CONFIG': { + const { field, value } = typedMove.data + const clearPausedGame = !!state.pausedGamePhase return { ...state, [field]: value, // Update placedCards array size if cardCount changes - ...(field === "cardCount" - ? { placedCards: new Array(value as number).fill(null) } - : {}), + ...(field === 'cardCount' ? { placedCards: new Array(value as number).fill(null) } : {}), // Clear paused game if config changed ...(clearPausedGame ? { @@ -275,17 +254,17 @@ function applyMoveOptimistically( originalConfig: undefined, } : {}), - }; + } } - case "RESUME_GAME": { + case 'RESUME_GAME': { if (!state.pausedGamePhase || !state.pausedGameState) { - return state; + return state } const correctOrder = [...state.pausedGameState.selectedCards].sort( - (a, b) => a.number - b.number, - ); + (a, b) => a.number - b.number + ) return { ...state, @@ -298,18 +277,18 @@ function applyMoveOptimistically( gameStartTime: state.pausedGameState.gameStartTime, pausedGamePhase: undefined, pausedGameState: undefined, - }; + } } - case "UPDATE_CARD_POSITIONS": { + case 'UPDATE_CARD_POSITIONS': { return { ...state, cardPositions: typedMove.data.positions, - }; + } } default: - return state; + return state } } @@ -317,126 +296,120 @@ function applyMoveOptimistically( * Card Sorting Provider - Single Player Pattern Recognition Game */ export function CardSortingProvider({ children }: { children: ReactNode }) { - const { data: viewerId } = useViewerId(); - const { roomData } = useRoomData(); - const { activePlayers, players } = useGameMode(); - const { mutate: updateGameConfig } = useUpdateGameConfig(); + const { data: viewerId } = useViewerId() + const { roomData } = useRoomData() + const { activePlayers, players } = useGameMode() + const { mutate: updateGameConfig } = useUpdateGameConfig() // Local UI state (not synced to server) - const [selectedCardId, setSelectedCardId] = useState(null); + const [selectedCardId, setSelectedCardId] = useState(null) // Get local player (single player game) const localPlayerId = useMemo(() => { return Array.from(activePlayers).find((id) => { - const player = players.get(id); - return player?.isLocal !== false; - }); - }, [activePlayers, players]); + const player = players.get(id) + return player?.isLocal !== false + }) + }, [activePlayers, players]) // Merge saved config from room data const mergedInitialState = useMemo(() => { - const gameConfig = roomData?.gameConfig as Record | null; - const savedConfig = gameConfig?.["card-sorting"] as - | Partial - | undefined; + const gameConfig = roomData?.gameConfig as Record | null + const savedConfig = gameConfig?.['card-sorting'] as Partial | undefined - return createInitialState(savedConfig || {}); - }, [roomData?.gameConfig]); + return createInitialState(savedConfig || {}) + }, [roomData?.gameConfig]) // Arcade session integration const { state, sendMove, exitSession } = useArcadeSession({ - userId: viewerId || "", + userId: viewerId || '', roomId: roomData?.id, initialState: mergedInitialState, applyMove: applyMoveOptimistically, - }); + }) // Build player metadata for the single local player const buildPlayerMetadata = useCallback(() => { if (!localPlayerId) { return { - id: "", - name: "", - emoji: "", - userId: "", - }; + id: '', + name: '', + emoji: '', + userId: '', + } } - const playerOwnership: Record = {}; + const playerOwnership: Record = {} if (viewerId) { - playerOwnership[localPlayerId] = viewerId; + playerOwnership[localPlayerId] = viewerId } const metadata = buildPlayerMetadataUtil( [localPlayerId], playerOwnership, players, - viewerId ?? undefined, - ); + viewerId ?? undefined + ) - return ( - metadata[localPlayerId] || { id: "", name: "", emoji: "", userId: "" } - ); - }, [localPlayerId, players, viewerId]); + return metadata[localPlayerId] || { id: '', name: '', emoji: '', userId: '' } + }, [localPlayerId, players, viewerId]) // Computed values const canCheckSolution = useMemo( () => state.placedCards.every((c) => c !== null), - [state.placedCards], - ); + [state.placedCards] + ) const placedCount = useMemo( () => state.placedCards.filter((c) => c !== null).length, - [state.placedCards], - ); + [state.placedCards] + ) const elapsedTime = useMemo(() => { - if (!state.gameStartTime) return 0; - const now = state.gameEndTime || Date.now(); - return Math.floor((now - state.gameStartTime) / 1000); - }, [state.gameStartTime, state.gameEndTime]); + if (!state.gameStartTime) return 0 + const now = state.gameEndTime || Date.now() + return Math.floor((now - state.gameStartTime) / 1000) + }, [state.gameStartTime, state.gameEndTime]) const hasConfigChanged = useMemo(() => { - if (!state.originalConfig) return false; + if (!state.originalConfig) return false return ( state.cardCount !== state.originalConfig.cardCount || state.timeLimit !== state.originalConfig.timeLimit || state.gameMode !== state.originalConfig.gameMode - ); - }, [state.cardCount, state.timeLimit, state.gameMode, state.originalConfig]); + ) + }, [state.cardCount, state.timeLimit, state.gameMode, state.originalConfig]) const canResumeGame = useMemo(() => { - return ( - !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged - ); - }, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged]); + return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged + }, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged]) // Action creators const startGame = useCallback(() => { if (!localPlayerId) { - return; + return } // Prevent rapid double-sends within 500ms to avoid duplicate game starts - const now = Date.now(); - const justStarted = state.gameStartTime && now - state.gameStartTime < 500; + const now = Date.now() + const justStarted = state.gameStartTime && now - state.gameStartTime < 500 if (justStarted) { - return; + return } - const playerMetadata = buildPlayerMetadata(); - const selectedCards = shuffleCards(generateRandomCards(state.cardCount)); + const playerMetadata = buildPlayerMetadata() + const selectedCards = shuffleCards(generateRandomCards(state.cardCount)) sendMove({ - type: "START_GAME", + type: 'START_GAME', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { playerMetadata, selectedCards, }, - }); + }) }, [ localPlayerId, state.cardCount, @@ -445,152 +418,149 @@ export function CardSortingProvider({ children }: { children: ReactNode }) { buildPlayerMetadata, sendMove, viewerId, - ]); + ]) const placeCard = useCallback( (cardId: string, position: number) => { - if (!localPlayerId) return; + if (!localPlayerId) return sendMove({ - type: "PLACE_CARD", + type: 'PLACE_CARD', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { cardId, position }, - }); + }) // Clear selection - setSelectedCardId(null); + setSelectedCardId(null) }, - [localPlayerId, sendMove, viewerId], - ); + [localPlayerId, sendMove, viewerId] + ) const insertCard = useCallback( (cardId: string, insertPosition: number) => { - if (!localPlayerId) return; + if (!localPlayerId) return sendMove({ - type: "INSERT_CARD", + type: 'INSERT_CARD', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { cardId, insertPosition }, - }); + }) // Clear selection - setSelectedCardId(null); + setSelectedCardId(null) }, - [localPlayerId, sendMove, viewerId], - ); + [localPlayerId, sendMove, viewerId] + ) const removeCard = useCallback( (position: number) => { - if (!localPlayerId) return; + if (!localPlayerId) return sendMove({ - type: "REMOVE_CARD", + type: 'REMOVE_CARD', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { position }, - }); + }) }, - [localPlayerId, sendMove, viewerId], - ); + [localPlayerId, sendMove, viewerId] + ) const checkSolution = useCallback( (finalSequence?: SortingCard[]) => { - if (!localPlayerId) return; + if (!localPlayerId) return // If finalSequence provided, use it. Otherwise check current placedCards if (!finalSequence && !canCheckSolution) { - return; + return } sendMove({ - type: "CHECK_SOLUTION", + type: 'CHECK_SOLUTION', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { finalSequence, }, - }); + }) }, - [localPlayerId, canCheckSolution, sendMove, viewerId], - ); + [localPlayerId, canCheckSolution, sendMove, viewerId] + ) const goToSetup = useCallback(() => { - if (!localPlayerId) return; + if (!localPlayerId) return sendMove({ - type: "GO_TO_SETUP", + type: 'GO_TO_SETUP', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [localPlayerId, sendMove, viewerId]); + }) + }, [localPlayerId, sendMove, viewerId]) const resumeGame = useCallback(() => { if (!localPlayerId || !canResumeGame) { - console.warn( - "[CardSortingProvider] Cannot resume - no paused game or config changed", - ); - return; + console.warn('[CardSortingProvider] Cannot resume - no paused game or config changed') + return } sendMove({ - type: "RESUME_GAME", + type: 'RESUME_GAME', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [localPlayerId, canResumeGame, sendMove, viewerId]); + }) + }, [localPlayerId, canResumeGame, sendMove, viewerId]) const setConfig = useCallback( - (field: "cardCount" | "timeLimit" | "gameMode", value: unknown) => { - if (!localPlayerId) return; + (field: 'cardCount' | 'timeLimit' | 'gameMode', value: unknown) => { + if (!localPlayerId) return sendMove({ - type: "SET_CONFIG", + type: 'SET_CONFIG', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { field, value }, - }); + }) // Persist to database if (roomData?.id) { - const currentGameConfig = - (roomData.gameConfig as Record) || {}; + const currentGameConfig = (roomData.gameConfig as Record) || {} const currentCardSortingConfig = - (currentGameConfig["card-sorting"] as Record) || {}; + (currentGameConfig['card-sorting'] as Record) || {} const updatedConfig = { ...currentGameConfig, - "card-sorting": { + 'card-sorting': { ...currentCardSortingConfig, [field]: value, }, - }; + } updateGameConfig({ roomId: roomData.id, gameConfig: updatedConfig, - }); + }) } }, - [localPlayerId, sendMove, viewerId, roomData, updateGameConfig], - ); + [localPlayerId, sendMove, viewerId, roomData, updateGameConfig] + ) const updateCardPositions = useCallback( (positions: CardPosition[]) => { - if (!localPlayerId) return; + if (!localPlayerId) return sendMove({ - type: "UPDATE_CARD_POSITIONS", + type: 'UPDATE_CARD_POSITIONS', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { positions }, - }); + }) }, - [localPlayerId, sendMove, viewerId], - ); + [localPlayerId, sendMove, viewerId] + ) const contextValue: CardSortingContextValue = { state, @@ -619,22 +589,18 @@ export function CardSortingProvider({ children }: { children: ReactNode }) { isSpectating: !localPlayerId, // Multiplayer players, - }; + } - return ( - - {children} - - ); + return {children} } /** * Hook to access Card Sorting context */ export function useCardSorting() { - const context = useContext(CardSortingContext); + const context = useContext(CardSortingContext) if (!context) { - throw new Error("useCardSorting must be used within CardSortingProvider"); + throw new Error('useCardSorting must be used within CardSortingProvider') } - return context; + return context } diff --git a/apps/web/src/arcade-games/card-sorting/Validator.ts b/apps/web/src/arcade-games/card-sorting/Validator.ts index 60062e7f..f224af50 100644 --- a/apps/web/src/arcade-games/card-sorting/Validator.ts +++ b/apps/web/src/arcade-games/card-sorting/Validator.ts @@ -2,96 +2,77 @@ import type { GameValidator, ValidationContext, ValidationResult, -} from "@/lib/arcade/validation/types"; -import type { - CardSortingConfig, - CardSortingMove, - CardSortingState, - CardPosition, -} from "./types"; -import { calculateScore } from "./utils/scoringAlgorithm"; -import { - placeCardAtPosition, - insertCardAtPosition, - removeCardAtPosition, -} from "./utils/validation"; +} from '@/lib/arcade/validation/types' +import type { CardSortingConfig, CardSortingMove, CardSortingState, CardPosition } from './types' +import { calculateScore } from './utils/scoringAlgorithm' +import { placeCardAtPosition, insertCardAtPosition, removeCardAtPosition } from './utils/validation' -export class CardSortingValidator - implements GameValidator -{ +export class CardSortingValidator implements GameValidator { validateMove( state: CardSortingState, move: CardSortingMove, - context: ValidationContext, + context: ValidationContext ): ValidationResult { switch (move.type) { - case "START_GAME": - return this.validateStartGame(state, move.data, move.playerId); - case "PLACE_CARD": - return this.validatePlaceCard( - state, - move.data.cardId, - move.data.position, - ); - case "INSERT_CARD": - return this.validateInsertCard( - state, - move.data.cardId, - move.data.insertPosition, - ); - case "REMOVE_CARD": - return this.validateRemoveCard(state, move.data.position); - case "CHECK_SOLUTION": - return this.validateCheckSolution(state, move.data.finalSequence); - 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); + case 'START_GAME': + return this.validateStartGame(state, move.data, move.playerId) + case 'PLACE_CARD': + return this.validatePlaceCard(state, move.data.cardId, move.data.position) + case 'INSERT_CARD': + return this.validateInsertCard(state, move.data.cardId, move.data.insertPosition) + case 'REMOVE_CARD': + return this.validateRemoveCard(state, move.data.position) + case 'CHECK_SOLUTION': + return this.validateCheckSolution(state, move.data.finalSequence) + 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, error: `Unknown move type: ${(move as CardSortingMove).type}`, - }; + } } } private validateStartGame( state: CardSortingState, data: { playerMetadata: unknown; selectedCards: unknown }, - playerId: string, + playerId: string ): ValidationResult { // Allow starting a new game from any phase (for "Play Again" button) // Validate selectedCards if (!Array.isArray(data.selectedCards)) { - return { valid: false, error: "selectedCards must be an array" }; + return { valid: false, error: 'selectedCards must be an array' } } if (data.selectedCards.length !== state.cardCount) { return { valid: false, error: `Must provide exactly ${state.cardCount} cards`, - }; + } } - const selectedCards = data.selectedCards as unknown[]; + const selectedCards = data.selectedCards as unknown[] // Create correct order (sorted) const correctOrder = [...selectedCards].sort((a: unknown, b: unknown) => { - const cardA = a as { number: number }; - const cardB = b as { number: number }; - return cardA.number - cardB.number; - }); + const cardA = a as { number: number } + const cardB = b as { number: number } + return cardA.number - cardB.number + }) return { valid: true, newState: { ...state, - gamePhase: "playing", + gamePhase: 'playing', playerId, playerMetadata: data.playerMetadata, gameStartTime: Date.now(), @@ -103,26 +84,26 @@ export class CardSortingValidator cardPositions: [], // Will be set by first position update scoreBreakdown: null, }, - }; + } } private validatePlaceCard( state: CardSortingState, cardId: string, - position: number, + position: number ): ValidationResult { // Must be in playing phase - if (state.gamePhase !== "playing") { + if (state.gamePhase !== 'playing') { return { valid: false, - error: "Can only place cards during playing phase", - }; + error: 'Can only place cards during playing phase', + } } // Card must exist in availableCards - const card = state.availableCards.find((c) => c.id === cardId); + const card = state.availableCards.find((c) => c.id === cardId) if (!card) { - return { valid: false, error: "Card not found in available cards" }; + return { valid: false, error: 'Card not found in available cards' } } // Position must be valid (0 to cardCount-1) @@ -130,22 +111,22 @@ export class CardSortingValidator return { valid: false, error: `Invalid position: must be between 0 and ${state.cardCount - 1}`, - }; + } } // Place the card using utility function (simple replacement) const { placedCards: newPlaced, replacedCard } = placeCardAtPosition( state.placedCards, card, - position, - ); + position + ) // Remove card from available - let newAvailable = state.availableCards.filter((c) => c.id !== cardId); + let newAvailable = state.availableCards.filter((c) => c.id !== cardId) // If slot was occupied, add replaced card back to available if (replacedCard) { - newAvailable = [...newAvailable, replacedCard]; + newAvailable = [...newAvailable, replacedCard] } return { @@ -155,26 +136,26 @@ export class CardSortingValidator availableCards: newAvailable, placedCards: newPlaced, }, - }; + } } private validateInsertCard( state: CardSortingState, cardId: string, - insertPosition: number, + insertPosition: number ): ValidationResult { // Must be in playing phase - if (state.gamePhase !== "playing") { + if (state.gamePhase !== 'playing') { return { valid: false, - error: "Can only insert cards during playing phase", - }; + error: 'Can only insert cards during playing phase', + } } // Card must exist in availableCards - const card = state.availableCards.find((c) => c.id === cardId); + const card = state.availableCards.find((c) => c.id === cardId) if (!card) { - return { valid: false, error: "Card not found in available cards" }; + return { valid: false, error: 'Card not found in available cards' } } // Position must be valid (0 to cardCount, inclusive - can insert after last position) @@ -182,7 +163,7 @@ export class CardSortingValidator return { valid: false, error: `Invalid insert position: must be between 0 and ${state.cardCount}`, - }; + } } // Insert the card using utility function (with shift and compact) @@ -190,15 +171,15 @@ export class CardSortingValidator state.placedCards, card, insertPosition, - state.cardCount, - ); + state.cardCount + ) // Remove card from available - let newAvailable = state.availableCards.filter((c) => c.id !== cardId); + let newAvailable = state.availableCards.filter((c) => c.id !== cardId) // Add any excess cards back to available (shouldn't normally happen) if (excessCards.length > 0) { - newAvailable = [...newAvailable, ...excessCards]; + newAvailable = [...newAvailable, ...excessCards] } return { @@ -208,19 +189,16 @@ export class CardSortingValidator availableCards: newAvailable, placedCards: newPlaced, }, - }; + } } - private validateRemoveCard( - state: CardSortingState, - position: number, - ): ValidationResult { + private validateRemoveCard(state: CardSortingState, position: number): ValidationResult { // Must be in playing phase - if (state.gamePhase !== "playing") { + if (state.gamePhase !== 'playing') { return { valid: false, - error: "Can only remove cards during playing phase", - }; + error: 'Can only remove cards during playing phase', + } } // Position must be valid @@ -228,26 +206,26 @@ export class CardSortingValidator return { valid: false, error: `Invalid position: must be between 0 and ${state.cardCount - 1}`, - }; + } } // Card must exist at position if (state.placedCards[position] === null) { - return { valid: false, error: "No card at this position" }; + return { valid: false, error: 'No card at this position' } } // Remove the card using utility function const { placedCards: newPlaced, removedCard } = removeCardAtPosition( state.placedCards, - position, - ); + position + ) if (!removedCard) { - return { valid: false, error: "Failed to remove card" }; + return { valid: false, error: 'Failed to remove card' } } // Add back to available - const newAvailable = [...state.availableCards, removedCard]; + const newAvailable = [...state.availableCards, removedCard] return { valid: true, @@ -256,67 +234,62 @@ export class CardSortingValidator availableCards: newAvailable, placedCards: newPlaced, }, - }; + } } private validateCheckSolution( state: CardSortingState, - finalSequence?: typeof state.selectedCards, + finalSequence?: typeof state.selectedCards ): ValidationResult { // Must be in playing phase - if (state.gamePhase !== "playing") { + if (state.gamePhase !== 'playing') { return { valid: false, - error: "Can only check solution during playing phase", - }; + error: 'Can only check solution during playing phase', + } } // Use finalSequence if provided, otherwise use placedCards const userCards = finalSequence || - state.placedCards.filter( - (c): c is (typeof state.selectedCards)[0] => c !== null, - ); + state.placedCards.filter((c): c is (typeof state.selectedCards)[0] => c !== null) // Must have all cards if (userCards.length !== state.cardCount) { - return { valid: false, error: "Must place all cards before checking" }; + return { valid: false, error: 'Must place all cards before checking' } } // Calculate score using scoring algorithms - const userSequence = userCards.map((c) => c.number); - const correctSequence = state.correctOrder.map((c) => c.number); + const userSequence = userCards.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() + ) // If finalSequence was provided, update placedCards with it const newPlacedCards = finalSequence - ? [ - ...userCards, - ...new Array(state.cardCount - userCards.length).fill(null), - ] - : state.placedCards; + ? [...userCards, ...new Array(state.cardCount - userCards.length).fill(null)] + : state.placedCards return { valid: true, newState: { ...state, - gamePhase: "results", + gamePhase: 'results', gameEndTime: Date.now(), scoreBreakdown, placedCards: newPlacedCards, availableCards: [], // All cards are now placed }, - }; + } } private validateGoToSetup(state: CardSortingState): ValidationResult { // Save current game state for resume (if in playing phase) - if (state.gamePhase === "playing") { + if (state.gamePhase === 'playing') { return { valid: true, newState: { @@ -330,7 +303,7 @@ export class CardSortingValidator timeLimit: state.timeLimit, gameMode: state.gameMode, }, - pausedGamePhase: "playing", + pausedGamePhase: 'playing', pausedGameState: { selectedCards: state.selectedCards, availableCards: state.availableCards, @@ -339,7 +312,7 @@ export class CardSortingValidator gameStartTime: state.gameStartTime || Date.now(), }, }, - }; + } } // Just go to setup @@ -350,24 +323,24 @@ export class CardSortingValidator timeLimit: state.timeLimit, gameMode: state.gameMode, }), - }; + } } private validateSetConfig( state: CardSortingState, field: string, - value: unknown, + value: unknown ): ValidationResult { // Must be in setup phase - if (state.gamePhase !== "setup") { - return { valid: false, error: "Can only change config in setup phase" }; + if (state.gamePhase !== 'setup') { + return { valid: false, error: 'Can only change config in setup phase' } } // Validate field and value switch (field) { - case "cardCount": + case 'cardCount': if (![5, 8, 12, 15].includes(value as number)) { - return { valid: false, error: "cardCount must be 5, 8, 12, or 15" }; + return { valid: false, error: 'cardCount must be 5, 8, 12, or 15' } } return { valid: true, @@ -379,14 +352,14 @@ export class CardSortingValidator pausedGamePhase: undefined, pausedGameState: undefined, }, - }; + } - case "timeLimit": - if (value !== null && (typeof value !== "number" || value < 30)) { + case 'timeLimit': + if (value !== null && (typeof value !== 'number' || value < 30)) { return { valid: false, - error: "timeLimit must be null or a number >= 30", - }; + error: 'timeLimit must be null or a number >= 30', + } } return { valid: true, @@ -397,49 +370,40 @@ export class CardSortingValidator pausedGamePhase: undefined, pausedGameState: undefined, }, - }; + } - case "gameMode": - if ( - !["solo", "collaborative", "competitive", "relay"].includes( - value as string, - ) - ) { + case 'gameMode': + if (!['solo', 'collaborative', 'competitive', 'relay'].includes(value as string)) { return { valid: false, - error: - "gameMode must be solo, collaborative, competitive, or relay", - }; + error: 'gameMode must be solo, collaborative, competitive, or relay', + } } return { valid: true, newState: { ...state, - gameMode: value as - | "solo" - | "collaborative" - | "competitive" - | "relay", + 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}` }; + return { valid: false, error: `Unknown config field: ${field}` } } } private validateResumeGame(state: CardSortingState): ValidationResult { // Must be in setup phase - if (state.gamePhase !== "setup") { - return { valid: false, error: "Can only resume from setup phase" }; + if (state.gamePhase !== 'setup') { + return { valid: false, error: 'Can only resume from setup phase' } } // Must have paused game state if (!state.pausedGamePhase || !state.pausedGameState) { - return { valid: false, error: "No paused game to resume" }; + return { valid: false, error: 'No paused game to resume' } } // Restore paused state @@ -449,9 +413,7 @@ export class CardSortingValidator ...state, gamePhase: state.pausedGamePhase, selectedCards: state.pausedGameState.selectedCards, - correctOrder: [...state.pausedGameState.selectedCards].sort( - (a, b) => a.number - b.number, - ), + correctOrder: [...state.pausedGameState.selectedCards].sort((a, b) => a.number - b.number), availableCards: state.pausedGameState.availableCards, placedCards: state.pausedGameState.placedCards, cardPositions: state.pausedGameState.cardPositions, @@ -459,42 +421,42 @@ export class CardSortingValidator pausedGamePhase: undefined, pausedGameState: undefined, }, - }; + } } private validateUpdateCardPositions( state: CardSortingState, - positions: CardPosition[], + positions: CardPosition[] ): ValidationResult { // Must be in playing phase - if (state.gamePhase !== "playing") { + if (state.gamePhase !== 'playing') { return { valid: false, - error: "Can only update positions during playing phase", - }; + error: 'Can only update positions during playing phase', + } } // Validate positions array if (!Array.isArray(positions)) { - return { valid: false, error: "positions must be an array" }; + 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.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.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.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.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" }; + if (typeof pos.cardId !== 'string') { + return { valid: false, error: 'cardId must be a string' } } } @@ -504,11 +466,11 @@ export class CardSortingValidator ...state, cardPositions: positions, }, - }; + } } isGameComplete(state: CardSortingState): boolean { - return state.gamePhase === "results"; + return state.gamePhase === 'results' } getInitialState(config: CardSortingConfig): CardSortingState { @@ -516,13 +478,13 @@ export class CardSortingValidator cardCount: config.cardCount, timeLimit: config.timeLimit, gameMode: config.gameMode, - gamePhase: "setup", - playerId: "", + gamePhase: 'setup', + playerId: '', playerMetadata: { - id: "", - name: "", - emoji: "", - userId: "", + id: '', + name: '', + emoji: '', + userId: '', }, activePlayers: [], allPlayerMetadata: new Map(), @@ -536,8 +498,8 @@ export class CardSortingValidator cursorPositions: new Map(), selectedCardId: null, scoreBreakdown: null, - }; + } } } -export const cardSortingValidator = new CardSortingValidator(); +export const cardSortingValidator = new CardSortingValidator() diff --git a/apps/web/src/arcade-games/card-sorting/components/GameComponent.tsx b/apps/web/src/arcade-games/card-sorting/components/GameComponent.tsx index eb0fc7cd..46851757 100644 --- a/apps/web/src/arcade-games/card-sorting/components/GameComponent.tsx +++ b/apps/web/src/arcade-games/card-sorting/components/GameComponent.tsx @@ -1,48 +1,47 @@ -"use client"; +'use client' -import { useRouter } from "next/navigation"; -import { useEffect, useRef } from "react"; -import { PageWithNav } from "@/components/PageWithNav"; -import { css } from "../../../../styled-system/css"; -import { StandardGameLayout } from "@/components/StandardGameLayout"; -import { useFullscreen } from "@/contexts/FullscreenContext"; -import { useCardSorting } from "../Provider"; -import { SetupPhase } from "./SetupPhase"; -import { PlayingPhaseDrag } from "./PlayingPhaseDrag"; -import { ResultsPhase } from "./ResultsPhase"; +import { useRouter } from 'next/navigation' +import { useEffect, useRef } from 'react' +import { PageWithNav } from '@/components/PageWithNav' +import { css } from '../../../../styled-system/css' +import { StandardGameLayout } from '@/components/StandardGameLayout' +import { useFullscreen } from '@/contexts/FullscreenContext' +import { useCardSorting } from '../Provider' +import { SetupPhase } from './SetupPhase' +import { PlayingPhaseDrag } from './PlayingPhaseDrag' +import { ResultsPhase } from './ResultsPhase' export function GameComponent() { - const router = useRouter(); - const { state, exitSession, startGame, goToSetup, isSpectating } = - useCardSorting(); - const { setFullscreenElement } = useFullscreen(); - const gameRef = useRef(null); + const router = useRouter() + const { state, exitSession, startGame, goToSetup, isSpectating } = useCardSorting() + const { setFullscreenElement } = useFullscreen() + const gameRef = useRef(null) useEffect(() => { // Register fullscreen element if (gameRef.current) { - setFullscreenElement(gameRef.current); + setFullscreenElement(gameRef.current) } - }, [setFullscreenElement]); + }, [setFullscreenElement]) return ( { - exitSession(); - router.push("/arcade"); + exitSession() + router.push('/arcade') }} onSetup={ goToSetup ? () => { - goToSetup(); + goToSetup() } : undefined } onNewGame={() => { - startGame(); + startGame() }} > @@ -50,75 +49,68 @@ export function GameComponent() { ref={gameRef} className={css({ flex: 1, - display: "flex", - flexDirection: "column", - position: "relative", - overflow: "hidden", + display: 'flex', + flexDirection: 'column', + position: 'relative', + overflow: 'hidden', // Remove all padding/margins for playing phase - padding: - state.gamePhase === "playing" - ? "0" - : { base: "12px", sm: "16px", md: "20px" }, + padding: state.gamePhase === 'playing' ? '0' : { base: '12px', sm: '16px', md: '20px' }, })} > {/* Spectator Mode Banner - only show in setup/results */} - {isSpectating && - state.gamePhase !== "setup" && - state.gamePhase !== "playing" && ( -
- - 👀 - - - Spectating {state.playerMetadata?.name || "player"}'s game - -
- )} + {isSpectating && state.gamePhase !== 'setup' && state.gamePhase !== 'playing' && ( +
+ + 👀 + + Spectating {state.playerMetadata?.name || 'player'}'s game +
+ )} {/* For playing phase, render full viewport. For setup/results, use container */} - {state.gamePhase === "playing" ? ( + {state.gamePhase === 'playing' ? ( ) : (
- {state.gamePhase === "setup" && } - {state.gamePhase === "results" && } + {state.gamePhase === 'setup' && } + {state.gamePhase === 'results' && }
)}
- ); + ) } diff --git a/apps/web/src/arcade-games/card-sorting/components/PlayingPhase.tsx b/apps/web/src/arcade-games/card-sorting/components/PlayingPhase.tsx index 86d6350d..33f062ef 100644 --- a/apps/web/src/arcade-games/card-sorting/components/PlayingPhase.tsx +++ b/apps/web/src/arcade-games/card-sorting/components/PlayingPhase.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { css } from "../../../../styled-system/css"; -import { useCardSorting } from "../Provider"; -import { useState, useEffect } from "react"; +import { css } from '../../../../styled-system/css' +import { useCardSorting } from '../Provider' +import { useState, useEffect } from 'react' export function PlayingPhase() { const { @@ -18,118 +18,106 @@ export function PlayingPhase() { placedCount, elapsedTime, isSpectating, - } = useCardSorting(); + } = useCardSorting() // Status message (mimics Python updateSortingStatus) const [statusMessage, setStatusMessage] = useState( - `Arrange the ${state.cardCount} cards in ascending order (smallest to largest)`, - ); + `Arrange the ${state.cardCount} cards in ascending order (smallest to largest)` + ) // Update status message based on state useEffect(() => { - if (state.gamePhase !== "playing") return; + if (state.gamePhase !== 'playing') return if (selectedCardId) { - const card = state.availableCards.find((c) => c.id === selectedCardId); + const card = state.availableCards.find((c) => c.id === selectedCardId) if (card) { setStatusMessage( - `Selected card with value ${card.number}. Click a position or + button to place it.`, - ); + `Selected card with value ${card.number}. Click a position or + button to place it.` + ) } } else if (placedCount === state.cardCount) { - setStatusMessage( - 'All cards placed! Click "Check My Solution" to see how you did.', - ); + setStatusMessage('All cards placed! Click "Check My Solution" to see how you did.') } else { setStatusMessage( - `${placedCount}/${state.cardCount} cards placed. Select ${placedCount === 0 ? "a" : "another"} card to continue.`, - ); + `${placedCount}/${state.cardCount} cards placed. Select ${placedCount === 0 ? 'a' : 'another'} card to continue.` + ) } - }, [ - selectedCardId, - placedCount, - state.cardCount, - state.gamePhase, - state.availableCards, - ]); + }, [selectedCardId, placedCount, state.cardCount, state.gamePhase, state.availableCards]) // Format time display const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, "0")}`; - }; + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${s.toString().padStart(2, '0')}` + } // Calculate gradient for position slots (darker = smaller, lighter = larger) const getSlotGradient = (position: number, total: number) => { - const intensity = position / (total - 1 || 1); - const lightness = 30 + intensity * 45; // 30% to 75% + const intensity = position / (total - 1 || 1) + const lightness = 30 + intensity * 45 // 30% to 75% return { background: `hsl(220, 8%, ${lightness}%)`, - color: lightness > 60 ? "#2c3e50" : "#ffffff", - borderColor: lightness > 60 ? "#2c5f76" : "rgba(255,255,255,0.4)", - }; - }; + color: lightness > 60 ? '#2c3e50' : '#ffffff', + borderColor: lightness > 60 ? '#2c5f76' : 'rgba(255,255,255,0.4)', + } + } const handleCardClick = (cardId: string) => { - if (isSpectating) return; // Spectators cannot interact + if (isSpectating) return // Spectators cannot interact if (selectedCardId === cardId) { - selectCard(null); // Deselect + selectCard(null) // Deselect } else { - selectCard(cardId); + selectCard(cardId) } - }; + } const handleSlotClick = (position: number) => { - if (isSpectating) return; // Spectators cannot interact + if (isSpectating) return // Spectators cannot interact if (!selectedCardId) { // No card selected - if slot has a card, move it back and auto-select if (state.placedCards[position]) { - const cardToMove = state.placedCards[position]!; - removeCard(position); + const cardToMove = state.placedCards[position]! + removeCard(position) // Auto-select the card that was moved back - selectCard(cardToMove.id); + selectCard(cardToMove.id) } else { - setStatusMessage( - "Select a card first, or click a placed card to move it back.", - ); + setStatusMessage('Select a card first, or click a placed card to move it back.') } } else { // Card is selected - place it (replaces existing card if any) - placeCard(selectedCardId, position); + placeCard(selectedCardId, position) } - }; + } const handleInsertClick = (insertPosition: number) => { - if (isSpectating) return; // Spectators cannot interact + if (isSpectating) return // Spectators cannot interact if (!selectedCardId) { - setStatusMessage( - "Please select a card first, then click where to insert it.", - ); - return; + setStatusMessage('Please select a card first, then click where to insert it.') + return } - insertCard(selectedCardId, insertPosition); - }; + insertCard(selectedCardId, insertPosition) + } return (
{/* Status message */}
@@ -139,31 +127,31 @@ export function PlayingPhase() { {/* Header with timer and actions */}
-
+
Time
{formatTime(elapsedTime)} @@ -172,18 +160,18 @@ export function PlayingPhase() {
Progress
{placedCount}/{state.cardCount} @@ -191,26 +179,23 @@ export function PlayingPhase() {
-
+
- ); + ) })}
- ); + ) } diff --git a/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx b/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx index 41196838..5631f38c 100644 --- a/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx +++ b/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx @@ -1,15 +1,15 @@ -"use client"; +'use client' -import { css } from "../../../../styled-system/css"; -import { useCardSorting } from "../Provider"; -import { useState, useEffect, useRef, useCallback } from "react"; -import { useSpring, animated, to } from "@react-spring/web"; -import { useViewport } from "@/contexts/ViewportContext"; -import type { SortingCard } from "../types"; +import { css } from '../../../../styled-system/css' +import { useCardSorting } from '../Provider' +import { useState, useEffect, useRef, useCallback } from 'react' +import { useSpring, animated, to } from '@react-spring/web' +import { useViewport } from '@/contexts/ViewportContext' +import type { SortingCard } from '../types' // Add celebration animations -if (typeof document !== "undefined") { - const style = document.createElement("style"); +if (typeof document !== 'undefined') { + const style = document.createElement('style') style.textContent = ` @keyframes celebrate { 0%, 100% { @@ -51,15 +51,15 @@ if (typeof document !== "undefined") { transform: translate(-50%, -50%) scale(1.15); } } - `; - document.head.appendChild(style); + ` + document.head.appendChild(style) } interface CardState { - x: number; // % of viewport width (0-100) - y: number; // % of viewport height (0-100) - rotation: number; // degrees - zIndex: number; + x: number // % of viewport width (0-100) + y: number // % of viewport height (0-100) + rotation: number // degrees + zIndex: number } /** @@ -76,66 +76,66 @@ interface CardState { */ function inferSequenceFromPositions( cardStates: Map, - allCards: SortingCard[], + allCards: SortingCard[] ): SortingCard[] { - const VERTICAL_TOLERANCE = 8; // Cards within 8% of viewport height are in the same "lane" + const VERTICAL_TOLERANCE = 8 // Cards within 8% of viewport height are in the same "lane" // Get all positioned cards const positionedCards = allCards .map((card) => { - const state = cardStates.get(card.id); - if (!state) return null; - return { card, ...state }; + const state = cardStates.get(card.id) + if (!state) return null + return { card, ...state } }) .filter( ( - item, + item ): item is { - card: SortingCard; - x: number; - y: number; - rotation: number; - zIndex: number; - } => item !== null, - ); + card: SortingCard + x: number + y: number + rotation: number + zIndex: number + } => item !== null + ) - if (positionedCards.length === 0) return []; + if (positionedCards.length === 0) return [] // Sort by x position first - const sortedByX = [...positionedCards].sort((a, b) => a.x - b.x); + const sortedByX = [...positionedCards].sort((a, b) => a.x - b.x) // Group into lanes - const lanes: (typeof positionedCards)[] = []; + const lanes: (typeof positionedCards)[] = [] for (const item of sortedByX) { // Find a lane this card fits into (similar y position) const matchingLane = lanes.find((lane) => { // Check if card's y is within tolerance of lane's average y - const laneAvgY = lane.reduce((sum, c) => sum + c.y, 0) / lane.length; - return Math.abs(item.y - laneAvgY) < VERTICAL_TOLERANCE; - }); + const laneAvgY = lane.reduce((sum, c) => sum + c.y, 0) / lane.length + return Math.abs(item.y - laneAvgY) < VERTICAL_TOLERANCE + }) if (matchingLane) { - matchingLane.push(item); + matchingLane.push(item) } else { - lanes.push([item]); + lanes.push([item]) } } // Sort lanes top-to-bottom lanes.sort((laneA, laneB) => { - const avgYA = laneA.reduce((sum, c) => sum + c.y, 0) / laneA.length; - const avgYB = laneB.reduce((sum, c) => sum + c.y, 0) / laneB.length; - return avgYA - avgYB; - }); + const avgYA = laneA.reduce((sum, c) => sum + c.y, 0) / laneA.length + const avgYB = laneB.reduce((sum, c) => sum + c.y, 0) / laneB.length + return avgYA - avgYB + }) // Within each lane, sort left-to-right for (const lane of lanes) { - lane.sort((a, b) => a.x - b.x); + lane.sort((a, b) => a.x - b.x) } // Flatten to get final sequence - return lanes.flat().map((item) => item.card); + return lanes.flat().map((item) => item.card) } /** @@ -150,71 +150,68 @@ function ContinuousSequencePath({ spectatorEducationalMode, isSpectating, }: { - cardStates: Map; - sequence: SortingCard[]; - correctOrder: SortingCard[]; - viewportWidth: number; - viewportHeight: number; - spectatorEducationalMode: boolean; - isSpectating: boolean; + cardStates: Map + sequence: SortingCard[] + correctOrder: SortingCard[] + viewportWidth: number + viewportHeight: number + spectatorEducationalMode: boolean + isSpectating: boolean }) { - if (sequence.length < 2) return null; + if (sequence.length < 2) return null // Card dimensions (base size) - const CARD_WIDTH = 140; - const CARD_HEIGHT = 180; - const CARD_HALF_WIDTH = CARD_WIDTH / 2; - const CARD_HALF_HEIGHT = CARD_HEIGHT / 2; + const CARD_WIDTH = 140 + const CARD_HEIGHT = 180 + const CARD_HALF_WIDTH = CARD_WIDTH / 2 + const CARD_HALF_HEIGHT = CARD_HEIGHT / 2 // Helper to check if a card is part of the correct prefix or suffix (and thus scaled to 50%) const isCardCorrect = (card: SortingCard): boolean => { - const positionInSequence = sequence.findIndex((c) => c.id === card.id); - if (positionInSequence < 0) return false; + const positionInSequence = sequence.findIndex((c) => c.id === card.id) + if (positionInSequence < 0) return false // Check if card is part of correct prefix - let isInCorrectPrefix = true; + let isInCorrectPrefix = true for (let i = 0; i <= positionInSequence; i++) { if (sequence[i]?.id !== correctOrder[i]?.id) { - isInCorrectPrefix = false; - break; + isInCorrectPrefix = false + break } } // Check if card is part of correct suffix - let isInCorrectSuffix = true; - const offsetFromEnd = sequence.length - 1 - positionInSequence; + let isInCorrectSuffix = true + const offsetFromEnd = sequence.length - 1 - positionInSequence for (let i = 0; i <= offsetFromEnd; i++) { - const seqIdx = sequence.length - 1 - i; - const correctIdx = correctOrder.length - 1 - i; + const seqIdx = sequence.length - 1 - i + const correctIdx = correctOrder.length - 1 - i if (sequence[seqIdx]?.id !== correctOrder[correctIdx]?.id) { - isInCorrectSuffix = false; - break; + isInCorrectSuffix = false + break } } - const isCorrect = isInCorrectPrefix || isInCorrectSuffix; - return isSpectating ? spectatorEducationalMode && isCorrect : isCorrect; - }; + const isCorrect = isInCorrectPrefix || isInCorrectSuffix + return isSpectating ? spectatorEducationalMode && isCorrect : isCorrect + } // Get all card positions (card centers) with scale information const cardCenters = sequence .map((card) => { - const state = cardStates.get(card.id); - if (!state) return null; - const scale = isCardCorrect(card) ? 0.5 : 1; + const state = cardStates.get(card.id) + if (!state) return null + const scale = isCardCorrect(card) ? 0.5 : 1 return { x: (state.x / 100) * viewportWidth + CARD_HALF_WIDTH, y: (state.y / 100) * viewportHeight + CARD_HALF_HEIGHT, cardId: card.id, scale, - }; + } }) - .filter( - (p): p is { x: number; y: number; cardId: string; scale: number } => - p !== null, - ); + .filter((p): p is { x: number; y: number; cardId: string; scale: number } => p !== null) - if (cardCenters.length < 2) return null; + if (cardCenters.length < 2) return null // Helper function to find intersection of line from center in direction (dx, dy) with card rectangle const findCardEdgePoint = ( @@ -222,97 +219,96 @@ function ContinuousSequencePath({ centerY: number, dx: number, dy: number, - scale: number, + scale: number ): { x: number; y: number } => { // Normalize direction - const length = Math.sqrt(dx * dx + dy * dy); - const ndx = dx / length; - const ndy = dy / length; + const length = Math.sqrt(dx * dx + dy * dy) + const ndx = dx / length + const ndy = dy / length // Apply scale to card dimensions - const scaledHalfWidth = CARD_HALF_WIDTH * scale; - const scaledHalfHeight = CARD_HALF_HEIGHT * scale; + const scaledHalfWidth = CARD_HALF_WIDTH * scale + const scaledHalfHeight = CARD_HALF_HEIGHT * scale // Find which edge we hit first - const txRight = ndx > 0 ? scaledHalfWidth / ndx : Number.POSITIVE_INFINITY; - const txLeft = ndx < 0 ? -scaledHalfWidth / ndx : Number.POSITIVE_INFINITY; - const tyBottom = - ndy > 0 ? scaledHalfHeight / ndy : Number.POSITIVE_INFINITY; - const tyTop = ndy < 0 ? -scaledHalfHeight / ndy : Number.POSITIVE_INFINITY; + const txRight = ndx > 0 ? scaledHalfWidth / ndx : Number.POSITIVE_INFINITY + const txLeft = ndx < 0 ? -scaledHalfWidth / ndx : Number.POSITIVE_INFINITY + const tyBottom = ndy > 0 ? scaledHalfHeight / ndy : Number.POSITIVE_INFINITY + const tyTop = ndy < 0 ? -scaledHalfHeight / ndy : Number.POSITIVE_INFINITY - const t = Math.min(txRight, txLeft, tyBottom, tyTop); + const t = Math.min(txRight, txLeft, tyBottom, tyTop) return { x: centerX + ndx * t, y: centerY + ndy * t, - }; - }; + } + } // Calculate edge points for each card based on direction to next/prev card const positions = cardCenters.map((center, i) => { if (i === 0) { // First card: direction towards next card - const next = cardCenters[i + 1]; - const dx = next.x - center.x; - const dy = next.y - center.y; - return findCardEdgePoint(center.x, center.y, dx, dy, center.scale); + const next = cardCenters[i + 1] + const dx = next.x - center.x + const dy = next.y - center.y + return findCardEdgePoint(center.x, center.y, dx, dy, center.scale) } if (i === cardCenters.length - 1) { // Last card: direction from previous card - const prev = cardCenters[i - 1]; - const dx = center.x - prev.x; - const dy = center.y - prev.y; - return findCardEdgePoint(center.x, center.y, dx, dy, center.scale); + const prev = cardCenters[i - 1] + const dx = center.x - prev.x + const dy = center.y - prev.y + return findCardEdgePoint(center.x, center.y, dx, dy, center.scale) } // Middle cards: average direction between prev and next - const prev = cardCenters[i - 1]; - const next = cardCenters[i + 1]; - const dx = next.x - prev.x; - const dy = next.y - prev.y; - return findCardEdgePoint(center.x, center.y, dx, dy, center.scale); - }); + const prev = cardCenters[i - 1] + const next = cardCenters[i + 1] + const dx = next.x - prev.x + const dy = next.y - prev.y + return findCardEdgePoint(center.x, center.y, dx, dy, center.scale) + }) - if (positions.length < 2) return null; + if (positions.length < 2) return null // Build continuous curved path using cubic bezier curves with smooth transitions // Use Catmull-Rom style control points for smooth continuous curves - let pathD = `M ${positions[0].x} ${positions[0].y}`; + let pathD = `M ${positions[0].x} ${positions[0].y}` for (let i = 0; i < positions.length - 1; i++) { - const current = positions[i]; - const next = positions[i + 1]; + const current = positions[i] + const next = positions[i + 1] // Get previous and next-next points for tangent calculation (or use current/next if at edges) - const prev = i > 0 ? positions[i - 1] : current; - const nextNext = i < positions.length - 2 ? positions[i + 2] : next; + const prev = i > 0 ? positions[i - 1] : current + const nextNext = i < positions.length - 2 ? positions[i + 2] : next // Calculate tangent vectors for smooth curve // Tangent at current point: direction from prev to next - const tension = 0.3; // Adjust this to control curve tightness (0 = loose, 1 = tight) + const tension = 0.3 // Adjust this to control curve tightness (0 = loose, 1 = tight) - const tangent1X = (next.x - prev.x) * tension; - const tangent1Y = (next.y - prev.y) * tension; + const tangent1X = (next.x - prev.x) * tension + const tangent1Y = (next.y - prev.y) * tension // Tangent at next point: direction from current to nextNext - const tangent2X = (nextNext.x - current.x) * tension; - const tangent2Y = (nextNext.y - current.y) * tension; + const tangent2X = (nextNext.x - current.x) * tension + const tangent2Y = (nextNext.y - current.y) * tension // Control points for cubic bezier - const cp1X = current.x + tangent1X; - const cp1Y = current.y + tangent1Y; - const cp2X = next.x - tangent2X; - const cp2Y = next.y - tangent2Y; + const cp1X = current.x + tangent1X + const cp1Y = current.y + tangent1Y + const cp2X = next.x - tangent2X + const cp2Y = next.y - tangent2Y - pathD += ` C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${next.x} ${next.y}`; + pathD += ` C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${next.x} ${next.y}` } // Calculate badge positions along the actual drawn path at arc-length midpoint const badges: Array<{ - x: number; - y: number; - number: number; - isCorrect: boolean; - }> = []; + x: number + y: number + number: number + isCorrect: boolean + }> = [] // Helper to evaluate cubic bezier at parameter t const evalCubicBezier = ( @@ -324,78 +320,57 @@ function ContinuousSequencePath({ cp2y: number, p1x: number, p1y: number, - t: number, + t: number ) => { - const mt = 1 - t; + const mt = 1 - t return { - x: - mt * mt * mt * p0x + - 3 * mt * mt * t * cp1x + - 3 * mt * t * t * cp2x + - t * t * t * p1x, - y: - mt * mt * mt * p0y + - 3 * mt * mt * t * cp1y + - 3 * mt * t * t * cp2y + - t * t * t * p1y, - }; - }; + x: mt * mt * mt * p0x + 3 * mt * mt * t * cp1x + 3 * mt * t * t * cp2x + t * t * t * p1x, + y: mt * mt * mt * p0y + 3 * mt * mt * t * cp1y + 3 * mt * t * t * cp2y + t * t * t * p1y, + } + } for (let i = 0; i < positions.length - 1; i++) { - const current = positions[i]; - const next = positions[i + 1]; + const current = positions[i] + const next = positions[i + 1] // Use the actual edge-based control points (same as the drawn path) - const prev = i > 0 ? positions[i - 1] : current; - const nextNext = i < positions.length - 2 ? positions[i + 2] : next; - const tension = 0.3; + const prev = i > 0 ? positions[i - 1] : current + const nextNext = i < positions.length - 2 ? positions[i + 2] : next + const tension = 0.3 - const tangent1X = (next.x - prev.x) * tension; - const tangent1Y = (next.y - prev.y) * tension; - const tangent2X = (nextNext.x - current.x) * tension; - const tangent2Y = (nextNext.y - current.y) * tension; + const tangent1X = (next.x - prev.x) * tension + const tangent1Y = (next.y - prev.y) * tension + const tangent2X = (nextNext.x - current.x) * tension + const tangent2Y = (nextNext.y - current.y) * tension - const cp1X = current.x + tangent1X; - const cp1Y = current.y + tangent1Y; - const cp2X = next.x - tangent2X; - const cp2Y = next.y - tangent2Y; + const cp1X = current.x + tangent1X + const cp1Y = current.y + tangent1Y + const cp2X = next.x - tangent2X + const cp2Y = next.y - tangent2Y // Sample the curve at many points to calculate arc length - const samples = 50; - const arcLengths: number[] = [0]; - let prevPoint = { x: current.x, y: current.y }; + const samples = 50 + const arcLengths: number[] = [0] + let prevPoint = { x: current.x, y: current.y } for (let j = 1; j <= samples; j++) { - const t = j / samples; - const point = evalCubicBezier( - current.x, - current.y, - cp1X, - cp1Y, - cp2X, - cp2Y, - next.x, - next.y, - t, - ); - const segmentLength = Math.sqrt( - (point.x - prevPoint.x) ** 2 + (point.y - prevPoint.y) ** 2, - ); - arcLengths.push(arcLengths[arcLengths.length - 1] + segmentLength); - prevPoint = point; + const t = j / samples + const point = evalCubicBezier(current.x, current.y, cp1X, cp1Y, cp2X, cp2Y, next.x, next.y, t) + const segmentLength = Math.sqrt((point.x - prevPoint.x) ** 2 + (point.y - prevPoint.y) ** 2) + arcLengths.push(arcLengths[arcLengths.length - 1] + segmentLength) + prevPoint = point } // Find the t value that corresponds to 50% of arc length - const totalArcLength = arcLengths[arcLengths.length - 1]; - const targetLength = totalArcLength * 0.5; + const totalArcLength = arcLengths[arcLengths.length - 1] + const targetLength = totalArcLength * 0.5 - let tAtMidArc = 0.5; + let tAtMidArc = 0.5 for (let j = 0; j < arcLengths.length - 1; j++) { if (arcLengths[j] <= targetLength && targetLength <= arcLengths[j + 1]) { - const ratio = - (targetLength - arcLengths[j]) / (arcLengths[j + 1] - arcLengths[j]); - tAtMidArc = (j + ratio) / samples; - break; + const ratio = (targetLength - arcLengths[j]) / (arcLengths[j + 1] - arcLengths[j]) + tAtMidArc = (j + ratio) / samples + break } } @@ -409,130 +384,119 @@ function ContinuousSequencePath({ cp2Y, next.x, next.y, - tAtMidArc, - ); + tAtMidArc + ) // Calculate tangent at this t for perpendicular offset - const mt = 1 - tAtMidArc; + const mt = 1 - tAtMidArc const tangentAtMidX = 3 * mt * mt * (cp1X - current.x) + 6 * mt * tAtMidArc * (cp2X - cp1X) + - 3 * tAtMidArc * tAtMidArc * (next.x - cp2X); + 3 * tAtMidArc * tAtMidArc * (next.x - cp2X) const tangentAtMidY = 3 * mt * mt * (cp1Y - current.y) + 6 * mt * tAtMidArc * (cp2Y - cp1Y) + - 3 * tAtMidArc * tAtMidArc * (next.y - cp2Y); + 3 * tAtMidArc * tAtMidArc * (next.y - cp2Y) // Small perpendicular offset so badges sit slightly off the curve line - const perpX = -tangentAtMidY; - const perpY = tangentAtMidX; - const perpLength = Math.sqrt(perpX * perpX + perpY * perpY); - const offsetDistance = 5; // Small offset + const perpX = -tangentAtMidY + const perpY = tangentAtMidX + const perpLength = Math.sqrt(perpX * perpX + perpY * perpY) + const offsetDistance = 5 // Small offset - const finalX = midPoint.x + (perpX / perpLength) * offsetDistance; - const finalY = midPoint.y + (perpY / perpLength) * offsetDistance; + const finalX = midPoint.x + (perpX / perpLength) * offsetDistance + const finalY = midPoint.y + (perpY / perpLength) * offsetDistance // Check if this connection is correct const isCorrect = - correctOrder[i]?.id === sequence[i].id && - correctOrder[i + 1]?.id === sequence[i + 1].id; + correctOrder[i]?.id === sequence[i].id && correctOrder[i + 1]?.id === sequence[i + 1].id badges.push({ x: finalX, y: finalY, number: i + 1, isCorrect, - }); + }) } // Check if entire sequence is correct for coloring - const allCorrect = sequence.every( - (card, idx) => correctOrder[idx]?.id === card.id, - ); + const allCorrect = sequence.every((card, idx) => correctOrder[idx]?.id === card.id) return (
{/* Continuous curved path */} {/* Arrowhead at the end */} {(() => { - const i = positions.length - 2; // Last segment index - const current = positions[i]; - const next = positions[i + 1]; + const i = positions.length - 2 // Last segment index + const current = positions[i] + const next = positions[i + 1] // Recalculate control points for last segment - const prev = i > 0 ? positions[i - 1] : current; - const nextNext = next; // At the end, so nextNext is same as next - const tension = 0.3; + const prev = i > 0 ? positions[i - 1] : current + const nextNext = next // At the end, so nextNext is same as next + const tension = 0.3 - const tangent1X = (next.x - prev.x) * tension; - const tangent1Y = (next.y - prev.y) * tension; - const tangent2X = (nextNext.x - current.x) * tension; - const tangent2Y = (nextNext.y - current.y) * tension; + const tangent1X = (next.x - prev.x) * tension + const tangent1Y = (next.y - prev.y) * tension + const tangent2X = (nextNext.x - current.x) * tension + const tangent2Y = (nextNext.y - current.y) * tension - const cp1X = current.x + tangent1X; - const cp1Y = current.y + tangent1Y; - const cp2X = next.x - tangent2X; - const cp2Y = next.y - tangent2Y; + const cp1X = current.x + tangent1X + const cp1Y = current.y + tangent1Y + const cp2X = next.x - tangent2X + const cp2Y = next.y - tangent2Y // Calculate tangent at t=1 (end of curve) for cubic bezier // Derivative: B'(t) = 3(1-t)²(P1-P0) + 6(1-t)t(P2-P1) + 3t²(P3-P2) // At t=1: B'(1) = 3(P3-P2) - const tangentX = 3 * (next.x - cp2X); - const tangentY = 3 * (next.y - cp2Y); - const arrowAngle = Math.atan2(tangentY, tangentX); + const tangentX = 3 * (next.x - cp2X) + const tangentY = 3 * (next.y - cp2Y) + const arrowAngle = Math.atan2(tangentY, tangentX) // Arrow triangle - const tipX = next.x; - const tipY = next.y; - const baseX = tipX - Math.cos(arrowAngle) * 10; - const baseY = tipY - Math.sin(arrowAngle) * 10; - const left = `${baseX + Math.cos(arrowAngle + Math.PI / 2) * 6},${baseY + Math.sin(arrowAngle + Math.PI / 2) * 6}`; - const right = `${baseX + Math.cos(arrowAngle - Math.PI / 2) * 6},${baseY + Math.sin(arrowAngle - Math.PI / 2) * 6}`; + const tipX = next.x + const tipY = next.y + const baseX = tipX - Math.cos(arrowAngle) * 10 + const baseY = tipY - Math.sin(arrowAngle) * 10 + const left = `${baseX + Math.cos(arrowAngle + Math.PI / 2) * 6},${baseY + Math.sin(arrowAngle + Math.PI / 2) * 6}` + const right = `${baseX + Math.cos(arrowAngle - Math.PI / 2) * 6},${baseY + Math.sin(arrowAngle - Math.PI / 2) * 6}` return ( - ); + ) })()} @@ -541,34 +505,32 @@ function ContinuousSequencePath({
{badge.number}
))}
- ); + ) } /** @@ -584,36 +546,36 @@ function AnimatedArrow({ viewportWidth, viewportHeight, }: { - fromCard: CardState; - toCard: CardState; - isCorrect: boolean; - sequenceNumber: number; - isDragging: boolean; - isResizing: boolean; - viewportWidth: number; - viewportHeight: number; + fromCard: CardState + toCard: CardState + isCorrect: boolean + sequenceNumber: number + isDragging: boolean + isResizing: boolean + viewportWidth: number + viewportHeight: number }) { // Convert percentage positions to pixels const fromPx = { x: (fromCard.x / 100) * viewportWidth, y: (fromCard.y / 100) * viewportHeight, - }; + } const toPx = { x: (toCard.x / 100) * viewportWidth, y: (toCard.y / 100) * viewportHeight, - }; + } // Calculate arrow position (from center of current card to center of next card) - const fromX = fromPx.x + 70; // 70 = half of card width (140px) - const fromY = fromPx.y + 90; // 90 = half of card height (180px) - const toX = toPx.x + 70; - const toY = toPx.y + 90; + const fromX = fromPx.x + 70 // 70 = half of card width (140px) + const fromY = fromPx.y + 90 // 90 = half of card height (180px) + const toX = toPx.x + 70 + const toY = toPx.y + 90 // Calculate angle and distance - const dx = toX - fromX; - const dy = toY - fromY; - const angle = Math.atan2(dy, dx) * (180 / Math.PI); - const distance = Math.sqrt(dx * dx + dy * dy); + const dx = toX - fromX + const dy = toY - fromY + const angle = Math.atan2(dy, dx) * (180 / Math.PI) + const distance = Math.sqrt(dx * dx + dy * dy) // Use spring animation for arrow position and size // Disable animation when dragging or resizing @@ -627,179 +589,147 @@ function AnimatedArrow({ tension: 300, friction: 30, }, - }); + }) // Don't draw arrow if cards are too close - if (distance < 80) return null; + if (distance < 80) return null // Calculate control point for bezier curve (perpendicular to line, offset by 30px) - const midX = (fromX + toX) / 2; - const midY = (fromY + toY) / 2; - const perpAngle = angle + 90; // Perpendicular to the line - const curveOffset = 30; // How much to curve (in pixels) - const controlX = midX + Math.cos((perpAngle * Math.PI) / 180) * curveOffset; - const controlY = midY + Math.sin((perpAngle * Math.PI) / 180) * curveOffset; + const midX = (fromX + toX) / 2 + const midY = (fromY + toY) / 2 + const perpAngle = angle + 90 // Perpendicular to the line + const curveOffset = 30 // How much to curve (in pixels) + const controlX = midX + Math.cos((perpAngle * Math.PI) / 180) * curveOffset + const controlY = midY + Math.sin((perpAngle * Math.PI) / 180) * curveOffset // Calculate arrowhead position and angle at the end of the curve // For a quadratic bezier, the tangent at t=1 is: 2*(P2 - P1) - const tangentX = toX - controlX; - const tangentY = toY - controlY; - const arrowAngle = Math.atan2(tangentY, tangentX) * (180 / Math.PI); + const tangentX = toX - controlX + const tangentY = toY - controlY + const arrowAngle = Math.atan2(tangentY, tangentX) * (180 / Math.PI) return ( {/* Curved line using quadratic bezier */} { // Recalculate curve with animated values - const tx = fx + Math.cos((ang * Math.PI) / 180) * dist; - const ty = fy + Math.sin((ang * Math.PI) / 180) * dist; - const mx = (fx + tx) / 2; - const my = (fy + ty) / 2; - const perpAng = ang + 90; - const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset; - const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset; - return `M ${fx} ${fy} Q ${cx} ${cy} ${tx} ${ty}`; - }, + const tx = fx + Math.cos((ang * Math.PI) / 180) * dist + const ty = fy + Math.sin((ang * Math.PI) / 180) * dist + const mx = (fx + tx) / 2 + const my = (fy + ty) / 2 + const perpAng = ang + 90 + const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset + const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset + return `M ${fx} ${fy} Q ${cx} ${cy} ${tx} ${ty}` + } )} - stroke={ - isCorrect ? "rgba(34, 197, 94, 0.8)" : "rgba(251, 146, 60, 0.7)" - } - strokeWidth={isCorrect ? "4" : "3"} + stroke={isCorrect ? 'rgba(34, 197, 94, 0.8)' : 'rgba(251, 146, 60, 0.7)'} + strokeWidth={isCorrect ? '4' : '3'} fill="none" style={{ - filter: isCorrect - ? "drop-shadow(0 0 8px rgba(34, 197, 94, 0.6))" - : "none", + filter: isCorrect ? 'drop-shadow(0 0 8px rgba(34, 197, 94, 0.6))' : 'none', }} /> {/* Arrowhead */} { // Recalculate end position and angle - const tx = fx + Math.cos((ang * Math.PI) / 180) * dist; - const ty = fy + Math.sin((ang * Math.PI) / 180) * dist; - const mx = (fx + tx) / 2; - const my = (fy + ty) / 2; - const perpAng = ang + 90; - const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset; - const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset; - const tangX = tx - cx; - const tangY = ty - cy; - const aAngle = Math.atan2(tangY, tangX); + const tx = fx + Math.cos((ang * Math.PI) / 180) * dist + const ty = fy + Math.sin((ang * Math.PI) / 180) * dist + const mx = (fx + tx) / 2 + const my = (fy + ty) / 2 + const perpAng = ang + 90 + const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset + const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset + const tangX = tx - cx + const tangY = ty - cy + const aAngle = Math.atan2(tangY, tangX) // Arrow points relative to tip - const tipX = tx; - const tipY = ty; - const baseX = tipX - Math.cos(aAngle) * 10; - const baseY = tipY - Math.sin(aAngle) * 10; - const left = `${baseX + Math.cos(aAngle + Math.PI / 2) * 6},${baseY + Math.sin(aAngle + Math.PI / 2) * 6}`; - const right = `${baseX + Math.cos(aAngle - Math.PI / 2) * 6},${baseY + Math.sin(aAngle - Math.PI / 2) * 6}`; - return `${tipX},${tipY} ${left} ${right}`; - }, + const tipX = tx + const tipY = ty + const baseX = tipX - Math.cos(aAngle) * 10 + const baseY = tipY - Math.sin(aAngle) * 10 + const left = `${baseX + Math.cos(aAngle + Math.PI / 2) * 6},${baseY + Math.sin(aAngle + Math.PI / 2) * 6}` + const right = `${baseX + Math.cos(aAngle - Math.PI / 2) * 6},${baseY + Math.sin(aAngle - Math.PI / 2) * 6}` + return `${tipX},${tipY} ${left} ${right}` + } )} - fill={ - isCorrect ? "rgba(34, 197, 94, 0.9)" : "rgba(251, 146, 60, 0.8)" - } + fill={isCorrect ? 'rgba(34, 197, 94, 0.9)' : 'rgba(251, 146, 60, 0.8)'} /> {/* Sequence number badge */} { - const tx = fx + Math.cos((ang * Math.PI) / 180) * dist; - const mx = (fx + tx) / 2; - const perpAng = ang + 90; - const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset; - return `${cx}px`; - }, + const tx = fx + Math.cos((ang * Math.PI) / 180) * dist + const mx = (fx + tx) / 2 + const perpAng = ang + 90 + const cx = mx + Math.cos((perpAng * Math.PI) / 180) * curveOffset + return `${cx}px` + } ), top: to( - [ - springProps.fromX, - springProps.fromY, - springProps.distance, - springProps.angle, - ], + [springProps.fromX, springProps.fromY, springProps.distance, springProps.angle], (fx, fy, dist, ang) => { - const tx = fx + Math.cos((ang * Math.PI) / 180) * dist; - const ty = fy + Math.sin((ang * Math.PI) / 180) * dist; - const my = (fy + ty) / 2; - const perpAng = ang + 90; - const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset; - return `${cy}px`; - }, + const tx = fx + Math.cos((ang * Math.PI) / 180) * dist + const ty = fy + Math.sin((ang * Math.PI) / 180) * dist + const my = (fy + ty) / 2 + const perpAng = ang + 90 + const cy = my + Math.sin((perpAng * Math.PI) / 180) * curveOffset + return `${cy}px` + } ), - transform: "translate(-50%, -50%)", - background: isCorrect - ? "rgba(34, 197, 94, 0.95)" - : "rgba(251, 146, 60, 0.95)", - color: "white", - borderRadius: "50%", - width: "24px", - height: "24px", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: "12px", - fontWeight: "bold", - border: "2px solid white", - boxShadow: isCorrect - ? "0 0 12px rgba(34, 197, 94, 0.6)" - : "0 2px 4px rgba(0,0,0,0.2)", - animation: isCorrect - ? "correctBadgePulse 1.5s ease-in-out infinite" - : "none", + transform: 'translate(-50%, -50%)', + background: isCorrect ? 'rgba(34, 197, 94, 0.95)' : 'rgba(251, 146, 60, 0.95)', + color: 'white', + borderRadius: '50%', + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '12px', + fontWeight: 'bold', + border: '2px solid white', + boxShadow: isCorrect ? '0 0 12px rgba(34, 197, 94, 0.6)' : '0 2px 4px rgba(0,0,0,0.2)', + animation: isCorrect ? 'correctBadgePulse 1.5s ease-in-out infinite' : 'none', }} > {sequenceNumber} - ); + ) } /** @@ -821,34 +751,34 @@ function AnimatedCard({ onPointerMove, onPointerUp, }: { - card: SortingCard; - cardState: CardState; - isDragging: boolean; - isResizing: boolean; - isSpectating: boolean; - isCorrect: boolean; - draggedByPlayerId?: string; - localPlayerId?: string; - players: Map; - viewportWidth: number; - viewportHeight: number; - onPointerDown: (e: React.PointerEvent) => void; - onPointerMove: (e: React.PointerEvent) => void; - onPointerUp: (e: React.PointerEvent) => void; + card: SortingCard + cardState: CardState + isDragging: boolean + isResizing: boolean + isSpectating: boolean + isCorrect: boolean + draggedByPlayerId?: string + localPlayerId?: string + players: Map + viewportWidth: number + viewportHeight: number + onPointerDown: (e: React.PointerEvent) => void + onPointerMove: (e: React.PointerEvent) => void + onPointerUp: (e: React.PointerEvent) => void }) { // Convert percentage position to pixels for rendering const pixelPos = { x: (cardState.x / 100) * viewportWidth, y: (cardState.y / 100) * viewportHeight, - }; + } // Determine if card is in correct prefix or suffix position // These cards should be scaled down to 50% and faded to 50% opacity const isInCorrectPosition = (() => { // For AnimatedCard, we need to recalculate since we don't have inferredSequence here // This is a simplified check - we'll need to pass this as a prop or recalculate - return isCorrect; - })(); + return isCorrect + })() // Use spring animation for position, rotation, scale, and opacity // Disable animation when: @@ -862,15 +792,15 @@ function AnimatedCard({ opacity: isInCorrectPosition ? 0.5 : 1, immediate: (key) => { // Scale and opacity always animate smoothly - if (key === "scale" || key === "opacity") return false; + if (key === 'scale' || key === 'opacity') return false // Position and rotation are immediate when dragging or resizing - return isDragging || isResizing; + return isDragging || isResizing }, config: { tension: 300, friction: 30, }, - }); + }) return ( `${val}px`), top: springProps.top.to((val) => `${val}px`), transform: to( [springProps.rotation, springProps.scale], - (r, s) => `rotate(${r}deg) scale(${s})`, + (r, s) => `rotate(${r}deg) scale(${s})` ), opacity: springProps.opacity, zIndex: cardState.zIndex, - boxShadow: isDragging - ? "0 20px 40px rgba(0, 0, 0, 0.3)" - : "0 4px 8px rgba(0, 0, 0, 0.15)", + boxShadow: isDragging ? '0 20px 40px rgba(0, 0, 0, 0.3)' : '0 4px 8px rgba(0, 0, 0, 0.15)', }} >
@@ -925,34 +853,34 @@ function AnimatedCard({ {draggedByPlayerId && draggedByPlayerId !== localPlayerId && (() => { - const player = players.get(draggedByPlayerId); - if (!player) return null; + const player = players.get(draggedByPlayerId) + if (!player) return null return (
{player.emoji}
- ); + ) })()} - ); + ) } export function PlayingPhaseDrag() { @@ -967,102 +895,99 @@ export function PlayingPhaseDrag() { isSpectating, localPlayerId, players, - } = useCardSorting(); + } = useCardSorting() // Spectator educational mode (show correctness indicators) - const [spectatorEducationalMode, setSpectatorEducationalMode] = - useState(false); + const [spectatorEducationalMode, setSpectatorEducationalMode] = useState(false) // Spectator stats sidebar collapsed state - const [spectatorStatsCollapsed, setSpectatorStatsCollapsed] = useState(false); + const [spectatorStatsCollapsed, setSpectatorStatsCollapsed] = useState(false) // Activity feed notifications interface ActivityNotification { - id: string; - playerId: string; - playerEmoji: string; - playerName: string; - action: string; - timestamp: number; + id: string + playerId: string + playerEmoji: string + playerName: string + action: string + timestamp: number } - const [activityFeed, setActivityFeed] = useState([]); - const activityIdCounter = useRef(0); + const [activityFeed, setActivityFeed] = useState([]) + const activityIdCounter = useRef(0) // Perfect sequence countdown (auto-submit after 3-2-1) - const [perfectCountdown, setPerfectCountdown] = useState(null); + const [perfectCountdown, setPerfectCountdown] = useState(null) - const containerRef = useRef(null); + const containerRef = useRef(null) const dragStateRef = useRef<{ - cardId: string; - offsetX: number; - offsetY: number; - startX: number; - startY: number; - initialRotation: number; - } | null>(null); + cardId: string + offsetX: number + offsetY: number + startX: number + startY: number + initialRotation: number + } | null>(null) // Generate a stable unique ID for this browser window/tab // This allows us to identify our own position updates when they echo back from the server - const windowIdRef = useRef(); + const windowIdRef = useRef() if (!windowIdRef.current) { - windowIdRef.current = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + windowIdRef.current = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` } // Track card positions and visual states (UI only - not game state) - const [cardStates, setCardStates] = useState>( - new Map(), - ); - const [draggingCardId, setDraggingCardId] = useState(null); - const [nextZIndex, setNextZIndex] = useState(1); + const [cardStates, setCardStates] = useState>(new Map()) + const [draggingCardId, setDraggingCardId] = useState(null) + const [nextZIndex, setNextZIndex] = useState(1) // Track viewport dimensions for responsive positioning // Get viewport dimensions (uses mock dimensions in preview mode) - const viewport = useViewport(); + const viewport = useViewport() // For spectators, reduce dimensions to account for panels const getEffectiveViewportWidth = () => { - const baseWidth = viewport.width; + const baseWidth = viewport.width // Sidebar is hidden on mobile (< 768px), narrower on desktop if (isSpectating && !spectatorStatsCollapsed && baseWidth >= 768) { - return baseWidth - 240; // Subtract stats sidebar width on desktop + return baseWidth - 240 // Subtract stats sidebar width on desktop } - return baseWidth; - }; + return baseWidth + } const getEffectiveViewportHeight = () => { - const baseHeight = viewport.height; - const baseWidth = viewport.width; + const baseHeight = viewport.height + const baseWidth = viewport.width if (isSpectating) { // Banner is 170px on mobile (130px mini nav + 40px spectator banner), 56px on desktop - return baseHeight - (baseWidth < 768 ? 170 : 56); + return baseHeight - (baseWidth < 768 ? 170 : 56) } - return baseHeight; - }; + return baseHeight + } const [viewportDimensions, setViewportDimensions] = useState({ width: getEffectiveViewportWidth(), height: getEffectiveViewportHeight(), - }); + }) // Track if we're currently resizing to disable spring animations - const [isResizing, setIsResizing] = useState(false); - const resizeTimeoutRef = useRef(null); + const [isResizing, setIsResizing] = useState(false) + const resizeTimeoutRef = useRef(null) // Throttle position updates during drag (every 100ms) - const lastSyncTimeRef = useRef(0); + const lastSyncTimeRef = useRef(0) // Track when we're waiting to check solution - const [waitingToCheck, setWaitingToCheck] = useState(false); - const cardsToInsertRef = useRef([]); - const currentInsertIndexRef = useRef(0); + const [waitingToCheck, setWaitingToCheck] = useState(false) + const cardsToInsertRef = useRef([]) + const currentInsertIndexRef = useRef(0) // Helper to add activity notifications (only in collaborative mode) const addActivityNotification = useCallback( (playerId: string, action: string) => { - if (state.gameMode !== "collaborative") return; - if (playerId === localPlayerId) return; // Don't show notifications for own actions + if (state.gameMode !== 'collaborative') return + if (playerId === localPlayerId) return // Don't show notifications for own actions - const player = players.get(playerId); - if (!player) return; + const player = players.get(playerId) + if (!player) return const notification: ActivityNotification = { id: `activity-${activityIdCounter.current++}`, @@ -1071,98 +996,93 @@ export function PlayingPhaseDrag() { playerName: player.name, action, timestamp: Date.now(), - }; + } - setActivityFeed((prev) => [...prev, notification]); + setActivityFeed((prev) => [...prev, notification]) }, - [state.gameMode, localPlayerId, players], - ); + [state.gameMode, localPlayerId, players] + ) // Auto-dismiss notifications after 3 seconds useEffect(() => { - if (activityFeed.length === 0) return; + if (activityFeed.length === 0) return const timeout = setTimeout(() => { - const now = Date.now(); - setActivityFeed((prev) => prev.filter((n) => now - n.timestamp < 3000)); - }, 100); // Check every 100ms for smooth removal + const now = Date.now() + setActivityFeed((prev) => prev.filter((n) => now - n.timestamp < 3000)) + }, 100) // Check every 100ms for smooth removal - return () => clearTimeout(timeout); - }, [activityFeed]); + return () => clearTimeout(timeout) + }, [activityFeed]) // Track previous state for detecting changes - const prevDraggingPlayersRef = useRef>(new Set()); + const prevDraggingPlayersRef = useRef>(new Set()) // Detect state changes and generate activity notifications useEffect(() => { // Only track in collaborative mode - if (state.gameMode !== "collaborative") return; - if (!state.cardPositions) return; + if (state.gameMode !== 'collaborative') return + if (!state.cardPositions) return // Detect who is currently dragging cards - const currentlyDragging = new Set(); + const currentlyDragging = new Set() for (const pos of state.cardPositions) { if (pos.draggedByPlayerId && pos.draggedByPlayerId !== localPlayerId) { - currentlyDragging.add(pos.draggedByPlayerId); + currentlyDragging.add(pos.draggedByPlayerId) } } // Detect new players starting to drag (activity notification) for (const playerId of currentlyDragging) { if (!prevDraggingPlayersRef.current.has(playerId)) { - addActivityNotification(playerId, "is moving cards"); + addActivityNotification(playerId, 'is moving cards') } } - prevDraggingPlayersRef.current = currentlyDragging; - }, [ - state.cardPositions, - state.gameMode, - localPlayerId, - addActivityNotification, - ]); + prevDraggingPlayersRef.current = currentlyDragging + }, [state.cardPositions, state.gameMode, localPlayerId, addActivityNotification]) // Handle viewport resize useEffect(() => { const handleResize = () => { // Set resizing flag to disable spring animations - setIsResizing(true); + setIsResizing(true) // Update viewport dimensions immediately (accounting for spectator panels) setViewportDimensions({ width: getEffectiveViewportWidth(), height: getEffectiveViewportHeight(), - }); + }) // Clear any existing timeout if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); + clearTimeout(resizeTimeoutRef.current) } // After 150ms of no resize events, re-enable spring animations resizeTimeoutRef.current = setTimeout(() => { - setIsResizing(false); - }, 150); - }; + setIsResizing(false) + }, 150) + } - window.addEventListener("resize", handleResize); + window.addEventListener('resize', handleResize) return () => { - window.removeEventListener("resize", handleResize); + window.removeEventListener('resize', handleResize) if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); + clearTimeout(resizeTimeoutRef.current) } - }; + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []) // Update viewport dimensions when spectator panels change useEffect(() => { setViewportDimensions({ width: getEffectiveViewportWidth(), height: getEffectiveViewportHeight(), - }); + }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSpectating, spectatorStatsCollapsed]); + }, [isSpectating, spectatorStatsCollapsed]) // Initialize card positions when game starts or restarts useEffect(() => { @@ -1170,25 +1090,23 @@ export function PlayingPhaseDrag() { const allCards = [ ...state.availableCards, ...state.placedCards.filter((c): c is SortingCard => c !== null), - ]; + ] // Only initialize if we have cards and either: // 1. No card states exist yet, OR // 2. The number of cards has changed (new game started) const shouldInitialize = - allCards.length > 0 && - (cardStates.size === 0 || cardStates.size !== allCards.length); + allCards.length > 0 && (cardStates.size === 0 || cardStates.size !== allCards.length) - if (!shouldInitialize) return; + if (!shouldInitialize) return - const newStates = new Map(); + const newStates = new Map() // Check if we have server positions to restore from - const hasServerPositions = - state.cardPositions && state.cardPositions.length === allCards.length; + const hasServerPositions = state.cardPositions && state.cardPositions.length === allCards.length allCards.forEach((card, index) => { - const serverPos = state.cardPositions?.find((p) => p.cardId === card.id); + const serverPos = state.cardPositions?.find((p) => p.cardId === card.id) if (hasServerPositions && serverPos) { // Restore from server (already in percentages) @@ -1197,75 +1115,68 @@ export function PlayingPhaseDrag() { y: serverPos.y, rotation: serverPos.rotation, zIndex: serverPos.zIndex, - }); + }) } else { // Generate scattered positions that look like cards thrown on a table // Card is ~140px wide on ~1000px viewport = ~14% of width // Card is ~180px tall on ~800px viewport = ~22.5% of height - const xMargin = 5; // 5% margin on sides - const yMargin = 15; // 15% margin for top UI + const xMargin = 5 // 5% margin on sides + const yMargin = 15 // 15% margin for top UI // Create a more natural distribution by using clusters // Divide the play area into a rough grid, then add randomness - const numCards = allCards.length; - const cols = Math.ceil(Math.sqrt(numCards * 1.5)); // Slightly wider grid - const rows = Math.ceil(numCards / cols); + const numCards = allCards.length + const cols = Math.ceil(Math.sqrt(numCards * 1.5)) // Slightly wider grid + const rows = Math.ceil(numCards / cols) - const row = Math.floor(index / cols); - const col = index % cols; + const row = Math.floor(index / cols) + const col = index % cols // Available space after margins - const availableWidth = 100 - 2 * xMargin - 14; - const availableHeight = 100 - yMargin - 22.5; + const availableWidth = 100 - 2 * xMargin - 14 + const availableHeight = 100 - yMargin - 22.5 // Grid cell size - const cellWidth = availableWidth / cols; - const cellHeight = availableHeight / rows; + const cellWidth = availableWidth / cols + const cellHeight = availableHeight / rows // Base position in grid (centered in cell) - const baseX = xMargin + col * cellWidth + cellWidth / 2 - 7; // -7 to center card - const baseY = yMargin + row * cellHeight + cellHeight / 2 - 11.25; // -11.25 to center card + const baseX = xMargin + col * cellWidth + cellWidth / 2 - 7 // -7 to center card + const baseY = yMargin + row * cellHeight + cellHeight / 2 - 11.25 // -11.25 to center card // Add significant randomness to make it look scattered (±40% of cell size) - const offsetX = (Math.random() - 0.5) * cellWidth * 0.8; - const offsetY = (Math.random() - 0.5) * cellHeight * 0.8; + const offsetX = (Math.random() - 0.5) * cellWidth * 0.8 + const offsetY = (Math.random() - 0.5) * cellHeight * 0.8 // Ensure we stay within bounds - const x = Math.max( - xMargin, - Math.min(100 - 14 - xMargin, baseX + offsetX), - ); - const y = Math.max(yMargin, Math.min(100 - 22.5, baseY + offsetY)); + const x = Math.max(xMargin, Math.min(100 - 14 - xMargin, baseX + offsetX)) + const y = Math.max(yMargin, Math.min(100 - 22.5, baseY + offsetY)) // More varied rotation for natural look - const rotation = (Math.random() - 0.5) * 40; // -20 to 20 degrees + const rotation = (Math.random() - 0.5) * 40 // -20 to 20 degrees // Randomize z-index for natural stacking - const zIndex = Math.floor(Math.random() * numCards); + const zIndex = Math.floor(Math.random() * numCards) - newStates.set(card.id, { x, y, rotation, zIndex }); + newStates.set(card.id, { x, y, rotation, zIndex }) } - }); + }) - setCardStates(newStates); - setNextZIndex( - Math.max(...Array.from(newStates.values()).map((s) => s.zIndex)) + 1, - ); + setCardStates(newStates) + setNextZIndex(Math.max(...Array.from(newStates.values()).map((s) => s.zIndex)) + 1) // If we generated new positions (not restored from server), send them to server if (!hasServerPositions && !isSpectating) { - const positions = Array.from(newStates.entries()).map( - ([id, cardState]) => ({ - cardId: id, - x: cardState.x, - y: cardState.y, - rotation: cardState.rotation, - zIndex: cardState.zIndex, - // Mark with our window ID to identify echoes - draggedByWindowId: windowIdRef.current, - }), - ); - updateCardPositions(positions); + const positions = Array.from(newStates.entries()).map(([id, cardState]) => ({ + cardId: id, + x: cardState.x, + y: cardState.y, + rotation: cardState.rotation, + zIndex: cardState.zIndex, + // Mark with our window ID to identify echoes + draggedByWindowId: windowIdRef.current, + })) + updateCardPositions(positions) } }, [ state.availableCards.length, @@ -1275,27 +1186,27 @@ export function PlayingPhaseDrag() { cardStates.size, isSpectating, updateCardPositions, - ]); + ]) // Sync server position updates (for spectators and multi-window sync) useEffect(() => { - if (!state.cardPositions || state.cardPositions.length === 0) return; - if (cardStates.size === 0) return; + if (!state.cardPositions || state.cardPositions.length === 0) return + if (cardStates.size === 0) return // Check if any updates originated from this window - if so, skip the entire batch // This prevents replaying our own movements when they echo back from the server const hasOurUpdates = state.cardPositions.some( - (pos) => pos.draggedByWindowId === windowIdRef.current, - ); - if (hasOurUpdates) return; + (pos) => pos.draggedByWindowId === windowIdRef.current + ) + if (hasOurUpdates) return // Check if server positions differ from current positions - let needsUpdate = false; - const newStates = new Map(cardStates); + let needsUpdate = false + const newStates = new Map(cardStates) for (const serverPos of state.cardPositions) { - const currentState = cardStates.get(serverPos.cardId); - if (!currentState) continue; + const currentState = cardStates.get(serverPos.cardId) + if (!currentState) continue // Compare percentages directly (tolerance: 0.5%) if ( @@ -1304,52 +1215,52 @@ export function PlayingPhaseDrag() { Math.abs(currentState.rotation - serverPos.rotation) > 1 || currentState.zIndex !== serverPos.zIndex ) { - needsUpdate = true; + needsUpdate = true newStates.set(serverPos.cardId, { x: serverPos.x, y: serverPos.y, rotation: serverPos.rotation, zIndex: serverPos.zIndex, - }); + }) } } if (needsUpdate && !draggingCardId) { // Only apply server updates if not currently dragging - setCardStates(newStates); + setCardStates(newStates) } - }, [state.cardPositions, draggingCardId, cardStates]); + }, [state.cardPositions, draggingCardId, cardStates]) // Infer sequence from card positions const inferredSequence = inferSequenceFromPositions(cardStates, [ ...state.availableCards, ...state.placedCards.filter((c): c is SortingCard => c !== null), - ]); + ]) // Format time display const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, "0")}`; - }; + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${s.toString().padStart(2, '0')}` + } // Handle pointer down (start drag) const handlePointerDown = (e: React.PointerEvent, cardId: string) => { - if (isSpectating) return; + if (isSpectating) return - const target = e.currentTarget as HTMLElement; - target.setPointerCapture(e.pointerId); + const target = e.currentTarget as HTMLElement + target.setPointerCapture(e.pointerId) // Get current card state to calculate proper offset - const currentCard = cardStates.get(cardId); - if (!currentCard) return; + const currentCard = cardStates.get(cardId) + if (!currentCard) return // Calculate offset from card's actual position (in pixels) to pointer // This accounts for rotation and prevents position jump - const cardPixelX = (currentCard.x / 100) * viewportDimensions.width; - const cardPixelY = (currentCard.y / 100) * viewportDimensions.height; - const offsetX = e.clientX - cardPixelX; - const offsetY = e.clientY - cardPixelY; + const cardPixelX = (currentCard.x / 100) * viewportDimensions.width + const cardPixelY = (currentCard.y / 100) * viewportDimensions.height + const offsetX = e.clientX - cardPixelX + const offsetY = e.clientY - cardPixelY dragStateRef.current = { cardId, @@ -1358,220 +1269,208 @@ export function PlayingPhaseDrag() { startX: e.clientX, startY: e.clientY, initialRotation: currentCard.rotation, - }; + } - setDraggingCardId(cardId); + setDraggingCardId(cardId) // Bring card to front setCardStates((prev) => { - const newStates = new Map(prev); - const cardState = newStates.get(cardId); + const newStates = new Map(prev) + const cardState = newStates.get(cardId) if (cardState) { - newStates.set(cardId, { ...cardState, zIndex: nextZIndex }); + newStates.set(cardId, { ...cardState, zIndex: nextZIndex }) } - return newStates; - }); - setNextZIndex((prev) => prev + 1); - }; + return newStates + }) + setNextZIndex((prev) => prev + 1) + } // Handle pointer move (dragging) const handlePointerMove = (e: React.PointerEvent, cardId: string) => { - if (!dragStateRef.current || dragStateRef.current.cardId !== cardId) return; + if (!dragStateRef.current || dragStateRef.current.cardId !== cardId) return - const { offsetX, offsetY } = dragStateRef.current; + const { offsetX, offsetY } = dragStateRef.current // Calculate new position in pixels - const newXPx = e.clientX - offsetX; - const newYPx = e.clientY - offsetY; + const newXPx = e.clientX - offsetX + const newYPx = e.clientY - offsetY // Convert to percentages - const viewportWidth = viewport.width; - const viewportHeight = viewport.height; - const newX = (newXPx / viewportWidth) * 100; - const newY = (newYPx / viewportHeight) * 100; + const viewportWidth = viewport.width + const viewportHeight = viewport.height + const newX = (newXPx / viewportWidth) * 100 + const newY = (newYPx / viewportHeight) * 100 // Calculate rotation based on drag velocity, adding to initial rotation - const dragDeltaX = e.clientX - dragStateRef.current.startX; - const dragRotation = Math.max(-15, Math.min(15, dragDeltaX * 0.05)); - const rotation = dragStateRef.current.initialRotation + dragRotation; + const dragDeltaX = e.clientX - dragStateRef.current.startX + const dragRotation = Math.max(-15, Math.min(15, dragDeltaX * 0.05)) + const rotation = dragStateRef.current.initialRotation + dragRotation setCardStates((prev) => { - const newStates = new Map(prev); - const cardState = newStates.get(cardId); + const newStates = new Map(prev) + const cardState = newStates.get(cardId) if (cardState) { newStates.set(cardId, { ...cardState, x: newX, y: newY, rotation, - }); + }) // Send real-time position updates (throttled to every 100ms) if (!isSpectating) { - const now = Date.now(); + const now = Date.now() if (now - lastSyncTimeRef.current > 100) { - lastSyncTimeRef.current = now; - const positions = Array.from(newStates.entries()).map( - ([id, state]) => ({ - cardId: id, - x: state.x, - y: state.y, - rotation: state.rotation, - zIndex: state.zIndex, - // Mark this card as being dragged by local player - draggedByPlayerId: id === cardId ? localPlayerId : undefined, - // Mark with our window ID to identify echoes - draggedByWindowId: windowIdRef.current, - }), - ); - updateCardPositions(positions); + lastSyncTimeRef.current = now + const positions = Array.from(newStates.entries()).map(([id, state]) => ({ + cardId: id, + x: state.x, + y: state.y, + rotation: state.rotation, + zIndex: state.zIndex, + // Mark this card as being dragged by local player + draggedByPlayerId: id === cardId ? localPlayerId : undefined, + // Mark with our window ID to identify echoes + draggedByWindowId: windowIdRef.current, + })) + updateCardPositions(positions) } } } - return newStates; - }); - }; + return newStates + }) + } // Handle pointer up (end drag) const handlePointerUp = (e: React.PointerEvent, cardId: string) => { - if (!dragStateRef.current || dragStateRef.current.cardId !== cardId) return; + if (!dragStateRef.current || dragStateRef.current.cardId !== cardId) return - const target = e.currentTarget as HTMLElement; - target.releasePointerCapture(e.pointerId); + const target = e.currentTarget as HTMLElement + target.releasePointerCapture(e.pointerId) // Reset rotation to slight random tilt - const updatedStates = new Map(cardStates); - const cardState = updatedStates.get(cardId); + const updatedStates = new Map(cardStates) + const cardState = updatedStates.get(cardId) if (cardState) { updatedStates.set(cardId, { ...cardState, rotation: Math.random() * 10 - 5, - }); - setCardStates(updatedStates); + }) + setCardStates(updatedStates) // Sync positions to server (already in percentages) if (!isSpectating) { - const positions = Array.from(updatedStates.entries()).map( - ([id, state]) => ({ - cardId: id, - x: state.x, - y: state.y, - rotation: state.rotation, - zIndex: state.zIndex, - // Clear draggedByPlayerId when drag ends - draggedByPlayerId: undefined, - // Mark with our window ID to identify echoes - draggedByWindowId: windowIdRef.current, - }), - ); - updateCardPositions(positions); + const positions = Array.from(updatedStates.entries()).map(([id, state]) => ({ + cardId: id, + x: state.x, + y: state.y, + rotation: state.rotation, + zIndex: state.zIndex, + // Clear draggedByPlayerId when drag ends + draggedByPlayerId: undefined, + // Mark with our window ID to identify echoes + draggedByWindowId: windowIdRef.current, + })) + updateCardPositions(positions) } } - dragStateRef.current = null; - setDraggingCardId(null); - }; + dragStateRef.current = null + setDraggingCardId(null) + } // For drag mode, check solution is available when we have a valid inferred sequence - const canCheckSolutionDrag = inferredSequence.length === state.cardCount; + const canCheckSolutionDrag = inferredSequence.length === state.cardCount // Real-time check: is the current sequence correct? const isSequenceCorrect = canCheckSolutionDrag && inferredSequence.every((card, index) => { - const correctCard = state.correctOrder[index]; - return correctCard && card.id === correctCard.id; - }); + const correctCard = state.correctOrder[index] + return correctCard && card.id === correctCard.id + }) // Start countdown when sequence is perfect useEffect(() => { if (isSequenceCorrect && !isSpectating) { // Start countdown from 3 - setPerfectCountdown(3); + setPerfectCountdown(3) } else { // Reset countdown if sequence is no longer perfect - setPerfectCountdown(null); + setPerfectCountdown(null) } - }, [isSequenceCorrect, isSpectating]); + }, [isSequenceCorrect, isSpectating]) // Countdown timer effect useEffect(() => { - if (perfectCountdown === null) return; + if (perfectCountdown === null) return if (perfectCountdown <= 0) { // Auto-submit when countdown reaches 0 - handleCheckSolution(); - setPerfectCountdown(null); - return; + handleCheckSolution() + setPerfectCountdown(null) + return } // Decrement every 1.5 seconds const timer = setTimeout(() => { - setPerfectCountdown((prev) => (prev !== null ? prev - 1 : null)); - }, 1500); + setPerfectCountdown((prev) => (prev !== null ? prev - 1 : null)) + }, 1500) - return () => clearTimeout(timer); + return () => clearTimeout(timer) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [perfectCountdown]); + }, [perfectCountdown]) // Watch for server confirmations and insert next card or check solution useEffect(() => { - if (!waitingToCheck) return; + if (!waitingToCheck) return - const cardsToInsert = cardsToInsertRef.current; - const currentIndex = currentInsertIndexRef.current; + const cardsToInsert = cardsToInsertRef.current + const currentIndex = currentInsertIndexRef.current - console.log("[PlayingPhaseDrag] useEffect check:", { + console.log('[PlayingPhaseDrag] useEffect check:', { waitingToCheck, currentIndex, totalCards: cardsToInsert.length, canCheckSolution, - }); + }) // If all cards have been sent, wait for server to confirm all are placed if (currentIndex >= cardsToInsert.length) { if (canCheckSolution) { - console.log( - "[PlayingPhaseDrag] ✅ Server confirmed all cards placed, checking solution", - ); - setWaitingToCheck(false); - cardsToInsertRef.current = []; - currentInsertIndexRef.current = 0; - checkSolution(); + console.log('[PlayingPhaseDrag] ✅ Server confirmed all cards placed, checking solution') + setWaitingToCheck(false) + cardsToInsertRef.current = [] + currentInsertIndexRef.current = 0 + checkSolution() } - return; + return } // Send next card - const card = cardsToInsert[currentIndex]; - const position = inferredSequence.findIndex((c) => c.id === card.id); + const card = cardsToInsert[currentIndex] + const position = inferredSequence.findIndex((c) => c.id === card.id) console.log( - `[PlayingPhaseDrag] 📥 Inserting card ${currentIndex + 1}/${cardsToInsert.length}: ${card.id} at position ${position}`, - ); - insertCard(card.id, position); - currentInsertIndexRef.current++; - }, [ - waitingToCheck, - canCheckSolution, - checkSolution, - insertCard, - inferredSequence, - ]); + `[PlayingPhaseDrag] 📥 Inserting card ${currentIndex + 1}/${cardsToInsert.length}: ${card.id} at position ${position}` + ) + insertCard(card.id, position) + currentInsertIndexRef.current++ + }, [waitingToCheck, canCheckSolution, checkSolution, insertCard, inferredSequence]) // Custom check solution that uses the inferred sequence const handleCheckSolution = () => { - if (isSpectating) return; - if (!canCheckSolutionDrag) return; + if (isSpectating) return + if (!canCheckSolutionDrag) return // Send the complete inferred sequence to the server - checkSolution(inferredSequence); - }; + checkSolution(inferredSequence) + } return (
{/* Player info */}
- - 👀 Spectating: - + 👀 Spectating: {state.playerMetadata.emoji} {state.playerMetadata.name} @@ -1619,71 +1516,58 @@ export function PlayingPhaseDrag() { {/* Progress */}
- - Progress: - + Progress: - {state.placedCards.filter((c) => c !== null).length}/ - {state.cardCount} - - - cards + {state.placedCards.filter((c) => c !== null).length}/{state.cardCount} + cards
{/* Educational Mode Toggle */} @@ -1695,26 +1579,26 @@ export function PlayingPhaseDrag() { {isSpectating && (
{/* Collapse/Expand Toggle */} @@ -1722,167 +1606,145 @@ export function PlayingPhaseDrag() { type="button" onClick={() => setSpectatorStatsCollapsed(!spectatorStatsCollapsed)} className={css({ - position: "absolute", + position: 'absolute', // Mobile: top center, Desktop: left middle - left: { base: "50%", md: "-40px" }, - top: { base: "-30px", md: "50%" }, - transform: { base: "translateX(-50%)", md: "translateY(-50%)" }, - width: { base: "80px", md: "40px" }, - height: { base: "30px", md: "80px" }, - background: "rgba(255, 255, 255, 0.95)", - border: "none", - borderRadius: { base: "8px 8px 0 0", md: "8px 0 0 8px" }, + left: { base: '50%', md: '-40px' }, + top: { base: '-30px', md: '50%' }, + transform: { base: 'translateX(-50%)', md: 'translateY(-50%)' }, + width: { base: '80px', md: '40px' }, + height: { base: '30px', md: '80px' }, + background: 'rgba(255, 255, 255, 0.95)', + border: 'none', + borderRadius: { base: '8px 8px 0 0', md: '8px 0 0 8px' }, boxShadow: { - base: "0 -2px 8px rgba(0, 0, 0, 0.1)", - md: "-2px 0 8px rgba(0, 0, 0, 0.1)", + base: '0 -2px 8px rgba(0, 0, 0, 0.1)', + md: '-2px 0 8px rgba(0, 0, 0, 0.1)', }, - cursor: "pointer", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: { base: "16px", md: "20px" }, - transition: "all 0.2s", + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: { base: '16px', md: '20px' }, + transition: 'all 0.2s', _hover: { - background: "rgba(255, 255, 255, 1)", + background: 'rgba(255, 255, 255, 1)', }, })} > - - {spectatorStatsCollapsed ? "▲" : "▼"} + + {spectatorStatsCollapsed ? '▲' : '▼'} - - {spectatorStatsCollapsed ? "◀" : "▶"} + + {spectatorStatsCollapsed ? '◀' : '▶'} {/* Stats Content */}

- + 📊 Live Stats - - 📊 Stats - + 📊 Stats

{/* Mobile: horizontal layout, Desktop: vertical layout */}
{/* Time Elapsed */}
- + ⏱️ Time Elapsed - - ⏱️ - + ⏱️
- {Math.floor(elapsedTime / 60)}: - {(elapsedTime % 60).toString().padStart(2, "0")} + {Math.floor(elapsedTime / 60)}:{(elapsedTime % 60).toString().padStart(2, '0')}
{/* Cards Placed */}
- + 🎯 Cards Placed - - 🎯 - + 🎯
- {state.placedCards.filter((c) => c !== null).length} /{" "} - {state.cardCount} + {state.placedCards.filter((c) => c !== null).length} / {state.cardCount}
{Math.round( - (state.placedCards.filter((c) => c !== null).length / - state.cardCount) * - 100, + (state.placedCards.filter((c) => c !== null).length / state.cardCount) * 100 )} % complete
@@ -1891,55 +1753,49 @@ export function PlayingPhaseDrag() { {/* Current Accuracy */}
- + ✨ Current Accuracy - - ✨ - +
{(() => { const placedCards = state.placedCards.filter( - (c): c is SortingCard => c !== null, - ); - if (placedCards.length === 0) return "0%"; + (c): c is SortingCard => c !== null + ) + if (placedCards.length === 0) return '0%' const correctCount = placedCards.filter( - (c, i) => state.correctOrder[i]?.id === c.id, - ).length; - return `${Math.round((correctCount / placedCards.length) * 100)}%`; + (c, i) => state.correctOrder[i]?.id === c.id + ).length + return `${Math.round((correctCount / placedCards.length) * 100)}%` })()}
Cards in correct position @@ -1954,21 +1810,21 @@ export function PlayingPhaseDrag() { {!isSpectating && (
{/* Check Solution Button with Label */}
- {isSequenceCorrect ? "PERFECT!" : "Done?"} + {isSequenceCorrect ? 'PERFECT!' : 'Done?'}
@@ -2047,18 +1891,18 @@ export function PlayingPhaseDrag() { {!isSpectating && (
⏱️ {formatTime(elapsedTime)} @@ -2069,23 +1913,23 @@ export function PlayingPhaseDrag() {
= 768 - ? "calc(100vw - 240px)" - : "100vw", + ? 'calc(100vw - 240px)' + : '100vw', height: isSpectating ? viewport.width < 768 - ? "calc(100vh - 170px)" - : "calc(100vh - 56px)" - : "100vh", - top: isSpectating ? (viewport.width < 768 ? "170px" : "56px") : "0", + ? 'calc(100vh - 170px)' + : 'calc(100vh - 56px)' + : '100vh', + top: isSpectating ? (viewport.width < 768 ? '170px' : '56px') : '0', }} > {/* Render continuous curved path through the entire sequence */} @@ -2106,56 +1950,48 @@ export function PlayingPhaseDrag() { ...state.availableCards, ...state.placedCards.filter((c): c is SortingCard => c !== null), ].map((card) => { - const cardState = cardStates.get(card.id); - if (!cardState) return null; + const cardState = cardStates.get(card.id) + if (!cardState) return null - const isDragging = draggingCardId === card.id; + const isDragging = draggingCardId === card.id // Check if card is in correct prefix or suffix position (for scaling/fading) - const positionInSequence = inferredSequence.findIndex( - (c) => c.id === card.id, - ); - let isInCorrectPrefixOrSuffix = false; + const positionInSequence = inferredSequence.findIndex((c) => c.id === card.id) + let isInCorrectPrefixOrSuffix = false if (positionInSequence >= 0) { // Check if card is part of correct prefix - let isInCorrectPrefix = true; + let isInCorrectPrefix = true for (let i = 0; i <= positionInSequence; i++) { if (inferredSequence[i]?.id !== state.correctOrder[i]?.id) { - isInCorrectPrefix = false; - break; + isInCorrectPrefix = false + break } } // Check if card is part of correct suffix - let isInCorrectSuffix = true; - const offsetFromEnd = - inferredSequence.length - 1 - positionInSequence; + let isInCorrectSuffix = true + const offsetFromEnd = inferredSequence.length - 1 - positionInSequence for (let i = 0; i <= offsetFromEnd; i++) { - const seqIdx = inferredSequence.length - 1 - i; - const correctIdx = state.correctOrder.length - 1 - i; - if ( - inferredSequence[seqIdx]?.id !== - state.correctOrder[correctIdx]?.id - ) { - isInCorrectSuffix = false; - break; + const seqIdx = inferredSequence.length - 1 - i + const correctIdx = state.correctOrder.length - 1 - i + if (inferredSequence[seqIdx]?.id !== state.correctOrder[correctIdx]?.id) { + isInCorrectSuffix = false + break } } - isInCorrectPrefixOrSuffix = isInCorrectPrefix || isInCorrectSuffix; + isInCorrectPrefixOrSuffix = isInCorrectPrefix || isInCorrectSuffix } // Show correctness based on educational mode for spectators const isCorrect = isSpectating ? spectatorEducationalMode && isInCorrectPrefixOrSuffix - : isInCorrectPrefixOrSuffix; + : isInCorrectPrefixOrSuffix // Get draggedByPlayerId from server state - const serverPosition = state.cardPositions.find( - (p) => p.cardId === card.id, - ); - const draggedByPlayerId = serverPosition?.draggedByPlayerId; + const serverPosition = state.cardPositions.find((p) => p.cardId === card.id) + const draggedByPlayerId = serverPosition?.draggedByPlayerId return ( handlePointerMove(e, card.id)} onPointerUp={(e) => handlePointerUp(e, card.id)} /> - ); + ) })}
{/* Activity Feed (collaborative mode only, hidden for spectators) */} - {state.gameMode === "collaborative" && - !isSpectating && - activityFeed.length > 0 && ( -
- {activityFeed.map((notification) => { - const age = Date.now() - notification.timestamp; - const opacity = Math.max(0, 1 - age / 3000); // Fade out over 3 seconds + {state.gameMode === 'collaborative' && !isSpectating && activityFeed.length > 0 && ( +
+ {activityFeed.map((notification) => { + const age = Date.now() - notification.timestamp + const opacity = Math.max(0, 1 - age / 3000) // Fade out over 3 seconds - return ( -
- - {notification.playerEmoji} - - - {notification.playerName}{" "} - {notification.action} - -
- ); - })} -
- )} + return ( +
+ {notification.playerEmoji} + + {notification.playerName} {notification.action} + +
+ ) + })} +
+ )}
- ); + ) } diff --git a/apps/web/src/arcade-games/card-sorting/components/ResultsPhase.tsx b/apps/web/src/arcade-games/card-sorting/components/ResultsPhase.tsx index ca013d10..7b482ba5 100644 --- a/apps/web/src/arcade-games/card-sorting/components/ResultsPhase.tsx +++ b/apps/web/src/arcade-games/card-sorting/components/ResultsPhase.tsx @@ -1,13 +1,13 @@ -"use client"; +'use client' -import { css } from "../../../../styled-system/css"; -import { useCardSorting } from "../Provider"; -import { useState, useEffect } from "react"; -import type { SortingCard } from "../types"; +import { css } from '../../../../styled-system/css' +import { useCardSorting } from '../Provider' +import { useState, useEffect } from 'react' +import type { SortingCard } from '../types' // Add result animations -if (typeof document !== "undefined") { - const style = document.createElement("style"); +if (typeof document !== 'undefined') { + const style = document.createElement('style') style.textContent = ` @keyframes scoreReveal { 0% { @@ -27,167 +27,155 @@ if (typeof document !== "undefined") { 25% { transform: scale(1.1) rotate(-5deg); } 75% { transform: scale(1.1) rotate(5deg); } } - `; - document.head.appendChild(style); + ` + document.head.appendChild(style) } export function ResultsPhase() { - const { state, startGame, goToSetup, exitSession, players } = - useCardSorting(); - const { scoreBreakdown } = state; - const [showCorrections, setShowCorrections] = useState(false); + const { state, startGame, goToSetup, exitSession, players } = useCardSorting() + const { scoreBreakdown } = state + const [showCorrections, setShowCorrections] = useState(false) // Determine if this is a collaborative game - const isCollaborative = state.gameMode === "collaborative"; + const isCollaborative = state.gameMode === 'collaborative' // Get user's sequence from placedCards - const userSequence = state.placedCards.filter( - (c): c is SortingCard => c !== null, - ); + const userSequence = state.placedCards.filter((c): c is SortingCard => c !== null) // Show corrections after a delay useEffect(() => { const timer = setTimeout(() => { - setShowCorrections(true); - }, 1000); - return () => clearTimeout(timer); - }, []); + setShowCorrections(true) + }, 1000) + return () => clearTimeout(timer) + }, []) if (!scoreBreakdown) { return (
No score data available
- ); + ) } - const isPerfect = scoreBreakdown.finalScore === 100; - const isExcellent = scoreBreakdown.finalScore >= 80; + const isPerfect = scoreBreakdown.finalScore === 100 + const isExcellent = scoreBreakdown.finalScore >= 80 const getMessage = (score: number) => { if (isCollaborative) { - if (score === 100) return "Perfect teamwork! All cards in correct order!"; - if (score >= 80) return "Excellent collaboration! Very close to perfect!"; - if (score >= 60) return "Good team effort! You worked well together!"; - return "Keep working together! Communication is key."; + if (score === 100) return 'Perfect teamwork! All cards in correct order!' + if (score >= 80) return 'Excellent collaboration! Very close to perfect!' + if (score >= 60) return 'Good team effort! You worked well together!' + return 'Keep working together! Communication is key.' } - if (score === 100) return "Perfect! All cards in correct order!"; - if (score >= 80) return "Excellent! Very close to perfect!"; - if (score >= 60) return "Good job! You understand the pattern!"; - return "Keep practicing! Focus on reading each abacus carefully."; - }; + if (score === 100) return 'Perfect! All cards in correct order!' + if (score >= 80) return 'Excellent! Very close to perfect!' + if (score >= 60) return 'Good job! You understand the pattern!' + return 'Keep practicing! Focus on reading each abacus carefully.' + } const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, "0")}`; - }; + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${s.toString().padStart(2, '0')}` + } return (
{/* Cards Grid Area */}
{userSequence.map((card, userIndex) => { - const isCorrect = state.correctOrder[userIndex]?.id === card.id; - const correctIndex = state.correctOrder.findIndex( - (c) => c.id === card.id, - ); + const isCorrect = state.correctOrder[userIndex]?.id === card.id + const correctIndex = state.correctOrder.findIndex((c) => c.id === card.id) return (
{/* Card */}
@@ -196,75 +184,71 @@ export function ResultsPhase() { {showCorrections && (
8 - ? { base: "-6px", md: "-12px" } - : { base: "-8px", md: "-12px" }, + ? { base: '-6px', md: '-12px' } + : { base: '-8px', md: '-12px' }, right: state.cardCount > 8 - ? { base: "-6px", md: "-12px" } - : { base: "-8px", md: "-12px" }, + ? { base: '-6px', md: '-12px' } + : { base: '-8px', md: '-12px' }, width: state.cardCount > 8 - ? { base: "20px", md: "32px" } - : { base: "24px", md: "32px" }, + ? { base: '20px', md: '32px' } + : { base: '24px', md: '32px' }, height: state.cardCount > 8 - ? { base: "20px", md: "32px" } - : { base: "24px", md: "32px" }, - borderRadius: "50%", - background: isCorrect ? "#22c55e" : "#ef4444", - display: "flex", - alignItems: "center", - justifyContent: "center", + ? { base: '20px', md: '32px' } + : { base: '24px', md: '32px' }, + borderRadius: '50%', + background: isCorrect ? '#22c55e' : '#ef4444', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', fontSize: state.cardCount > 8 - ? { base: "12px", md: "20px" } - : { base: "16px", md: "20px" }, - color: "white", - fontWeight: "bold", - boxShadow: "0 2px 8px rgba(0, 0, 0, 0.2)", - animation: "scoreReveal 0.4s ease-out", + ? { base: '12px', md: '20px' } + : { base: '16px', md: '20px' }, + color: 'white', + fontWeight: 'bold', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)', + animation: 'scoreReveal 0.4s ease-out', })} > - {isCorrect ? "✓" : "✗"} + {isCorrect ? '✓' : '✗'}
)} {/* Position number */}
8 - ? { base: "-5px", md: "-8px" } - : { base: "-6px", md: "-8px" }, - left: "50%", - transform: "translateX(-50%)", - background: isCorrect - ? "#22c55e" - : showCorrections - ? "#ef4444" - : "#0369a1", - color: "white", + ? { base: '-5px', md: '-8px' } + : { base: '-6px', md: '-8px' }, + left: '50%', + transform: 'translateX(-50%)', + background: isCorrect ? '#22c55e' : showCorrections ? '#ef4444' : '#0369a1', + color: 'white', padding: state.cardCount > 8 - ? { base: "2px 5px", md: "4px 8px" } - : { base: "3px 6px", md: "4px 8px" }, - borderRadius: { base: "6px", md: "12px" }, + ? { base: '2px 5px', md: '4px 8px' } + : { base: '3px 6px', md: '4px 8px' }, + borderRadius: { base: '6px', md: '12px' }, fontSize: state.cardCount > 8 - ? { base: "9px", md: "12px" } - : { base: "10px", md: "12px" }, - fontWeight: "bold", - boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)", + ? { base: '9px', md: '12px' } + : { base: '10px', md: '12px' }, + fontWeight: 'bold', + boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)', })} > #{showCorrections ? correctIndex + 1 : userIndex + 1}
- ); + ) })}
@@ -272,21 +256,21 @@ export function ResultsPhase() { {/* Score panel */}
{scoreBreakdown.finalScore}
- {isPerfect ? "🏆" : isExcellent ? "⭐" : "%"} + {isPerfect ? '🏆' : isExcellent ? '⭐' : '%'}
{/* Team/Solo Label */} {isCollaborative && (
👥 Team Score @@ -380,12 +364,12 @@ export function ResultsPhase() {
{getMessage(scoreBreakdown.finalScore)} @@ -394,13 +378,13 @@ export function ResultsPhase() { {/* Time Badge */}
⏱️ {formatTime(scoreBreakdown.elapsedTime)} @@ -412,55 +396,52 @@ export function ResultsPhase() { {isCollaborative && state.activePlayers.length > 0 && (
Team Members ({state.activePlayers.length})
{state.activePlayers .map((playerId) => players.get(playerId)) - .filter( - (player): player is NonNullable => - player !== undefined, - ) + .filter((player): player is NonNullable => player !== undefined) .map((player) => (
- {player.emoji} + {player.emoji} {player.name}
))} @@ -471,46 +452,46 @@ export function ResultsPhase() { {/* Score Details - Compact Cards */}
{/* Exact Matches */}
Exact
{scoreBreakdown.exactMatches} /{state.cardCount} @@ -521,38 +502,38 @@ export function ResultsPhase() { {/* Sequence */}
Sequence
{scoreBreakdown.lcsLength} /{state.cardCount} @@ -563,30 +544,30 @@ export function ResultsPhase() { {/* Misplaced */}
Wrong
{scoreBreakdown.inversions} @@ -597,38 +578,36 @@ export function ResultsPhase() { {/* Action Buttons */}
@@ -636,27 +615,25 @@ export function ResultsPhase() { type="button" onClick={goToSetup} className={css({ - padding: { base: "10px 8px", md: "12px 20px" }, - background: "white", - border: "2px solid rgba(59, 130, 246, 0.3)", - borderRadius: { base: "8px", md: "12px" }, - fontSize: { base: "11px", md: "14px" }, - fontWeight: "700", - color: "#0c4a6e", - cursor: "pointer", - transition: "all 0.2s ease", - textTransform: "uppercase", - letterSpacing: "0.5px", - flex: { base: 1, md: "none" }, + padding: { base: '10px 8px', md: '12px 20px' }, + background: 'white', + border: '2px solid rgba(59, 130, 246, 0.3)', + borderRadius: { base: '8px', md: '12px' }, + fontSize: { base: '11px', md: '14px' }, + fontWeight: '700', + color: '#0c4a6e', + cursor: 'pointer', + transition: 'all 0.2s ease', + textTransform: 'uppercase', + letterSpacing: '0.5px', + flex: { base: 1, md: 'none' }, _hover: { - borderColor: "rgba(59, 130, 246, 0.5)", - background: "rgba(59, 130, 246, 0.05)", + borderColor: 'rgba(59, 130, 246, 0.5)', + background: 'rgba(59, 130, 246, 0.05)', }, })} > - - ⚙️{" "} - + ⚙️ Settings @@ -664,31 +641,29 @@ export function ResultsPhase() { type="button" onClick={exitSession} className={css({ - padding: { base: "10px 8px", md: "12px 20px" }, - background: "white", - border: "2px solid rgba(239, 68, 68, 0.3)", - borderRadius: { base: "8px", md: "12px" }, - fontSize: { base: "11px", md: "14px" }, - fontWeight: "700", - color: "#991b1b", - cursor: "pointer", - transition: "all 0.2s ease", - textTransform: "uppercase", - letterSpacing: "0.5px", - flex: { base: 1, md: "none" }, + padding: { base: '10px 8px', md: '12px 20px' }, + background: 'white', + border: '2px solid rgba(239, 68, 68, 0.3)', + borderRadius: { base: '8px', md: '12px' }, + fontSize: { base: '11px', md: '14px' }, + fontWeight: '700', + color: '#991b1b', + cursor: 'pointer', + transition: 'all 0.2s ease', + textTransform: 'uppercase', + letterSpacing: '0.5px', + flex: { base: 1, md: 'none' }, _hover: { - borderColor: "rgba(239, 68, 68, 0.5)", - background: "rgba(239, 68, 68, 0.05)", + borderColor: 'rgba(239, 68, 68, 0.5)', + background: 'rgba(239, 68, 68, 0.05)', }, })} > - - 🚪{" "} - + 🚪 Exit
- ); + ) } diff --git a/apps/web/src/arcade-games/card-sorting/components/SetupPhase.tsx b/apps/web/src/arcade-games/card-sorting/components/SetupPhase.tsx index 12b787e7..4a49d3c6 100644 --- a/apps/web/src/arcade-games/card-sorting/components/SetupPhase.tsx +++ b/apps/web/src/arcade-games/card-sorting/components/SetupPhase.tsx @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { AbacusReact } from "@soroban/abacus-react"; -import { css } from "../../../../styled-system/css"; -import { useCardSorting } from "../Provider"; +import { AbacusReact } from '@soroban/abacus-react' +import { css } from '../../../../styled-system/css' +import { useCardSorting } from '../Provider' // Add animations const animations = ` @@ -35,185 +35,181 @@ const animations = ` 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); +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 { 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, + 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", + ? '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", + ? '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)", + 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)", + ? '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)", + transform: 'translateY(-2px) scale(1.01)', }, - }); - }; + }) + } const cardCountInfo = { 5: { - icon: "🌱", - label: "Gentle", - description: "Perfect to start", - emoji: "🟢", - difficulty: "Easy", + icon: '🌱', + label: 'Gentle', + description: 'Perfect to start', + emoji: '🟢', + difficulty: 'Easy', }, 8: { - icon: "⚡", - label: "Swift", - description: "Nice challenge", - emoji: "🟡", - difficulty: "Medium", + icon: '⚡', + label: 'Swift', + description: 'Nice challenge', + emoji: '🟡', + difficulty: 'Medium', }, 12: { - icon: "🔥", - label: "Intense", - description: "Test your memory", - emoji: "🟠", - difficulty: "Hard", + icon: '🔥', + label: 'Intense', + description: 'Test your memory', + emoji: '🟠', + difficulty: 'Hard', }, 15: { - icon: "💎", - label: "Master", - description: "Ultimate test", - emoji: "🔴", - difficulty: "Expert", + icon: '💎', + label: 'Master', + description: 'Ultimate test', + emoji: '🔴', + difficulty: 'Expert', }, - }; + } return (
{/* Hero Section */}
{/* Animated background pattern */}
-
+

🎴 Card Sorting Challenge

- Arrange abacus cards in order using{" "} - only visual patterns — no numbers shown! + Arrange abacus cards in order using only visual patterns — no numbers + shown!

{/* Sample cards preview */}
{[3, 7, 12].map((value, idx) => (
@@ -228,25 +224,25 @@ export function SetupPhase() {
🎯

@@ -256,34 +252,34 @@ export function SetupPhase() {
{([5, 8, 12, 15] as const).map((count) => { - const info = cardCountInfo[count]; + const info = cardCountInfo[count] return ( - ); + ) })}

- {cardCountInfo[state.cardCount].emoji}{" "} - {state.cardCount} cards •{" "} - {cardCountInfo[state.cardCount].difficulty} difficulty •{" "} + {cardCountInfo[state.cardCount].emoji} {state.cardCount} cards •{' '} + {cardCountInfo[state.cardCount].difficulty} difficulty •{' '} {cardCountInfo[state.cardCount].description}

@@ -351,8 +346,8 @@ export function SetupPhase() { {/* Start Button */}
{canResumeGame && ( @@ -360,43 +355,41 @@ export function SetupPhase() { type="button" onClick={resumeGame} className={css({ - width: "100%", - background: - "linear-gradient(135deg, #10b981 0%, #059669 50%, #34d399 100%)", - color: "white", - border: "none", - borderRadius: { base: "16px", md: "20px" }, - padding: { base: "16px", md: "20px" }, - fontSize: { base: "18px", md: "22px" }, - fontWeight: "black", - 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", + width: '100%', + background: 'linear-gradient(135deg, #10b981 0%, #059669 50%, #34d399 100%)', + color: 'white', + border: 'none', + borderRadius: { base: '16px', md: '20px' }, + padding: { base: '16px', md: '20px' }, + fontSize: { base: '18px', md: '22px' }, + fontWeight: 'black', + 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', _hover: { - transform: "translateY(-4px) scale(1.02)", + 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)", + '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)", + transform: 'translateY(-2px) scale(1.01)', }, })} >
▶️ @@ -404,9 +397,9 @@ export function SetupPhase() { RESUME GAME 🎮 @@ -419,71 +412,70 @@ export function SetupPhase() { type="button" onClick={startGame} className={css({ - width: "100%", + width: '100%', background: canResumeGame - ? "linear-gradient(135deg, #64748b, #475569)" - : "linear-gradient(135deg, #14b8a6 0%, #0d9488 50%, #5eead4 100%)", - color: "white", - border: "none", - borderRadius: { base: "16px", md: "20px" }, - padding: { base: "14px", md: "18px" }, - fontSize: { base: "18px", md: "20px" }, - fontWeight: "black", - cursor: "pointer", - transition: "all 0.4s cubic-bezier(0.4, 0, 0.2, 1)", + ? 'linear-gradient(135deg, #64748b, #475569)' + : 'linear-gradient(135deg, #14b8a6 0%, #0d9488 50%, #5eead4 100%)', + color: 'white', + border: 'none', + borderRadius: { base: '16px', md: '20px' }, + padding: { base: '14px', md: '18px' }, + fontSize: { base: '18px', md: '20px' }, + fontWeight: 'black', + 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", + ? '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", + 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%", + left: '-200%', + width: '200%', + height: '100%', + background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)', + backgroundSize: '200% 100%', }, _hover: { - transform: "translateY(-4px) scale(1.02)", + 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)", + ? '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", + animation: 'shimmer 1.5s ease-in-out', }, }, _active: { - transform: "translateY(-2px) scale(1.01)", + transform: 'translateY(-2px) scale(1.01)', }, })} >
🚀 - {canResumeGame ? "START NEW GAME" : "START GAME"} + {canResumeGame ? 'START NEW GAME' : 'START GAME'} 🎴 @@ -494,17 +486,16 @@ export function SetupPhase() { {!canResumeGame && (

- 💡 Tip: Look for patterns in the beads — focus on positions, not - numbers! + 💡 Tip: Look for patterns in the beads — focus on positions, not numbers!

)}
- ); + ) } diff --git a/apps/web/src/arcade-games/card-sorting/index.ts b/apps/web/src/arcade-games/card-sorting/index.ts index 787a9009..a69f83f6 100644 --- a/apps/web/src/arcade-games/card-sorting/index.ts +++ b/apps/web/src/arcade-games/card-sorting/index.ts @@ -5,93 +5,76 @@ * in ascending order using only visual patterns (no numbers shown). */ -import { defineGame, getGameTheme } from "@/lib/arcade/game-sdk"; -import type { GameManifest } from "@/lib/arcade/game-sdk"; -import { GameComponent } from "./components/GameComponent"; -import { CardSortingProvider } from "./Provider"; -import type { - CardSortingConfig, - CardSortingMove, - CardSortingState, -} from "./types"; -import { cardSortingValidator } from "./Validator"; +import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk' +import type { GameManifest } from '@/lib/arcade/game-sdk' +import { GameComponent } from './components/GameComponent' +import { CardSortingProvider } from './Provider' +import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types' +import { cardSortingValidator } from './Validator' const manifest: GameManifest = { - name: "card-sorting", - displayName: "Card Sorting Challenge", - icon: "🔢", - description: "Sort abacus cards using pattern recognition", + name: 'card-sorting', + displayName: 'Card Sorting Challenge', + icon: '🔢', + description: 'Sort abacus cards using pattern recognition', longDescription: - "Challenge your abacus reading skills! Arrange cards in ascending order using only " + - "the visual patterns - no numbers shown. Perfect for practicing number recognition and " + - "developing mental math intuition.", + 'Challenge your abacus reading skills! Arrange cards in ascending order using only ' + + 'the visual patterns - no numbers shown. Perfect for practicing number recognition and ' + + 'developing mental math intuition.', maxPlayers: 1, // Single player only - difficulty: "Intermediate", - chips: ["🧠 Pattern Recognition", "🎯 Solo Challenge", "📊 Smart Scoring"], - ...getGameTheme("green"), + difficulty: 'Intermediate', + chips: ['🧠 Pattern Recognition', '🎯 Solo Challenge', '📊 Smart Scoring'], + ...getGameTheme('green'), available: true, -}; +} const defaultConfig: CardSortingConfig = { cardCount: 8, showNumbers: true, timeLimit: null, - gameMode: "solo", -}; + gameMode: 'solo', +} // Config validation function -function validateCardSortingConfig( - config: unknown, -): config is CardSortingConfig { - if (typeof config !== "object" || config === null) { - return false; +function validateCardSortingConfig(config: unknown): config is CardSortingConfig { + if (typeof config !== 'object' || config === null) { + return false } - const c = config as Record; + const c = config as Record // Validate cardCount - if (!("cardCount" in c) || ![5, 8, 12, 15].includes(c.cardCount as number)) { - return false; + if (!('cardCount' in c) || ![5, 8, 12, 15].includes(c.cardCount as number)) { + return false } // Validate showNumbers - if (!("showNumbers" in c) || typeof c.showNumbers !== "boolean") { - return false; + if (!('showNumbers' in c) || typeof c.showNumbers !== 'boolean') { + return false } // Validate timeLimit - if ("timeLimit" in c) { - if ( - c.timeLimit !== null && - (typeof c.timeLimit !== "number" || c.timeLimit < 30) - ) { - return false; + if ('timeLimit' in c) { + if (c.timeLimit !== null && (typeof c.timeLimit !== 'number' || c.timeLimit < 30)) { + return false } } // Validate gameMode (optional, defaults to 'solo') - if ("gameMode" in c) { - if ( - !["solo", "collaborative", "competitive", "relay"].includes( - c.gameMode as string, - ) - ) { - return false; + if ('gameMode' in c) { + if (!['solo', 'collaborative', 'competitive', 'relay'].includes(c.gameMode as string)) { + return false } } - return true; + return true } -export const cardSortingGame = defineGame< - CardSortingConfig, - CardSortingState, - CardSortingMove ->({ +export const cardSortingGame = defineGame({ manifest, Provider: CardSortingProvider, GameComponent, validator: cardSortingValidator, defaultConfig, validateConfig: validateCardSortingConfig, -}); +}) diff --git a/apps/web/src/arcade-games/card-sorting/types.ts b/apps/web/src/arcade-games/card-sorting/types.ts index 0ddecc8a..cad6c96e 100644 --- a/apps/web/src/arcade-games/card-sorting/types.ts +++ b/apps/web/src/arcade-games/card-sorting/types.ts @@ -1,64 +1,64 @@ -import type { GameConfig, GameState } from "@/lib/arcade/game-sdk/types"; +import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types' // ============================================================================ // Player Metadata // ============================================================================ export interface PlayerMetadata { - id: string; // Player ID (UUID) - name: string; - emoji: string; - userId: string; + id: string // Player ID (UUID) + name: string + emoji: string + userId: string } // ============================================================================ // Configuration // ============================================================================ -export type GameMode = "solo" | "collaborative" | "competitive" | "relay"; +export type GameMode = 'solo' | 'collaborative' | 'competitive' | 'relay' export interface CardSortingConfig extends GameConfig { - cardCount: 5 | 8 | 12 | 15; // Difficulty (number of cards) - timeLimit: number | null; // Optional time limit (seconds), null = unlimited - gameMode: GameMode; // Game mode (solo, collaborative, competitive, relay) + cardCount: 5 | 8 | 12 | 15 // Difficulty (number of cards) + timeLimit: number | null // Optional time limit (seconds), null = unlimited + gameMode: GameMode // Game mode (solo, collaborative, competitive, relay) } // ============================================================================ // Core Data Types // ============================================================================ -export type GamePhase = "setup" | "playing" | "results"; +export type GamePhase = 'setup' | 'playing' | 'results' export interface SortingCard { - id: string; // Unique ID for this card instance - number: number; // The abacus value (0-99+) - svgContent: string; // Serialized AbacusReact SVG + id: string // Unique ID for this card instance + number: number // The abacus value (0-99+) + 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 + 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) + card: SortingCard // The card data + position: number // Which slot it's in (0-indexed) } export interface ScoreBreakdown { - finalScore: number; // 0-100 weighted average - exactMatches: number; // Cards in exactly correct position - lcsLength: number; // Longest common subsequence length - inversions: number; // Number of out-of-order pairs - relativeOrderScore: number; // 0-100 based on LCS - exactPositionScore: number; // 0-100 based on exact matches - inversionScore: number; // 0-100 based on inversions - elapsedTime: number; // Seconds taken + finalScore: number // 0-100 weighted average + exactMatches: number // Cards in exactly correct position + lcsLength: number // Longest common subsequence length + inversions: number // Number of out-of-order pairs + relativeOrderScore: number // 0-100 based on LCS + exactPositionScore: number // 0-100 based on exact matches + inversionScore: number // 0-100 based on inversions + elapsedTime: number // Seconds taken } // ============================================================================ @@ -67,47 +67,47 @@ export interface ScoreBreakdown { export interface CardSortingState extends GameState { // Configuration - cardCount: 5 | 8 | 12 | 15; - timeLimit: number | null; - gameMode: GameMode; + cardCount: 5 | 8 | 12 | 15 + timeLimit: number | null + gameMode: GameMode // Game phase - gamePhase: GamePhase; + gamePhase: GamePhase // Player & timing - playerId: string; // Single player ID (primary player in solo/collaborative) - playerMetadata: PlayerMetadata; // Player display info - activePlayers: string[]; // All active player IDs (for collaborative mode) - allPlayerMetadata: Map; // Metadata for all players - gameStartTime: number | null; - gameEndTime: number | null; + playerId: string // Single player ID (primary player in solo/collaborative) + playerMetadata: PlayerMetadata // Player display info + activePlayers: string[] // All active player IDs (for collaborative mode) + allPlayerMetadata: Map // Metadata for all players + gameStartTime: number | null + gameEndTime: number | null // Cards - selectedCards: SortingCard[]; // The N cards for this game - 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 + selectedCards: SortingCard[] // The N cards for this game + 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; // Player ID -> cursor position + cursorPositions: Map // Player ID -> cursor position // UI state (client-only, not in server state) - selectedCardId: string | null; // Currently selected card + selectedCardId: string | null // Currently selected card // Results - scoreBreakdown: ScoreBreakdown | null; // Final score details + scoreBreakdown: ScoreBreakdown | null // Final score details // Pause/Resume (standard pattern) - originalConfig?: CardSortingConfig; - pausedGamePhase?: GamePhase; + originalConfig?: CardSortingConfig + pausedGamePhase?: GamePhase pausedGameState?: { - selectedCards: SortingCard[]; - availableCards: SortingCard[]; - placedCards: (SortingCard | null)[]; - cardPositions: CardPosition[]; - gameStartTime: number; - }; + selectedCards: SortingCard[] + availableCards: SortingCard[] + placedCards: (SortingCard | null)[] + cardPositions: CardPosition[] + gameStartTime: number + } } // ============================================================================ @@ -116,138 +116,138 @@ export interface CardSortingState extends GameState { export type CardSortingMove = | { - type: "START_GAME"; - playerId: string; - userId: string; - timestamp: number; + type: 'START_GAME' + playerId: string + userId: string + timestamp: number data: { - playerMetadata: PlayerMetadata; - selectedCards: SortingCard[]; // Pre-selected random cards - }; + playerMetadata: PlayerMetadata + selectedCards: SortingCard[] // Pre-selected random cards + } } | { - type: "PLACE_CARD"; - playerId: string; - userId: string; - timestamp: number; + type: 'PLACE_CARD' + playerId: string + userId: string + timestamp: number data: { - cardId: string; // Which card to place - position: number; // Which slot (0-indexed) - }; + cardId: string // Which card to place + position: number // Which slot (0-indexed) + } } | { - type: "INSERT_CARD"; - playerId: string; - userId: string; - timestamp: number; + type: 'INSERT_CARD' + playerId: string + userId: string + timestamp: number data: { - cardId: string; // Which card to insert - insertPosition: number; // Where to insert (0-indexed, can be 0 to cardCount) - }; + cardId: string // Which card to insert + insertPosition: number // Where to insert (0-indexed, can be 0 to cardCount) + } } | { - type: "REMOVE_CARD"; - playerId: string; - userId: string; - timestamp: number; + type: 'REMOVE_CARD' + playerId: string + userId: string + timestamp: number data: { - position: number; // Which slot to remove from - }; + position: number // Which slot to remove from + } } | { - type: "CHECK_SOLUTION"; - playerId: string; - userId: string; - timestamp: number; + type: 'CHECK_SOLUTION' + playerId: string + userId: string + timestamp: number data: { - finalSequence?: SortingCard[]; // Optional - if provided, use this as the final placement - }; + finalSequence?: SortingCard[] // Optional - if provided, use this as the final placement + } } | { - type: "GO_TO_SETUP"; - playerId: string; - userId: string; - timestamp: number; - data: Record; + type: 'GO_TO_SETUP' + playerId: string + userId: string + timestamp: number + data: Record } | { - type: "SET_CONFIG"; - playerId: string; - userId: string; - timestamp: number; + type: 'SET_CONFIG' + playerId: string + userId: string + timestamp: number data: { - field: "cardCount" | "timeLimit" | "gameMode"; - value: unknown; - }; + field: 'cardCount' | 'timeLimit' | 'gameMode' + value: unknown + } } | { - type: "RESUME_GAME"; - playerId: string; - userId: string; - timestamp: number; - data: Record; + type: 'RESUME_GAME' + playerId: string + userId: string + timestamp: number + data: Record } | { - type: "UPDATE_CARD_POSITIONS"; - playerId: string; - userId: string; - timestamp: number; + type: 'UPDATE_CARD_POSITIONS' + playerId: string + userId: string + timestamp: number data: { - positions: CardPosition[]; - }; + positions: CardPosition[] + } } | { - type: "JOIN_COLLABORATIVE_GAME"; - playerId: string; - userId: string; - timestamp: number; + type: 'JOIN_COLLABORATIVE_GAME' + playerId: string + userId: string + timestamp: number data: { - playerMetadata: PlayerMetadata; - }; + playerMetadata: PlayerMetadata + } } | { - type: "LEAVE_COLLABORATIVE_GAME"; - playerId: string; - userId: string; - timestamp: number; - data: Record; + type: 'LEAVE_COLLABORATIVE_GAME' + playerId: string + userId: string + timestamp: number + data: Record } | { - type: "UPDATE_CURSOR_POSITION"; - playerId: string; - userId: string; - timestamp: number; + 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) - }; - }; + x: number // % of viewport width (0-100) + y: number // % of viewport height (0-100) + } + } // ============================================================================ // Component Props // ============================================================================ export interface SortingCardProps { - card: SortingCard; - isSelected: boolean; - isPlaced: boolean; - isCorrect?: boolean; // After checking solution - onClick: () => void; + card: SortingCard + isSelected: boolean + isPlaced: boolean + isCorrect?: boolean // After checking solution + onClick: () => void } export interface PositionSlotProps { - position: number; - card: SortingCard | null; - isActive: boolean; // If slot is clickable - isCorrect?: boolean; // After checking solution - gradientStyle: React.CSSProperties; - onClick: () => void; + position: number + card: SortingCard | null + isActive: boolean // If slot is clickable + isCorrect?: boolean // After checking solution + gradientStyle: React.CSSProperties + onClick: () => void } export interface ScoreDisplayProps { - breakdown: ScoreBreakdown; - correctOrder: SortingCard[]; - userOrder: SortingCard[]; - onNewGame: () => void; - onExit: () => void; + breakdown: ScoreBreakdown + correctOrder: SortingCard[] + userOrder: SortingCard[] + onNewGame: () => void + onExit: () => void } diff --git a/apps/web/src/arcade-games/card-sorting/utils/cardGeneration.tsx b/apps/web/src/arcade-games/card-sorting/utils/cardGeneration.tsx index 3f71797e..0d1f30c4 100644 --- a/apps/web/src/arcade-games/card-sorting/utils/cardGeneration.tsx +++ b/apps/web/src/arcade-games/card-sorting/utils/cardGeneration.tsx @@ -1,6 +1,6 @@ -import { AbacusReact } from "@soroban/abacus-react"; -import { renderToString } from "react-dom/server"; -import type { SortingCard } from "../types"; +import { AbacusReact } from '@soroban/abacus-react' +import { renderToString } from 'react-dom/server' +import type { SortingCard } from '../types' /** * Generate random cards for sorting game @@ -8,21 +8,16 @@ import type { SortingCard } from "../types"; * @param minValue Minimum abacus value (default 0) * @param maxValue Maximum abacus value (default 99) */ -export function generateRandomCards( - count: number, - minValue = 0, - maxValue = 99, -): SortingCard[] { +export function generateRandomCards(count: number, minValue = 0, maxValue = 99): SortingCard[] { // Generate pool of unique random numbers - const numbers = new Set(); + const numbers = new Set() while (numbers.size < count) { - const num = - Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue; - numbers.add(num); + const num = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue + numbers.add(num) } // Convert to sorted array (for answer key) - const sortedNumbers = Array.from(numbers).sort((a, b) => a - b); + const sortedNumbers = Array.from(numbers).sort((a, b) => a - b) // Create card objects with SVG content return sortedNumbers.map((number, index) => { @@ -35,25 +30,25 @@ export function generateRandomCards( interactive={false} showNumbers={false} animated={false} - />, - ); + /> + ) return { id: `card-${index}-${number}`, number, svgContent, - }; - }); + } + }) } /** * Shuffle array for random order */ export function shuffleCards(cards: SortingCard[]): SortingCard[] { - const shuffled = [...cards]; + const shuffled = [...cards] for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] } - return shuffled; + return shuffled } diff --git a/apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts b/apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts index 76b01fcf..4c604976 100644 --- a/apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts +++ b/apps/web/src/arcade-games/card-sorting/utils/scoringAlgorithm.ts @@ -1,60 +1,54 @@ -import type { ScoreBreakdown } from "../types"; +import type { ScoreBreakdown } from '../types' /** * Calculate Longest Common Subsequence length * Measures how many cards are in correct relative order */ -export function longestCommonSubsequence( - seq1: number[], - seq2: number[], -): number { - const m = seq1.length; - const n = seq2.length; +export function longestCommonSubsequence(seq1: number[], seq2: number[]): number { + const m = seq1.length + const n = seq2.length const dp: number[][] = Array(m + 1) .fill(0) - .map(() => Array(n + 1).fill(0)); + .map(() => Array(n + 1).fill(0)) for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (seq1[i - 1] === seq2[j - 1]) { - dp[i][j] = dp[i - 1][j - 1] + 1; + dp[i][j] = dp[i - 1][j - 1] + 1 } else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]) } } } - return dp[m][n]; + return dp[m][n] } /** * Count inversions (out-of-order pairs) * Measures how scrambled the sequence is */ -export function countInversions( - userSeq: number[], - correctSeq: number[], -): number { +export function countInversions(userSeq: number[], correctSeq: number[]): number { // Create mapping from value to correct position - const correctPositions: Record = {}; + const correctPositions: Record = {} for (let idx = 0; idx < correctSeq.length; idx++) { - correctPositions[correctSeq[idx]] = idx; + correctPositions[correctSeq[idx]] = idx } // Convert user sequence to correct-position sequence - const userCorrectPositions = userSeq.map((val) => correctPositions[val]); + const userCorrectPositions = userSeq.map((val) => correctPositions[val]) // Count inversions - let inversions = 0; + let inversions = 0 for (let i = 0; i < userCorrectPositions.length; i++) { for (let j = i + 1; j < userCorrectPositions.length; j++) { if (userCorrectPositions[i] > userCorrectPositions[j]) { - inversions++; + inversions++ } } } - return inversions; + return inversions } /** @@ -63,37 +57,33 @@ export function countInversions( export function calculateScore( userSequence: number[], correctSequence: number[], - startTime: number, + startTime: number ): ScoreBreakdown { // LCS-based score (relative order) - const lcsLength = longestCommonSubsequence(userSequence, correctSequence); - const relativeOrderScore = (lcsLength / correctSequence.length) * 100; + const lcsLength = longestCommonSubsequence(userSequence, correctSequence) + const relativeOrderScore = (lcsLength / correctSequence.length) * 100 // Exact position matches - let exactMatches = 0; + let exactMatches = 0 for (let i = 0; i < userSequence.length; i++) { if (userSequence[i] === correctSequence[i]) { - exactMatches++; + exactMatches++ } } - const exactPositionScore = (exactMatches / correctSequence.length) * 100; + const exactPositionScore = (exactMatches / correctSequence.length) * 100 // Inversion-based score (organization) - const inversions = countInversions(userSequence, correctSequence); - const maxInversions = - (correctSequence.length * (correctSequence.length - 1)) / 2; - const inversionScore = Math.max( - 0, - ((maxInversions - inversions) / maxInversions) * 100, - ); + const inversions = countInversions(userSequence, correctSequence) + const maxInversions = (correctSequence.length * (correctSequence.length - 1)) / 2 + const inversionScore = Math.max(0, ((maxInversions - inversions) / maxInversions) * 100) // Weighted final score // - 50% for relative order (LCS) // - 30% for exact positions // - 20% for organization (inversions) const finalScore = Math.round( - relativeOrderScore * 0.5 + exactPositionScore * 0.3 + inversionScore * 0.2, - ); + relativeOrderScore * 0.5 + exactPositionScore * 0.3 + inversionScore * 0.2 + ) return { finalScore, @@ -104,5 +94,5 @@ export function calculateScore( exactPositionScore: Math.round(exactPositionScore), inversionScore: Math.round(inversionScore), elapsedTime: Math.floor((Date.now() - startTime) / 1000), - }; + } } diff --git a/apps/web/src/arcade-games/card-sorting/utils/validation.ts b/apps/web/src/arcade-games/card-sorting/utils/validation.ts index e28a7916..38af8827 100644 --- a/apps/web/src/arcade-games/card-sorting/utils/validation.ts +++ b/apps/web/src/arcade-games/card-sorting/utils/validation.ts @@ -1,4 +1,4 @@ -import type { SortingCard } from "../types"; +import type { SortingCard } from '../types' /** * Place a card at a specific position (simple replacement, can leave gaps) @@ -8,12 +8,12 @@ import type { SortingCard } from "../types"; export function placeCardAtPosition( placedCards: (SortingCard | null)[], cardToPlace: SortingCard, - position: number, + position: number ): { placedCards: (SortingCard | null)[]; replacedCard: SortingCard | null } { - const newPlaced = [...placedCards]; - const replacedCard = newPlaced[position]; - newPlaced[position] = cardToPlace; - return { placedCards: newPlaced, replacedCard }; + const newPlaced = [...placedCards] + const replacedCard = newPlaced[position] + newPlaced[position] = cardToPlace + return { placedCards: newPlaced, replacedCard } } /** @@ -25,50 +25,50 @@ export function insertCardAtPosition( placedCards: (SortingCard | null)[], cardToPlace: SortingCard, insertPosition: number, - totalSlots: number, + totalSlots: number ): { placedCards: (SortingCard | null)[]; excessCards: SortingCard[] } { // Create working array - const newPlaced = new Array(totalSlots).fill(null); + const newPlaced = new Array(totalSlots).fill(null) // Copy existing cards, shifting those at/after position for (let i = 0; i < placedCards.length; i++) { if (placedCards[i] !== null) { if (i < insertPosition) { // Before insert position - stays same - newPlaced[i] = placedCards[i]; + newPlaced[i] = placedCards[i] } else { // At or after position - shift right if (i + 1 < totalSlots) { - newPlaced[i + 1] = placedCards[i]; + newPlaced[i + 1] = placedCards[i] } else { // Card would fall off, will be handled by compaction - newPlaced[i + 1] = placedCards[i]; + newPlaced[i + 1] = placedCards[i] } } } } // Place new card at insert position - newPlaced[insertPosition] = cardToPlace; + newPlaced[insertPosition] = cardToPlace // Compact to remove gaps (shift all cards left) - const compacted: SortingCard[] = []; + const compacted: SortingCard[] = [] for (const card of newPlaced) { if (card !== null) { - compacted.push(card); + compacted.push(card) } } // Fill final array with compacted cards (no gaps) - const result = new Array(totalSlots).fill(null); + const result = new Array(totalSlots).fill(null) for (let i = 0; i < Math.min(compacted.length, totalSlots); i++) { - result[i] = compacted[i]; + result[i] = compacted[i] } // Any excess cards are returned - const excess = compacted.slice(totalSlots); + const excess = compacted.slice(totalSlots) - return { placedCards: result, excessCards: excess }; + return { placedCards: result, excessCards: excess } } /** @@ -76,27 +76,27 @@ export function insertCardAtPosition( */ export function removeCardAtPosition( placedCards: (SortingCard | null)[], - position: number, + position: number ): { placedCards: (SortingCard | null)[]; removedCard: SortingCard | null } { - const removedCard = placedCards[position]; + const removedCard = placedCards[position] if (!removedCard) { - return { placedCards, removedCard: null }; + return { placedCards, removedCard: null } } // Remove card and compact - const compacted: SortingCard[] = []; + const compacted: SortingCard[] = [] for (let i = 0; i < placedCards.length; i++) { if (i !== position && placedCards[i] !== null) { - compacted.push(placedCards[i] as SortingCard); + compacted.push(placedCards[i] as SortingCard) } } // Fill new array - const newPlaced = new Array(placedCards.length).fill(null); + const newPlaced = new Array(placedCards.length).fill(null) for (let i = 0; i < compacted.length; i++) { - newPlaced[i] = compacted[i]; + newPlaced[i] = compacted[i] } - return { placedCards: newPlaced, removedCard }; + return { placedCards: newPlaced, removedCard } } diff --git a/apps/web/src/arcade-games/complement-race/Provider.tsx b/apps/web/src/arcade-games/complement-race/Provider.tsx index bde9efd7..f5993941 100644 --- a/apps/web/src/arcade-games/complement-race/Provider.tsx +++ b/apps/web/src/arcade-games/complement-race/Provider.tsx @@ -3,7 +3,7 @@ * Manages multiplayer game state using the Arcade SDK */ -"use client"; +'use client' import { createContext, @@ -14,7 +14,7 @@ import { useRef, useState, type ReactNode, -} from "react"; +} from 'react' import { type GameMove, buildPlayerMetadata, @@ -23,14 +23,10 @@ import { useRoomData, useUpdateGameConfig, useViewerId, -} from "@/lib/arcade/game-sdk"; -import { DEFAULT_COMPLEMENT_RACE_CONFIG } from "@/lib/arcade/game-configs"; -import type { DifficultyTracker } from "@/app/arcade/complement-race/lib/gameTypes"; -import type { - ComplementRaceConfig, - ComplementRaceMove, - ComplementRaceState, -} from "./types"; +} from '@/lib/arcade/game-sdk' +import { DEFAULT_COMPLEMENT_RACE_CONFIG } from '@/lib/arcade/game-configs' +import type { DifficultyTracker } from '@/app/arcade/complement-race/lib/gameTypes' +import type { ComplementRaceConfig, ComplementRaceMove, ComplementRaceState } from './types' /** * Compatible state shape that matches the old single-player GameState interface @@ -38,177 +34,168 @@ import type { */ interface CompatibleGameState { // Game configuration (extracted from config object) - mode: string; - style: string; - timeoutSetting: string; - complementDisplay: string; - maxConcurrentPassengers: number; + mode: string + style: string + timeoutSetting: string + complementDisplay: string + maxConcurrentPassengers: number // Current question (extracted from currentQuestions[localPlayerId]) - currentQuestion: any | null; - previousQuestion: any | null; + currentQuestion: any | null + previousQuestion: any | null // Game progress (extracted from players[localPlayerId]) - score: number; - streak: number; - bestStreak: number; - totalQuestions: number; - correctAnswers: number; + score: number + streak: number + bestStreak: number + totalQuestions: number + correctAnswers: number // Game status - isGameActive: boolean; - isPaused: boolean; - gamePhase: "intro" | "controls" | "countdown" | "playing" | "results"; + isGameActive: boolean + isPaused: boolean + gamePhase: 'intro' | 'controls' | 'countdown' | 'playing' | 'results' // Timing - gameStartTime: number | null; - questionStartTime: number; + gameStartTime: number | null + questionStartTime: number // Race mechanics (extracted from players[localPlayerId] and config) - raceGoal: number; - timeLimit: number | null; - speedMultiplier: number; - aiRacers: any[]; + raceGoal: number + timeLimit: number | null + speedMultiplier: number + aiRacers: any[] // Sprint mode specific (extracted from players[localPlayerId]) - momentum: number; - trainPosition: number; - pressure: number; - elapsedTime: number; - lastCorrectAnswerTime: number; - currentRoute: number; - stations: any[]; - passengers: any[]; - deliveredPassengers: number; - cumulativeDistance: number; - showRouteCelebration: boolean; + momentum: number + trainPosition: number + pressure: number + elapsedTime: number + lastCorrectAnswerTime: number + currentRoute: number + stations: any[] + passengers: any[] + deliveredPassengers: number + cumulativeDistance: number + showRouteCelebration: boolean // Survival mode specific - playerLap: number; - aiLaps: Map; - survivalMultiplier: number; + playerLap: number + aiLaps: Map + survivalMultiplier: number // Input (local UI state) - currentInput: string; + currentInput: string // UI state - showScoreModal: boolean; - activeSpeechBubbles: Map; - adaptiveFeedback: { message: string; type: string } | null; - difficultyTracker: DifficultyTracker; + showScoreModal: boolean + activeSpeechBubbles: Map + adaptiveFeedback: { message: string; type: string } | null + difficultyTracker: DifficultyTracker } /** * Context value interface */ interface ComplementRaceContextValue { - state: CompatibleGameState; // Return adapted state - multiplayerState: ComplementRaceState; // Raw multiplayer state for rendering other players - localPlayerId: string | undefined; // Local player ID for filtering - dispatch: (action: { type: string; [key: string]: any }) => void; // Compatibility layer - lastError: string | null; - startGame: () => void; - submitAnswer: (answer: number, responseTime: number) => void; - claimPassenger: (passengerId: string, carIndex: number) => void; - deliverPassenger: (passengerId: string) => void; - nextQuestion: () => void; - endGame: () => void; - playAgain: () => void; - goToSetup: () => void; - setConfig: (field: keyof ComplementRaceConfig, value: unknown) => void; - clearError: () => void; - exitSession: () => void; - boostMomentum: (correct: boolean) => void; // Client-side momentum boost/reduce + state: CompatibleGameState // Return adapted state + multiplayerState: ComplementRaceState // Raw multiplayer state for rendering other players + localPlayerId: string | undefined // Local player ID for filtering + dispatch: (action: { type: string; [key: string]: any }) => void // Compatibility layer + lastError: string | null + startGame: () => void + submitAnswer: (answer: number, responseTime: number) => void + claimPassenger: (passengerId: string, carIndex: number) => void + deliverPassenger: (passengerId: string) => void + nextQuestion: () => void + endGame: () => void + playAgain: () => void + goToSetup: () => void + setConfig: (field: keyof ComplementRaceConfig, value: unknown) => void + clearError: () => void + exitSession: () => void + boostMomentum: (correct: boolean) => void // Client-side momentum boost/reduce } -const ComplementRaceContext = createContext( - null, -); +const ComplementRaceContext = createContext(null) /** * Hook to access Complement Race context */ export function useComplementRace() { - const context = useContext(ComplementRaceContext); + const context = useContext(ComplementRaceContext) if (!context) { - throw new Error( - "useComplementRace must be used within ComplementRaceProvider", - ); + throw new Error('useComplementRace must be used within ComplementRaceProvider') } - return context; + return context } /** * Optimistic move application (client-side prediction) * Apply moves immediately on client for responsive UI, server will confirm or reject */ -function applyMoveOptimistically( - state: ComplementRaceState, - move: GameMove, -): ComplementRaceState { - const typedMove = move as ComplementRaceMove; +function applyMoveOptimistically(state: ComplementRaceState, move: GameMove): ComplementRaceState { + const typedMove = move as ComplementRaceMove switch (typedMove.type) { - case "CLAIM_PASSENGER": { + case 'CLAIM_PASSENGER': { // Optimistically mark passenger as claimed and assign to car - const passengerId = typedMove.data.passengerId; - const carIndex = typedMove.data.carIndex; + const passengerId = typedMove.data.passengerId + const carIndex = typedMove.data.carIndex const updatedPassengers = state.passengers.map((p) => - p.id === passengerId - ? { ...p, claimedBy: typedMove.playerId, carIndex } - : p, - ); + p.id === passengerId ? { ...p, claimedBy: typedMove.playerId, carIndex } : p + ) // Optimistically add to player's passenger list - const updatedPlayers = { ...state.players }; - const player = updatedPlayers[typedMove.playerId]; + const updatedPlayers = { ...state.players } + const player = updatedPlayers[typedMove.playerId] if (player) { updatedPlayers[typedMove.playerId] = { ...player, passengers: [...player.passengers, passengerId], - }; + } } return { ...state, passengers: updatedPassengers, players: updatedPlayers, - }; + } } - case "DELIVER_PASSENGER": { + case 'DELIVER_PASSENGER': { // Optimistically mark passenger as delivered and award points - const passengerId = typedMove.data.passengerId; - const passenger = state.passengers.find((p) => p.id === passengerId); - if (!passenger) return state; + const passengerId = typedMove.data.passengerId + const passenger = state.passengers.find((p) => p.id === passengerId) + if (!passenger) return state - const points = passenger.isUrgent ? 20 : 10; + const points = passenger.isUrgent ? 20 : 10 const updatedPassengers = state.passengers.map((p) => - p.id === passengerId ? { ...p, deliveredBy: typedMove.playerId } : p, - ); + p.id === passengerId ? { ...p, deliveredBy: typedMove.playerId } : p + ) // Optimistically remove from player's passenger list and update score - const updatedPlayers = { ...state.players }; - const player = updatedPlayers[typedMove.playerId]; + const updatedPlayers = { ...state.players } + const player = updatedPlayers[typedMove.playerId] if (player) { updatedPlayers[typedMove.playerId] = { ...player, passengers: player.passengers.filter((id) => id !== passengerId), deliveredPassengers: player.deliveredPassengers + 1, score: player.score + points, - }; + } } return { ...state, passengers: updatedPassengers, players: updatedPlayers, - }; + } } default: // For other moves, rely on server validation - return state; + return state } } @@ -216,72 +203,54 @@ function applyMoveOptimistically( * Complement Race Provider Component */ export function ComplementRaceProvider({ children }: { children: ReactNode }) { - const { data: viewerId } = useViewerId(); - const { roomData } = useRoomData(); - const { activePlayers: activePlayerIds, players } = useGameMode(); - const { mutate: updateGameConfig } = useUpdateGameConfig(); + const { data: viewerId } = useViewerId() + const { roomData } = useRoomData() + const { activePlayers: activePlayerIds, players } = useGameMode() + const { mutate: updateGameConfig } = useUpdateGameConfig() // Get active players as array - const activePlayers = Array.from(activePlayerIds); + const activePlayers = Array.from(activePlayerIds) // Merge saved config from room with defaults const initialState = useMemo((): ComplementRaceState => { - const gameConfig = roomData?.gameConfig as - | Record - | null - | undefined; - const savedConfig = gameConfig?.["complement-race"] as - | Partial - | undefined; + const gameConfig = roomData?.gameConfig as Record | null | undefined + const savedConfig = gameConfig?.['complement-race'] as Partial | undefined const config: ComplementRaceConfig = { style: - (savedConfig?.style as ComplementRaceConfig["style"]) || + (savedConfig?.style as ComplementRaceConfig['style']) || DEFAULT_COMPLEMENT_RACE_CONFIG.style, mode: - (savedConfig?.mode as ComplementRaceConfig["mode"]) || - DEFAULT_COMPLEMENT_RACE_CONFIG.mode, + (savedConfig?.mode as ComplementRaceConfig['mode']) || DEFAULT_COMPLEMENT_RACE_CONFIG.mode, complementDisplay: - (savedConfig?.complementDisplay as ComplementRaceConfig["complementDisplay"]) || + (savedConfig?.complementDisplay as ComplementRaceConfig['complementDisplay']) || DEFAULT_COMPLEMENT_RACE_CONFIG.complementDisplay, timeoutSetting: - (savedConfig?.timeoutSetting as ComplementRaceConfig["timeoutSetting"]) || + (savedConfig?.timeoutSetting as ComplementRaceConfig['timeoutSetting']) || DEFAULT_COMPLEMENT_RACE_CONFIG.timeoutSetting, - enableAI: - savedConfig?.enableAI ?? DEFAULT_COMPLEMENT_RACE_CONFIG.enableAI, + enableAI: savedConfig?.enableAI ?? DEFAULT_COMPLEMENT_RACE_CONFIG.enableAI, aiOpponentCount: - savedConfig?.aiOpponentCount ?? - DEFAULT_COMPLEMENT_RACE_CONFIG.aiOpponentCount, - maxPlayers: - savedConfig?.maxPlayers ?? DEFAULT_COMPLEMENT_RACE_CONFIG.maxPlayers, - routeDuration: - savedConfig?.routeDuration ?? - DEFAULT_COMPLEMENT_RACE_CONFIG.routeDuration, + savedConfig?.aiOpponentCount ?? DEFAULT_COMPLEMENT_RACE_CONFIG.aiOpponentCount, + maxPlayers: savedConfig?.maxPlayers ?? DEFAULT_COMPLEMENT_RACE_CONFIG.maxPlayers, + routeDuration: savedConfig?.routeDuration ?? DEFAULT_COMPLEMENT_RACE_CONFIG.routeDuration, enablePassengers: - savedConfig?.enablePassengers ?? - DEFAULT_COMPLEMENT_RACE_CONFIG.enablePassengers, - passengerCount: - savedConfig?.passengerCount ?? - DEFAULT_COMPLEMENT_RACE_CONFIG.passengerCount, + savedConfig?.enablePassengers ?? DEFAULT_COMPLEMENT_RACE_CONFIG.enablePassengers, + passengerCount: savedConfig?.passengerCount ?? DEFAULT_COMPLEMENT_RACE_CONFIG.passengerCount, maxConcurrentPassengers: savedConfig?.maxConcurrentPassengers ?? DEFAULT_COMPLEMENT_RACE_CONFIG.maxConcurrentPassengers, - raceGoal: - savedConfig?.raceGoal ?? DEFAULT_COMPLEMENT_RACE_CONFIG.raceGoal, + raceGoal: savedConfig?.raceGoal ?? DEFAULT_COMPLEMENT_RACE_CONFIG.raceGoal, winCondition: - (savedConfig?.winCondition as ComplementRaceConfig["winCondition"]) || + (savedConfig?.winCondition as ComplementRaceConfig['winCondition']) || DEFAULT_COMPLEMENT_RACE_CONFIG.winCondition, - targetScore: - savedConfig?.targetScore ?? DEFAULT_COMPLEMENT_RACE_CONFIG.targetScore, - timeLimit: - savedConfig?.timeLimit ?? DEFAULT_COMPLEMENT_RACE_CONFIG.timeLimit, - routeCount: - savedConfig?.routeCount ?? DEFAULT_COMPLEMENT_RACE_CONFIG.routeCount, - }; + targetScore: savedConfig?.targetScore ?? DEFAULT_COMPLEMENT_RACE_CONFIG.targetScore, + timeLimit: savedConfig?.timeLimit ?? DEFAULT_COMPLEMENT_RACE_CONFIG.timeLimit, + routeCount: savedConfig?.routeCount ?? DEFAULT_COMPLEMENT_RACE_CONFIG.routeCount, + } return { config, - gamePhase: "setup", + gamePhase: 'setup', activePlayers: [], playerMetadata: {}, players: {}, @@ -298,8 +267,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { aiOpponents: [], gameStartTime: null, gameEndTime: null, - }; - }, [roomData?.gameConfig]); + } + }, [roomData?.gameConfig]) // Arcade session integration const { @@ -309,15 +278,15 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { lastError, clearError, } = useArcadeSession({ - userId: viewerId || "", + userId: viewerId || '', roomId: roomData?.id, initialState, applyMove: applyMoveOptimistically, - }); + }) // Local UI state (not synced to server) const [localUIState, setLocalUIState] = useState({ - currentInput: "", + currentInput: '', previousQuestion: null as any, isPaused: false, showScoreModal: false, @@ -333,54 +302,54 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { learningMode: true, adaptationRate: 0.1, }, - }); + }) // Get local player ID const localPlayerId = useMemo(() => { const foundId = activePlayers.find((id) => { - const player = players.get(id); - return player?.isLocal; - }); - return foundId; - }, [activePlayers, players]); + const player = players.get(id) + return player?.isLocal + }) + return foundId + }, [activePlayers, players]) // Client-side game state (NOT synced to server - purely visual/gameplay) - const [clientMomentum, setClientMomentum] = useState(10); // Start at 10 for gentle push - const [clientPosition, setClientPosition] = useState(0); - const [clientPressure, setClientPressure] = useState(0); + const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push + const [clientPosition, setClientPosition] = useState(0) + const [clientPressure, setClientPressure] = useState(0) // Track if we've synced position from server (for reconnect/reload scenarios) - const hasInitializedPositionRef = useRef(false); + const hasInitializedPositionRef = useRef(false) // Ref to track latest position for broadcasting (avoids recreating interval on every position change) - const clientPositionRef = useRef(clientPosition); + const clientPositionRef = useRef(clientPosition) // Refs for throttled logging - const lastBroadcastLogRef = useRef({ position: 0, time: 0 }); - const broadcastCountRef = useRef(0); - const lastReceivedPositionsRef = useRef>({}); + const lastBroadcastLogRef = useRef({ position: 0, time: 0 }) + const broadcastCountRef = useRef(0) + const lastReceivedPositionsRef = useRef>({}) // Ref to hold sendMove so interval doesn't restart when sendMove changes - const sendMoveRef = useRef(sendMove); + const sendMoveRef = useRef(sendMove) useEffect(() => { - sendMoveRef.current = sendMove; - }, [sendMove]); + sendMoveRef.current = sendMove + }, [sendMove]) const [clientAIRacers, setClientAIRacers] = useState< Array<{ - id: string; - name: string; - position: number; - speed: number; - personality: "competitive" | "analytical"; - icon: string; - lastComment: number; - commentCooldown: number; - previousPosition: number; + id: string + name: string + position: number + speed: number + personality: 'competitive' | 'analytical' + icon: string + lastComment: number + commentCooldown: number + previousPosition: number }> - >([]); - const lastUpdateRef = useRef(Date.now()); - const gameStartTimeRef = useRef(0); + >([]) + const lastUpdateRef = useRef(Date.now()) + const gameStartTimeRef = useRef(0) // Decay rates based on skill level (momentum lost per second) const MOMENTUM_DECAY_RATES = { @@ -391,34 +360,29 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { normal: 9.0, fast: 11.0, expert: 13.0, - }; + } - const MOMENTUM_GAIN_PER_CORRECT = 15; - const MOMENTUM_LOSS_PER_WRONG = 10; - const SPEED_MULTIPLIER = 0.15; // momentum * 0.15 = % per second - const UPDATE_INTERVAL = 50; // 50ms = ~20fps + const MOMENTUM_GAIN_PER_CORRECT = 15 + const MOMENTUM_LOSS_PER_WRONG = 10 + const SPEED_MULTIPLIER = 0.15 // momentum * 0.15 = % per second + const UPDATE_INTERVAL = 50 // 50ms = ~20fps // Transform multiplayer state to look like single-player state const compatibleState = useMemo((): CompatibleGameState => { - const localPlayer = localPlayerId - ? multiplayerState.players[localPlayerId] - : null; + const localPlayer = localPlayerId ? multiplayerState.players[localPlayerId] : null // Map gamePhase: setup/lobby -> controls - let gamePhase: "intro" | "controls" | "countdown" | "playing" | "results"; - if ( - multiplayerState.gamePhase === "setup" || - multiplayerState.gamePhase === "lobby" - ) { - gamePhase = "controls"; - } else if (multiplayerState.gamePhase === "countdown") { - gamePhase = "countdown"; - } else if (multiplayerState.gamePhase === "playing") { - gamePhase = "playing"; - } else if (multiplayerState.gamePhase === "results") { - gamePhase = "results"; + let gamePhase: 'intro' | 'controls' | 'countdown' | 'playing' | 'results' + if (multiplayerState.gamePhase === 'setup' || multiplayerState.gamePhase === 'lobby') { + gamePhase = 'controls' + } else if (multiplayerState.gamePhase === 'countdown') { + gamePhase = 'countdown' + } else if (multiplayerState.gamePhase === 'playing') { + gamePhase = 'playing' + } else if (multiplayerState.gamePhase === 'results') { + gamePhase = 'results' } else { - gamePhase = "controls"; + gamePhase = 'controls' } return { @@ -443,7 +407,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { correctAnswers: localPlayer?.correctAnswers || 0, // Game status - isGameActive: gamePhase === "playing", + isGameActive: gamePhase === 'playing', isPaused: localUIState.isPaused, gamePhase, @@ -455,9 +419,9 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { raceGoal: multiplayerState.config.raceGoal, timeLimit: multiplayerState.config.timeLimit ?? null, speedMultiplier: - multiplayerState.config.style === "practice" + multiplayerState.config.style === 'practice' ? 0.7 - : multiplayerState.config.style === "sprint" + : multiplayerState.config.style === 'sprint' ? 0.9 : 1.0, // Base speed multipliers by mode aiRacers: clientAIRacers, // Use client-side AI state @@ -466,9 +430,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { momentum: clientMomentum, // Client-only state with continuous decay trainPosition: clientPosition, // Client-calculated from momentum pressure: clientPressure, // Client-calculated from momentum (0-150 PSI) - elapsedTime: multiplayerState.gameStartTime - ? Date.now() - multiplayerState.gameStartTime - : 0, + elapsedTime: multiplayerState.gameStartTime ? Date.now() - multiplayerState.gameStartTime : 0, lastCorrectAnswerTime: localPlayer?.lastAnswerTime || Date.now(), currentRoute: multiplayerState.currentRoute, stations: multiplayerState.stations, @@ -488,7 +450,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { activeSpeechBubbles: localUIState.activeSpeechBubbles, adaptiveFeedback: localUIState.adaptiveFeedback, difficultyTracker: localUIState.difficultyTracker, - }; + } }, [ multiplayerState, localPlayerId, @@ -497,7 +459,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { clientPressure, clientMomentum, clientAIRacers, - ]); + ]) // Sync client position from server on reconnect/reload (multiplayer only) useEffect(() => { @@ -508,134 +470,122 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { // 4. We have a local player with a position from server if ( !hasInitializedPositionRef.current && - multiplayerState.gamePhase === "playing" && - multiplayerState.config.style === "sprint" && + multiplayerState.gamePhase === 'playing' && + multiplayerState.config.style === 'sprint' && localPlayerId ) { - const serverPosition = multiplayerState.players[localPlayerId]?.position; + const serverPosition = multiplayerState.players[localPlayerId]?.position if (serverPosition !== undefined && serverPosition > 0) { - console.log( - `[POSITION_SYNC] Restoring position from server: ${serverPosition.toFixed(1)}%`, - ); - setClientPosition(serverPosition); - hasInitializedPositionRef.current = true; + console.log(`[POSITION_SYNC] Restoring position from server: ${serverPosition.toFixed(1)}%`) + setClientPosition(serverPosition) + hasInitializedPositionRef.current = true } } // Reset sync flag when game ends - if (multiplayerState.gamePhase !== "playing") { - hasInitializedPositionRef.current = false; + if (multiplayerState.gamePhase !== 'playing') { + hasInitializedPositionRef.current = false } }, [ multiplayerState.gamePhase, multiplayerState.config.style, multiplayerState.players, localPlayerId, - ]); + ]) // Initialize game start time when game becomes active useEffect(() => { - if (compatibleState.isGameActive && compatibleState.style === "sprint") { + if (compatibleState.isGameActive && compatibleState.style === 'sprint') { if (gameStartTimeRef.current === 0) { - gameStartTimeRef.current = Date.now(); - lastUpdateRef.current = Date.now(); + gameStartTimeRef.current = Date.now() + lastUpdateRef.current = Date.now() // Reset client state for new game (only if not restored from server) if (!hasInitializedPositionRef.current) { - setClientMomentum(10); // Start with gentle push - setClientPosition(0); - setClientPressure((10 / 100) * 150); // Initial pressure from starting momentum + setClientMomentum(10) // Start with gentle push + setClientPosition(0) + setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum } } } else { // Reset when game ends - gameStartTimeRef.current = 0; + gameStartTimeRef.current = 0 } - }, [compatibleState.isGameActive, compatibleState.style]); + }, [compatibleState.isGameActive, compatibleState.style]) // Initialize AI racers when game starts useEffect(() => { if (compatibleState.isGameActive && multiplayerState.config.enableAI) { - const count = multiplayerState.config.aiOpponentCount; + const count = multiplayerState.config.aiOpponentCount if (count > 0 && clientAIRacers.length === 0) { - const aiNames = ["Swift AI", "Math Bot", "Speed Demon", "Brain Bot"]; - const personalities: Array<"competitive" | "analytical"> = [ - "competitive", - "analytical", - ]; + const aiNames = ['Swift AI', 'Math Bot', 'Speed Demon', 'Brain Bot'] + const personalities: Array<'competitive' | 'analytical'> = ['competitive', 'analytical'] - const newAI = []; + const newAI = [] for (let i = 0; i < Math.min(count, aiNames.length); i++) { // Use original balanced speeds: 0.32 for Swift AI, 0.2 for Math Bot - const baseSpeed = i === 0 ? 0.32 : 0.2; + const baseSpeed = i === 0 ? 0.32 : 0.2 newAI.push({ id: `ai-${i}`, name: aiNames[i], - personality: personalities[i % personalities.length] as - | "competitive" - | "analytical", + personality: personalities[i % personalities.length] as 'competitive' | 'analytical', position: 0, speed: baseSpeed, // Balanced speed from original single-player version - icon: - personalities[i % personalities.length] === "competitive" - ? "🏃‍♂️" - : "🏃", + icon: personalities[i % personalities.length] === 'competitive' ? '🏃‍♂️' : '🏃', lastComment: 0, commentCooldown: 0, previousPosition: 0, - }); + }) } - setClientAIRacers(newAI); + setClientAIRacers(newAI) } } else if (!compatibleState.isGameActive) { // Clear AI when game ends - setClientAIRacers([]); + setClientAIRacers([]) } }, [ compatibleState.isGameActive, multiplayerState.config.enableAI, multiplayerState.config.aiOpponentCount, clientAIRacers.length, - ]); + ]) // Main client-side game loop: momentum decay and position calculation useEffect(() => { - if (!compatibleState.isGameActive || compatibleState.style !== "sprint") - return; + if (!compatibleState.isGameActive || compatibleState.style !== 'sprint') return const interval = setInterval(() => { - const now = Date.now(); - const deltaTime = now - lastUpdateRef.current; - lastUpdateRef.current = now; + const now = Date.now() + const deltaTime = now - lastUpdateRef.current + lastUpdateRef.current = now // Get decay rate based on skill level const decayRate = - MOMENTUM_DECAY_RATES[ - compatibleState.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES - ] || MOMENTUM_DECAY_RATES.normal; + MOMENTUM_DECAY_RATES[compatibleState.timeoutSetting as keyof typeof MOMENTUM_DECAY_RATES] || + MOMENTUM_DECAY_RATES.normal setClientMomentum((prevMomentum) => { // Calculate momentum decay for this frame - const momentumLoss = (decayRate * deltaTime) / 1000; + const momentumLoss = (decayRate * deltaTime) / 1000 // Update momentum (don't go below 0) - const newMomentum = Math.max(0, prevMomentum - momentumLoss); + const newMomentum = Math.max(0, prevMomentum - momentumLoss) // Calculate speed from momentum (% per second) - const speed = newMomentum * SPEED_MULTIPLIER; + const speed = newMomentum * SPEED_MULTIPLIER // Update position (accumulate, never go backward) - const positionDelta = (speed * deltaTime) / 1000; - setClientPosition((prev) => prev + positionDelta); + const positionDelta = (speed * deltaTime) / 1000 + setClientPosition((prev) => prev + positionDelta) // Calculate pressure (0-150 PSI) - const pressure = Math.min(150, (newMomentum / 100) * 150); - setClientPressure(pressure); + const pressure = Math.min(150, (newMomentum / 100) * 150) + setClientPressure(pressure) - return newMomentum; - }); - }, UPDATE_INTERVAL); + return newMomentum + }) + }, UPDATE_INTERVAL) - return () => clearInterval(interval); + return () => clearInterval(interval) }, [ compatibleState.isGameActive, compatibleState.style, @@ -643,97 +593,84 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { MOMENTUM_DECAY_RATES, SPEED_MULTIPLIER, UPDATE_INTERVAL, - ]); + ]) // Reset client position when route changes useEffect(() => { - const currentRoute = multiplayerState.currentRoute; + const currentRoute = multiplayerState.currentRoute // When route changes, reset position and give starting momentum - if (currentRoute > 1 && compatibleState.style === "sprint") { - setClientPosition(0); - setClientMomentum(10); // Reset to starting momentum (gentle push) + if (currentRoute > 1 && compatibleState.style === 'sprint') { + setClientPosition(0) + setClientMomentum(10) // Reset to starting momentum (gentle push) } - }, [ - multiplayerState.currentRoute, - compatibleState.style, - multiplayerState.passengers.length, - ]); + }, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length]) // Keep position ref in sync with latest position useEffect(() => { - clientPositionRef.current = clientPosition; - }, [clientPosition]); + clientPositionRef.current = clientPosition + }, [clientPosition]) // Log when we receive position updates from other players useEffect(() => { - if (!multiplayerState?.players || !localPlayerId) return; + if (!multiplayerState?.players || !localPlayerId) return Object.entries(multiplayerState.players).forEach(([playerId, player]) => { - if (playerId === localPlayerId || !player.isActive) return; + if (playerId === localPlayerId || !player.isActive) return - const lastPos = lastReceivedPositionsRef.current[playerId] ?? -1; - const currentPos = player.position; + const lastPos = lastReceivedPositionsRef.current[playerId] ?? -1 + const currentPos = player.position // Log when position changes significantly (>2%) if (Math.abs(currentPos - lastPos) > 2) { console.log( - `[POS_RECEIVED] ${player.name}: ${currentPos.toFixed(1)}% (was ${lastPos.toFixed(1)}%, delta=${(currentPos - lastPos).toFixed(1)}%)`, - ); - lastReceivedPositionsRef.current[playerId] = currentPos; + `[POS_RECEIVED] ${player.name}: ${currentPos.toFixed(1)}% (was ${lastPos.toFixed(1)}%, delta=${(currentPos - lastPos).toFixed(1)}%)` + ) + lastReceivedPositionsRef.current[playerId] = currentPos } - }); - }, [multiplayerState?.players, localPlayerId]); + }) + }, [multiplayerState?.players, localPlayerId]) // Broadcast position to server for multiplayer ghost trains useEffect(() => { - const isGameActive = multiplayerState.gamePhase === "playing"; - const isSprint = multiplayerState.config.style === "sprint"; + const isGameActive = multiplayerState.gamePhase === 'playing' + const isSprint = multiplayerState.config.style === 'sprint' if (!isGameActive || !isSprint || !localPlayerId) { - return; + return } - console.log("[POS_BROADCAST] Starting position broadcast interval"); + console.log('[POS_BROADCAST] Starting position broadcast interval') // Send position update every 100ms for smoother ghost trains (reads from refs to avoid restarting interval) const interval = setInterval(() => { - const currentPos = clientPositionRef.current; - broadcastCountRef.current++; + const currentPos = clientPositionRef.current + broadcastCountRef.current++ // Throttled logging: only log when position changes by >2% or every 5 seconds - const now = Date.now(); - const posDiff = Math.abs( - currentPos - lastBroadcastLogRef.current.position, - ); - const timeDiff = now - lastBroadcastLogRef.current.time; + const now = Date.now() + const posDiff = Math.abs(currentPos - lastBroadcastLogRef.current.position) + const timeDiff = now - lastBroadcastLogRef.current.time if (posDiff > 2 || timeDiff > 5000) { console.log( - `[POS_BROADCAST] #${broadcastCountRef.current} pos=${currentPos.toFixed(1)}% (delta=${posDiff.toFixed(1)}%)`, - ); - lastBroadcastLogRef.current = { position: currentPos, time: now }; + `[POS_BROADCAST] #${broadcastCountRef.current} pos=${currentPos.toFixed(1)}% (delta=${posDiff.toFixed(1)}%)` + ) + lastBroadcastLogRef.current = { position: currentPos, time: now } } sendMoveRef.current({ - type: "UPDATE_POSITION", + type: 'UPDATE_POSITION', playerId: localPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { position: currentPos }, - } as ComplementRaceMove); - }, 100); + } as ComplementRaceMove) + }, 100) return () => { - console.log( - `[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`, - ); - clearInterval(interval); - }; - }, [ - multiplayerState.gamePhase, - multiplayerState.config.style, - localPlayerId, - viewerId, - ]); + console.log(`[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`) + clearInterval(interval) + } + }, [multiplayerState.gamePhase, multiplayerState.config.style, localPlayerId, viewerId]) // Keep lastLogRef for future debugging needs // (removed debug logging) @@ -741,267 +678,249 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { // Action creators const startGame = useCallback(() => { if (activePlayers.length === 0) { - console.error("Need at least 1 player to start"); - return; + console.error('Need at least 1 player to start') + return } - const playerMetadata = buildPlayerMetadata( - activePlayers, - {}, - players, - viewerId || undefined, - ); + const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined) sendMove({ - type: "START_GAME", + type: 'START_GAME', playerId: activePlayers[0], - userId: viewerId || "", + userId: viewerId || '', data: { activePlayers, playerMetadata, }, - } as ComplementRaceMove); - }, [activePlayers, players, viewerId, sendMove]); + } as ComplementRaceMove) + }, [activePlayers, players, viewerId, sendMove]) const submitAnswer = useCallback( (answer: number, responseTime: number) => { // Find the current player's ID (the one who is answering) const currentPlayerId = activePlayers.find((id) => { - const player = players.get(id); - return player?.isLocal; - }); + const player = players.get(id) + return player?.isLocal + }) if (!currentPlayerId) { - console.error("No local player found to submit answer"); - return; + console.error('No local player found to submit answer') + return } sendMove({ - type: "SUBMIT_ANSWER", + type: 'SUBMIT_ANSWER', playerId: currentPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { answer, responseTime }, - } as ComplementRaceMove); + } as ComplementRaceMove) }, - [activePlayers, players, viewerId, sendMove], - ); + [activePlayers, players, viewerId, sendMove] + ) const claimPassenger = useCallback( (passengerId: string, carIndex: number) => { const currentPlayerId = activePlayers.find((id) => { - const player = players.get(id); - return player?.isLocal; - }); + const player = players.get(id) + return player?.isLocal + }) - if (!currentPlayerId) return; + if (!currentPlayerId) return sendMove({ - type: "CLAIM_PASSENGER", + type: 'CLAIM_PASSENGER', playerId: currentPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { passengerId, carIndex }, - } as ComplementRaceMove); + } as ComplementRaceMove) }, - [activePlayers, players, viewerId, sendMove], - ); + [activePlayers, players, viewerId, sendMove] + ) const deliverPassenger = useCallback( (passengerId: string) => { const currentPlayerId = activePlayers.find((id) => { - const player = players.get(id); - return player?.isLocal; - }); + const player = players.get(id) + return player?.isLocal + }) - if (!currentPlayerId) return; + if (!currentPlayerId) return sendMove({ - type: "DELIVER_PASSENGER", + type: 'DELIVER_PASSENGER', playerId: currentPlayerId, - userId: viewerId || "", + userId: viewerId || '', data: { passengerId }, - } as ComplementRaceMove); + } as ComplementRaceMove) }, - [activePlayers, players, viewerId, sendMove], - ); + [activePlayers, players, viewerId, sendMove] + ) const nextQuestion = useCallback(() => { sendMove({ - type: "NEXT_QUESTION", - playerId: activePlayers[0] || "", - userId: viewerId || "", + type: 'NEXT_QUESTION', + playerId: activePlayers[0] || '', + userId: viewerId || '', data: {}, - } as ComplementRaceMove); - }, [activePlayers, viewerId, sendMove]); + } as ComplementRaceMove) + }, [activePlayers, viewerId, sendMove]) const endGame = useCallback(() => { sendMove({ - type: "END_GAME", - playerId: activePlayers[0] || "", - userId: viewerId || "", + type: 'END_GAME', + playerId: activePlayers[0] || '', + userId: viewerId || '', data: {}, - } as ComplementRaceMove); - }, [activePlayers, viewerId, sendMove]); + } as ComplementRaceMove) + }, [activePlayers, viewerId, sendMove]) const playAgain = useCallback(() => { sendMove({ - type: "PLAY_AGAIN", - playerId: activePlayers[0] || "", - userId: viewerId || "", + type: 'PLAY_AGAIN', + playerId: activePlayers[0] || '', + userId: viewerId || '', data: {}, - } as ComplementRaceMove); - }, [activePlayers, viewerId, sendMove]); + } as ComplementRaceMove) + }, [activePlayers, viewerId, sendMove]) const goToSetup = useCallback(() => { sendMove({ - type: "GO_TO_SETUP", - playerId: activePlayers[0] || "", - userId: viewerId || "", + type: 'GO_TO_SETUP', + playerId: activePlayers[0] || '', + userId: viewerId || '', data: {}, - } as ComplementRaceMove); - }, [activePlayers, viewerId, sendMove]); + } as ComplementRaceMove) + }, [activePlayers, viewerId, sendMove]) const setConfig = useCallback( (field: keyof ComplementRaceConfig, value: unknown) => { sendMove({ - type: "SET_CONFIG", - playerId: activePlayers[0] || "", - userId: viewerId || "", + type: 'SET_CONFIG', + playerId: activePlayers[0] || '', + userId: viewerId || '', data: { field, value }, - } as ComplementRaceMove); + } as ComplementRaceMove) // Persist to database if (roomData?.id) { - const currentGameConfig = - (roomData.gameConfig as Record) || {}; + const currentGameConfig = (roomData.gameConfig as Record) || {} const currentComplementRaceConfig = - (currentGameConfig["complement-race"] as Record) || - {}; + (currentGameConfig['complement-race'] as Record) || {} const updatedConfig = { ...currentGameConfig, - "complement-race": { + 'complement-race': { ...currentComplementRaceConfig, [field]: value, }, - }; + } updateGameConfig({ roomId: roomData.id, gameConfig: updatedConfig, - }); + }) } }, - [ - activePlayers, - viewerId, - sendMove, - roomData?.id, - roomData?.gameConfig, - updateGameConfig, - ], - ); + [activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig] + ) // Compatibility dispatch function for existing UI components const dispatch = useCallback( (action: { type: string; [key: string]: any }) => { // Map old reducer actions to new action creators switch (action.type) { - case "START_COUNTDOWN": - case "BEGIN_GAME": - startGame(); - break; - case "SUBMIT_ANSWER": + case 'START_COUNTDOWN': + case 'BEGIN_GAME': + startGame() + break + case 'SUBMIT_ANSWER': if (action.answer !== undefined) { - const responseTime = - Date.now() - (multiplayerState.questionStartTime || Date.now()); - submitAnswer(action.answer, responseTime); + const responseTime = Date.now() - (multiplayerState.questionStartTime || Date.now()) + submitAnswer(action.answer, responseTime) } - break; - case "NEXT_QUESTION": - setLocalUIState((prev) => ({ ...prev, currentInput: "" })); - nextQuestion(); - break; - case "END_RACE": - case "SHOW_RESULTS": - endGame(); - break; - case "RESET_GAME": - case "SHOW_CONTROLS": - goToSetup(); - break; - case "SET_MODE": + break + case 'NEXT_QUESTION': + setLocalUIState((prev) => ({ ...prev, currentInput: '' })) + nextQuestion() + break + case 'END_RACE': + case 'SHOW_RESULTS': + endGame() + break + case 'RESET_GAME': + case 'SHOW_CONTROLS': + goToSetup() + break + case 'SET_MODE': if (action.mode !== undefined) { - setConfig("mode", action.mode); + setConfig('mode', action.mode) } - break; - case "SET_STYLE": + break + case 'SET_STYLE': if (action.style !== undefined) { - setConfig("style", action.style); + setConfig('style', action.style) } - break; - case "SET_TIMEOUT": + break + case 'SET_TIMEOUT': if (action.timeout !== undefined) { - setConfig("timeoutSetting", action.timeout); + setConfig('timeoutSetting', action.timeout) } - break; - case "SET_COMPLEMENT_DISPLAY": + break + case 'SET_COMPLEMENT_DISPLAY': if (action.display !== undefined) { - setConfig("complementDisplay", action.display); + setConfig('complementDisplay', action.display) } - break; - case "BOARD_PASSENGER": - case "CLAIM_PASSENGER": - if ( - action.passengerId !== undefined && - action.carIndex !== undefined - ) { - claimPassenger(action.passengerId, action.carIndex); + break + case 'BOARD_PASSENGER': + case 'CLAIM_PASSENGER': + if (action.passengerId !== undefined && action.carIndex !== undefined) { + claimPassenger(action.passengerId, action.carIndex) } - break; - case "DELIVER_PASSENGER": + break + case 'DELIVER_PASSENGER': if (action.passengerId !== undefined) { - deliverPassenger(action.passengerId); + deliverPassenger(action.passengerId) } - break; - case "START_NEW_ROUTE": + break + case 'START_NEW_ROUTE': // Send route progression to server if (action.routeNumber !== undefined) { sendMove({ - type: "START_NEW_ROUTE", - playerId: activePlayers[0] || "", - userId: viewerId || "", + type: 'START_NEW_ROUTE', + playerId: activePlayers[0] || '', + userId: viewerId || '', data: { routeNumber: action.routeNumber }, - } as ComplementRaceMove); + } as ComplementRaceMove) } - break; + break // Local UI state actions - case "UPDATE_INPUT": + case 'UPDATE_INPUT': setLocalUIState((prev) => ({ ...prev, - currentInput: action.input || "", - })); - break; - case "PAUSE_RACE": - setLocalUIState((prev) => ({ ...prev, isPaused: true })); - break; - case "RESUME_RACE": - setLocalUIState((prev) => ({ ...prev, isPaused: false })); - break; - case "SHOW_ADAPTIVE_FEEDBACK": + currentInput: action.input || '', + })) + break + case 'PAUSE_RACE': + setLocalUIState((prev) => ({ ...prev, isPaused: true })) + break + case 'RESUME_RACE': + setLocalUIState((prev) => ({ ...prev, isPaused: false })) + break + case 'SHOW_ADAPTIVE_FEEDBACK': setLocalUIState((prev) => ({ ...prev, adaptiveFeedback: action.feedback, - })); - break; - case "CLEAR_ADAPTIVE_FEEDBACK": - setLocalUIState((prev) => ({ ...prev, adaptiveFeedback: null })); - break; - case "TRIGGER_AI_COMMENTARY": { + })) + break + case 'CLEAR_ADAPTIVE_FEEDBACK': + setLocalUIState((prev) => ({ ...prev, adaptiveFeedback: null })) + break + case 'TRIGGER_AI_COMMENTARY': { setLocalUIState((prev) => { - const newBubbles = new Map(prev.activeSpeechBubbles); - newBubbles.set(action.racerId, action.message); - return { ...prev, activeSpeechBubbles: newBubbles }; - }); + const newBubbles = new Map(prev.activeSpeechBubbles) + newBubbles.set(action.racerId, action.message) + return { ...prev, activeSpeechBubbles: newBubbles } + }) // Update racer's lastComment time and cooldown to prevent spam setClientAIRacers((prevRacers) => prevRacers.map((racer) => @@ -1011,80 +930,78 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { lastComment: Date.now(), commentCooldown: Math.random() * 4000 + 2000, // 2-6 seconds } - : racer, - ), - ); - break; + : racer + ) + ) + break } - case "CLEAR_AI_COMMENT": { + case 'CLEAR_AI_COMMENT': { setLocalUIState((prev) => { - const newBubbles = new Map(prev.activeSpeechBubbles); - newBubbles.delete(action.racerId); - return { ...prev, activeSpeechBubbles: newBubbles }; - }); - break; + const newBubbles = new Map(prev.activeSpeechBubbles) + newBubbles.delete(action.racerId) + return { ...prev, activeSpeechBubbles: newBubbles } + }) + break } - case "UPDATE_AI_POSITIONS": { + case 'UPDATE_AI_POSITIONS': { // Update client-side AI positions if (action.positions && Array.isArray(action.positions)) { setClientAIRacers((prevRacers) => prevRacers.map((racer) => { const update = action.positions.find( - (p: { id: string; position: number }) => p.id === racer.id, - ); + (p: { id: string; position: number }) => p.id === racer.id + ) return update ? { ...racer, previousPosition: racer.position, position: update.position, } - : racer; - }), - ); + : racer + }) + ) } - break; + break } - case "UPDATE_AI_SPEEDS": { + case 'UPDATE_AI_SPEEDS': { // Update client-side AI speeds (adaptive difficulty) if (action.racers && Array.isArray(action.racers)) { setClientAIRacers((prevRacers) => prevRacers.map((racer) => { const update = action.racers.find( - (r: { id: string; speed: number }) => r.id === racer.id, - ); + (r: { id: string; speed: number }) => r.id === racer.id + ) return update ? { ...racer, speed: update.speed, } - : racer; - }), - ); + : racer + }) + ) } - break; + break } - case "UPDATE_DIFFICULTY_TRACKER": { + case 'UPDATE_DIFFICULTY_TRACKER': { // Update local difficulty tracker state setLocalUIState((prev) => ({ ...prev, difficultyTracker: action.tracker, - })); - break; + })) + break } // Other local actions that don't affect UI (can be ignored for now) - case "UPDATE_MOMENTUM": - case "UPDATE_TRAIN_POSITION": - case "UPDATE_STEAM_JOURNEY": - case "GENERATE_PASSENGERS": // Passengers generated server-side when route starts - case "COMPLETE_ROUTE": - case "HIDE_ROUTE_CELEBRATION": - case "COMPLETE_LAP": + case 'UPDATE_MOMENTUM': + case 'UPDATE_TRAIN_POSITION': + case 'UPDATE_STEAM_JOURNEY': + case 'GENERATE_PASSENGERS': // Passengers generated server-side when route starts + case 'COMPLETE_ROUTE': + case 'HIDE_ROUTE_CELEBRATION': + case 'COMPLETE_LAP': // These are now handled by the server state or can be ignored - break; + break default: - console.warn( - `[ComplementRaceProvider] Unknown action type: ${action.type}`, - ); + console.warn(`[ComplementRaceProvider] Unknown action type: ${action.type}`) } }, [ @@ -1100,24 +1017,24 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { sendMove, activePlayers, viewerId, - ], - ); + ] + ) // Client-side momentum boost/reduce (sprint mode only) const boostMomentum = useCallback( (correct: boolean) => { - if (compatibleState.style !== "sprint") return; + if (compatibleState.style !== 'sprint') return setClientMomentum((prevMomentum) => { if (correct) { - return Math.min(100, prevMomentum + MOMENTUM_GAIN_PER_CORRECT); + return Math.min(100, prevMomentum + MOMENTUM_GAIN_PER_CORRECT) } else { - return Math.max(0, prevMomentum - MOMENTUM_LOSS_PER_WRONG); + return Math.max(0, prevMomentum - MOMENTUM_LOSS_PER_WRONG) } - }); + }) }, - [compatibleState.style, MOMENTUM_GAIN_PER_CORRECT, MOMENTUM_LOSS_PER_WRONG], - ); + [compatibleState.style, MOMENTUM_GAIN_PER_CORRECT, MOMENTUM_LOSS_PER_WRONG] + ) const contextValue: ComplementRaceContextValue = { state: compatibleState, // Use transformed state @@ -1137,11 +1054,9 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) { clearError, exitSession, boostMomentum, // Client-side momentum control - }; + } return ( - - {children} - - ); + {children} + ) } diff --git a/apps/web/src/arcade-games/complement-race/Validator.ts b/apps/web/src/arcade-games/complement-race/Validator.ts index 62813fca..ebb69c80 100644 --- a/apps/web/src/arcade-games/complement-race/Validator.ts +++ b/apps/web/src/arcade-games/complement-race/Validator.ts @@ -3,7 +3,7 @@ * Handles question generation, answer validation, passenger management, and race progression */ -import type { GameValidator, ValidationResult } from "@/lib/arcade/game-sdk"; +import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk' import type { ComplementRaceState, ComplementRaceMove, @@ -13,66 +13,66 @@ import type { Station, PlayerState, AnswerValidation, -} from "./types"; +} from './types' // ============================================================================ // Constants // ============================================================================ -const PLAYER_COLORS = ["#3B82F6", "#10B981", "#F59E0B", "#8B5CF6"]; // Blue, Green, Amber, Purple +const PLAYER_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6'] // Blue, Green, Amber, Purple const DEFAULT_STATIONS: Station[] = [ - { id: "depot", name: "Depot", position: 0, icon: "🚉", emoji: "🚉" }, - { id: "riverside", name: "Riverside", position: 20, icon: "🌊", emoji: "🌊" }, - { id: "hillside", name: "Hillside", position: 40, icon: "⛰️", emoji: "⛰️" }, - { id: "canyon", name: "Canyon View", position: 60, icon: "🏜️", emoji: "🏜️" }, - { id: "meadows", name: "Meadows", position: 80, icon: "🌾", emoji: "🌾" }, + { id: 'depot', name: 'Depot', position: 0, icon: '🚉', emoji: '🚉' }, + { id: 'riverside', name: 'Riverside', position: 20, icon: '🌊', emoji: '🌊' }, + { id: 'hillside', name: 'Hillside', position: 40, icon: '⛰️', emoji: '⛰️' }, + { id: 'canyon', name: 'Canyon View', position: 60, icon: '🏜️', emoji: '🏜️' }, + { id: 'meadows', name: 'Meadows', position: 80, icon: '🌾', emoji: '🌾' }, { - id: "grand-central", - name: "Grand Central", + id: 'grand-central', + name: 'Grand Central', position: 100, - icon: "🏛️", - emoji: "🏛️", + icon: '🏛️', + emoji: '🏛️', }, -]; +] const PASSENGER_NAMES = [ - "Alice", - "Bob", - "Charlie", - "Diana", - "Eve", - "Frank", - "Grace", - "Henry", - "Iris", - "Jack", - "Kate", - "Leo", - "Mia", - "Noah", - "Olivia", - "Paul", -]; + 'Alice', + 'Bob', + 'Charlie', + 'Diana', + 'Eve', + 'Frank', + 'Grace', + 'Henry', + 'Iris', + 'Jack', + 'Kate', + 'Leo', + 'Mia', + 'Noah', + 'Olivia', + 'Paul', +] const PASSENGER_AVATARS = [ - "👨‍💼", - "👩‍💼", - "👨‍🎓", - "👩‍🎓", - "👨‍🍳", - "👩‍🍳", - "👨‍⚕️", - "👩‍⚕️", - "👨‍🔧", - "👩‍🔧", - "👨‍🏫", - "👩‍🏫", - "👵", - "👴", - "🧑‍🎨", - "👨‍🚒", -]; + '👨‍💼', + '👩‍💼', + '👨‍🎓', + '👩‍🎓', + '👨‍🍳', + '👩‍🍳', + '👨‍⚕️', + '👩‍⚕️', + '👨‍🔧', + '👩‍🔧', + '👨‍🏫', + '👩‍🏫', + '👵', + '👴', + '🧑‍🎨', + '👨‍🚒', +] // ============================================================================ // Validator Class @@ -81,77 +81,62 @@ const PASSENGER_AVATARS = [ export class ComplementRaceValidator implements GameValidator { - validateMove( - state: ComplementRaceState, - move: ComplementRaceMove, - ): ValidationResult { + validateMove(state: ComplementRaceState, move: ComplementRaceMove): ValidationResult { switch (move.type) { - case "START_GAME": - return this.validateStartGame( - state, - move.data.activePlayers, - move.data.playerMetadata, - ); + case 'START_GAME': + return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata) - case "SET_READY": - return this.validateSetReady(state, move.playerId, move.data.ready); + case 'SET_READY': + return this.validateSetReady(state, move.playerId, move.data.ready) - case "SET_CONFIG": - return this.validateSetConfig(state, move.data.field, move.data.value); + case 'SET_CONFIG': + return this.validateSetConfig(state, move.data.field, move.data.value) - case "SUBMIT_ANSWER": + case 'SUBMIT_ANSWER': return this.validateSubmitAnswer( state, move.playerId, move.data.answer, - move.data.responseTime, - ); + move.data.responseTime + ) - case "UPDATE_INPUT": - return this.validateUpdateInput(state, move.playerId, move.data.input); + case 'UPDATE_INPUT': + return this.validateUpdateInput(state, move.playerId, move.data.input) - case "UPDATE_POSITION": - return this.validateUpdatePosition( - state, - move.playerId, - move.data.position, - ); + case 'UPDATE_POSITION': + return this.validateUpdatePosition(state, move.playerId, move.data.position) - case "CLAIM_PASSENGER": + case 'CLAIM_PASSENGER': return this.validateClaimPassenger( state, move.playerId, move.data.passengerId, - move.data.carIndex, - ); + move.data.carIndex + ) - case "DELIVER_PASSENGER": - return this.validateDeliverPassenger( - state, - move.playerId, - move.data.passengerId, - ); + case 'DELIVER_PASSENGER': + return this.validateDeliverPassenger(state, move.playerId, move.data.passengerId) - case "NEXT_QUESTION": - return this.validateNextQuestion(state); + case 'NEXT_QUESTION': + return this.validateNextQuestion(state) - case "START_NEW_ROUTE": - return this.validateStartNewRoute(state, move.data.routeNumber); + case 'START_NEW_ROUTE': + return this.validateStartNewRoute(state, move.data.routeNumber) - case "END_GAME": - return this.validateEndGame(state); + case 'END_GAME': + return this.validateEndGame(state) - case "PLAY_AGAIN": - return this.validatePlayAgain(state); + case 'PLAY_AGAIN': + return this.validatePlayAgain(state) - case "GO_TO_SETUP": - return this.validateGoToSetup(state); + case 'GO_TO_SETUP': + return this.validateGoToSetup(state) default: return { valid: false, error: `Unknown move type: ${(move as { type: string }).type}`, - }; + } } } @@ -162,28 +147,28 @@ export class ComplementRaceValidator private validateStartGame( state: ComplementRaceState, activePlayers: string[], - playerMetadata: Record, + playerMetadata: Record ): ValidationResult { - if (state.gamePhase !== "setup" && state.gamePhase !== "lobby") { - return { valid: false, error: "Game already started" }; + if (state.gamePhase !== 'setup' && state.gamePhase !== 'lobby') { + return { valid: false, error: 'Game already started' } } if (!activePlayers || activePlayers.length < 1) { - return { valid: false, error: "Need at least 1 player" }; + return { valid: false, error: 'Need at least 1 player' } } if (activePlayers.length > state.config.maxPlayers) { return { valid: false, error: `Too many players (max ${state.config.maxPlayers})`, - }; + } } // Initialize player states - const players: Record = {}; + const players: Record = {} for (let i = 0; i < activePlayers.length; i++) { - const playerId = activePlayers[i]; - const metadata = playerMetadata[playerId] as { name: string }; + const playerId = activePlayers[i] + const metadata = playerMetadata[playerId] as { name: string } players[playerId] = { id: playerId, @@ -201,67 +186,67 @@ export class ComplementRaceValidator lastAnswerTime: null, passengers: [], deliveredPassengers: 0, - }; + } } // Generate initial questions for each player - const currentQuestions: Record = {}; + const currentQuestions: Record = {} for (const playerId of activePlayers) { - currentQuestions[playerId] = this.generateQuestion(state.config.mode); + currentQuestions[playerId] = this.generateQuestion(state.config.mode) } // Sprint mode: generate initial passengers const passengers = - state.config.style === "sprint" + state.config.style === 'sprint' ? this.generatePassengers(state.config.passengerCount, state.stations) - : []; + : [] // Calculate maxConcurrentPassengers based on initial passenger layout (sprint mode only) - let updatedConfig = state.config; - if (state.config.style === "sprint" && passengers.length > 0) { + let updatedConfig = state.config + if (state.config.style === 'sprint' && passengers.length > 0) { const maxConcurrentPassengers = Math.max( 1, - this.calculateMaxConcurrentPassengers(passengers, state.stations), - ); + this.calculateMaxConcurrentPassengers(passengers, state.stations) + ) console.log( - `[Game Start] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${passengers.length} passengers`, - ); + `[Game Start] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${passengers.length} passengers` + ) updatedConfig = { ...state.config, maxConcurrentPassengers, - }; + } } const newState: ComplementRaceState = { ...state, config: updatedConfig, - gamePhase: "playing", // Go directly to playing (countdown can be added later) + gamePhase: 'playing', // Go directly to playing (countdown can be added later) activePlayers, playerMetadata: playerMetadata as typeof state.playerMetadata, players, currentQuestions, questionStartTime: Date.now(), passengers, - routeStartTime: state.config.style === "sprint" ? Date.now() : null, + routeStartTime: state.config.style === 'sprint' ? Date.now() : null, raceStartTime: Date.now(), // Race starts immediately gameStartTime: Date.now(), aiOpponents: [], // AI handled client-side - }; + } - return { valid: true, newState }; + return { valid: true, newState } } private validateSetReady( state: ComplementRaceState, playerId: string, - ready: boolean, + ready: boolean ): ValidationResult { - if (state.gamePhase !== "lobby") { - return { valid: false, error: "Not in lobby phase" }; + if (state.gamePhase !== 'lobby') { + return { valid: false, error: 'Not in lobby phase' } } if (!state.players[playerId]) { - return { valid: false, error: "Player not in game" }; + return { valid: false, error: 'Player not in game' } } const newState: ComplementRaceState = { @@ -273,25 +258,25 @@ export class ComplementRaceValidator isReady: ready, }, }, - }; - - // Check if all players are ready - const allReady = Object.values(newState.players).every((p) => p.isReady); - if (allReady && state.activePlayers.length >= 1) { - newState.gamePhase = "countdown"; - newState.raceStartTime = Date.now() + 3000; // 3 second countdown } - return { valid: true, newState }; + // Check if all players are ready + const allReady = Object.values(newState.players).every((p) => p.isReady) + if (allReady && state.activePlayers.length >= 1) { + newState.gamePhase = 'countdown' + newState.raceStartTime = Date.now() + 3000 // 3 second countdown + } + + return { valid: true, newState } } private validateSetConfig( state: ComplementRaceState, field: keyof ComplementRaceConfig, - value: unknown, + value: unknown ): ValidationResult { - if (state.gamePhase !== "setup") { - return { valid: false, error: "Can only change config in setup" }; + if (state.gamePhase !== 'setup') { + return { valid: false, error: 'Can only change config in setup' } } // Validate the value based on field @@ -303,9 +288,9 @@ export class ComplementRaceValidator ...state.config, [field]: value, }, - }; + } - return { valid: true, newState }; + return { valid: true, newState } } // ========================================================================== @@ -316,66 +301,61 @@ export class ComplementRaceValidator state: ComplementRaceState, playerId: string, answer: number, - responseTime: number, + responseTime: number ): ValidationResult { - if (state.gamePhase !== "playing") { - return { valid: false, error: "Game not in playing phase" }; + if (state.gamePhase !== 'playing') { + return { valid: false, error: 'Game not in playing phase' } } - const player = state.players[playerId]; + const player = state.players[playerId] if (!player) { - return { valid: false, error: "Player not found" }; + return { valid: false, error: 'Player not found' } } - const question = state.currentQuestions[playerId]; + const question = state.currentQuestions[playerId] if (!question) { - return { valid: false, error: "No question for this player" }; + return { valid: false, error: 'No question for this player' } } // Validate answer - const correct = answer === question.correctAnswer; + const correct = answer === question.correctAnswer const validation = this.calculateAnswerScore( correct, responseTime, player.streak, - state.config.style, - ); + state.config.style + ) // Update player state const updatedPlayer: PlayerState = { ...player, totalQuestions: player.totalQuestions + 1, - correctAnswers: correct - ? player.correctAnswers + 1 - : player.correctAnswers, + correctAnswers: correct ? player.correctAnswers + 1 : player.correctAnswers, score: player.score + validation.totalPoints, streak: validation.newStreak, bestStreak: Math.max(player.bestStreak, validation.newStreak), lastAnswerTime: Date.now(), currentAnswer: null, - }; + } // Update position based on game mode - if (state.config.style === "practice") { + if (state.config.style === 'practice') { // Practice: Move forward on correct answer if (correct) { - updatedPlayer.position = Math.min( - 100, - player.position + 100 / state.config.raceGoal, - ); + updatedPlayer.position = Math.min(100, player.position + 100 / state.config.raceGoal) } - } else if (state.config.style === "sprint") { + } else if (state.config.style === 'sprint') { // Sprint: All momentum/position handled client-side for smooth 20fps movement // Server only tracks scoring, passengers, and game progression // No server-side position updates needed - } else if (state.config.style === "survival") { + } else if (state.config.style === 'survival') { // Survival: Always move forward, speed based on accuracy - const moveDistance = correct ? 5 : 2; - updatedPlayer.position = player.position + moveDistance; + const moveDistance = correct ? 5 : 2 + updatedPlayer.position = player.position + moveDistance } // Generate new question for this player - const newQuestion = this.generateQuestion(state.config.mode); + const newQuestion = this.generateQuestion(state.config.mode) const newState: ComplementRaceState = { ...state, @@ -387,32 +367,32 @@ export class ComplementRaceValidator ...state.currentQuestions, [playerId]: newQuestion, }, - }; - - // Check win conditions - const winner = this.checkWinCondition(newState); - if (winner) { - newState.gamePhase = "results"; - newState.winner = winner; - newState.raceEndTime = Date.now(); - newState.leaderboard = this.calculateLeaderboard(newState); } - return { valid: true, newState }; + // Check win conditions + const winner = this.checkWinCondition(newState) + if (winner) { + newState.gamePhase = 'results' + newState.winner = winner + newState.raceEndTime = Date.now() + newState.leaderboard = this.calculateLeaderboard(newState) + } + + return { valid: true, newState } } private validateUpdateInput( state: ComplementRaceState, playerId: string, - input: string, + input: string ): ValidationResult { - if (state.gamePhase !== "playing") { - return { valid: false, error: "Game not in playing phase" }; + if (state.gamePhase !== 'playing') { + return { valid: false, error: 'Game not in playing phase' } } - const player = state.players[playerId]; + const player = state.players[playerId] if (!player) { - return { valid: false, error: "Player not found" }; + return { valid: false, error: 'Player not found' } } const newState: ComplementRaceState = { @@ -424,28 +404,28 @@ export class ComplementRaceValidator currentAnswer: input, }, }, - }; + } - return { valid: true, newState }; + return { valid: true, newState } } private validateUpdatePosition( state: ComplementRaceState, playerId: string, - position: number, + position: number ): ValidationResult { - if (state.gamePhase !== "playing") { - return { valid: false, error: "Game not in playing phase" }; + if (state.gamePhase !== 'playing') { + return { valid: false, error: 'Game not in playing phase' } } - const player = state.players[playerId]; + const player = state.players[playerId] if (!player) { - return { valid: false, error: "Player not found" }; + return { valid: false, error: 'Player not found' } } // Validate position is a reasonable number (0-100) - if (typeof position !== "number" || position < 0 || position > 100) { - return { valid: false, error: "Invalid position value" }; + if (typeof position !== 'number' || position < 0 || position > 100) { + return { valid: false, error: 'Invalid position value' } } const newState: ComplementRaceState = { @@ -457,9 +437,9 @@ export class ComplementRaceValidator position, }, }, - }; + } - return { valid: true, newState }; + return { valid: true, newState } } // ========================================================================== @@ -470,62 +450,58 @@ export class ComplementRaceValidator state: ComplementRaceState, playerId: string, passengerId: string, - carIndex: number, + carIndex: number ): ValidationResult { - if (state.config.style !== "sprint") { + if (state.config.style !== 'sprint') { return { valid: false, - error: "Passengers only available in sprint mode", - }; + error: 'Passengers only available in sprint mode', + } } - const player = state.players[playerId]; + const player = state.players[playerId] if (!player) { - return { valid: false, error: "Player not found" }; + return { valid: false, error: 'Player not found' } } // Check if player has space if (player.passengers.length >= state.config.maxConcurrentPassengers) { - return { valid: false, error: "Train is full" }; + return { valid: false, error: 'Train is full' } } // Find passenger - const passengerIndex = state.passengers.findIndex( - (p) => p.id === passengerId, - ); + const passengerIndex = state.passengers.findIndex((p) => p.id === passengerId) if (passengerIndex === -1) { - return { valid: false, error: "Passenger not found" }; + return { valid: false, error: 'Passenger not found' } } - const passenger = state.passengers[passengerIndex]; + const passenger = state.passengers[passengerIndex] if (passenger.claimedBy !== null) { - return { valid: false, error: "Passenger already claimed" }; + return { valid: false, error: 'Passenger already claimed' } } // Sprint mode: Position is client-side, trust client's spatial checking // (Client checks position in useSteamJourney before sending CLAIM move) // Other modes: Validate position server-side - if (state.config.style !== "sprint") { - const originStation = state.stations.find( - (s) => s.id === passenger.originStationId, - ); + if (state.config.style !== 'sprint') { + const originStation = state.stations.find((s) => s.id === passenger.originStationId) if (!originStation) { - return { valid: false, error: "Origin station not found" }; + return { valid: false, error: 'Origin station not found' } } - const distance = Math.abs(player.position - originStation.position); + const distance = Math.abs(player.position - originStation.position) if (distance > 5) { - return { valid: false, error: "Not at origin station" }; + return { valid: false, error: 'Not at origin station' } } } // Claim passenger and assign to physical car - const updatedPassengers = [...state.passengers]; + const updatedPassengers = [...state.passengers] updatedPassengers[passengerIndex] = { ...passenger, claimedBy: playerId, carIndex, // Store which physical car (0-N) the passenger is seated in - }; + } const newState: ComplementRaceState = { ...state, @@ -537,70 +513,66 @@ export class ComplementRaceValidator passengers: [...player.passengers, passengerId], }, }, - }; + } - return { valid: true, newState }; + return { valid: true, newState } } private validateDeliverPassenger( state: ComplementRaceState, playerId: string, - passengerId: string, + passengerId: string ): ValidationResult { - if (state.config.style !== "sprint") { + if (state.config.style !== 'sprint') { return { valid: false, - error: "Passengers only available in sprint mode", - }; + error: 'Passengers only available in sprint mode', + } } - const player = state.players[playerId]; + const player = state.players[playerId] if (!player) { - return { valid: false, error: "Player not found" }; + return { valid: false, error: 'Player not found' } } // Check if player has this passenger if (!player.passengers.includes(passengerId)) { - return { valid: false, error: "Player does not have this passenger" }; + return { valid: false, error: 'Player does not have this passenger' } } // Find passenger - const passengerIndex = state.passengers.findIndex( - (p) => p.id === passengerId, - ); + const passengerIndex = state.passengers.findIndex((p) => p.id === passengerId) if (passengerIndex === -1) { - return { valid: false, error: "Passenger not found" }; + return { valid: false, error: 'Passenger not found' } } - const passenger = state.passengers[passengerIndex]; + const passenger = state.passengers[passengerIndex] if (passenger.deliveredBy !== null) { - return { valid: false, error: "Passenger already delivered" }; + return { valid: false, error: 'Passenger already delivered' } } // Sprint mode: Position is client-side, trust client's spatial checking // (Client checks position in useSteamJourney before sending DELIVER move) // Other modes: Validate position server-side - if (state.config.style !== "sprint") { - const destStation = state.stations.find( - (s) => s.id === passenger.destinationStationId, - ); + if (state.config.style !== 'sprint') { + const destStation = state.stations.find((s) => s.id === passenger.destinationStationId) if (!destStation) { - return { valid: false, error: "Destination station not found" }; + return { valid: false, error: 'Destination station not found' } } - const distance = Math.abs(player.position - destStation.position); + const distance = Math.abs(player.position - destStation.position) if (distance > 5) { - return { valid: false, error: "Not at destination station" }; + return { valid: false, error: 'Not at destination station' } } } // Deliver passenger and award points - const points = passenger.isUrgent ? 20 : 10; - const updatedPassengers = [...state.passengers]; + const points = passenger.isUrgent ? 20 : 10 + const updatedPassengers = [...state.passengers] updatedPassengers[passengerIndex] = { ...passenger, deliveredBy: playerId, - }; + } const newState: ComplementRaceState = { ...state, @@ -614,44 +586,38 @@ export class ComplementRaceValidator score: player.score + points, }, }, - }; + } - return { valid: true, newState }; + return { valid: true, newState } } - private validateStartNewRoute( - state: ComplementRaceState, - routeNumber: number, - ): ValidationResult { - if (state.config.style !== "sprint") { - return { valid: false, error: "Routes only available in sprint mode" }; + private validateStartNewRoute(state: ComplementRaceState, routeNumber: number): ValidationResult { + if (state.config.style !== 'sprint') { + return { valid: false, error: 'Routes only available in sprint mode' } } // Reset all player positions to 0 for new route (client handles momentum reset) - const resetPlayers: Record = {}; + const resetPlayers: Record = {} for (const [playerId, player] of Object.entries(state.players)) { resetPlayers[playerId] = { ...player, position: 0, // Server position not used in sprint; client will reset passengers: [], // Clear any remaining passengers - }; + } } // Generate new passengers - const newPassengers = this.generatePassengers( - state.config.passengerCount, - state.stations, - ); + const newPassengers = this.generatePassengers(state.config.passengerCount, state.stations) // Calculate maxConcurrentPassengers based on the new route's passenger layout const maxConcurrentPassengers = Math.max( 1, - this.calculateMaxConcurrentPassengers(newPassengers, state.stations), - ); + this.calculateMaxConcurrentPassengers(newPassengers, state.stations) + ) console.log( - `[Route ${routeNumber}] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${newPassengers.length} passengers`, - ); + `[Route ${routeNumber}] Calculated maxConcurrentPassengers: ${maxConcurrentPassengers} for ${newPassengers.length} passengers` + ) const newState: ComplementRaceState = { ...state, @@ -663,9 +629,9 @@ export class ComplementRaceValidator ...state.config, maxConcurrentPassengers, // Update config with calculated value }, - }; + } - return { valid: true, newState }; + return { valid: true, newState } } // ========================================================================== @@ -674,64 +640,62 @@ export class ComplementRaceValidator private validateNextQuestion(state: ComplementRaceState): ValidationResult { // Generate new questions for all players - const newQuestions: Record = {}; + const newQuestions: Record = {} for (const playerId of state.activePlayers) { - newQuestions[playerId] = this.generateQuestion(state.config.mode); + newQuestions[playerId] = this.generateQuestion(state.config.mode) } const newState: ComplementRaceState = { ...state, currentQuestions: newQuestions, questionStartTime: Date.now(), - }; + } - return { valid: true, newState }; + return { valid: true, newState } } private validateEndGame(state: ComplementRaceState): ValidationResult { const newState: ComplementRaceState = { ...state, - gamePhase: "results", + gamePhase: 'results', raceEndTime: Date.now(), leaderboard: this.calculateLeaderboard(state), - }; + } - return { valid: true, newState }; + return { valid: true, newState } } private validatePlayAgain(state: ComplementRaceState): ValidationResult { - if (state.gamePhase !== "results") { - return { valid: false, error: "Game not finished" }; + if (state.gamePhase !== 'results') { + return { valid: false, error: 'Game not finished' } } // Reset to lobby with same players - return this.validateGoToSetup(state); + return this.validateGoToSetup(state) } private validateGoToSetup(state: ComplementRaceState): ValidationResult { - const newState: ComplementRaceState = this.getInitialState(state.config); + const newState: ComplementRaceState = this.getInitialState(state.config) - return { valid: true, newState }; + return { valid: true, newState } } // ========================================================================== // Helper Methods // ========================================================================== - private generateQuestion( - mode: "friends5" | "friends10" | "mixed", - ): ComplementQuestion { - let targetSum: number; - if (mode === "friends5") { - targetSum = 5; - } else if (mode === "friends10") { - targetSum = 10; + private generateQuestion(mode: 'friends5' | 'friends10' | 'mixed'): ComplementQuestion { + let targetSum: number + if (mode === 'friends5') { + targetSum = 5 + } else if (mode === 'friends10') { + targetSum = 10 } else { - targetSum = Math.random() < 0.5 ? 5 : 10; + targetSum = Math.random() < 0.5 ? 5 : 10 } - const number = Math.floor(Math.random() * targetSum); - const correctAnswer = targetSum - number; + const number = Math.floor(Math.random() * targetSum) + const correctAnswer = targetSum - number return { id: `q-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, @@ -740,14 +704,14 @@ export class ComplementRaceValidator correctAnswer, showAsAbacus: Math.random() < 0.5, // 50/50 random display timestamp: Date.now(), - }; + } } private calculateAnswerScore( correct: boolean, responseTime: number, currentStreak: number, - gameStyle: "practice" | "sprint" | "survival", + gameStyle: 'practice' | 'sprint' | 'survival' ): AnswerValidation { if (!correct) { return { @@ -757,21 +721,21 @@ export class ComplementRaceValidator streakBonus: 0, totalPoints: 0, newStreak: 0, - }; + } } // Base points - const basePoints = 100; + const basePoints = 100 // Speed bonus (max 300 for <500ms, down to 0 at 3000ms) - const speedBonus = Math.max(0, 300 - Math.floor(responseTime / 100)); + const speedBonus = Math.max(0, 300 - Math.floor(responseTime / 100)) // Streak bonus - const newStreak = currentStreak + 1; - const streakBonus = newStreak * 50; + const newStreak = currentStreak + 1 + const streakBonus = newStreak * 50 // Total - const totalPoints = basePoints + speedBonus + streakBonus; + const totalPoints = basePoints + speedBonus + streakBonus return { correct: true, @@ -780,59 +744,52 @@ export class ComplementRaceValidator streakBonus, totalPoints, newStreak, - }; + } } private generatePassengers(count: number, stations: Station[]): Passenger[] { - const passengers: Passenger[] = []; - const usedCombos = new Set(); + const passengers: Passenger[] = [] + const usedCombos = new Set() for (let i = 0; i < count; i++) { - let name: string; - let avatar: string; - let comboKey: string; + let name: string + let avatar: string + let comboKey: string // Keep trying until we get a unique name/avatar combo do { - const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length); - const avatarIndex = Math.floor( - Math.random() * PASSENGER_AVATARS.length, - ); - name = PASSENGER_NAMES[nameIndex]; - avatar = PASSENGER_AVATARS[avatarIndex]; - comboKey = `${name}-${avatar}`; - } while (usedCombos.has(comboKey) && usedCombos.size < 100); // Prevent infinite loop + const nameIndex = Math.floor(Math.random() * PASSENGER_NAMES.length) + const avatarIndex = Math.floor(Math.random() * PASSENGER_AVATARS.length) + name = PASSENGER_NAMES[nameIndex] + avatar = PASSENGER_AVATARS[avatarIndex] + comboKey = `${name}-${avatar}` + } while (usedCombos.has(comboKey) && usedCombos.size < 100) // Prevent infinite loop - usedCombos.add(comboKey); + usedCombos.add(comboKey) // Pick origin and destination stations // KEY: Destination must be AHEAD of origin (higher position on track) // This ensures passengers travel forward, creating better overlap - let originStation: Station; - let destinationStation: Station; + let originStation: Station + let destinationStation: Station if (Math.random() < 0.4 || stations.length < 3) { // 40% chance to start at depot (first station) - originStation = stations[0]; + originStation = stations[0] // Pick any station ahead as destination - const stationsAhead = stations.slice(1); - destinationStation = - stationsAhead[Math.floor(Math.random() * stationsAhead.length)]; + const stationsAhead = stations.slice(1) + destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)] } else { // Start at a random non-depot, non-final station - const nonDepotStations = stations.slice(1, -1); // Exclude depot and final station - originStation = - nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)]; + const nonDepotStations = stations.slice(1, -1) // Exclude depot and final station + originStation = nonDepotStations[Math.floor(Math.random() * nonDepotStations.length)] // Pick a station ahead of origin (higher position) - const stationsAhead = stations.filter( - (s) => s.position > originStation.position, - ); - destinationStation = - stationsAhead[Math.floor(Math.random() * stationsAhead.length)]; + const stationsAhead = stations.filter((s) => s.position > originStation.position) + destinationStation = stationsAhead[Math.floor(Math.random() * stationsAhead.length)] } // 30% chance of urgent - const isUrgent = Math.random() < 0.3; + const isUrgent = Math.random() < 0.3 const passenger = { id: `p-${Date.now()}-${i}-${Math.random().toString(36).substr(2, 9)}`, @@ -845,172 +802,161 @@ export class ComplementRaceValidator deliveredBy: null, carIndex: null, // Not boarded yet timestamp: Date.now(), - }; + } - passengers.push(passenger); + passengers.push(passenger) console.log( - `[Passenger ${i + 1}/${count}] ${name} waiting at ${originStation.emoji} ${originStation.name} (pos ${originStation.position}) → ${destinationStation.emoji} ${destinationStation.name} (pos ${destinationStation.position}) ${isUrgent ? "⚡ URGENT" : ""}`, - ); + `[Passenger ${i + 1}/${count}] ${name} waiting at ${originStation.emoji} ${originStation.name} (pos ${originStation.position}) → ${destinationStation.emoji} ${destinationStation.name} (pos ${destinationStation.position}) ${isUrgent ? '⚡ URGENT' : ''}` + ) } - console.log(`[Generated ${passengers.length} passengers total]`); - return passengers; + console.log(`[Generated ${passengers.length} passengers total]`) + return passengers } /** * Calculate the maximum number of passengers that will be on the train * concurrently at any given moment during the route */ - private calculateMaxConcurrentPassengers( - passengers: Passenger[], - stations: Station[], - ): number { + private calculateMaxConcurrentPassengers(passengers: Passenger[], stations: Station[]): number { // Create events for boarding and delivery interface StationEvent { - position: number; - isBoarding: boolean; // true = board, false = delivery + position: number + isBoarding: boolean // true = board, false = delivery } - const events: StationEvent[] = []; + const events: StationEvent[] = [] for (const passenger of passengers) { - const originStation = stations.find( - (s) => s.id === passenger.originStationId, - ); - const destStation = stations.find( - (s) => s.id === passenger.destinationStationId, - ); + const originStation = stations.find((s) => s.id === passenger.originStationId) + const destStation = stations.find((s) => s.id === passenger.destinationStationId) if (originStation && destStation) { - events.push({ position: originStation.position, isBoarding: true }); - events.push({ position: destStation.position, isBoarding: false }); + events.push({ position: originStation.position, isBoarding: true }) + events.push({ position: destStation.position, isBoarding: false }) } } // Sort events by position, with deliveries before boardings at the same position events.sort((a, b) => { - if (a.position !== b.position) return a.position - b.position; + if (a.position !== b.position) return a.position - b.position // At same position, deliveries happen before boarding - return a.isBoarding ? 1 : -1; - }); + return a.isBoarding ? 1 : -1 + }) // Track current passenger count and maximum - let currentCount = 0; - let maxCount = 0; + let currentCount = 0 + let maxCount = 0 for (const event of events) { if (event.isBoarding) { - currentCount++; - maxCount = Math.max(maxCount, currentCount); + currentCount++ + maxCount = Math.max(maxCount, currentCount) } else { - currentCount--; + currentCount-- } } - return maxCount; + return maxCount } private checkWinCondition(state: ComplementRaceState): string | null { - const { config, players } = state; + const { config, players } = state // Infinite mode: Never end the game - if (config.winCondition === "infinite") { - return null; + if (config.winCondition === 'infinite') { + return null } // Practice mode: First to reach goal - if (config.style === "practice") { + if (config.style === 'practice') { for (const [playerId, player] of Object.entries(players)) { if (player.correctAnswers >= config.raceGoal) { - return playerId; + return playerId } } // AI wins handled client-side via useAIRacers hook } // Sprint mode: Check route-based, score-based, or time-based win conditions - if (config.style === "sprint") { - if (config.winCondition === "score-based" && config.targetScore) { + if (config.style === 'sprint') { + if (config.winCondition === 'score-based' && config.targetScore) { for (const [playerId, player] of Object.entries(players)) { if (player.score >= config.targetScore) { - return playerId; + return playerId } } } - if (config.winCondition === "route-based" && config.routeCount) { + if (config.winCondition === 'route-based' && config.routeCount) { if (state.currentRoute >= config.routeCount) { // Find player with highest score - let maxScore = 0; - let winner: string | null = null; + let maxScore = 0 + let winner: string | null = null for (const [playerId, player] of Object.entries(players)) { if (player.score > maxScore) { - maxScore = player.score; - winner = playerId; + maxScore = player.score + winner = playerId } } - return winner; + return winner } } - if (config.winCondition === "time-based" && config.timeLimit) { - const elapsed = state.routeStartTime - ? (Date.now() - state.routeStartTime) / 1000 - : 0; + if (config.winCondition === 'time-based' && config.timeLimit) { + const elapsed = state.routeStartTime ? (Date.now() - state.routeStartTime) / 1000 : 0 if (elapsed >= config.timeLimit) { // Find player with most deliveries - let maxDeliveries = 0; - let winner: string | null = null; + let maxDeliveries = 0 + let winner: string | null = null for (const [playerId, player] of Object.entries(players)) { if (player.deliveredPassengers > maxDeliveries) { - maxDeliveries = player.deliveredPassengers; - winner = playerId; + maxDeliveries = player.deliveredPassengers + winner = playerId } } - return winner; + return winner } } } // Survival mode: Most laps in time limit - if (config.style === "survival" && config.timeLimit) { - const elapsed = state.raceStartTime - ? (Date.now() - state.raceStartTime) / 1000 - : 0; + if (config.style === 'survival' && config.timeLimit) { + const elapsed = state.raceStartTime ? (Date.now() - state.raceStartTime) / 1000 : 0 if (elapsed >= config.timeLimit) { // Find player with highest position (most laps) - let maxPosition = 0; - let winner: string | null = null; + let maxPosition = 0 + let winner: string | null = null for (const [playerId, player] of Object.entries(players)) { if (player.position > maxPosition) { - maxPosition = player.position; - winner = playerId; + maxPosition = player.position + winner = playerId } } // AI wins handled client-side via useAIRacers hook - return winner; + return winner } } - return null; + return null } private calculateLeaderboard(state: ComplementRaceState): Array<{ - playerId: string; - score: number; - rank: number; + playerId: string + score: number + rank: number }> { const entries = Object.values(state.players) .map((p) => ({ playerId: p.id, score: p.score })) - .sort((a, b) => b.score - a.score); + .sort((a, b) => b.score - a.score) return entries.map((entry, index) => ({ ...entry, rank: index + 1, - })); + })) } // ========================================================================== @@ -1018,15 +964,15 @@ export class ComplementRaceValidator // ========================================================================== isGameComplete(state: ComplementRaceState): boolean { - return state.gamePhase === "results" && state.winner !== null; + return state.gamePhase === 'results' && state.winner !== null } getInitialState(config: unknown): ComplementRaceState { - const typedConfig = config as ComplementRaceConfig; + const typedConfig = config as ComplementRaceConfig return { config: typedConfig, - gamePhase: "setup", + gamePhase: 'setup', activePlayers: [], playerMetadata: {}, players: {}, @@ -1043,8 +989,8 @@ export class ComplementRaceValidator aiOpponents: [], gameStartTime: null, gameEndTime: null, - }; + } } } -export const complementRaceValidator = new ComplementRaceValidator(); +export const complementRaceValidator = new ComplementRaceValidator() diff --git a/apps/web/src/arcade-games/complement-race/components/GameComponent.tsx b/apps/web/src/arcade-games/complement-race/components/GameComponent.tsx index 4d68f0eb..c02a17c5 100644 --- a/apps/web/src/arcade-games/complement-race/components/GameComponent.tsx +++ b/apps/web/src/arcade-games/complement-race/components/GameComponent.tsx @@ -3,57 +3,57 @@ * Wraps the existing ComplementRaceGame with PageWithNav for arcade play */ -"use client"; +'use client' -import { useRouter } from "next/navigation"; -import { PageWithNav } from "@/components/PageWithNav"; -import { ComplementRaceGame } from "@/app/arcade/complement-race/components/ComplementRaceGame"; -import { useComplementRace } from "../Provider"; +import { useRouter } from 'next/navigation' +import { PageWithNav } from '@/components/PageWithNav' +import { ComplementRaceGame } from '@/app/arcade/complement-race/components/ComplementRaceGame' +import { useComplementRace } from '../Provider' export function GameComponent() { - const router = useRouter(); - const { state, exitSession, goToSetup } = useComplementRace(); + const router = useRouter() + const { state, exitSession, goToSetup } = useComplementRace() // Get display name based on style const getNavTitle = () => { switch (state.style) { - case "sprint": - return "Steam Sprint"; - case "survival": - return "Endless Circuit"; - case "practice": + case 'sprint': + return 'Steam Sprint' + case 'survival': + return 'Endless Circuit' + case 'practice': default: - return "Complement Race"; + return 'Complement Race' } - }; + } // Get emoji based on style const getNavEmoji = () => { switch (state.style) { - case "sprint": - return "🚂"; - case "survival": - return "♾️"; - case "practice": + case 'sprint': + return '🚂' + case 'survival': + return '♾️' + case 'practice': default: - return "🏁"; + return '🏁' } - }; + } return ( { - exitSession(); - router.push("/arcade"); + exitSession() + router.push('/arcade') }} onNewGame={() => { - goToSetup(); + goToSetup() }} > - ); + ) } diff --git a/apps/web/src/arcade-games/complement-race/index.tsx b/apps/web/src/arcade-games/complement-race/index.tsx index 66c5d654..d56202b2 100644 --- a/apps/web/src/arcade-games/complement-race/index.tsx +++ b/apps/web/src/arcade-games/complement-race/index.tsx @@ -3,43 +3,34 @@ * Complete integration into the arcade system with multiplayer support */ -import { defineGame, getGameTheme } from "@/lib/arcade/game-sdk"; -import type { GameManifest } from "@/lib/arcade/game-sdk"; -import { complementRaceValidator } from "./Validator"; -import { ComplementRaceProvider } from "./Provider"; -import { GameComponent } from "./components/GameComponent"; -import type { - ComplementRaceConfig, - ComplementRaceState, - ComplementRaceMove, -} from "./types"; +import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk' +import type { GameManifest } from '@/lib/arcade/game-sdk' +import { complementRaceValidator } from './Validator' +import { ComplementRaceProvider } from './Provider' +import { GameComponent } from './components/GameComponent' +import type { ComplementRaceConfig, ComplementRaceState, ComplementRaceMove } from './types' // Game manifest const manifest: GameManifest = { - name: "complement-race", - displayName: "Speed Complement Race 🏁", - description: "Race against opponents while solving complement problems", + name: 'complement-race', + displayName: 'Speed Complement Race 🏁', + description: 'Race against opponents while solving complement problems', longDescription: - "Battle AI opponents or real players in an epic math race! Find complement numbers (friends of 5 and 10) to build momentum and speed ahead. Choose from three exciting modes: Practice (linear race), Sprint (train journey with passengers), or Survival (infinite laps). Perfect for multiplayer competition!", + 'Battle AI opponents or real players in an epic math race! Find complement numbers (friends of 5 and 10) to build momentum and speed ahead. Choose from three exciting modes: Practice (linear race), Sprint (train journey with passengers), or Survival (infinite laps). Perfect for multiplayer competition!', maxPlayers: 4, - icon: "🏁", - chips: [ - "👥 1-4 Players", - "🚂 Sprint Mode", - "🤖 AI Opponents", - "🔥 Speed Challenge", - ], - ...getGameTheme("blue"), - difficulty: "Intermediate", + icon: '🏁', + chips: ['👥 1-4 Players', '🚂 Sprint Mode', '🤖 AI Opponents', '🔥 Speed Challenge'], + ...getGameTheme('blue'), + difficulty: 'Intermediate', available: true, -}; +} // Default configuration const defaultConfig: ComplementRaceConfig = { - style: "practice", - mode: "mixed", - complementDisplay: "random", - timeoutSetting: "normal", + style: 'practice', + mode: 'mixed', + complementDisplay: 'random', + timeoutSetting: 'normal', enableAI: true, aiOpponentCount: 2, maxPlayers: 4, @@ -48,26 +39,24 @@ const defaultConfig: ComplementRaceConfig = { passengerCount: 6, maxConcurrentPassengers: 3, raceGoal: 20, - winCondition: "infinite", // Sprint mode is infinite by default (Steam Sprint) + winCondition: 'infinite', // Sprint mode is infinite by default (Steam Sprint) routeCount: 3, targetScore: 100, timeLimit: 300, -}; +} // Config validation function -function validateComplementRaceConfig( - config: unknown, -): config is ComplementRaceConfig { - const c = config as any; +function validateComplementRaceConfig(config: unknown): config is ComplementRaceConfig { + const c = config as any return ( - typeof c === "object" && + typeof c === 'object' && c !== null && - ["practice", "sprint", "survival"].includes(c.style) && - ["friends5", "friends10", "mixed"].includes(c.mode) && - typeof c.maxPlayers === "number" && + ['practice', 'sprint', 'survival'].includes(c.style) && + ['friends5', 'friends10', 'mixed'].includes(c.mode) && + typeof c.maxPlayers === 'number' && c.maxPlayers >= 1 && c.maxPlayers <= 4 - ); + ) } // Export game definition with proper generics @@ -82,12 +71,12 @@ export const complementRaceGame = defineGame< validator: complementRaceValidator, defaultConfig, validateConfig: validateComplementRaceConfig, -}); +}) // Re-export types for convenience export type { ComplementRaceConfig, ComplementRaceState, ComplementRaceMove, -} from "./types"; -export { complementRaceValidator } from "./Validator"; +} from './types' +export { complementRaceValidator } from './Validator' diff --git a/apps/web/src/arcade-games/complement-race/types.ts b/apps/web/src/arcade-games/complement-race/types.ts index 04a3c1d8..378d7250 100644 --- a/apps/web/src/arcade-games/complement-race/types.ts +++ b/apps/web/src/arcade-games/complement-race/types.ts @@ -2,47 +2,47 @@ * Type definitions for Complement Race multiplayer game */ -import type { GameMove as BaseGameMove } from "@/lib/arcade/game-sdk"; -import type { ComplementRaceGameConfig } from "@/lib/arcade/game-configs"; +import type { GameMove as BaseGameMove } from '@/lib/arcade/game-sdk' +import type { ComplementRaceGameConfig } from '@/lib/arcade/game-configs' // ============================================================================ // Configuration Types // ============================================================================ -export type { ComplementRaceGameConfig as ComplementRaceConfig } from "@/lib/arcade/game-configs"; +export type { ComplementRaceGameConfig as ComplementRaceConfig } from '@/lib/arcade/game-configs' // ============================================================================ // Question & Game Mechanic Types // ============================================================================ export interface ComplementQuestion { - id: string; - number: number; // The visible number (e.g., 3 in "3 + ? = 5") - targetSum: number; // 5 or 10 - correctAnswer: number; // The missing number - showAsAbacus: boolean; // Display as abacus visualization? - timestamp: number; // When question was generated + id: string + number: number // The visible number (e.g., 3 in "3 + ? = 5") + targetSum: number // 5 or 10 + correctAnswer: number // The missing number + showAsAbacus: boolean // Display as abacus visualization? + timestamp: number // When question was generated } export interface Station { - id: string; - name: string; - position: number; // 0-100% along track - icon: string; - emoji: string; // Alias for icon (for backward compatibility) + id: string + name: string + position: number // 0-100% along track + icon: string + emoji: string // Alias for icon (for backward compatibility) } export interface Passenger { - id: string; - name: string; - avatar: string; - originStationId: string; - destinationStationId: string; - isUrgent: boolean; // Urgent passengers worth 2x points - claimedBy: string | null; // playerId who picked up this passenger (null = unclaimed) - deliveredBy: string | null; // playerId who delivered (null = not delivered yet) - carIndex: number | null; // Physical car index (0-N) where passenger is seated (null = not boarded) - timestamp: number; // When passenger spawned + id: string + name: string + avatar: string + originStationId: string + destinationStationId: string + isUrgent: boolean // Urgent passengers worth 2x points + claimedBy: string | null // playerId who picked up this passenger (null = unclaimed) + deliveredBy: string | null // playerId who delivered (null = not delivered yet) + carIndex: number | null // Physical car index (0-N) where passenger is seated (null = not boarded) + timestamp: number // When passenger spawned } // ============================================================================ @@ -50,29 +50,29 @@ export interface Passenger { // ============================================================================ export interface PlayerState { - id: string; - name: string; - color: string; // For ghost train visualization + id: string + name: string + color: string // For ghost train visualization // Scores - score: number; - streak: number; - bestStreak: number; - correctAnswers: number; - totalQuestions: number; + score: number + streak: number + bestStreak: number + correctAnswers: number + totalQuestions: number // Position & Progress - position: number; // 0-100% for practice/survival only (sprint mode: client-side) + position: number // 0-100% for practice/survival only (sprint mode: client-side) // Current state - isReady: boolean; - isActive: boolean; - currentAnswer: string | null; // Their current typed answer (for "thinking" indicator) - lastAnswerTime: number | null; + isReady: boolean + isActive: boolean + currentAnswer: string | null // Their current typed answer (for "thinking" indicator) + lastAnswerTime: number | null // Sprint mode: passengers currently on this player's train - passengers: string[]; // Array of passenger IDs (max 3) - deliveredPassengers: number; // Total count + passengers: string[] // Array of passenger IDs (max 3) + deliveredPassengers: number // Total count } // ============================================================================ @@ -81,49 +81,49 @@ export interface PlayerState { export interface ComplementRaceState { // Configuration (from room settings) - config: ComplementRaceGameConfig; + config: ComplementRaceGameConfig // Game Phase - gamePhase: "setup" | "lobby" | "countdown" | "playing" | "results"; + gamePhase: 'setup' | 'lobby' | 'countdown' | 'playing' | 'results' // Players - activePlayers: string[]; // Array of player IDs - playerMetadata: Record; // playerId -> metadata - players: Record; // playerId -> state + activePlayers: string[] // Array of player IDs + playerMetadata: Record // playerId -> metadata + players: Record // playerId -> state // Current Question (shared for competitive, individual for each player) - currentQuestions: Record; // playerId -> question - questionStartTime: number; // When current question batch started + currentQuestions: Record // playerId -> question + questionStartTime: number // When current question batch started // Sprint Mode: Shared passenger pool - stations: Station[]; - passengers: Passenger[]; // All passengers (claimed and unclaimed) - currentRoute: number; - routeStartTime: number | null; + stations: Station[] + passengers: Passenger[] // All passengers (claimed and unclaimed) + currentRoute: number + routeStartTime: number | null // Race Progress - raceStartTime: number | null; - raceEndTime: number | null; - winner: string | null; // playerId of winner - leaderboard: Array<{ playerId: string; score: number; rank: number }>; + raceStartTime: number | null + raceEndTime: number | null + winner: string | null // playerId of winner + leaderboard: Array<{ playerId: string; score: number; rank: number }> // AI Opponents (optional) aiOpponents: Array<{ - id: string; - name: string; - personality: "competitive" | "analytical"; - position: number; - speed: number; - lastComment: string | null; - lastCommentTime: number; - }>; + id: string + name: string + personality: 'competitive' | 'analytical' + position: number + speed: number + lastComment: string | null + lastCommentTime: number + }> // Timing - gameStartTime: number | null; - gameEndTime: number | null; + gameStartTime: number | null + gameEndTime: number | null // Index signature to satisfy GameState constraint - [key: string]: unknown; + [key: string]: unknown } // ============================================================================ @@ -132,57 +132,58 @@ export interface ComplementRaceState { export type ComplementRaceMove = BaseGameMove & // Setup phase - (| { - type: "START_GAME"; - data: { - activePlayers: string[]; - playerMetadata: Record; - }; - } - | { type: "SET_READY"; data: { ready: boolean } } + ( | { - type: "SET_CONFIG"; - data: { field: keyof ComplementRaceGameConfig; value: unknown }; + type: 'START_GAME' + data: { + activePlayers: string[] + playerMetadata: Record + } + } + | { type: 'SET_READY'; data: { ready: boolean } } + | { + type: 'SET_CONFIG' + data: { field: keyof ComplementRaceGameConfig; value: unknown } } // Playing phase - | { type: "SUBMIT_ANSWER"; data: { answer: number; responseTime: number } } - | { type: "UPDATE_INPUT"; data: { input: string } } // Show "thinking" indicator - | { type: "UPDATE_POSITION"; data: { position: number } } // Sprint mode: sync train position + | { type: 'SUBMIT_ANSWER'; data: { answer: number; responseTime: number } } + | { type: 'UPDATE_INPUT'; data: { input: string } } // Show "thinking" indicator + | { type: 'UPDATE_POSITION'; data: { position: number } } // Sprint mode: sync train position | { - type: "CLAIM_PASSENGER"; - data: { passengerId: string; carIndex: number }; + type: 'CLAIM_PASSENGER' + data: { passengerId: string; carIndex: number } } // Sprint mode: pickup - | { type: "DELIVER_PASSENGER"; data: { passengerId: string } } // Sprint mode: delivery + | { type: 'DELIVER_PASSENGER'; data: { passengerId: string } } // Sprint mode: delivery // Game flow - | { type: "NEXT_QUESTION"; data: Record } - | { type: "END_GAME"; data: Record } - | { type: "PLAY_AGAIN"; data: Record } - | { type: "GO_TO_SETUP"; data: Record } + | { type: 'NEXT_QUESTION'; data: Record } + | { type: 'END_GAME'; data: Record } + | { type: 'PLAY_AGAIN'; data: Record } + | { type: 'GO_TO_SETUP'; data: Record } // Sprint mode route progression - | { type: "START_NEW_ROUTE"; data: { routeNumber: number } } - ); + | { type: 'START_NEW_ROUTE'; data: { routeNumber: number } } + ) // ============================================================================ // Helper Types // ============================================================================ export interface AnswerValidation { - correct: boolean; - responseTime: number; - speedBonus: number; - streakBonus: number; - totalPoints: number; - newStreak: number; + correct: boolean + responseTime: number + speedBonus: number + streakBonus: number + totalPoints: number + newStreak: number } export interface PassengerAction { - type: "claim" | "deliver"; - passengerId: string; - playerId: string; - station: Station; - points: number; - timestamp: number; + type: 'claim' | 'deliver' + passengerId: string + playerId: string + station: Station + points: number + timestamp: number } diff --git a/apps/web/src/arcade-games/matching/Provider.tsx b/apps/web/src/arcade-games/matching/Provider.tsx index f9081a8c..c8dac8df 100644 --- a/apps/web/src/arcade-games/matching/Provider.tsx +++ b/apps/web/src/arcade-games/matching/Provider.tsx @@ -1,44 +1,37 @@ -"use client"; +'use client' -import { - type ReactNode, - useCallback, - useEffect, - useMemo, - createContext, - useContext, -} from "react"; -import { useArcadeSession } from "@/hooks/useArcadeSession"; -import { useRoomData, useUpdateGameConfig } from "@/hooks/useRoomData"; -import { useViewerId } from "@/hooks/useViewerId"; +import { type ReactNode, useCallback, useEffect, useMemo, createContext, useContext } from 'react' +import { useArcadeSession } from '@/hooks/useArcadeSession' +import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData' +import { useViewerId } from '@/hooks/useViewerId' import { buildPlayerMetadata as buildPlayerMetadataUtil, buildPlayerOwnershipFromRoomData, -} from "@/lib/arcade/player-ownership.client"; -import type { GameMove } from "@/lib/arcade/validation"; -import { useGameMode } from "@/contexts/GameModeContext"; -import { generateGameCards } from "./utils/cardGeneration"; +} from '@/lib/arcade/player-ownership.client' +import type { GameMove } from '@/lib/arcade/validation' +import { useGameMode } from '@/contexts/GameModeContext' +import { generateGameCards } from './utils/cardGeneration' import type { GameMode, GameStatistics, MatchingContextValue, MatchingState, MatchingMove, -} from "./types"; +} from './types' // Create context for Matching game -const MatchingContext = createContext(null); +const MatchingContext = createContext(null) // Initial state const initialState: MatchingState = { cards: [], gameCards: [], flippedCards: [], - gameType: "abacus-numeral", + gameType: 'abacus-numeral', difficulty: 6, turnTimer: 30, - gamePhase: "setup", - currentPlayer: "", // Will be set to first player ID on START_GAME + gamePhase: 'setup', + currentPlayer: '', // Will be set to first player ID on START_GAME matchedPairs: 0, totalPairs: 6, moves: 0, @@ -60,23 +53,20 @@ const initialState: MatchingState = { pausedGameState: undefined, // HOVER: Initialize hover state playerHovers: {}, -}; +} /** * Optimistic move application (client-side prediction) * The server will validate and send back the authoritative state */ -function applyMoveOptimistically( - state: MatchingState, - move: GameMove, -): MatchingState { - const typedMove = move as MatchingMove; +function applyMoveOptimistically(state: MatchingState, move: GameMove): MatchingState { + const typedMove = move as MatchingMove switch (typedMove.type) { - case "START_GAME": + case 'START_GAME': // Generate cards and initialize game return { ...state, - gamePhase: "playing", + gamePhase: 'playing', gameCards: typedMove.data.cards, cards: typedMove.data.cards, flippedCards: [], @@ -84,15 +74,15 @@ function applyMoveOptimistically( moves: 0, scores: typedMove.data.activePlayers.reduce( (acc: any, p: string) => ({ ...acc, [p]: 0 }), - {}, + {} ), consecutiveMatches: typedMove.data.activePlayers.reduce( (acc: any, p: string) => ({ ...acc, [p]: 0 }), - {}, + {} ), activePlayers: typedMove.data.activePlayers, playerMetadata: typedMove.data.playerMetadata || {}, // Include player metadata - currentPlayer: typedMove.data.activePlayers[0] || "", + currentPlayer: typedMove.data.activePlayers[0] || '', gameStartTime: Date.now(), gameEndTime: null, currentMoveStartTime: Date.now(), @@ -108,35 +98,34 @@ function applyMoveOptimistically( }, pausedGamePhase: undefined, pausedGameState: undefined, - }; + } - case "FLIP_CARD": { + case 'FLIP_CARD': { // Optimistically flip the card // Defensive check: ensure arrays exist - const gameCards = state.gameCards || []; - const flippedCards = state.flippedCards || []; + const gameCards = state.gameCards || [] + const flippedCards = state.flippedCards || [] - const card = gameCards.find((c) => c.id === typedMove.data.cardId); - if (!card) return state; + const card = gameCards.find((c) => c.id === typedMove.data.cardId) + if (!card) return state - const newFlippedCards = [...flippedCards, card]; + const newFlippedCards = [...flippedCards, card] return { ...state, flippedCards: newFlippedCards, - currentMoveStartTime: - flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime, + currentMoveStartTime: flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime, isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped showMismatchFeedback: false, - }; + } } - case "CLEAR_MISMATCH": { + case 'CLEAR_MISMATCH': { // Clear hover for all non-current players - const clearedHovers = { ...state.playerHovers }; + const clearedHovers = { ...state.playerHovers } for (const playerId of state.activePlayers) { if (playerId !== state.currentPlayer) { - clearedHovers[playerId] = null; + clearedHovers[playerId] = null } } @@ -148,17 +137,16 @@ function applyMoveOptimistically( isProcessingMove: false, // Clear hovers for non-current players playerHovers: clearedHovers, - }; + } } - case "GO_TO_SETUP": { + case 'GO_TO_SETUP': { // Return to setup phase - pause game if coming from playing/results - const isPausingGame = - state.gamePhase === "playing" || state.gamePhase === "results"; + const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results' return { ...state, - gamePhase: "setup", + gamePhase: 'setup', // PAUSE: Save game state if pausing from active game pausedGamePhase: isPausingGame ? state.gamePhase : undefined, pausedGameState: isPausingGame @@ -178,7 +166,7 @@ function applyMoveOptimistically( gameCards: [], cards: [], flippedCards: [], - currentPlayer: "", + currentPlayer: '', matchedPairs: 0, moves: 0, scores: {}, @@ -192,19 +180,19 @@ function applyMoveOptimistically( isProcessingMove: false, showMismatchFeedback: false, lastMatchedPair: null, - }; + } } - case "SET_CONFIG": { + case 'SET_CONFIG': { // Update configuration field optimistically - const { field, value } = typedMove.data; - const clearPausedGame = !!state.pausedGamePhase; + const { field, value } = typedMove.data + const clearPausedGame = !!state.pausedGamePhase return { ...state, [field]: value, // Update totalPairs if difficulty changes - ...(field === "difficulty" ? { totalPairs: value } : {}), + ...(field === 'difficulty' ? { totalPairs: value } : {}), // Clear paused game if config changed ...(clearPausedGame ? { @@ -213,13 +201,13 @@ function applyMoveOptimistically( originalConfig: undefined, } : {}), - }; + } } - case "RESUME_GAME": { + case 'RESUME_GAME': { // Resume paused game if (!state.pausedGamePhase || !state.pausedGameState) { - return state; // No paused game, no-op + return state // No paused game, no-op } return { @@ -238,10 +226,10 @@ function applyMoveOptimistically( // Clear paused state pausedGamePhase: undefined, pausedGameState: undefined, - }; + } } - case "HOVER_CARD": { + case 'HOVER_CARD': { // Update player hover state for networked presence return { ...state, @@ -249,11 +237,11 @@ function applyMoveOptimistically( ...state.playerHovers, [typedMove.playerId]: typedMove.data.cardId, }, - }; + } } default: - return state; + return state } } @@ -261,25 +249,21 @@ function applyMoveOptimistically( // NOTE: This provider should ONLY be used for room-based multiplayer games. // For arcade sessions without rooms, use LocalMemoryPairsProvider instead. export function MatchingProvider({ children }: { children: ReactNode }) { - const { data: viewerId } = useViewerId(); - const { roomData } = useRoomData(); // Fetch room data for room-based play - const { - activePlayerCount, - activePlayers: activePlayerIds, - players, - } = useGameMode(); - const { mutate: updateGameConfig } = useUpdateGameConfig(); + const { data: viewerId } = useViewerId() + const { roomData } = useRoomData() // Fetch room data for room-based play + const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode() + const { mutate: updateGameConfig } = useUpdateGameConfig() // Get active player IDs directly as strings (UUIDs) - const activePlayers = Array.from(activePlayerIds) as string[]; + const activePlayers = Array.from(activePlayerIds) as string[] // Derive game mode from active player count - const gameMode = activePlayerCount > 1 ? "multiplayer" : "single"; + const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single' // Track roomData.gameConfig changes useEffect(() => { console.log( - "[MatchingProvider] roomData.gameConfig changed:", + '[MatchingProvider] roomData.gameConfig changed:', JSON.stringify( { gameConfig: roomData?.gameConfig, @@ -287,50 +271,42 @@ export function MatchingProvider({ children }: { children: ReactNode }) { gameName: roomData?.gameName, }, null, - 2, - ), - ); - }, [roomData?.gameConfig, roomData?.id, roomData?.gameName]); + 2 + ) + ) + }, [roomData?.gameConfig, roomData?.id, roomData?.gameName]) // Merge saved game config from room with initialState // Settings are scoped by game name to preserve settings when switching games const mergedInitialState = useMemo(() => { - const gameConfig = roomData?.gameConfig as - | Record - | null - | undefined; + const gameConfig = roomData?.gameConfig as Record | null | undefined console.log( - "[MatchingProvider] Loading settings from database:", + '[MatchingProvider] Loading settings from database:', JSON.stringify( { gameConfig, roomId: roomData?.id, }, null, - 2, - ), - ); + 2 + ) + ) if (!gameConfig) { - console.log("[MatchingProvider] No gameConfig, using initialState"); - return initialState; + console.log('[MatchingProvider] No gameConfig, using initialState') + return initialState } // Get settings for this specific game (matching) - const savedConfig = gameConfig.matching as - | Record - | null - | undefined; + const savedConfig = gameConfig.matching as Record | null | undefined console.log( - "[MatchingProvider] Saved config for matching:", - JSON.stringify(savedConfig, null, 2), - ); + '[MatchingProvider] Saved config for matching:', + JSON.stringify(savedConfig, null, 2) + ) if (!savedConfig) { - console.log( - "[MatchingProvider] No saved config for matching, using initialState", - ); - return initialState; + console.log('[MatchingProvider] No saved config for matching, using initialState') + return initialState } const merged = { @@ -339,9 +315,9 @@ export function MatchingProvider({ children }: { children: ReactNode }) { gameType: savedConfig.gameType ?? initialState.gameType, difficulty: savedConfig.difficulty ?? initialState.difficulty, turnTimer: savedConfig.turnTimer ?? initialState.turnTimer, - }; + } console.log( - "[MatchingProvider] Merged state:", + '[MatchingProvider] Merged state:', JSON.stringify( { gameType: merged.gameType, @@ -349,12 +325,12 @@ export function MatchingProvider({ children }: { children: ReactNode }) { turnTimer: merged.turnTimer, }, null, - 2, - ), - ); + 2 + ) + ) - return merged; - }, [roomData?.gameConfig]); + return merged + }, [roomData?.gameConfig]) // Arcade session integration WITH room sync const { @@ -363,15 +339,15 @@ export function MatchingProvider({ children }: { children: ReactNode }) { connected: _connected, exitSession, } = useArcadeSession({ - userId: viewerId || "", + userId: viewerId || '', roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members initialState: mergedInitialState, applyMove: applyMoveOptimistically, - }); + }) // Detect state corruption/mismatch (e.g., game type mismatch between sessions) const hasStateCorruption = - !state.gameCards || !state.flippedCards || !Array.isArray(state.gameCards); + !state.gameCards || !state.flippedCards || !Array.isArray(state.gameCards) // Handle mismatch feedback timeout useEffect(() => { @@ -381,14 +357,14 @@ export function MatchingProvider({ children }: { children: ReactNode }) { // Server will validate that cards are still in mismatch state before clearing const timeout = setTimeout(() => { sendMove({ - type: "CLEAR_MISMATCH", + type: 'CLEAR_MISMATCH', playerId: state.currentPlayer, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, 1500); + }) + }, 1500) - return () => clearTimeout(timeout); + return () => clearTimeout(timeout) } }, [ state.showMismatchFeedback, @@ -396,84 +372,76 @@ export function MatchingProvider({ children }: { children: ReactNode }) { sendMove, state.currentPlayer, viewerId, - ]); + ]) // Computed values - const isGameActive = state.gamePhase === "playing"; + const isGameActive = state.gamePhase === 'playing' const canFlipCard = useCallback( (cardId: string): boolean => { // Defensive check: ensure required state exists - const flippedCards = state.flippedCards || []; - const gameCards = state.gameCards || []; + const flippedCards = state.flippedCards || [] + const gameCards = state.gameCards || [] - console.log("[RoomProvider][canFlipCard] Checking card:", { + console.log('[RoomProvider][canFlipCard] Checking card:', { cardId, isGameActive, isProcessingMove: state.isProcessingMove, currentPlayer: state.currentPlayer, hasRoomData: !!roomData, flippedCardsCount: flippedCards.length, - }); + }) if (!isGameActive || state.isProcessingMove) { - console.log( - "[RoomProvider][canFlipCard] Blocked: game not active or processing", - ); - return false; + console.log('[RoomProvider][canFlipCard] Blocked: game not active or processing') + return false } - const card = gameCards.find((c) => c.id === cardId); + const card = gameCards.find((c) => c.id === cardId) if (!card || card.matched) { - console.log( - "[RoomProvider][canFlipCard] Blocked: card not found or already matched", - ); - return false; + console.log('[RoomProvider][canFlipCard] Blocked: card not found or already matched') + return false } // Can't flip if already flipped if (flippedCards.some((c) => c.id === cardId)) { - console.log( - "[RoomProvider][canFlipCard] Blocked: card already flipped", - ); - return false; + console.log('[RoomProvider][canFlipCard] Blocked: card already flipped') + return false } // Can't flip more than 2 cards if (flippedCards.length >= 2) { - console.log( - "[RoomProvider][canFlipCard] Blocked: 2 cards already flipped", - ); - return false; + console.log('[RoomProvider][canFlipCard] Blocked: 2 cards already flipped') + return false } // Authorization check: Only allow flipping if it's your player's turn if (roomData && state.currentPlayer) { - const currentPlayerData = players.get(state.currentPlayer); - console.log("[RoomProvider][canFlipCard] Authorization check:", { + const currentPlayerData = players.get(state.currentPlayer) + console.log('[RoomProvider][canFlipCard] Authorization check:', { currentPlayerId: state.currentPlayer, currentPlayerFound: !!currentPlayerData, currentPlayerIsLocal: currentPlayerData?.isLocal, - }); + }) // Block if current player is explicitly marked as remote (isLocal === false) if (currentPlayerData && currentPlayerData.isLocal === false) { console.log( - "[RoomProvider][canFlipCard] BLOCKED: Current player is remote (not your turn)", - ); - return false; + '[RoomProvider][canFlipCard] BLOCKED: Current player is remote (not your turn)' + ) + return false } // If player data not found in map, this might be an issue - allow for now but warn if (!currentPlayerData) { console.warn( - "[RoomProvider][canFlipCard] WARNING: Current player not found in players map, allowing move", - ); + '[RoomProvider][canFlipCard] WARNING: Current player not found in players map, allowing move' + ) } } - console.log("[RoomProvider][canFlipCard] ALLOWED: All checks passed"); - return true; + console.log('[RoomProvider][canFlipCard] ALLOWED: All checks passed') + return true }, [ isGameActive, @@ -483,195 +451,150 @@ export function MatchingProvider({ children }: { children: ReactNode }) { state.currentPlayer, roomData, players, - ], - ); + ] + ) const currentGameStatistics: GameStatistics = useMemo( () => ({ totalMoves: state.moves, matchedPairs: state.matchedPairs, totalPairs: state.totalPairs, - gameTime: state.gameStartTime - ? (state.gameEndTime || Date.now()) - state.gameStartTime - : 0, + gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0, accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0, averageTimePerMove: state.moves > 0 && state.gameStartTime - ? ((state.gameEndTime || Date.now()) - state.gameStartTime) / - state.moves + ? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves : 0, }), - [ - state.moves, - state.matchedPairs, - state.totalPairs, - state.gameStartTime, - state.gameEndTime, - ], - ); + [state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime] + ) // PAUSE/RESUME: Computed values for pause/resume functionality const hasConfigChanged = useMemo(() => { - if (!state.originalConfig) return false; + if (!state.originalConfig) return false return ( state.gameType !== state.originalConfig.gameType || state.difficulty !== state.originalConfig.difficulty || state.turnTimer !== state.originalConfig.turnTimer - ); - }, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig]); + ) + }, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig]) const canResumeGame = useMemo(() => { - return ( - !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged - ); - }, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged]); + return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged + }, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged]) // Helper to build player metadata with correct userId ownership // Uses centralized ownership utilities const buildPlayerMetadata = useCallback( (playerIds: string[]) => { // Build ownership map from room data - const playerOwnership = buildPlayerOwnershipFromRoomData(roomData); + const playerOwnership = buildPlayerOwnershipFromRoomData(roomData) // Use centralized utility to build metadata - return buildPlayerMetadataUtil( - playerIds, - playerOwnership, - players, - viewerId ?? undefined, - ); + return buildPlayerMetadataUtil(playerIds, playerOwnership, players, viewerId ?? undefined) }, - [players, roomData, viewerId], - ); + [players, roomData, viewerId] + ) // Action creators - send moves to arcade session const startGame = useCallback(() => { // Must have at least one active player if (activePlayers.length === 0) { - console.error( - "[MatchingProvider] Cannot start game without active players", - ); - return; + console.error('[MatchingProvider] Cannot start game without active players') + return } // Capture player metadata from local players map // This ensures all room members can display player info even if they don't own the players - const playerMetadata = buildPlayerMetadata(activePlayers); + const playerMetadata = buildPlayerMetadata(activePlayers) // Use current session state configuration (no local state!) - const cards = generateGameCards(state.gameType, state.difficulty); + const cards = generateGameCards(state.gameType, state.difficulty) // Use first active player as playerId for START_GAME move - const firstPlayer = activePlayers[0] as string; + const firstPlayer = activePlayers[0] as string sendMove({ - type: "START_GAME", + type: 'START_GAME', playerId: firstPlayer, - userId: viewerId || "", + userId: viewerId || '', data: { cards, activePlayers, playerMetadata, }, - }); - }, [ - state.gameType, - state.difficulty, - activePlayers, - buildPlayerMetadata, - sendMove, - viewerId, - ]); + }) + }, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove, viewerId]) const flipCard = useCallback( (cardId: string) => { - console.log("[RoomProvider] flipCard called:", { + console.log('[RoomProvider] flipCard called:', { cardId, viewerId, currentPlayer: state.currentPlayer, activePlayers: state.activePlayers, gamePhase: state.gamePhase, canFlip: canFlipCard(cardId), - }); + }) if (!canFlipCard(cardId)) { - console.log( - "[RoomProvider] Cannot flip card - canFlipCard returned false", - ); - return; + console.log('[RoomProvider] Cannot flip card - canFlipCard returned false') + return } const move = { - type: "FLIP_CARD" as const, + type: 'FLIP_CARD' as const, playerId: state.currentPlayer, // Use the current player ID from game state (database player ID) - userId: viewerId || "", + userId: viewerId || '', data: { cardId }, - }; - console.log("[RoomProvider] Sending FLIP_CARD move via sendMove:", move); - sendMove(move); + } + console.log('[RoomProvider] Sending FLIP_CARD move via sendMove:', move) + sendMove(move) }, - [ - canFlipCard, - sendMove, - viewerId, - state.currentPlayer, - state.activePlayers, - state.gamePhase, - ], - ); + [canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase] + ) const resetGame = useCallback(() => { // Must have at least one active player if (activePlayers.length === 0) { - console.error( - "[MatchingProvider] Cannot reset game without active players", - ); - return; + console.error('[MatchingProvider] Cannot reset game without active players') + return } // Capture player metadata with correct userId ownership - const playerMetadata = buildPlayerMetadata(activePlayers); + const playerMetadata = buildPlayerMetadata(activePlayers) // Use current session state configuration (no local state!) - const cards = generateGameCards(state.gameType, state.difficulty); + const cards = generateGameCards(state.gameType, state.difficulty) // Use first active player as playerId for START_GAME move - const firstPlayer = activePlayers[0] as string; + const firstPlayer = activePlayers[0] as string sendMove({ - type: "START_GAME", + type: 'START_GAME', playerId: firstPlayer, - userId: viewerId || "", + userId: viewerId || '', data: { cards, activePlayers, playerMetadata, }, - }); - }, [ - state.gameType, - state.difficulty, - activePlayers, - buildPlayerMetadata, - sendMove, - viewerId, - ]); + }) + }, [state.gameType, state.difficulty, activePlayers, buildPlayerMetadata, sendMove, viewerId]) const setGameType = useCallback( (gameType: typeof state.gameType) => { - console.log("[MatchingProvider] setGameType called:", gameType); + console.log('[MatchingProvider] setGameType called:', gameType) // Use first active player as playerId, or empty string if none - const playerId = (activePlayers[0] as string) || ""; + const playerId = (activePlayers[0] as string) || '' sendMove({ - type: "SET_CONFIG", + type: 'SET_CONFIG', playerId, - userId: viewerId || "", - data: { field: "gameType", value: gameType }, - }); + userId: viewerId || '', + data: { field: 'gameType', value: gameType }, + }) // Save setting to room's gameConfig for persistence if (roomData?.id) { - const currentGameConfig = - (roomData.gameConfig as Record) || {}; - const currentMatchingConfig = - (currentGameConfig.matching as Record) || {}; + const currentGameConfig = (roomData.gameConfig as Record) || {} + const currentMatchingConfig = (currentGameConfig.matching as Record) || {} const updatedConfig = { ...currentGameConfig, @@ -679,56 +602,45 @@ export function MatchingProvider({ children }: { children: ReactNode }) { ...currentMatchingConfig, gameType, }, - }; + } console.log( - "[MatchingProvider] Saving gameType to database:", + '[MatchingProvider] Saving gameType to database:', JSON.stringify( { roomId: roomData.id, updatedConfig, }, null, - 2, - ), - ); + 2 + ) + ) updateGameConfig({ roomId: roomData.id, gameConfig: updatedConfig, - }); + }) } else { - console.warn( - "[MatchingProvider] Cannot save gameType - no roomData.id", - ); + console.warn('[MatchingProvider] Cannot save gameType - no roomData.id') } }, - [ - activePlayers, - sendMove, - viewerId, - roomData?.id, - roomData?.gameConfig, - updateGameConfig, - ], - ); + [activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig] + ) const setDifficulty = useCallback( (difficulty: typeof state.difficulty) => { - console.log("[MatchingProvider] setDifficulty called:", difficulty); + console.log('[MatchingProvider] setDifficulty called:', difficulty) - const playerId = (activePlayers[0] as string) || ""; + const playerId = (activePlayers[0] as string) || '' sendMove({ - type: "SET_CONFIG", + type: 'SET_CONFIG', playerId, - userId: viewerId || "", - data: { field: "difficulty", value: difficulty }, - }); + userId: viewerId || '', + data: { field: 'difficulty', value: difficulty }, + }) // Save setting to room's gameConfig for persistence if (roomData?.id) { - const currentGameConfig = - (roomData.gameConfig as Record) || {}; - const currentMatchingConfig = - (currentGameConfig.matching as Record) || {}; + const currentGameConfig = (roomData.gameConfig as Record) || {} + const currentMatchingConfig = (currentGameConfig.matching as Record) || {} const updatedConfig = { ...currentGameConfig, @@ -736,56 +648,45 @@ export function MatchingProvider({ children }: { children: ReactNode }) { ...currentMatchingConfig, difficulty, }, - }; + } console.log( - "[MatchingProvider] Saving difficulty to database:", + '[MatchingProvider] Saving difficulty to database:', JSON.stringify( { roomId: roomData.id, updatedConfig, }, null, - 2, - ), - ); + 2 + ) + ) updateGameConfig({ roomId: roomData.id, gameConfig: updatedConfig, - }); + }) } else { - console.warn( - "[MatchingProvider] Cannot save difficulty - no roomData.id", - ); + console.warn('[MatchingProvider] Cannot save difficulty - no roomData.id') } }, - [ - activePlayers, - sendMove, - viewerId, - roomData?.id, - roomData?.gameConfig, - updateGameConfig, - ], - ); + [activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig] + ) const setTurnTimer = useCallback( (turnTimer: typeof state.turnTimer) => { - console.log("[MatchingProvider] setTurnTimer called:", turnTimer); + console.log('[MatchingProvider] setTurnTimer called:', turnTimer) - const playerId = (activePlayers[0] as string) || ""; + const playerId = (activePlayers[0] as string) || '' sendMove({ - type: "SET_CONFIG", + type: 'SET_CONFIG', playerId, - userId: viewerId || "", - data: { field: "turnTimer", value: turnTimer }, - }); + userId: viewerId || '', + data: { field: 'turnTimer', value: turnTimer }, + }) // Save setting to room's gameConfig for persistence if (roomData?.id) { - const currentGameConfig = - (roomData.gameConfig as Record) || {}; - const currentMatchingConfig = - (currentGameConfig.matching as Record) || {}; + const currentGameConfig = (roomData.gameConfig as Record) || {} + const currentMatchingConfig = (currentGameConfig.matching as Record) || {} const updatedConfig = { ...currentGameConfig, @@ -793,158 +694,146 @@ export function MatchingProvider({ children }: { children: ReactNode }) { ...currentMatchingConfig, turnTimer, }, - }; + } console.log( - "[MatchingProvider] Saving turnTimer to database:", + '[MatchingProvider] Saving turnTimer to database:', JSON.stringify( { roomId: roomData.id, updatedConfig, }, null, - 2, - ), - ); + 2 + ) + ) updateGameConfig({ roomId: roomData.id, gameConfig: updatedConfig, - }); + }) } else { - console.warn( - "[MatchingProvider] Cannot save turnTimer - no roomData.id", - ); + console.warn('[MatchingProvider] Cannot save turnTimer - no roomData.id') } }, - [ - activePlayers, - sendMove, - viewerId, - roomData?.id, - roomData?.gameConfig, - updateGameConfig, - ], - ); + [activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig] + ) const goToSetup = useCallback(() => { // Send GO_TO_SETUP move - synchronized across all room members - const playerId = (activePlayers[0] as string) || state.currentPlayer || ""; + const playerId = (activePlayers[0] as string) || state.currentPlayer || '' sendMove({ - type: "GO_TO_SETUP", + type: 'GO_TO_SETUP', playerId, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [activePlayers, state.currentPlayer, sendMove, viewerId]); + }) + }, [activePlayers, state.currentPlayer, sendMove, viewerId]) const resumeGame = useCallback(() => { // PAUSE/RESUME: Resume paused game if config unchanged if (!canResumeGame) { - console.warn( - "[MatchingProvider] Cannot resume - no paused game or config changed", - ); - return; + console.warn('[MatchingProvider] Cannot resume - no paused game or config changed') + return } - const playerId = (activePlayers[0] as string) || state.currentPlayer || ""; + const playerId = (activePlayers[0] as string) || state.currentPlayer || '' sendMove({ - type: "RESUME_GAME", + type: 'RESUME_GAME', playerId, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [canResumeGame, activePlayers, state.currentPlayer, sendMove, viewerId]); + }) + }, [canResumeGame, activePlayers, state.currentPlayer, sendMove, viewerId]) const hoverCard = useCallback( (cardId: string | null) => { // HOVER: Send hover state for networked presence // Use current player as the one hovering - const playerId = - state.currentPlayer || (activePlayers[0] as string) || ""; - if (!playerId) return; // No active player to send hover for + const playerId = state.currentPlayer || (activePlayers[0] as string) || '' + if (!playerId) return // No active player to send hover for sendMove({ - type: "HOVER_CARD", + type: 'HOVER_CARD', playerId, - userId: viewerId || "", + userId: viewerId || '', data: { cardId }, - }); + }) }, - [state.currentPlayer, activePlayers, sendMove, viewerId], - ); + [state.currentPlayer, activePlayers, sendMove, viewerId] + ) // NO MORE effectiveState merging! Just use session state directly with gameMode added const effectiveState = { ...state, gameMode } as MatchingState & { - gameMode: GameMode; - }; + gameMode: GameMode + } // If state is corrupted, show error message instead of crashing if (hasStateCorruption) { return (
⚠️

Game State Mismatch

- There's a mismatch between game types in this room. This usually - happens when room members are playing different games. + There's a mismatch between game types in this room. This usually happens when room members + are playing different games.

To fix this:

  1. Make sure all room members are on the same game page
  2. @@ -955,29 +844,27 @@ export function MatchingProvider({ children }: { children: ReactNode }) {
- ); + ) } const contextValue: MatchingContextValue = { state: effectiveState, dispatch: () => { // No-op - replaced with sendMove - console.warn( - "dispatch() is deprecated in arcade mode, use action creators instead", - ); + console.warn('dispatch() is deprecated in arcade mode, use action creators instead') }, isGameActive, canFlipCard, @@ -996,20 +883,16 @@ export function MatchingProvider({ children }: { children: ReactNode }) { exitSession, gameMode, activePlayers, - }; + } - return ( - - {children} - - ); + return {children} } // Export the hook for this provider export function useMatching() { - const context = useContext(MatchingContext); + const context = useContext(MatchingContext) if (!context) { - throw new Error("useMatching must be used within MatchingProvider"); + throw new Error('useMatching must be used within MatchingProvider') } - return context; + return context } diff --git a/apps/web/src/arcade-games/matching/Validator.ts b/apps/web/src/arcade-games/matching/Validator.ts index 18d2edd1..1c6f6a51 100644 --- a/apps/web/src/arcade-games/matching/Validator.ts +++ b/apps/web/src/arcade-games/matching/Validator.ts @@ -3,65 +3,49 @@ * Validates all game moves and state transitions */ -import type { - GameCard, - MatchingConfig, - MatchingMove, - MatchingState, - Player, -} from "./types"; -import { generateGameCards } from "./utils/cardGeneration"; -import { canFlipCard, validateMatch } from "./utils/matchValidation"; -import type { - GameValidator, - ValidationResult, -} from "@/lib/arcade/validation/types"; +import type { GameCard, MatchingConfig, MatchingMove, MatchingState, Player } from './types' +import { generateGameCards } from './utils/cardGeneration' +import { canFlipCard, validateMatch } from './utils/matchValidation' +import type { GameValidator, ValidationResult } from '@/lib/arcade/validation/types' -export class MatchingGameValidator - implements GameValidator -{ +export class MatchingGameValidator implements GameValidator { validateMove( state: MatchingState, move: MatchingMove, - context?: { userId?: string; playerOwnership?: Record }, + context?: { userId?: string; playerOwnership?: Record } ): ValidationResult { switch (move.type) { - case "FLIP_CARD": - return this.validateFlipCard( - state, - move.data.cardId, - move.playerId, - context, - ); + case 'FLIP_CARD': + return this.validateFlipCard(state, move.data.cardId, move.playerId, context) - case "START_GAME": + case 'START_GAME': return this.validateStartGame( state, move.data.activePlayers, move.data.cards, - move.data.playerMetadata, - ); + move.data.playerMetadata + ) - case "CLEAR_MISMATCH": - return this.validateClearMismatch(state); + case 'CLEAR_MISMATCH': + return this.validateClearMismatch(state) - case "GO_TO_SETUP": - return this.validateGoToSetup(state); + case 'GO_TO_SETUP': + return this.validateGoToSetup(state) - case "SET_CONFIG": - return this.validateSetConfig(state, move.data.field, move.data.value); + case 'SET_CONFIG': + return this.validateSetConfig(state, move.data.field, move.data.value) - case "RESUME_GAME": - return this.validateResumeGame(state); + case 'RESUME_GAME': + return this.validateResumeGame(state) - case "HOVER_CARD": - return this.validateHoverCard(state, move.data.cardId, move.playerId); + case 'HOVER_CARD': + return this.validateHoverCard(state, move.data.cardId, move.playerId) default: return { valid: false, error: `Unknown move type: ${(move as any).type}`, - }; + } } } @@ -69,79 +53,79 @@ export class MatchingGameValidator state: MatchingState, cardId: string, playerId: string, - context?: { userId?: string; playerOwnership?: Record }, + context?: { userId?: string; playerOwnership?: Record } ): ValidationResult { // Game must be in playing phase - if (state.gamePhase !== "playing") { + if (state.gamePhase !== 'playing') { return { valid: false, - error: "Cannot flip cards outside of playing phase", - }; + error: 'Cannot flip cards outside of playing phase', + } } // Check if it's the player's turn (in multiplayer) if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) { - console.log("[Validator] Turn check failed:", { + console.log('[Validator] Turn check failed:', { activePlayers: state.activePlayers, currentPlayer: state.currentPlayer, currentPlayerType: typeof state.currentPlayer, playerId, playerIdType: typeof playerId, matches: state.currentPlayer === playerId, - }); + }) return { valid: false, - error: "Not your turn", - }; + error: 'Not your turn', + } } // Check player ownership authorization (if context provided) if (context?.userId && context?.playerOwnership) { - const playerOwner = context.playerOwnership[playerId]; + const playerOwner = context.playerOwnership[playerId] if (playerOwner && playerOwner !== context.userId) { - console.log("[Validator] Player ownership check failed:", { + console.log('[Validator] Player ownership check failed:', { playerId, playerOwner, requestingUserId: context.userId, - }); + }) return { valid: false, - error: "You can only move your own players", - }; + error: 'You can only move your own players', + } } } // Find the card - const card = state.gameCards.find((c) => c.id === cardId); + const card = state.gameCards.find((c) => c.id === cardId) if (!card) { return { valid: false, - error: "Card not found", - }; + error: 'Card not found', + } } // Validate using existing game logic if (!canFlipCard(card, state.flippedCards, state.isProcessingMove)) { return { valid: false, - error: "Cannot flip this card", - }; + error: 'Cannot flip this card', + } } // Calculate new state - const newFlippedCards = [...state.flippedCards, card]; + const newFlippedCards = [...state.flippedCards, card] let newState = { ...state, flippedCards: newFlippedCards, isProcessingMove: newFlippedCards.length === 2, // Clear mismatch feedback when player flips a new card showMismatchFeedback: false, - }; + } // If two cards are flipped, check for match if (newFlippedCards.length === 2) { - const [card1, card2] = newFlippedCards; - const matchResult = validateMatch(card1, card2); + const [card1, card2] = newFlippedCards + const matchResult = validateMatch(card1, card2) if (matchResult.isValid) { // Match found - update cards @@ -150,7 +134,7 @@ export class MatchingGameValidator gameCards: newState.gameCards.map((c) => c.id === card1.id || c.id === card2.id ? { ...c, matched: true, matchedBy: state.currentPlayer } - : c, + : c ), matchedPairs: state.matchedPairs + 1, scores: { @@ -159,33 +143,31 @@ export class MatchingGameValidator }, consecutiveMatches: { ...state.consecutiveMatches, - [state.currentPlayer]: - (state.consecutiveMatches[state.currentPlayer] || 0) + 1, + [state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1, }, moves: state.moves + 1, flippedCards: [], isProcessingMove: false, - }; + } // Check if game is complete if (newState.matchedPairs === newState.totalPairs) { newState = { ...newState, - gamePhase: "results", + gamePhase: 'results', gameEndTime: Date.now(), - }; + } } } else { // Match failed - keep cards flipped briefly so player can see them // Client will handle clearing them after a delay - const shouldSwitchPlayer = state.activePlayers.length > 1; + const shouldSwitchPlayer = state.activePlayers.length > 1 const nextPlayerIndex = shouldSwitchPlayer - ? (state.activePlayers.indexOf(state.currentPlayer) + 1) % - state.activePlayers.length - : 0; + ? (state.activePlayers.indexOf(state.currentPlayer) + 1) % state.activePlayers.length + : 0 const nextPlayer = shouldSwitchPlayer ? state.activePlayers[nextPlayerIndex] - : state.currentPlayer; + : state.currentPlayer newState = { ...newState, @@ -204,21 +186,21 @@ export class MatchingGameValidator ...state.playerHovers, [state.currentPlayer]: null, }, - }; + } } } return { valid: true, newState, - }; + } } private validateStartGame( state: MatchingState, activePlayers: Player[], cards?: GameCard[], - playerMetadata?: { [playerId: string]: any }, + playerMetadata?: { [playerId: string]: any } ): ValidationResult { // Allow starting a new game from any phase (for "New Game" button) @@ -226,13 +208,12 @@ export class MatchingGameValidator if (!activePlayers || activePlayers.length === 0) { return { valid: false, - error: "Must have at least one player", - }; + error: 'Must have at least one player', + } } // Use provided cards or generate new ones - const gameCards = - cards || generateGameCards(state.gameType, state.difficulty); + const gameCards = cards || generateGameCards(state.gameType, state.difficulty) const newState: MatchingState = { ...state, @@ -240,17 +221,14 @@ export class MatchingGameValidator cards: gameCards, activePlayers, playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility - gamePhase: "playing", + gamePhase: 'playing', gameStartTime: Date.now(), currentPlayer: activePlayers[0], flippedCards: [], matchedPairs: 0, moves: 0, scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}), - consecutiveMatches: activePlayers.reduce( - (acc, p) => ({ ...acc, [p]: 0 }), - {}, - ), + consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}), // PAUSE/RESUME: Save original config so we can detect changes originalConfig: { gameType: state.gameType, @@ -262,12 +240,12 @@ export class MatchingGameValidator pausedGameState: undefined, // Clear hover state when starting new game playerHovers: {}, - }; + } return { valid: true, newState, - }; + } } private validateClearMismatch(state: MatchingState): ValidationResult { @@ -278,17 +256,17 @@ export class MatchingGameValidator return { valid: true, newState: state, - }; + } } // Get the list of all non-current players whose hovers should be cleared // (They're not playing this turn, so their hovers from previous turns should not show) - const clearedHovers = { ...state.playerHovers }; + const clearedHovers = { ...state.playerHovers } for (const playerId of state.activePlayers) { // Clear hover for all players except the current player // This ensures only the current player's active hover shows if (playerId !== state.currentPlayer) { - clearedHovers[playerId] = null; + clearedHovers[playerId] = null } } @@ -303,7 +281,7 @@ export class MatchingGameValidator // Clear hovers for non-current players when cards are cleared playerHovers: clearedHovers, }, - }; + } } /** @@ -326,14 +304,13 @@ export class MatchingGameValidator */ private validateGoToSetup(state: MatchingState): ValidationResult { // Determine if we're pausing an active game (for Resume functionality) - const isPausingGame = - state.gamePhase === "playing" || state.gamePhase === "results"; + const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results' return { valid: true, newState: { ...state, - gamePhase: "setup", + gamePhase: 'setup', // Pause/Resume: Save game state if pausing from active game pausedGamePhase: isPausingGame ? state.gamePhase : undefined, @@ -358,7 +335,7 @@ export class MatchingGameValidator gameCards: [], cards: [], flippedCards: [], - currentPlayer: "", + currentPlayer: '', matchedPairs: 0, moves: 0, scores: {}, @@ -376,7 +353,7 @@ export class MatchingGameValidator // Preserve configuration - players can modify in setup // gameType, difficulty, turnTimer stay as-is }, - }; + } } /** @@ -397,44 +374,44 @@ export class MatchingGameValidator */ private validateSetConfig( state: MatchingState, - field: "gameType" | "difficulty" | "turnTimer", - value: any, + field: 'gameType' | 'difficulty' | 'turnTimer', + value: any ): ValidationResult { // Can only change config during setup phase - if (state.gamePhase !== "setup") { + if (state.gamePhase !== 'setup') { return { valid: false, - error: "Cannot change configuration outside of setup phase", - }; + error: 'Cannot change configuration outside of setup phase', + } } // Validate field-specific values switch (field) { - case "gameType": - if (value !== "abacus-numeral" && value !== "complement-pairs") { - return { valid: false, error: `Invalid gameType: ${value}` }; + case 'gameType': + if (value !== 'abacus-numeral' && value !== 'complement-pairs') { + return { valid: false, error: `Invalid gameType: ${value}` } } - break; + break - case "difficulty": + case 'difficulty': if (![6, 8, 12, 15].includes(value)) { - return { valid: false, error: `Invalid difficulty: ${value}` }; + return { valid: false, error: `Invalid difficulty: ${value}` } } - break; + break - case "turnTimer": - if (typeof value !== "number" || value < 5 || value > 300) { - return { valid: false, error: `Invalid turnTimer: ${value}` }; + case 'turnTimer': + if (typeof value !== 'number' || value < 5 || value > 300) { + return { valid: false, error: `Invalid turnTimer: ${value}` } } - break; + break default: - return { valid: false, error: `Unknown config field: ${field}` }; + return { valid: false, error: `Unknown config field: ${field}` } } // PAUSE/RESUME: If there's a paused game and config is changing, // clear the paused game state (can't resume anymore) - const clearPausedGame = !!state.pausedGamePhase; + const clearPausedGame = !!state.pausedGamePhase // Apply the configuration change return { @@ -443,7 +420,7 @@ export class MatchingGameValidator ...state, [field]: value, // Update totalPairs if difficulty changes - ...(field === "difficulty" ? { totalPairs: value } : {}), + ...(field === 'difficulty' ? { totalPairs: value } : {}), // Clear paused game if config changed ...(clearPausedGame ? { @@ -453,7 +430,7 @@ export class MatchingGameValidator } : {}), }, - }; + } } /** @@ -470,19 +447,19 @@ export class MatchingGameValidator */ private validateResumeGame(state: MatchingState): ValidationResult { // Must be in setup phase - if (state.gamePhase !== "setup") { + if (state.gamePhase !== 'setup') { return { valid: false, - error: "Can only resume from setup phase", - }; + error: 'Can only resume from setup phase', + } } // Must have a paused game if (!state.pausedGamePhase || !state.pausedGameState) { return { valid: false, - error: "No paused game to resume", - }; + error: 'No paused game to resume', + } } // Config must match original (no changes while paused) @@ -490,13 +467,13 @@ export class MatchingGameValidator const configChanged = state.gameType !== state.originalConfig.gameType || state.difficulty !== state.originalConfig.difficulty || - state.turnTimer !== state.originalConfig.turnTimer; + state.turnTimer !== state.originalConfig.turnTimer if (configChanged) { return { valid: false, - error: "Cannot resume - configuration has changed", - }; + error: 'Cannot resume - configuration has changed', + } } } @@ -521,7 +498,7 @@ export class MatchingGameValidator pausedGameState: undefined, // Keep originalConfig for potential future pauses }, - }; + } } /** @@ -533,7 +510,7 @@ export class MatchingGameValidator private validateHoverCard( state: MatchingState, cardId: string | null, - playerId: string, + playerId: string ): ValidationResult { // Hover is always valid - it's just UI state for networked presence // Update the player's hover state @@ -546,13 +523,11 @@ export class MatchingGameValidator [playerId]: cardId, }, }, - }; + } } isGameComplete(state: MatchingState): boolean { - return ( - state.gamePhase === "results" || state.matchedPairs === state.totalPairs - ); + return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs } getInitialState(config: MatchingConfig): MatchingState { @@ -563,8 +538,8 @@ export class MatchingGameValidator gameType: config.gameType, difficulty: config.difficulty, turnTimer: config.turnTimer, - gamePhase: "setup", - currentPlayer: "", + gamePhase: 'setup', + currentPlayer: '', matchedPairs: 0, totalPairs: config.difficulty, moves: 0, @@ -586,9 +561,9 @@ export class MatchingGameValidator pausedGameState: undefined, // HOVER: Initialize hover state playerHovers: {}, - }; + } } } // Singleton instance -export const matchingGameValidator = new MatchingGameValidator(); +export const matchingGameValidator = new MatchingGameValidator() diff --git a/apps/web/src/arcade-games/matching/components/EmojiPicker.tsx b/apps/web/src/arcade-games/matching/components/EmojiPicker.tsx index 7a3c86a0..4b0100d2 100644 --- a/apps/web/src/arcade-games/matching/components/EmojiPicker.tsx +++ b/apps/web/src/arcade-games/matching/components/EmojiPicker.tsx @@ -1,56 +1,56 @@ -"use client"; +'use client' -import emojiData from "emojibase-data/en/data.json"; -import { useMemo, useState } from "react"; -import { css } from "../../../../styled-system/css"; -import { PLAYER_EMOJIS } from "@/constants/playerEmojis"; +import emojiData from 'emojibase-data/en/data.json' +import { useMemo, useState } from 'react' +import { css } from '../../../../styled-system/css' +import { PLAYER_EMOJIS } from '@/constants/playerEmojis' // Proper TypeScript interface for emojibase-data structure interface EmojibaseEmoji { - label: string; - hexcode: string; - tags?: string[]; - emoji: string; - text: string; - type: number; - order: number; - group: number; - subgroup: number; - version: number; - emoticon?: string | string[]; // Can be string, array, or undefined + label: string + hexcode: string + tags?: string[] + emoji: string + text: string + type: number + order: number + group: number + subgroup: number + version: number + emoticon?: string | string[] // Can be string, array, or undefined } interface EmojiPickerProps { - currentEmoji: string; - onEmojiSelect: (emoji: string) => void; - onClose: () => void; - playerNumber: number; + currentEmoji: string + onEmojiSelect: (emoji: string) => void + onClose: () => void + playerNumber: number } // Emoji group categories from emojibase (matching Unicode CLDR group IDs) const EMOJI_GROUPS = { - 0: { name: "Smileys & Emotion", icon: "😀" }, - 1: { name: "People & Body", icon: "👤" }, - 3: { name: "Animals & Nature", icon: "🐶" }, - 4: { name: "Food & Drink", icon: "🍎" }, - 5: { name: "Travel & Places", icon: "🚗" }, - 6: { name: "Activities", icon: "⚽" }, - 7: { name: "Objects", icon: "💡" }, - 8: { name: "Symbols", icon: "❤️" }, - 9: { name: "Flags", icon: "🏁" }, -} as const; + 0: { name: 'Smileys & Emotion', icon: '😀' }, + 1: { name: 'People & Body', icon: '👤' }, + 3: { name: 'Animals & Nature', icon: '🐶' }, + 4: { name: 'Food & Drink', icon: '🍎' }, + 5: { name: 'Travel & Places', icon: '🚗' }, + 6: { name: 'Activities', icon: '⚽' }, + 7: { name: 'Objects', icon: '💡' }, + 8: { name: 'Symbols', icon: '❤️' }, + 9: { name: 'Flags', icon: '🏁' }, +} as const // Create a map of emoji to their searchable data and group -const emojiMap = new Map(); -(emojiData as EmojibaseEmoji[]).forEach((emoji) => { +const emojiMap = new Map() +;(emojiData as EmojibaseEmoji[]).forEach((emoji) => { if (emoji.emoji) { // Handle emoticon field which can be string, array, or undefined - const emoticons: string[] = []; + const emoticons: string[] = [] if (emoji.emoticon) { if (Array.isArray(emoji.emoticon)) { - emoticons.push(...emoji.emoticon.map((e) => e.toLowerCase())); + emoticons.push(...emoji.emoticon.map((e) => e.toLowerCase())) } else { - emoticons.push(emoji.emoticon.toLowerCase()); + emoticons.push(emoji.emoticon.toLowerCase()) } } @@ -61,30 +61,26 @@ const emojiMap = new Map(); ...emoticons, ].filter(Boolean), group: emoji.group, - }); + }) } -}); +}) // Enhanced search function using emojibase-data function getEmojiKeywords(emoji: string): string[] { - const data = emojiMap.get(emoji); + const data = emojiMap.get(emoji) if (data) { - return data.keywords; + return data.keywords } // Fallback categories for emojis not in emojibase-data - if (/[\u{1F600}-\u{1F64F}]/u.test(emoji)) - return ["face", "emotion", "person", "expression"]; - if (/[\u{1F400}-\u{1F43F}]/u.test(emoji)) - return ["animal", "nature", "cute", "pet"]; - if (/[\u{1F440}-\u{1F4FF}]/u.test(emoji)) return ["object", "symbol", "tool"]; - if (/[\u{1F300}-\u{1F3FF}]/u.test(emoji)) - return ["nature", "travel", "activity", "place"]; - if (/[\u{1F680}-\u{1F6FF}]/u.test(emoji)) - return ["transport", "travel", "vehicle"]; - if (/[\u{2600}-\u{26FF}]/u.test(emoji)) return ["symbol", "misc", "sign"]; + if (/[\u{1F600}-\u{1F64F}]/u.test(emoji)) return ['face', 'emotion', 'person', 'expression'] + if (/[\u{1F400}-\u{1F43F}]/u.test(emoji)) return ['animal', 'nature', 'cute', 'pet'] + if (/[\u{1F440}-\u{1F4FF}]/u.test(emoji)) return ['object', 'symbol', 'tool'] + if (/[\u{1F300}-\u{1F3FF}]/u.test(emoji)) return ['nature', 'travel', 'activity', 'place'] + if (/[\u{1F680}-\u{1F6FF}]/u.test(emoji)) return ['transport', 'travel', 'vehicle'] + if (/[\u{2600}-\u{26FF}]/u.test(emoji)) return ['symbol', 'misc', 'sign'] - return ["misc", "other"]; + return ['misc', 'other'] } export function EmojiPicker({ @@ -93,133 +89,133 @@ export function EmojiPicker({ onClose, playerNumber, }: EmojiPickerProps) { - const [searchFilter, setSearchFilter] = useState(""); - const [selectedCategory, setSelectedCategory] = useState(null); - const [hoveredEmoji, setHoveredEmoji] = useState(null); - const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 }); + const [searchFilter, setSearchFilter] = useState('') + const [selectedCategory, setSelectedCategory] = useState(null) + const [hoveredEmoji, setHoveredEmoji] = useState(null) + const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 }) // Enhanced search functionality - clear separation between default and search - const isSearching = searchFilter.trim().length > 0; - const isCategoryFiltered = selectedCategory !== null && !isSearching; + const isSearching = searchFilter.trim().length > 0 + const isCategoryFiltered = selectedCategory !== null && !isSearching // Calculate which categories have emojis const availableCategories = useMemo(() => { - const categoryCounts: Record = {}; + const categoryCounts: Record = {} PLAYER_EMOJIS.forEach((emoji) => { - const data = emojiMap.get(emoji); + const data = emojiMap.get(emoji) if (data && data.group !== undefined) { - categoryCounts[data.group] = (categoryCounts[data.group] || 0) + 1; + categoryCounts[data.group] = (categoryCounts[data.group] || 0) + 1 } - }); + }) return Object.keys(EMOJI_GROUPS) .map(Number) - .filter((groupId) => categoryCounts[groupId] > 0); - }, []); + .filter((groupId) => categoryCounts[groupId] > 0) + }, []) const displayEmojis = useMemo(() => { // Start with all emojis - let emojis = PLAYER_EMOJIS; + let emojis = PLAYER_EMOJIS // Apply category filter first (unless searching) if (isCategoryFiltered) { emojis = emojis.filter((emoji) => { - const data = emojiMap.get(emoji); - return data && data.group === selectedCategory; - }); + const data = emojiMap.get(emoji) + return data && data.group === selectedCategory + }) } // Then apply search filter if (!isSearching) { - return emojis; + return emojis } - const searchTerm = searchFilter.toLowerCase().trim(); + const searchTerm = searchFilter.toLowerCase().trim() const results = PLAYER_EMOJIS.filter((emoji) => { - const keywords = getEmojiKeywords(emoji); - return keywords.some((keyword) => keyword?.includes(searchTerm)); - }); + const keywords = getEmojiKeywords(emoji) + return keywords.some((keyword) => keyword?.includes(searchTerm)) + }) // Sort results by relevance const sortedResults = results.sort((a, b) => { - const aKeywords = getEmojiKeywords(a); - const bKeywords = getEmojiKeywords(b); + const aKeywords = getEmojiKeywords(a) + const bKeywords = getEmojiKeywords(b) // Exact match priority - const aExact = aKeywords.some((k) => k === searchTerm); - const bExact = bKeywords.some((k) => k === searchTerm); + const aExact = aKeywords.some((k) => k === searchTerm) + const bExact = bKeywords.some((k) => k === searchTerm) - if (aExact && !bExact) return -1; - if (!aExact && bExact) return 1; + if (aExact && !bExact) return -1 + if (!aExact && bExact) return 1 // Word boundary matches (start of word) - const aStartsWithTerm = aKeywords.some((k) => k?.startsWith(searchTerm)); - const bStartsWithTerm = bKeywords.some((k) => k?.startsWith(searchTerm)); + const aStartsWithTerm = aKeywords.some((k) => k?.startsWith(searchTerm)) + const bStartsWithTerm = bKeywords.some((k) => k?.startsWith(searchTerm)) - if (aStartsWithTerm && !bStartsWithTerm) return -1; - if (!aStartsWithTerm && bStartsWithTerm) return 1; + if (aStartsWithTerm && !bStartsWithTerm) return -1 + if (!aStartsWithTerm && bStartsWithTerm) return 1 // Score by number of matching keywords - const aScore = aKeywords.filter((k) => k?.includes(searchTerm)).length; - const bScore = bKeywords.filter((k) => k?.includes(searchTerm)).length; + const aScore = aKeywords.filter((k) => k?.includes(searchTerm)).length + const bScore = bKeywords.filter((k) => k?.includes(searchTerm)).length - return bScore - aScore; - }); + return bScore - aScore + }) - return sortedResults; - }, [searchFilter, isSearching, selectedCategory, isCategoryFiltered]); + return sortedResults + }, [searchFilter, isSearching, selectedCategory, isCategoryFiltered]) return (
{/* Header */}

@@ -227,13 +223,13 @@ export function EmojiPicker({

{availableCategories.map((groupId) => { - const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS]; + const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS] return ( - ); + ) })}
)} @@ -405,33 +385,33 @@ export function EmojiPicker({ {isSearching && displayEmojis.length > 0 && (
🔍 Search Results for "{searchFilter}"
- Showing {displayEmojis.length} of {PLAYER_EMOJIS.length} emojis • - Clear search to see all + Showing {displayEmojis.length} of {PLAYER_EMOJIS.length} emojis • Clear search to see + all
)} @@ -440,36 +420,36 @@ export function EmojiPicker({ {!isSearching && (
{selectedCategory !== null ? `${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].icon} ${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].name}` - : "📝 All Available Characters"} + : '📝 All Available Characters'}
- {displayEmojis.length} emojis{" "} - {selectedCategory !== null ? "in category" : "available"} • Use - search to find specific emojis + {displayEmojis.length} emojis{' '} + {selectedCategory !== null ? 'in category' : 'available'} • Use search to find + specific emojis
)} @@ -479,105 +459,105 @@ export function EmojiPicker({
{displayEmojis.map((emoji) => { - const isSelected = emoji === currentEmoji; + const isSelected = emoji === currentEmoji const getSelectedBg = () => { - if (!isSelected) return "transparent"; - if (playerNumber === 1) return "blue.100"; - if (playerNumber === 2) return "pink.100"; - if (playerNumber === 3) return "purple.100"; - return "yellow.100"; - }; + if (!isSelected) return 'transparent' + if (playerNumber === 1) return 'blue.100' + if (playerNumber === 2) return 'pink.100' + if (playerNumber === 3) return 'purple.100' + return 'yellow.100' + } const getSelectedBorder = () => { - if (!isSelected) return "transparent"; - if (playerNumber === 1) return "blue.400"; - if (playerNumber === 2) return "pink.400"; - if (playerNumber === 3) return "purple.400"; - return "yellow.400"; - }; + if (!isSelected) return 'transparent' + if (playerNumber === 1) return 'blue.400' + if (playerNumber === 2) return 'pink.400' + if (playerNumber === 3) return 'purple.400' + return 'yellow.400' + } const getHoverBg = () => { - if (!isSelected) return "gray.100"; - if (playerNumber === 1) return "blue.200"; - if (playerNumber === 2) return "pink.200"; - if (playerNumber === 3) return "purple.200"; - return "yellow.200"; - }; + if (!isSelected) return 'gray.100' + if (playerNumber === 1) return 'blue.200' + if (playerNumber === 2) return 'pink.200' + if (playerNumber === 3) return 'purple.200' + return 'yellow.200' + } return ( - ); + ) })}
@@ -587,41 +567,39 @@ export function EmojiPicker({ {isSearching && displayEmojis.length === 0 && (
-
- 🔍 -
+
🔍
No emojis found for "{searchFilter}"
-
+
Try searching for "face", "smart", "heart", "animal", "food", etc.
@@ -631,18 +609,18 @@ export function EmojiPicker({ {/* Quick selection hint */}
- 💡 Powered by emojibase-data • Try: "face", "smart", "heart", - "animal", "food" • Click to select + 💡 Powered by emojibase-data • Try: "face", "smart", "heart", "animal", "food" • Click to + select
@@ -650,79 +628,78 @@ export function EmojiPicker({ {hoveredEmoji && (
{/* Outer glow ring */}
{/* Main preview card */}
{/* Sparkle effects */}
✨ @@ -734,16 +711,16 @@ export function EmojiPicker({ {/* Arrow pointing down with glow */}
@@ -795,7 +772,7 @@ export function EmojiPicker({ }} />
- ); + ) } // Add fade in animation @@ -804,15 +781,12 @@ const fadeInAnimation = ` from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } -`; +` // Inject animation styles -if ( - typeof document !== "undefined" && - !document.getElementById("emoji-picker-animations") -) { - const style = document.createElement("style"); - style.id = "emoji-picker-animations"; - style.textContent = fadeInAnimation; - document.head.appendChild(style); +if (typeof document !== 'undefined' && !document.getElementById('emoji-picker-animations')) { + const style = document.createElement('style') + style.id = 'emoji-picker-animations' + style.textContent = fadeInAnimation + document.head.appendChild(style) } diff --git a/apps/web/src/arcade-games/matching/components/GameCard.tsx b/apps/web/src/arcade-games/matching/components/GameCard.tsx index 8cdf12ff..8b7d902a 100644 --- a/apps/web/src/arcade-games/matching/components/GameCard.tsx +++ b/apps/web/src/arcade-games/matching/components/GameCard.tsx @@ -1,156 +1,148 @@ -"use client"; +'use client' -import { AbacusReact, useAbacusConfig } from "@soroban/abacus-react"; -import { css } from "../../../../styled-system/css"; -import { useGameMode } from "@/contexts/GameModeContext"; -import type { GameCardProps } from "../types"; +import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react' +import { css } from '../../../../styled-system/css' +import { useGameMode } from '@/contexts/GameModeContext' +import type { GameCardProps } from '../types' -export function GameCard({ - card, - isFlipped, - isMatched, - onClick, - disabled = false, -}: GameCardProps) { - const appConfig = useAbacusConfig(); - const { players: playerMap, activePlayers: activePlayerIds } = useGameMode(); +export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false }: GameCardProps) { + const appConfig = useAbacusConfig() + const { players: playerMap, activePlayers: activePlayerIds } = useGameMode() // Get active players array for mapping numeric IDs to actual players const activePlayers = Array.from(activePlayerIds) .map((id) => playerMap.get(id)) - .filter((p): p is NonNullable => p !== undefined); + .filter((p): p is NonNullable => p !== undefined) // Helper to get player index from ID (0-based) const getPlayerIndex = (playerId: string | undefined): number => { - if (!playerId) return -1; - return activePlayers.findIndex((p) => p.id === playerId); - }; + if (!playerId) return -1 + return activePlayers.findIndex((p) => p.id === playerId) + } const cardBackStyles = css({ - position: "absolute", - width: "100%", - height: "100%", - backfaceVisibility: "hidden", - borderRadius: "12px", - display: "flex", - alignItems: "center", - justifyContent: "center", - color: "white", - fontSize: "28px", - fontWeight: "bold", - textShadow: "1px 1px 2px rgba(0,0,0,0.3)", - cursor: disabled ? "default" : "pointer", - userSelect: "none", - transition: "all 0.2s ease", - }); + position: 'absolute', + width: '100%', + height: '100%', + backfaceVisibility: 'hidden', + borderRadius: '12px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: 'white', + fontSize: '28px', + fontWeight: 'bold', + textShadow: '1px 1px 2px rgba(0,0,0,0.3)', + cursor: disabled ? 'default' : 'pointer', + userSelect: 'none', + transition: 'all 0.2s ease', + }) const cardFrontStyles = css({ - position: "absolute", - width: "100%", - height: "100%", - backfaceVisibility: "hidden", - borderRadius: "12px", - background: "white", - border: "3px solid", - transform: "rotateY(180deg)", - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "8px", - overflow: "hidden", - transition: "all 0.2s ease", - }); + position: 'absolute', + width: '100%', + height: '100%', + backfaceVisibility: 'hidden', + borderRadius: '12px', + background: 'white', + border: '3px solid', + transform: 'rotateY(180deg)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '8px', + overflow: 'hidden', + transition: 'all 0.2s ease', + }) // Dynamic styling based on card type and state const getCardBackGradient = () => { if (isMatched) { // Player-specific colors for matched cards - find player index by ID - const playerIndex = getPlayerIndex(card.matchedBy); + const playerIndex = getPlayerIndex(card.matchedBy) if (playerIndex === 0) { - return "linear-gradient(135deg, #74b9ff, #0984e3)"; // Blue for first player + return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for first player } else if (playerIndex === 1) { - return "linear-gradient(135deg, #fd79a8, #e84393)"; // Pink for second player + return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for second player } - return "linear-gradient(135deg, #48bb78, #38a169)"; // Default green for single player or 3+ + return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player or 3+ } switch (card.type) { - case "abacus": - return "linear-gradient(135deg, #7b4397, #dc2430)"; - case "number": - return "linear-gradient(135deg, #2E86AB, #A23B72)"; - case "complement": - return "linear-gradient(135deg, #F18F01, #6A994E)"; + case 'abacus': + return 'linear-gradient(135deg, #7b4397, #dc2430)' + case 'number': + return 'linear-gradient(135deg, #2E86AB, #A23B72)' + case 'complement': + return 'linear-gradient(135deg, #F18F01, #6A994E)' default: - return "linear-gradient(135deg, #667eea, #764ba2)"; + return 'linear-gradient(135deg, #667eea, #764ba2)' } - }; + } const getCardBackIcon = () => { if (isMatched) { // Show player emoji for matched cards in multiplayer mode if (card.matchedBy) { - const matchedPlayer = activePlayers.find( - (p) => p.id === card.matchedBy, - ); - return matchedPlayer?.emoji || "✓"; + const matchedPlayer = activePlayers.find((p) => p.id === card.matchedBy) + return matchedPlayer?.emoji || '✓' } - return "✓"; // Default checkmark for single player + return '✓' // Default checkmark for single player } switch (card.type) { - case "abacus": - return "🧮"; - case "number": - return "🔢"; - case "complement": - return "🤝"; + case 'abacus': + return '🧮' + case 'number': + return '🔢' + case 'complement': + return '🤝' default: - return "❓"; + return '❓' } - }; + } const getBorderColor = () => { if (isMatched) { // Player-specific border colors for matched cards - const playerIndex = getPlayerIndex(card.matchedBy); + const playerIndex = getPlayerIndex(card.matchedBy) if (playerIndex === 0) { - return "#74b9ff"; // Blue for first player + return '#74b9ff' // Blue for first player } else if (playerIndex === 1) { - return "#fd79a8"; // Pink for second player + return '#fd79a8' // Pink for second player } - return "#48bb78"; // Default green for single player or 3+ + return '#48bb78' // Default green for single player or 3+ } - if (isFlipped) return "#667eea"; - return "#e2e8f0"; - }; + if (isFlipped) return '#667eea' + return '#e2e8f0' + } return (
{/* Card Back (hidden/face-down state) */} @@ -162,16 +154,16 @@ export function GameCard({ >
-
{getCardBackIcon()}
+
{getCardBackIcon()}
{isMatched && ( -
- {card.matchedBy ? "Claimed!" : "Matched!"} +
+ {card.matchedBy ? 'Claimed!' : 'Matched!'}
)}
@@ -184,13 +176,13 @@ export function GameCard({ borderColor: getBorderColor(), boxShadow: isMatched ? getPlayerIndex(card.matchedBy) === 0 - ? "0 0 20px rgba(116, 185, 255, 0.4)" // Blue glow for player 1 + ? '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for player 1 : getPlayerIndex(card.matchedBy) === 1 - ? "0 0 20px rgba(253, 121, 168, 0.4)" // Pink glow for player 2 - : "0 0 20px rgba(72, 187, 120, 0.4)" // Default green glow + ? '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for player 2 + : '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow : isFlipped - ? "0 0 15px rgba(102, 126, 234, 0.3)" - : "none", + ? '0 0 15px rgba(102, 126, 234, 0.3)' + : 'none', }} > {/* Player Badge for matched cards */} @@ -199,18 +191,15 @@ export function GameCard({ {/* Explosion Ring */}
@@ -218,55 +207,52 @@ export function GameCard({ {/* Main Badge */}
{card.matchedBy - ? activePlayers.find((p) => p.id === card.matchedBy) - ?.emoji || "✓" - : "✓"} + ? activePlayers.find((p) => p.id === card.matchedBy)?.emoji || '✓' + : '✓'}
@@ -275,13 +261,13 @@ export function GameCard({
)} - {card.type === "abacus" ? ( + {card.type === 'abacus' ? (
@@ -315,56 +301,56 @@ export function GameCard({ animated={false} />
- ) : card.type === "number" ? ( + ) : card.type === 'number' ? (
{card.number}
- ) : card.type === "complement" ? ( + ) : card.type === 'complement' ? (
{card.number}
- {card.targetSum === 5 ? "✋" : "🔟"} + {card.targetSum === 5 ? '✋' : '🔟'} Friends
{card.complement !== undefined && (
+ {card.complement} = {card.targetSum} @@ -374,8 +360,8 @@ export function GameCard({ ) : (
? @@ -388,22 +374,21 @@ export function GameCard({ {isMatched && (
)}
- ); + ) } // Add global animation styles @@ -556,15 +541,12 @@ const globalCardAnimations = ` 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } } -`; +` // Inject global styles -if ( - typeof document !== "undefined" && - !document.getElementById("memory-card-animations") -) { - const style = document.createElement("style"); - style.id = "memory-card-animations"; - style.textContent = globalCardAnimations; - document.head.appendChild(style); +if (typeof document !== 'undefined' && !document.getElementById('memory-card-animations')) { + const style = document.createElement('style') + style.id = 'memory-card-animations' + style.textContent = globalCardAnimations + document.head.appendChild(style) } diff --git a/apps/web/src/arcade-games/matching/components/GamePhase.tsx b/apps/web/src/arcade-games/matching/components/GamePhase.tsx index 134a05a6..bec4697e 100644 --- a/apps/web/src/arcade-games/matching/components/GamePhase.tsx +++ b/apps/web/src/arcade-games/matching/components/GamePhase.tsx @@ -1,30 +1,27 @@ -"use client"; +'use client' -import { useMemo } from "react"; -import { useViewerId } from "@/hooks/useViewerId"; -import { MemoryGrid } from "@/components/matching/MemoryGrid"; -import { css } from "../../../../styled-system/css"; -import { useMatching } from "../Provider"; -import { getGridConfiguration } from "../utils/cardGeneration"; -import { GameCard } from "./GameCard"; +import { useMemo } from 'react' +import { useViewerId } from '@/hooks/useViewerId' +import { MemoryGrid } from '@/components/matching/MemoryGrid' +import { css } from '../../../../styled-system/css' +import { useMatching } from '../Provider' +import { getGridConfiguration } from '../utils/cardGeneration' +import { GameCard } from './GameCard' export function GamePhase() { - const { state, flipCard, hoverCard, gameMode } = useMatching(); - const { data: viewerId } = useViewerId(); + const { state, flipCard, hoverCard, gameMode } = useMatching() + const { data: viewerId } = useViewerId() - const gridConfig = useMemo( - () => getGridConfiguration(state.difficulty), - [state.difficulty], - ); + const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty]) return (
{/* Game header removed - game type and player info now shown in nav bar */} @@ -33,10 +30,10 @@ export function GamePhase() {

- 💡{" "} - {state.gameType === "abacus-numeral" - ? "Match abacus beads with numbers" - : "Find pairs that add to 5 or 10"} + 💡{' '} + {state.gameType === 'abacus-numeral' + ? 'Match abacus beads with numbers' + : 'Find pairs that add to 5 or 10'}

)}
- ); + ) } diff --git a/apps/web/src/arcade-games/matching/components/MemoryPairsGame.tsx b/apps/web/src/arcade-games/matching/components/MemoryPairsGame.tsx index f88acd14..7886e155 100644 --- a/apps/web/src/arcade-games/matching/components/MemoryPairsGame.tsx +++ b/apps/web/src/arcade-games/matching/components/MemoryPairsGame.tsx @@ -1,57 +1,53 @@ -"use client"; +'use client' -import { useRouter } from "next/navigation"; -import { useEffect, useRef } from "react"; -import { PageWithNav } from "@/components/PageWithNav"; -import { css } from "../../../../styled-system/css"; -import { StandardGameLayout } from "@/components/StandardGameLayout"; -import { useFullscreen } from "@/contexts/FullscreenContext"; -import { useMatching } from "../Provider"; -import { GamePhase } from "./GamePhase"; -import { ResultsPhase } from "./ResultsPhase"; -import { SetupPhase } from "./SetupPhase"; +import { useRouter } from 'next/navigation' +import { useEffect, useRef } from 'react' +import { PageWithNav } from '@/components/PageWithNav' +import { css } from '../../../../styled-system/css' +import { StandardGameLayout } from '@/components/StandardGameLayout' +import { useFullscreen } from '@/contexts/FullscreenContext' +import { useMatching } from '../Provider' +import { GamePhase } from './GamePhase' +import { ResultsPhase } from './ResultsPhase' +import { SetupPhase } from './SetupPhase' export function MemoryPairsGame() { - const router = useRouter(); - const { state, exitSession, resetGame, goToSetup } = useMatching(); - const { setFullscreenElement } = useFullscreen(); - const gameRef = useRef(null); + const router = useRouter() + const { state, exitSession, resetGame, goToSetup } = useMatching() + const { setFullscreenElement } = useFullscreen() + const gameRef = useRef(null) useEffect(() => { // Register this component's main div as the fullscreen element if (gameRef.current) { - console.log( - "🎯 MemoryPairsGame: Registering fullscreen element:", - gameRef.current, - ); - setFullscreenElement(gameRef.current); + console.log('🎯 MemoryPairsGame: Registering fullscreen element:', gameRef.current) + setFullscreenElement(gameRef.current) } - }, [setFullscreenElement]); + }, [setFullscreenElement]) // Determine nav title and emoji based on game type - const navTitle = - state.gameType === "abacus-numeral" ? "Abacus Match" : "Complement Pairs"; - const navEmoji = state.gameType === "abacus-numeral" ? "🧮" : "🤝"; + const navTitle = state.gameType === 'abacus-numeral' ? 'Abacus Match' : 'Complement Pairs' + const navEmoji = state.gameType === 'abacus-numeral' ? '🧮' : '🤝' return ( { - exitSession(); - router.push("/arcade"); + exitSession() + router.push('/arcade') }} onSetup={ goToSetup ? () => { // Transition to setup phase (will pause game if active) - goToSetup(); + goToSetup() } : undefined } onNewGame={() => { - resetGame(); + resetGame() }} currentPlayerId={state.currentPlayer} playerScores={state.scores} @@ -62,36 +58,36 @@ export function MemoryPairsGame() { ref={gameRef} className={css({ flex: 1, - padding: { base: "12px", sm: "16px", md: "20px" }, - display: "flex", - flexDirection: "column", - alignItems: "center", - position: "relative", - overflow: "auto", + padding: { base: '12px', sm: '16px', md: '20px' }, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + position: 'relative', + overflow: 'auto', })} > {/* Note: Fullscreen restore prompt removed - client-side navigation preserves fullscreen */}
- {state.gamePhase === "setup" && } - {state.gamePhase === "playing" && } - {state.gamePhase === "results" && } + {state.gamePhase === 'setup' && } + {state.gamePhase === 'playing' && } + {state.gamePhase === 'results' && }
- ); + ) } diff --git a/apps/web/src/arcade-games/matching/components/PlayerStatusBar.tsx b/apps/web/src/arcade-games/matching/components/PlayerStatusBar.tsx index 18ad99a4..8e66ed8b 100644 --- a/apps/web/src/arcade-games/matching/components/PlayerStatusBar.tsx +++ b/apps/web/src/arcade-games/matching/components/PlayerStatusBar.tsx @@ -1,23 +1,23 @@ -"use client"; +'use client' -import { useViewerId } from "@/hooks/useViewerId"; -import { css } from "../../../../styled-system/css"; -import { gamePlurals } from "@/utils/pluralization"; -import { useMatching } from "../Provider"; +import { useViewerId } from '@/hooks/useViewerId' +import { css } from '../../../../styled-system/css' +import { gamePlurals } from '@/utils/pluralization' +import { useMatching } from '../Provider' interface PlayerStatusBarProps { - className?: string; + className?: string } export function PlayerStatusBar({ className }: PlayerStatusBarProps) { - const { state } = useMatching(); - const { data: viewerId } = useViewerId(); + const { state } = useMatching() + const { data: viewerId } = useViewerId() // Get active players from game state (not GameModeContext) // This ensures we only show players actually in this game const activePlayersData = state.activePlayers .map((id) => state.playerMetadata?.[id]) - .filter((p): p is NonNullable => p !== undefined); + .filter((p): p is NonNullable => p !== undefined) // Map active players to display data with scores // State now uses player IDs (UUIDs) as keys @@ -29,167 +29,158 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) { consecutiveMatches: state.consecutiveMatches?.[player.id] || 0, // Check if this player belongs to the current viewer isLocalPlayer: player.userId === viewerId, - })); + })) // Check if current player is local (your turn) or remote (waiting) - const currentPlayer = activePlayers.find((p) => p.id === state.currentPlayer); - const isYourTurn = currentPlayer?.isLocalPlayer === true; + const currentPlayer = activePlayers.find((p) => p.id === state.currentPlayer) + const isYourTurn = currentPlayer?.isLocalPlayer === true // Get celebration level based on consecutive matches const getCelebrationLevel = (consecutiveMatches: number) => { - if (consecutiveMatches >= 5) return "legendary"; - if (consecutiveMatches >= 3) return "epic"; - if (consecutiveMatches >= 2) return "great"; - return "normal"; - }; + if (consecutiveMatches >= 5) return 'legendary' + if (consecutiveMatches >= 3) return 'epic' + if (consecutiveMatches >= 2) return 'great' + return 'normal' + } if (activePlayers.length <= 1) { // Simple single player indicator return (
- {activePlayers[0]?.displayEmoji || "🚀"} + {activePlayers[0]?.displayEmoji || '🚀'}
- {activePlayers[0]?.displayName || "Player 1"} + {activePlayers[0]?.displayName || 'Player 1'}
- {gamePlurals.pair(state.matchedPairs)} of {state.totalPairs} •{" "} + {gamePlurals.pair(state.matchedPairs)} of {state.totalPairs} •{' '} {gamePlurals.move(state.moves)}
- ); + ) } // For multiplayer, show competitive status bar return (
{activePlayers.map((player) => { - const isCurrentPlayer = player.id === state.currentPlayer; + const isCurrentPlayer = player.id === state.currentPlayer const isLeading = - player.score === Math.max(...activePlayers.map((p) => p.score)) && - player.score > 0; - const celebrationLevel = getCelebrationLevel( - player.consecutiveMatches, - ); + player.score === Math.max(...activePlayers.map((p) => p.score)) && player.score > 0 + const celebrationLevel = getCelebrationLevel(player.consecutiveMatches) return (
{/* Leading crown with sparkle */} {isLeading && (
👑 @@ -200,14 +191,14 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) { {isCurrentPlayer && (
@@ -216,22 +207,18 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) { {/* Living, breathing player emoji */}
@@ -247,15 +234,11 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) { >
{player.displayName} @@ -263,54 +246,46 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
{gamePlurals.pair(player.score)} {isCurrentPlayer && ( - {player.isLocalPlayer ? " • Your turn" : " • Their turn"} + {player.isLocalPlayer ? ' • Your turn' : ' • Their turn'} )} {player.consecutiveMatches > 1 && (
🔥 {player.consecutiveMatches} streak! @@ -323,24 +298,24 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) { {isCurrentPlayer && (
{player.score}
)}
- ); + ) })}
- ); + ) } // Epic animations for extreme emphasis @@ -523,15 +498,12 @@ const epicAnimations = ` box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2); } } -`; +` // Inject animation styles -if ( - typeof document !== "undefined" && - !document.getElementById("player-status-animations") -) { - const style = document.createElement("style"); - style.id = "player-status-animations"; - style.textContent = epicAnimations; - document.head.appendChild(style); +if (typeof document !== 'undefined' && !document.getElementById('player-status-animations')) { + const style = document.createElement('style') + style.id = 'player-status-animations' + style.textContent = epicAnimations + document.head.appendChild(style) } diff --git a/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx b/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx index 1f9f11c0..4264ae01 100644 --- a/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx +++ b/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx @@ -1,24 +1,19 @@ -"use client"; +'use client' -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { css } from "../../../../styled-system/css"; -import { useGameMode } from "@/contexts/GameModeContext"; -import { useMatching } from "../Provider"; -import { - formatGameTime, - getMultiplayerWinner, - getPerformanceAnalysis, -} from "../utils/gameScoring"; -import { useRecordGameResult } from "@/hooks/useRecordGameResult"; -import type { GameResult } from "@/lib/arcade/stats/types"; +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { css } from '../../../../styled-system/css' +import { useGameMode } from '@/contexts/GameModeContext' +import { useMatching } from '../Provider' +import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring' +import { useRecordGameResult } from '@/hooks/useRecordGameResult' +import type { GameResult } from '@/lib/arcade/stats/types' export function ResultsPhase() { - const router = useRouter(); - const { state, resetGame, activePlayers, gameMode, exitSession } = - useMatching(); - const { players: playerMap, activePlayers: activePlayerIds } = useGameMode(); - const { mutate: recordGameResult } = useRecordGameResult(); + const router = useRouter() + const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching() + const { players: playerMap, activePlayers: activePlayerIds } = useGameMode() + const { mutate: recordGameResult } = useRecordGameResult() // Get active player data array const activePlayerData = Array.from(activePlayerIds) @@ -28,34 +23,28 @@ export function ResultsPhase() { ...player, displayName: player.name, displayEmoji: player.emoji, - })); + })) const gameTime = - state.gameEndTime && state.gameStartTime - ? state.gameEndTime - state.gameStartTime - : 0; + state.gameEndTime && state.gameStartTime ? state.gameEndTime - state.gameStartTime : 0 - const analysis = getPerformanceAnalysis(state); + const analysis = getPerformanceAnalysis(state) const multiplayerResult = - gameMode === "multiplayer" - ? getMultiplayerWinner(state, activePlayers) - : null; + gameMode === 'multiplayer' ? getMultiplayerWinner(state, activePlayers) : null // Record game stats when results are shown useEffect(() => { - if (!state.gameEndTime || !state.gameStartTime) return; + if (!state.gameEndTime || !state.gameStartTime) return // Build game result const gameResult: GameResult = { - gameType: "matching", + gameType: 'matching', playerResults: activePlayerData.map((player) => { - const isWinner = - gameMode === "single" || - multiplayerResult?.winners.includes(player.id); + const isWinner = gameMode === 'single' || multiplayerResult?.winners.includes(player.id) const score = - gameMode === "multiplayer" + gameMode === 'multiplayer' ? multiplayerResult?.scores[player.id] || 0 - : state.matchedPairs; + : state.matchedPairs return { playerId: player.id, @@ -67,7 +56,7 @@ export function ResultsPhase() { moves: state.moves, matchedPairs: state.matchedPairs, }, - }; + } }), completedAt: state.gameEndTime, duration: gameTime, @@ -76,62 +65,60 @@ export function ResultsPhase() { starRating: analysis.starRating, grade: analysis.grade, }, - }; + } - console.log("📊 Recording matching game result:", gameResult); - recordGameResult(gameResult); - }, []); // Empty deps - only record once when component mounts + console.log('📊 Recording matching game result:', gameResult) + recordGameResult(gameResult) + }, []) // Empty deps - only record once when component mounts return (
{/* Celebration Header */}

🎉 Game Complete! 🎉

- {gameMode === "single" ? ( + {gameMode === 'single' ? (

Congratulations!

) : ( multiplayerResult && ( -
+
{multiplayerResult.isTie ? (

🤝 It's a tie! @@ -139,24 +126,22 @@ export function ResultsPhase() { ) : multiplayerResult.winners.length === 1 ? (

- 🏆{" "} - {activePlayerData.find( - (p) => p.id === multiplayerResult.winners[0], - )?.displayName || - `Player ${multiplayerResult.winners[0]}`}{" "} + 🏆{' '} + {activePlayerData.find((p) => p.id === multiplayerResult.winners[0]) + ?.displayName || `Player ${multiplayerResult.winners[0]}`}{' '} Wins!

) : (

🏆 {multiplayerResult.winners.length} Champions! @@ -169,19 +154,19 @@ export function ResultsPhase() { {/* Star Rating */}

- {"⭐".repeat(analysis.starRating)} - {"☆".repeat(5 - analysis.starRating)} + {'⭐'.repeat(analysis.starRating)} + {'☆'.repeat(5 - analysis.starRating)}
Grade: {analysis.grade} @@ -191,34 +176,34 @@ export function ResultsPhase() { {/* Game Statistics */}
{state.matchedPairs}
@@ -228,24 +213,24 @@ export function ResultsPhase() {
{state.moves}
@@ -255,24 +240,24 @@ export function ResultsPhase() {
{formatGameTime(gameTime)}
@@ -282,24 +267,24 @@ export function ResultsPhase() {
{Math.round(analysis.statistics.accuracy)}%
@@ -309,46 +294,46 @@ export function ResultsPhase() {
{/* Multiplayer Scores */} - {gameMode === "multiplayer" && multiplayerResult && ( + {gameMode === 'multiplayer' && multiplayerResult && (
{activePlayerData.map((player) => { - const score = multiplayerResult.scores[player.id] || 0; - const isWinner = multiplayerResult.winners.includes(player.id); + const score = multiplayerResult.scores[player.id] || 0 + const isWinner = multiplayerResult.winners.includes(player.id) return (
{player.displayEmoji}
@@ -356,21 +341,17 @@ export function ResultsPhase() {
{score}
{isWinner && ( -
- 👑 -
+
👑
)}
- ); + ) })}
)} @@ -378,28 +359,28 @@ export function ResultsPhase() { {/* Action Buttons */}
- ); + ) } diff --git a/apps/web/src/arcade-games/matching/components/SetupPhase.tsx b/apps/web/src/arcade-games/matching/components/SetupPhase.tsx index 2df69bbf..24696c2b 100644 --- a/apps/web/src/arcade-games/matching/components/SetupPhase.tsx +++ b/apps/web/src/arcade-games/matching/components/SetupPhase.tsx @@ -1,9 +1,9 @@ -"use client"; +'use client' -import { useState } from "react"; -import { css } from "../../../../styled-system/css"; -import { useGameMode } from "@/contexts/GameModeContext"; -import { useMatching } from "../Provider"; +import { useState } from 'react' +import { css } from '../../../../styled-system/css' +import { useGameMode } from '@/contexts/GameModeContext' +import { useMatching } from '../Provider' // Add bounce animation for the start button const bounceAnimation = ` @@ -18,17 +18,14 @@ const bounceAnimation = ` transform: translateY(-5px); } } -`; +` // Inject animation styles -if ( - typeof document !== "undefined" && - !document.getElementById("setup-animations") -) { - const style = document.createElement("style"); - style.id = "setup-animations"; - style.textContent = bounceAnimation; - document.head.appendChild(style); +if (typeof document !== 'undefined' && !document.getElementById('setup-animations')) { + const style = document.createElement('style') + style.id = 'setup-animations' + style.textContent = bounceAnimation + document.head.appendChild(style) } export function SetupPhase() { @@ -42,188 +39,187 @@ export function SetupPhase() { canResumeGame, hasConfigChanged, activePlayers: _activePlayers, - } = useMatching(); + } = useMatching() - const { activePlayerCount, gameMode: _globalGameMode } = useGameMode(); + const { activePlayerCount, gameMode: _globalGameMode } = useGameMode() // PAUSE/RESUME: Warning dialog state - const [showConfigWarning, setShowConfigWarning] = useState(false); - const [hasSeenWarning, setHasSeenWarning] = useState(false); + const [showConfigWarning, setShowConfigWarning] = useState(false) + const [hasSeenWarning, setHasSeenWarning] = useState(false) const [pendingConfigChange, setPendingConfigChange] = useState<{ - type: "gameType" | "difficulty" | "turnTimer"; - value: any; - } | null>(null); + type: 'gameType' | 'difficulty' | 'turnTimer' + value: any + } | null>(null) // Check if we should show warning when changing config - const shouldShowWarning = - state.pausedGamePhase && !hasSeenWarning && !hasConfigChanged; + const shouldShowWarning = state.pausedGamePhase && !hasSeenWarning && !hasConfigChanged // Config change handlers that check for paused game const handleSetGameType = (value: typeof state.gameType) => { if (shouldShowWarning) { - setPendingConfigChange({ type: "gameType", value }); - setShowConfigWarning(true); + setPendingConfigChange({ type: 'gameType', value }) + setShowConfigWarning(true) } else { - setGameType(value); + setGameType(value) } - }; + } const handleSetDifficulty = (value: typeof state.difficulty) => { if (shouldShowWarning) { - setPendingConfigChange({ type: "difficulty", value }); - setShowConfigWarning(true); + setPendingConfigChange({ type: 'difficulty', value }) + setShowConfigWarning(true) } else { - setDifficulty(value); + setDifficulty(value) } - }; + } const handleSetTurnTimer = (value: typeof state.turnTimer) => { if (shouldShowWarning) { - setPendingConfigChange({ type: "turnTimer", value }); - setShowConfigWarning(true); + setPendingConfigChange({ type: 'turnTimer', value }) + setShowConfigWarning(true) } else { - setTurnTimer(value); + setTurnTimer(value) } - }; + } // Apply pending config change after warning const applyPendingChange = () => { if (pendingConfigChange) { switch (pendingConfigChange.type) { - case "gameType": - setGameType(pendingConfigChange.value); - break; - case "difficulty": - setDifficulty(pendingConfigChange.value); - break; - case "turnTimer": - setTurnTimer(pendingConfigChange.value); - break; + case 'gameType': + setGameType(pendingConfigChange.value) + break + case 'difficulty': + setDifficulty(pendingConfigChange.value) + break + case 'turnTimer': + setTurnTimer(pendingConfigChange.value) + break } - setHasSeenWarning(true); - setPendingConfigChange(null); - setShowConfigWarning(false); + setHasSeenWarning(true) + setPendingConfigChange(null) + setShowConfigWarning(false) } - }; + } // Cancel config change const cancelConfigChange = () => { - setPendingConfigChange(null); - setShowConfigWarning(false); - }; + setPendingConfigChange(null) + setShowConfigWarning(false) + } const handleStartOrResumeGame = () => { if (canResumeGame) { - resumeGame(); + resumeGame() } else { - startGame(); + startGame() } - }; + } const getButtonStyles = ( isSelected: boolean, - variant: "primary" | "secondary" | "difficulty" = "primary", + variant: 'primary' | 'secondary' | 'difficulty' = 'primary' ) => { const baseStyles = { - border: "none", - borderRadius: { base: "12px", md: "16px" }, - padding: { base: "12px 16px", sm: "14px 20px", md: "16px 24px" }, - fontSize: { base: "14px", sm: "15px", md: "16px" }, - fontWeight: "bold", - cursor: "pointer", - transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", - minWidth: { base: "120px", sm: "140px", md: "160px" }, - textAlign: "center" as const, - position: "relative" as const, - overflow: "hidden" as const, - textShadow: isSelected ? "0 1px 2px rgba(0,0,0,0.2)" : "none", - transform: "translateZ(0)", // Enable GPU acceleration - }; - - if (variant === "difficulty") { - return css({ - ...baseStyles, - background: isSelected - ? "linear-gradient(135deg, #ff6b6b, #ee5a24)" - : "linear-gradient(135deg, #f8f9fa, #e9ecef)", - color: isSelected ? "white" : "#495057", - boxShadow: isSelected - ? "0 8px 25px rgba(255, 107, 107, 0.4), inset 0 1px 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)", - _hover: { - transform: "translateY(-3px) scale(1.02)", - boxShadow: isSelected - ? "0 12px 35px rgba(255, 107, 107, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)" - : "0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)", - }, - _active: { - transform: "translateY(-1px) scale(1.01)", - }, - }); + border: 'none', + borderRadius: { base: '12px', md: '16px' }, + padding: { base: '12px 16px', sm: '14px 20px', md: '16px 24px' }, + fontSize: { base: '14px', sm: '15px', md: '16px' }, + fontWeight: 'bold', + cursor: 'pointer', + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + minWidth: { base: '120px', sm: '140px', md: '160px' }, + textAlign: 'center' as const, + position: 'relative' as const, + overflow: 'hidden' as const, + textShadow: isSelected ? '0 1px 2px rgba(0,0,0,0.2)' : 'none', + transform: 'translateZ(0)', // Enable GPU acceleration } - if (variant === "secondary") { + if (variant === 'difficulty') { return css({ ...baseStyles, background: isSelected - ? "linear-gradient(135deg, #a78bfa, #8b5cf6)" - : "linear-gradient(135deg, #f8fafc, #e2e8f0)", - color: isSelected ? "white" : "#475569", + ? 'linear-gradient(135deg, #ff6b6b, #ee5a24)' + : 'linear-gradient(135deg, #f8f9fa, #e9ecef)', + color: isSelected ? 'white' : '#495057', boxShadow: isSelected - ? "0 8px 25px rgba(167, 139, 250, 0.4), inset 0 1px 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)", + ? '0 8px 25px rgba(255, 107, 107, 0.4), inset 0 1px 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)', _hover: { - transform: "translateY(-3px) scale(1.02)", + transform: 'translateY(-3px) scale(1.02)', boxShadow: isSelected - ? "0 12px 35px rgba(167, 139, 250, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)" - : "0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)", + ? '0 12px 35px rgba(255, 107, 107, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)' + : '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)', }, _active: { - transform: "translateY(-1px) scale(1.01)", + transform: 'translateY(-1px) scale(1.01)', }, - }); + }) + } + + if (variant === 'secondary') { + return css({ + ...baseStyles, + background: isSelected + ? 'linear-gradient(135deg, #a78bfa, #8b5cf6)' + : 'linear-gradient(135deg, #f8fafc, #e2e8f0)', + color: isSelected ? 'white' : '#475569', + boxShadow: isSelected + ? '0 8px 25px rgba(167, 139, 250, 0.4), inset 0 1px 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)', + _hover: { + transform: 'translateY(-3px) scale(1.02)', + boxShadow: isSelected + ? '0 12px 35px rgba(167, 139, 250, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)' + : '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)', + }, + _active: { + transform: 'translateY(-1px) scale(1.01)', + }, + }) } // Primary variant return css({ ...baseStyles, background: isSelected - ? "linear-gradient(135deg, #667eea, #764ba2)" - : "linear-gradient(135deg, #ffffff, #f1f5f9)", - color: isSelected ? "white" : "#334155", + ? 'linear-gradient(135deg, #667eea, #764ba2)' + : 'linear-gradient(135deg, #ffffff, #f1f5f9)', + color: isSelected ? 'white' : '#334155', boxShadow: isSelected - ? "0 8px 25px rgba(102, 126, 234, 0.4), inset 0 1px 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)", + ? '0 8px 25px rgba(102, 126, 234, 0.4), inset 0 1px 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)', _hover: { - transform: "translateY(-3px) scale(1.02)", + transform: 'translateY(-3px) scale(1.02)', boxShadow: isSelected - ? "0 12px 35px rgba(102, 126, 234, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)" - : "0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)", + ? '0 12px 35px rgba(102, 126, 234, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)' + : '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)', }, _active: { - transform: "translateY(-1px) scale(1.01)", + transform: 'translateY(-1px) scale(1.01)', }, - }); - }; + }) + } return (

⚠️ Warning: Changing Settings Will End Current Game

- You have a paused game in progress. Changing any setting will end - it and you won't be able to resume. + You have a paused game in progress. Changing any setting will end it and you won't be + able to resume.

)} @@ -345,71 +340,64 @@ export function SetupPhase() {
- ); + ) })}

{state.difficulty} pairs = {state.difficulty * 2} cards total @@ -595,75 +569,67 @@ export function SetupPhase() {

{([15, 30, 45, 60] as const).map((timer) => { - const timerInfo: Record< - 15 | 30 | 45 | 60, - { icon: string; label: string } - > = { - 15: { icon: "💨", label: "Lightning" }, - 30: { icon: "⚡", label: "Quick" }, - 45: { icon: "🏃", label: "Standard" }, - 60: { icon: "🧘", label: "Relaxed" }, - }; + const timerInfo: Record<15 | 30 | 45 | 60, { icon: string; label: string }> = { + 15: { icon: '💨', label: 'Lightning' }, + 30: { icon: '⚡', label: 'Quick' }, + 45: { icon: '🏃', label: 'Standard' }, + 60: { icon: '🧘', label: 'Relaxed' }, + } return ( - ); + ) })}

Time limit for each player's turn @@ -674,99 +640,99 @@ export function SetupPhase() { {/* Start Game Button - Sticky at bottom */}

- ); + ) } diff --git a/apps/web/src/arcade-games/matching/components/index.ts b/apps/web/src/arcade-games/matching/components/index.ts index c3d9addf..f4896e37 100644 --- a/apps/web/src/arcade-games/matching/components/index.ts +++ b/apps/web/src/arcade-games/matching/components/index.ts @@ -2,10 +2,10 @@ * Matching Pairs Battle - Components */ -export { MemoryPairsGame } from "./MemoryPairsGame"; -export { SetupPhase } from "./SetupPhase"; -export { GamePhase } from "./GamePhase"; -export { ResultsPhase } from "./ResultsPhase"; -export { GameCard } from "./GameCard"; -export { PlayerStatusBar } from "./PlayerStatusBar"; -export { EmojiPicker } from "./EmojiPicker"; +export { MemoryPairsGame } from './MemoryPairsGame' +export { SetupPhase } from './SetupPhase' +export { GamePhase } from './GamePhase' +export { ResultsPhase } from './ResultsPhase' +export { GameCard } from './GameCard' +export { PlayerStatusBar } from './PlayerStatusBar' +export { EmojiPicker } from './EmojiPicker' diff --git a/apps/web/src/arcade-games/matching/index.ts b/apps/web/src/arcade-games/matching/index.ts index 852336ba..a7261ce4 100644 --- a/apps/web/src/arcade-games/matching/index.ts +++ b/apps/web/src/arcade-games/matching/index.ts @@ -5,77 +5,70 @@ * Supports both abacus-numeral matching and complement pairs modes. */ -import { defineGame, getGameTheme } from "@/lib/arcade/game-sdk"; -import type { GameManifest } from "@/lib/arcade/game-sdk"; -import { MemoryPairsGame } from "./components/MemoryPairsGame"; -import { MatchingProvider } from "./Provider"; -import type { MatchingConfig, MatchingMove, MatchingState } from "./types"; -import { matchingGameValidator } from "./Validator"; +import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk' +import type { GameManifest } from '@/lib/arcade/game-sdk' +import { MemoryPairsGame } from './components/MemoryPairsGame' +import { MatchingProvider } from './Provider' +import type { MatchingConfig, MatchingMove, MatchingState } from './types' +import { matchingGameValidator } from './Validator' const manifest: GameManifest = { - name: "matching", - displayName: "Matching Pairs Battle", - icon: "⚔️", - description: "Multiplayer memory battle with friends", + name: 'matching', + displayName: 'Matching Pairs Battle', + icon: '⚔️', + description: 'Multiplayer memory battle with friends', longDescription: - "Battle friends in epic memory challenges. Match pairs faster than your opponents in this exciting multiplayer experience. " + - "Choose between abacus-numeral matching or complement pairs mode. Strategic thinking and quick memory are key to victory!", + 'Battle friends in epic memory challenges. Match pairs faster than your opponents in this exciting multiplayer experience. ' + + 'Choose between abacus-numeral matching or complement pairs mode. Strategic thinking and quick memory are key to victory!', maxPlayers: 4, - difficulty: "Intermediate", - chips: ["👥 Multiplayer", "🎯 Strategic", "🏆 Competitive"], - ...getGameTheme("pink"), + difficulty: 'Intermediate', + chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'], + ...getGameTheme('pink'), available: true, -}; +} const defaultConfig: MatchingConfig = { - gameType: "abacus-numeral", + gameType: 'abacus-numeral', difficulty: 6, turnTimer: 30, -}; +} // Config validation function function validateMatchingConfig(config: unknown): config is MatchingConfig { - if (typeof config !== "object" || config === null) { - return false; + if (typeof config !== 'object' || config === null) { + return false } - const c = config as any; + const c = config as any // Validate gameType - if ( - !("gameType" in c) || - !["abacus-numeral", "complement-pairs"].includes(c.gameType) - ) { - return false; + if (!('gameType' in c) || !['abacus-numeral', 'complement-pairs'].includes(c.gameType)) { + return false } // Validate difficulty (number of pairs) - if (!("difficulty" in c) || ![6, 8, 12, 15].includes(c.difficulty)) { - return false; + if (!('difficulty' in c) || ![6, 8, 12, 15].includes(c.difficulty)) { + return false } // Validate turnTimer if ( - !("turnTimer" in c) || - typeof c.turnTimer !== "number" || + !('turnTimer' in c) || + typeof c.turnTimer !== 'number' || c.turnTimer < 5 || c.turnTimer > 300 ) { - return false; + return false } - return true; + return true } -export const matchingGame = defineGame< - MatchingConfig, - MatchingState, - MatchingMove ->({ +export const matchingGame = defineGame({ manifest, Provider: MatchingProvider, GameComponent: MemoryPairsGame, validator: matchingGameValidator, defaultConfig, validateConfig: validateMatchingConfig, -}); +}) diff --git a/apps/web/src/arcade-games/matching/types.ts b/apps/web/src/arcade-games/matching/types.ts index 58f8c114..e45a4024 100644 --- a/apps/web/src/arcade-games/matching/types.ts +++ b/apps/web/src/arcade-games/matching/types.ts @@ -4,19 +4,19 @@ * SDK-compatible types for the matching game. */ -import type { GameConfig, GameState } from "@/lib/arcade/game-sdk/types"; +import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types' // ============================================================================ // Core Types // ============================================================================ -export type GameMode = "single" | "multiplayer"; -export type GameType = "abacus-numeral" | "complement-pairs"; -export type GamePhase = "setup" | "playing" | "results"; -export type CardType = "abacus" | "number" | "complement"; -export type Difficulty = 6 | 8 | 12 | 15; // Number of pairs -export type Player = string; // Player ID (UUID) -export type TargetSum = 5 | 10 | 20; +export type GameMode = 'single' | 'multiplayer' +export type GameType = 'abacus-numeral' | 'complement-pairs' +export type GamePhase = 'setup' | 'playing' | 'results' +export type CardType = 'abacus' | 'number' | 'complement' +export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs +export type Player = string // Player ID (UUID) +export type TargetSum = 5 | 10 | 20 // ============================================================================ // Game Configuration (SDK-compatible) @@ -27,9 +27,9 @@ export type TargetSum = 5 | 10 | 20; * Extends GameConfig for SDK compatibility */ export interface MatchingConfig extends GameConfig { - gameType: GameType; - difficulty: Difficulty; - turnTimer: number; + gameType: GameType + difficulty: Difficulty + turnTimer: number } // ============================================================================ @@ -37,43 +37,43 @@ export interface MatchingConfig extends GameConfig { // ============================================================================ export interface GameCard { - id: string; - type: CardType; - number: number; - complement?: number; // For complement pairs - targetSum?: TargetSum; // For complement pairs - matched: boolean; - matchedBy?: Player; // For two-player mode - element?: HTMLElement | null; // For animations + id: string + type: CardType + number: number + complement?: number // For complement pairs + targetSum?: TargetSum // For complement pairs + matched: boolean + matchedBy?: Player // For two-player mode + element?: HTMLElement | null // For animations } export interface PlayerMetadata { - id: string; // Player ID (UUID) - name: string; - emoji: string; - userId: string; // Which user owns this player - color?: string; + id: string // Player ID (UUID) + name: string + emoji: string + userId: string // Which user owns this player + color?: string } export interface PlayerScore { - [playerId: string]: number; + [playerId: string]: number } export interface CelebrationAnimation { - id: string; - type: "match" | "win" | "confetti"; - x: number; - y: number; - timestamp: number; + id: string + type: 'match' | 'win' | 'confetti' + x: number + y: number + timestamp: number } export interface GameStatistics { - totalMoves: number; - matchedPairs: number; - totalPairs: number; - gameTime: number; - accuracy: number; // Percentage of successful matches - averageTimePerMove: number; + totalMoves: number + matchedPairs: number + totalPairs: number + gameTime: number + accuracy: number // Percentage of successful matches + averageTimePerMove: number } // ============================================================================ @@ -86,63 +86,63 @@ export interface GameStatistics { */ export interface MatchingState extends GameState { // Core game data - cards: GameCard[]; - gameCards: GameCard[]; - flippedCards: GameCard[]; + cards: GameCard[] + gameCards: GameCard[] + flippedCards: GameCard[] // Game configuration - gameType: GameType; - difficulty: Difficulty; - turnTimer: number; // Seconds for turn timer + gameType: GameType + difficulty: Difficulty + turnTimer: number // Seconds for turn timer // Game progression - gamePhase: GamePhase; - currentPlayer: Player; - matchedPairs: number; - totalPairs: number; - moves: number; - scores: PlayerScore; - activePlayers: Player[]; // Track active player IDs - playerMetadata: Record; // Player metadata for cross-user visibility - consecutiveMatches: Record; // Track consecutive matches per player + gamePhase: GamePhase + currentPlayer: Player + matchedPairs: number + totalPairs: number + moves: number + scores: PlayerScore + activePlayers: Player[] // Track active player IDs + playerMetadata: Record // Player metadata for cross-user visibility + consecutiveMatches: Record // Track consecutive matches per player // Timing - gameStartTime: number | null; - gameEndTime: number | null; - currentMoveStartTime: number | null; - timerInterval: NodeJS.Timeout | null; + gameStartTime: number | null + gameEndTime: number | null + currentMoveStartTime: number | null + timerInterval: NodeJS.Timeout | null // UI state - celebrationAnimations: CelebrationAnimation[]; - isProcessingMove: boolean; - showMismatchFeedback: boolean; - lastMatchedPair: [string, string] | null; + celebrationAnimations: CelebrationAnimation[] + isProcessingMove: boolean + showMismatchFeedback: boolean + lastMatchedPair: [string, string] | null // PAUSE/RESUME: Paused game state originalConfig?: { - gameType: GameType; - difficulty: Difficulty; - turnTimer: number; - }; - pausedGamePhase?: GamePhase; + gameType: GameType + difficulty: Difficulty + turnTimer: number + } + pausedGamePhase?: GamePhase pausedGameState?: { - gameCards: GameCard[]; - currentPlayer: Player; - matchedPairs: number; - moves: number; - scores: PlayerScore; - activePlayers: Player[]; - playerMetadata: Record; - consecutiveMatches: Record; - gameStartTime: number | null; - }; + gameCards: GameCard[] + currentPlayer: Player + matchedPairs: number + moves: number + scores: PlayerScore + activePlayers: Player[] + playerMetadata: Record + consecutiveMatches: Record + gameStartTime: number | null + } // HOVER: Networked hover state - playerHovers: Record; // playerId -> cardId (or null if not hovering) + playerHovers: Record // playerId -> cardId (or null if not hovering) } // For backwards compatibility with existing code -export type MemoryPairsState = MatchingState; +export type MemoryPairsState = MatchingState // ============================================================================ // Context Value @@ -153,31 +153,31 @@ export type MemoryPairsState = MatchingState; * Exposes state and action creators to components */ export interface MatchingContextValue { - state: MatchingState & { gameMode: GameMode }; - dispatch: React.Dispatch; // Deprecated - use action creators instead + state: MatchingState & { gameMode: GameMode } + dispatch: React.Dispatch // Deprecated - use action creators instead // Computed values - isGameActive: boolean; - canFlipCard: (cardId: string) => boolean; - currentGameStatistics: GameStatistics; - gameMode: GameMode; - activePlayers: Player[]; + isGameActive: boolean + canFlipCard: (cardId: string) => boolean + currentGameStatistics: GameStatistics + gameMode: GameMode + activePlayers: Player[] // Pause/Resume - hasConfigChanged: boolean; - canResumeGame: boolean; + hasConfigChanged: boolean + canResumeGame: boolean // Actions - startGame: () => void; - flipCard: (cardId: string) => void; - resetGame: () => void; - setGameType: (type: GameType) => void; - setDifficulty: (difficulty: Difficulty) => void; - setTurnTimer: (timer: number) => void; - goToSetup: () => void; - resumeGame: () => void; - hoverCard: (cardId: string | null) => void; - exitSession: () => void; + startGame: () => void + flipCard: (cardId: string) => void + resetGame: () => void + setGameType: (type: GameType) => void + setDifficulty: (difficulty: Difficulty) => void + setTurnTimer: (timer: number) => void + goToSetup: () => void + resumeGame: () => void + hoverCard: (cardId: string | null) => void + exitSession: () => void } // ============================================================================ @@ -190,89 +190,89 @@ export interface MatchingContextValue { */ export type MatchingMove = | { - type: "FLIP_CARD"; - playerId: string; - userId: string; - timestamp: number; + type: 'FLIP_CARD' + playerId: string + userId: string + timestamp: number data: { - cardId: string; - }; + cardId: string + } } | { - type: "START_GAME"; - playerId: string; - userId: string; - timestamp: number; + type: 'START_GAME' + playerId: string + userId: string + timestamp: number data: { - cards: GameCard[]; - activePlayers: string[]; - playerMetadata: Record; - }; + cards: GameCard[] + activePlayers: string[] + playerMetadata: Record + } } | { - type: "CLEAR_MISMATCH"; - playerId: string; - userId: string; - timestamp: number; - data: Record; + type: 'CLEAR_MISMATCH' + playerId: string + userId: string + timestamp: number + data: Record } | { - type: "GO_TO_SETUP"; - playerId: string; - userId: string; - timestamp: number; - data: Record; + type: 'GO_TO_SETUP' + playerId: string + userId: string + timestamp: number + data: Record } | { - type: "SET_CONFIG"; - playerId: string; - userId: string; - timestamp: number; + type: 'SET_CONFIG' + playerId: string + userId: string + timestamp: number data: { - field: "gameType" | "difficulty" | "turnTimer"; - value: any; - }; + field: 'gameType' | 'difficulty' | 'turnTimer' + value: any + } } | { - type: "RESUME_GAME"; - playerId: string; - userId: string; - timestamp: number; - data: Record; + type: 'RESUME_GAME' + playerId: string + userId: string + timestamp: number + data: Record } | { - type: "HOVER_CARD"; - playerId: string; - userId: string; - timestamp: number; + type: 'HOVER_CARD' + playerId: string + userId: string + timestamp: number data: { - cardId: string | null; - }; - }; + cardId: string | null + } + } // ============================================================================ // Component Props // ============================================================================ export interface GameCardProps { - card: GameCard; - isFlipped: boolean; - isMatched: boolean; - onClick: () => void; - disabled?: boolean; + card: GameCard + isFlipped: boolean + isMatched: boolean + onClick: () => void + disabled?: boolean } export interface PlayerIndicatorProps { - player: Player; - isActive: boolean; - score: number; - name?: string; + player: Player + isActive: boolean + score: number + name?: string } export interface GameGridProps { - cards: GameCard[]; - onCardClick: (cardId: string) => void; - disabled?: boolean; + cards: GameCard[] + onCardClick: (cardId: string) => void + disabled?: boolean } // ============================================================================ @@ -280,7 +280,7 @@ export interface GameGridProps { // ============================================================================ export interface MatchValidationResult { - isValid: boolean; - reason?: string; - type: "abacus-numeral" | "complement" | "invalid"; + isValid: boolean + reason?: string + type: 'abacus-numeral' | 'complement' | 'invalid' } diff --git a/apps/web/src/arcade-games/matching/utils/cardGeneration.ts b/apps/web/src/arcade-games/matching/utils/cardGeneration.ts index a69c1386..dd0b4ebe 100644 --- a/apps/web/src/arcade-games/matching/utils/cardGeneration.ts +++ b/apps/web/src/arcade-games/matching/utils/cardGeneration.ts @@ -1,29 +1,26 @@ -import type { Difficulty, GameCard, GameType } from "../types"; +import type { Difficulty, GameCard, GameType } from '../types' // Utility function to generate unique random numbers -function generateUniqueNumbers( - count: number, - options: { min: number; max: number }, -): number[] { - const numbers = new Set(); - const { min, max } = options; +function generateUniqueNumbers(count: number, options: { min: number; max: number }): number[] { + const numbers = new Set() + const { min, max } = options while (numbers.size < count) { - const randomNum = Math.floor(Math.random() * (max - min + 1)) + min; - numbers.add(randomNum); + const randomNum = Math.floor(Math.random() * (max - min + 1)) + min + numbers.add(randomNum) } - return Array.from(numbers); + return Array.from(numbers) } // Utility function to shuffle an array function shuffleArray(array: T[]): T[] { - const shuffled = [...array]; + const shuffled = [...array] for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] } - return shuffled; + return shuffled } // Generate cards for abacus-numeral game mode @@ -35,32 +32,32 @@ export function generateAbacusNumeralCards(pairs: Difficulty): GameCard[] { 8: { min: 1, max: 100 }, // 8 pairs: 1-100 12: { min: 1, max: 200 }, // 12 pairs: 1-200 15: { min: 1, max: 300 }, // 15 pairs: 1-300 - }; + } - const range = numberRanges[pairs]; - const numbers = generateUniqueNumbers(pairs, range); + const range = numberRanges[pairs] + const numbers = generateUniqueNumbers(pairs, range) - const cards: GameCard[] = []; + const cards: GameCard[] = [] numbers.forEach((number) => { // Abacus representation card cards.push({ id: `abacus_${number}`, - type: "abacus", + type: 'abacus', number, matched: false, - }); + }) // Numerical representation card cards.push({ id: `number_${number}`, - type: "number", + type: 'number', number, matched: false, - }); - }); + }) + }) - return shuffleArray(cards); + return shuffleArray(cards) } // Generate cards for complement pairs game mode @@ -90,51 +87,48 @@ export function generateComplementCards(pairs: Difficulty): GameCard[] { // More challenging pairs (can be used for expert mode) { pair: [11, 9], targetSum: 20 as const }, { pair: [12, 8], targetSum: 20 as const }, - ]; + ] // Select the required number of complement pairs - const selectedPairs = complementPairs.slice(0, pairs); - const cards: GameCard[] = []; + const selectedPairs = complementPairs.slice(0, pairs) + const cards: GameCard[] = [] selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => { // First number in the pair cards.push({ id: `comp1_${index}_${num1}`, - type: "complement", + type: 'complement', number: num1, complement: num2, targetSum, matched: false, - }); + }) // Second number in the pair cards.push({ id: `comp2_${index}_${num2}`, - type: "complement", + type: 'complement', number: num2, complement: num1, targetSum, matched: false, - }); - }); + }) + }) - return shuffleArray(cards); + return shuffleArray(cards) } // Main card generation function -export function generateGameCards( - gameType: GameType, - difficulty: Difficulty, -): GameCard[] { +export function generateGameCards(gameType: GameType, difficulty: Difficulty): GameCard[] { switch (gameType) { - case "abacus-numeral": - return generateAbacusNumeralCards(difficulty); + case 'abacus-numeral': + return generateAbacusNumeralCards(difficulty) - case "complement-pairs": - return generateComplementCards(difficulty); + case 'complement-pairs': + return generateComplementCards(difficulty) default: - throw new Error(`Unknown game type: ${gameType}`); + throw new Error(`Unknown game type: ${gameType}`) } } @@ -143,14 +137,14 @@ export function getGridConfiguration(difficulty: Difficulty) { const configs: Record< Difficulty, { - totalCards: number; + totalCards: number // Orientation-optimized responsive columns - mobileColumns: number; // Portrait mobile - tabletColumns: number; // Tablet - desktopColumns: number; // Desktop/landscape - landscapeColumns: number; // Landscape mobile/tablet - cardSize: { width: string; height: string }; - gridTemplate: string; + mobileColumns: number // Portrait mobile + tabletColumns: number // Tablet + desktopColumns: number // Desktop/landscape + landscapeColumns: number // Landscape mobile/tablet + cardSize: { width: string; height: string } + gridTemplate: string } > = { 6: { @@ -159,8 +153,8 @@ export function getGridConfiguration(difficulty: Difficulty) { tabletColumns: 4, // 4x3 grid on tablet desktopColumns: 4, // 4x3 grid on desktop landscapeColumns: 6, // 6x2 grid in landscape - cardSize: { width: "140px", height: "180px" }, - gridTemplate: "repeat(3, 1fr)", + cardSize: { width: '140px', height: '180px' }, + gridTemplate: 'repeat(3, 1fr)', }, 8: { totalCards: 16, @@ -168,8 +162,8 @@ export function getGridConfiguration(difficulty: Difficulty) { tabletColumns: 4, // 4x4 grid on tablet desktopColumns: 4, // 4x4 grid on desktop landscapeColumns: 6, // 6x3 grid in landscape (some spillover) - cardSize: { width: "120px", height: "160px" }, - gridTemplate: "repeat(3, 1fr)", + cardSize: { width: '120px', height: '160px' }, + gridTemplate: 'repeat(3, 1fr)', }, 12: { totalCards: 24, @@ -177,8 +171,8 @@ export function getGridConfiguration(difficulty: Difficulty) { tabletColumns: 4, // 4x6 grid on tablet desktopColumns: 6, // 6x4 grid on desktop landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3) - cardSize: { width: "100px", height: "140px" }, - gridTemplate: "repeat(3, 1fr)", + cardSize: { width: '100px', height: '140px' }, + gridTemplate: 'repeat(3, 1fr)', }, 15: { totalCards: 30, @@ -186,18 +180,15 @@ export function getGridConfiguration(difficulty: Difficulty) { tabletColumns: 5, // 5x6 grid on tablet desktopColumns: 6, // 6x5 grid on desktop landscapeColumns: 10, // 10x3 grid in landscape - cardSize: { width: "90px", height: "120px" }, - gridTemplate: "repeat(3, 1fr)", + cardSize: { width: '90px', height: '120px' }, + gridTemplate: 'repeat(3, 1fr)', }, - }; + } - return configs[difficulty]; + return configs[difficulty] } // Generate a unique ID for cards -export function generateCardId( - type: string, - identifier: string | number, -): string { - return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +export function generateCardId(type: string, identifier: string | number): string { + return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` } diff --git a/apps/web/src/arcade-games/matching/utils/gameScoring.ts b/apps/web/src/arcade-games/matching/utils/gameScoring.ts index e227db9f..ea398f64 100644 --- a/apps/web/src/arcade-games/matching/utils/gameScoring.ts +++ b/apps/web/src/arcade-games/matching/utils/gameScoring.ts @@ -1,4 +1,4 @@ -import type { GameStatistics, MemoryPairsState, Player } from "../types"; +import type { GameStatistics, MemoryPairsState, Player } from '../types' // Calculate final game score based on multiple factors export function calculateFinalScore( @@ -7,36 +7,31 @@ export function calculateFinalScore( moves: number, gameTime: number, difficulty: number, - gameMode: "single" | "two-player", + gameMode: 'single' | 'two-player' ): number { // Base score for completing pairs - const baseScore = matchedPairs * 100; + const baseScore = matchedPairs * 100 // Efficiency bonus (fewer moves = higher bonus) - const idealMoves = totalPairs * 2; // Perfect game would be 2 moves per pair - const efficiency = idealMoves / Math.max(moves, idealMoves); - const efficiencyBonus = Math.round(baseScore * efficiency * 0.5); + const idealMoves = totalPairs * 2 // Perfect game would be 2 moves per pair + const efficiency = idealMoves / Math.max(moves, idealMoves) + const efficiencyBonus = Math.round(baseScore * efficiency * 0.5) // Time bonus (faster completion = higher bonus) - const timeInMinutes = gameTime / (1000 * 60); - const timeBonus = Math.max( - 0, - Math.round((1000 * difficulty) / timeInMinutes), - ); + const timeInMinutes = gameTime / (1000 * 60) + const timeBonus = Math.max(0, Math.round((1000 * difficulty) / timeInMinutes)) // Difficulty multiplier - const difficultyMultiplier = 1 + (difficulty - 6) * 0.1; + const difficultyMultiplier = 1 + (difficulty - 6) * 0.1 // Two-player mode bonus - const modeMultiplier = gameMode === "two-player" ? 1.2 : 1.0; + const modeMultiplier = gameMode === 'two-player' ? 1.2 : 1.0 const finalScore = Math.round( - (baseScore + efficiencyBonus + timeBonus) * - difficultyMultiplier * - modeMultiplier, - ); + (baseScore + efficiencyBonus + timeBonus) * difficultyMultiplier * modeMultiplier + ) - return Math.max(0, finalScore); + return Math.max(0, finalScore) } // Calculate star rating (1-5 stars) based on performance @@ -44,161 +39,140 @@ export function calculateStarRating( accuracy: number, efficiency: number, gameTime: number, - difficulty: number, + difficulty: number ): number { // Normalize time score (assuming reasonable time ranges) - const expectedTime = difficulty * 30000; // 30 seconds per pair as baseline - const timeScore = Math.max(0, Math.min(100, (expectedTime / gameTime) * 100)); + const expectedTime = difficulty * 30000 // 30 seconds per pair as baseline + const timeScore = Math.max(0, Math.min(100, (expectedTime / gameTime) * 100)) // Weighted average of different factors - const overallScore = accuracy * 0.4 + efficiency * 0.4 + timeScore * 0.2; + const overallScore = accuracy * 0.4 + efficiency * 0.4 + timeScore * 0.2 // Convert to stars - if (overallScore >= 90) return 5; - if (overallScore >= 80) return 4; - if (overallScore >= 70) return 3; - if (overallScore >= 60) return 2; - return 1; + if (overallScore >= 90) return 5 + if (overallScore >= 80) return 4 + if (overallScore >= 70) return 3 + if (overallScore >= 60) return 2 + return 1 } // Get achievement badges based on performance export interface Achievement { - id: string; - name: string; - description: string; - icon: string; - earned: boolean; + id: string + name: string + description: string + icon: string + earned: boolean } export function getAchievements( state: MemoryPairsState, - gameMode: "single" | "multiplayer", + gameMode: 'single' | 'multiplayer' ): Achievement[] { - const { - matchedPairs, - totalPairs, - moves, - scores, - gameStartTime, - gameEndTime, - } = state; - const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0; - const gameTime = - gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0; - const gameTimeInSeconds = gameTime / 1000; + const { matchedPairs, totalPairs, moves, scores, gameStartTime, gameEndTime } = state + const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0 + const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0 + const gameTimeInSeconds = gameTime / 1000 const achievements: Achievement[] = [ { - id: "perfect_game", - name: "Perfect Memory", - description: "Complete a game with 100% accuracy", - icon: "🧠", + id: 'perfect_game', + name: 'Perfect Memory', + description: 'Complete a game with 100% accuracy', + icon: '🧠', earned: matchedPairs === totalPairs && moves === totalPairs * 2, }, { - id: "speed_demon", - name: "Speed Demon", - description: "Complete a game in under 2 minutes", - icon: "⚡", - earned: - gameTimeInSeconds > 0 && - gameTimeInSeconds < 120 && - matchedPairs === totalPairs, + id: 'speed_demon', + name: 'Speed Demon', + description: 'Complete a game in under 2 minutes', + icon: '⚡', + earned: gameTimeInSeconds > 0 && gameTimeInSeconds < 120 && matchedPairs === totalPairs, }, { - id: "accuracy_ace", - name: "Accuracy Ace", - description: "Achieve 90% accuracy or higher", - icon: "🎯", + id: 'accuracy_ace', + name: 'Accuracy Ace', + description: 'Achieve 90% accuracy or higher', + icon: '🎯', earned: accuracy >= 90 && matchedPairs === totalPairs, }, { - id: "marathon_master", - name: "Marathon Master", - description: "Complete the hardest difficulty (15 pairs)", - icon: "🏃", + id: 'marathon_master', + name: 'Marathon Master', + description: 'Complete the hardest difficulty (15 pairs)', + icon: '🏃', earned: totalPairs === 15 && matchedPairs === totalPairs, }, { - id: "complement_champion", - name: "Complement Champion", - description: "Master complement pairs mode", - icon: "🤝", + id: 'complement_champion', + name: 'Complement Champion', + description: 'Master complement pairs mode', + icon: '🤝', earned: - state.gameType === "complement-pairs" && - matchedPairs === totalPairs && - accuracy >= 85, + state.gameType === 'complement-pairs' && matchedPairs === totalPairs && accuracy >= 85, }, { - id: "two_player_triumph", - name: "Two-Player Triumph", - description: "Win a two-player game", - icon: "👥", + id: 'two_player_triumph', + name: 'Two-Player Triumph', + description: 'Win a two-player game', + icon: '👥', earned: - gameMode === "multiplayer" && + gameMode === 'multiplayer' && matchedPairs === totalPairs && Object.keys(scores).length > 1 && Math.max(...Object.values(scores)) > 0, }, { - id: "shutout_victory", - name: "Shutout Victory", - description: "Win a two-player game without opponent scoring", - icon: "🛡️", + id: 'shutout_victory', + name: 'Shutout Victory', + description: 'Win a two-player game without opponent scoring', + icon: '🛡️', earned: - gameMode === "multiplayer" && + gameMode === 'multiplayer' && matchedPairs === totalPairs && Object.values(scores).some((score) => score === totalPairs) && Object.values(scores).some((score) => score === 0), }, { - id: "comeback_kid", - name: "Comeback Kid", - description: "Win after being behind by 3+ points", - icon: "🔄", + id: 'comeback_kid', + name: 'Comeback Kid', + description: 'Win after being behind by 3+ points', + icon: '🔄', earned: false, // This would need more complex tracking during the game }, { - id: "first_timer", - name: "First Timer", - description: "Complete your first game", - icon: "🌟", + id: 'first_timer', + name: 'First Timer', + description: 'Complete your first game', + icon: '🌟', earned: matchedPairs === totalPairs, }, { - id: "consistency_king", - name: "Consistency King", - description: "Achieve 80%+ accuracy in 5 consecutive games", - icon: "👑", + id: 'consistency_king', + name: 'Consistency King', + description: 'Achieve 80%+ accuracy in 5 consecutive games', + icon: '👑', earned: false, // This would need persistent game history }, - ]; + ] - return achievements; + return achievements } // Get performance metrics and analysis export function getPerformanceAnalysis(state: MemoryPairsState): { - statistics: GameStatistics; - grade: "A+" | "A" | "B+" | "B" | "C+" | "C" | "D" | "F"; - strengths: string[]; - improvements: string[]; - starRating: number; + statistics: GameStatistics + grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F' + strengths: string[] + improvements: string[] + starRating: number } { - const { - matchedPairs, - totalPairs, - moves, - difficulty, - gameStartTime, - gameEndTime, - } = state; - const gameTime = - gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0; + const { matchedPairs, totalPairs, moves, difficulty, gameStartTime, gameEndTime } = state + const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0 // Calculate statistics - const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0; - const averageTimePerMove = moves > 0 ? gameTime / moves : 0; + const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0 + const averageTimePerMove = moves > 0 ? gameTime / moves : 0 const statistics: GameStatistics = { totalMoves: moves, matchedPairs, @@ -206,71 +180,62 @@ export function getPerformanceAnalysis(state: MemoryPairsState): { gameTime, accuracy, averageTimePerMove, - }; + } // Calculate efficiency (ideal vs actual moves) - const idealMoves = totalPairs * 2; - const efficiency = (idealMoves / Math.max(moves, idealMoves)) * 100; + const idealMoves = totalPairs * 2 + const efficiency = (idealMoves / Math.max(moves, idealMoves)) * 100 // Determine grade - let grade: "A+" | "A" | "B+" | "B" | "C+" | "C" | "D" | "F" = "F"; - if (accuracy >= 95 && efficiency >= 90) grade = "A+"; - else if (accuracy >= 90 && efficiency >= 85) grade = "A"; - else if (accuracy >= 85 && efficiency >= 80) grade = "B+"; - else if (accuracy >= 80 && efficiency >= 75) grade = "B"; - else if (accuracy >= 75 && efficiency >= 70) grade = "C+"; - else if (accuracy >= 70 && efficiency >= 65) grade = "C"; - else if (accuracy >= 60 && efficiency >= 50) grade = "D"; + let grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F' = 'F' + if (accuracy >= 95 && efficiency >= 90) grade = 'A+' + else if (accuracy >= 90 && efficiency >= 85) grade = 'A' + else if (accuracy >= 85 && efficiency >= 80) grade = 'B+' + else if (accuracy >= 80 && efficiency >= 75) grade = 'B' + else if (accuracy >= 75 && efficiency >= 70) grade = 'C+' + else if (accuracy >= 70 && efficiency >= 65) grade = 'C' + else if (accuracy >= 60 && efficiency >= 50) grade = 'D' // Calculate star rating - const starRating = calculateStarRating( - accuracy, - efficiency, - gameTime, - difficulty, - ); + const starRating = calculateStarRating(accuracy, efficiency, gameTime, difficulty) // Analyze strengths and areas for improvement - const strengths: string[] = []; - const improvements: string[] = []; + const strengths: string[] = [] + const improvements: string[] = [] if (accuracy >= 90) { - strengths.push("Excellent memory and pattern recognition"); + strengths.push('Excellent memory and pattern recognition') } else if (accuracy < 70) { - improvements.push("Focus on remembering card positions more carefully"); + improvements.push('Focus on remembering card positions more carefully') } if (efficiency >= 85) { - strengths.push("Very efficient with minimal unnecessary moves"); + strengths.push('Very efficient with minimal unnecessary moves') } else if (efficiency < 60) { - improvements.push( - "Try to reduce random guessing and use memory strategies", - ); + improvements.push('Try to reduce random guessing and use memory strategies') } - const avgTimePerMoveSeconds = averageTimePerMove / 1000; + const avgTimePerMoveSeconds = averageTimePerMove / 1000 if (avgTimePerMoveSeconds < 3) { - strengths.push("Quick decision making"); + strengths.push('Quick decision making') } else if (avgTimePerMoveSeconds > 8) { - improvements.push("Practice to improve decision speed"); + improvements.push('Practice to improve decision speed') } if (difficulty >= 12) { - strengths.push("Tackled challenging difficulty levels"); + strengths.push('Tackled challenging difficulty levels') } - if (state.gameType === "complement-pairs" && accuracy >= 80) { - strengths.push("Strong mathematical complement skills"); + if (state.gameType === 'complement-pairs' && accuracy >= 80) { + strengths.push('Strong mathematical complement skills') } // Fallback messages if (strengths.length === 0) { - strengths.push("Keep practicing to improve your skills!"); + strengths.push('Keep practicing to improve your skills!') } if (improvements.length === 0) { - improvements.push( - "Great job! Continue challenging yourself with harder difficulties.", - ); + improvements.push('Great job! Continue challenging yourself with harder difficulties.') } return { @@ -279,41 +244,41 @@ export function getPerformanceAnalysis(state: MemoryPairsState): { strengths, improvements, starRating, - }; + } } // Format time duration for display export function formatGameTime(milliseconds: number): string { - const seconds = Math.floor(milliseconds / 1000); - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; + const seconds = Math.floor(milliseconds / 1000) + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 if (minutes > 0) { - return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` } - return `${remainingSeconds}s`; + return `${remainingSeconds}s` } // Get two-player game winner // @deprecated Use getMultiplayerWinner instead which supports N players export function getTwoPlayerWinner( state: MemoryPairsState, - activePlayers: Player[], + activePlayers: Player[] ): { - winner: Player | "tie"; - winnerScore: number; - loserScore: number; - margin: number; + winner: Player | 'tie' + winnerScore: number + loserScore: number + margin: number } { - const { scores } = state; - const [player1, player2] = activePlayers; + const { scores } = state + const [player1, player2] = activePlayers if (!player1 || !player2) { - throw new Error("getTwoPlayerWinner requires at least 2 active players"); + throw new Error('getTwoPlayerWinner requires at least 2 active players') } - const score1 = scores[player1] || 0; - const score2 = scores[player2] || 0; + const score1 = scores[player1] || 0 + const score2 = scores[player2] || 0 if (score1 > score2) { return { @@ -321,50 +286,46 @@ export function getTwoPlayerWinner( winnerScore: score1, loserScore: score2, margin: score1 - score2, - }; + } } else if (score2 > score1) { return { winner: player2, winnerScore: score2, loserScore: score1, margin: score2 - score1, - }; + } } else { return { - winner: "tie", + winner: 'tie', winnerScore: score1, loserScore: score2, margin: 0, - }; + } } } // Get multiplayer game winner (supports N players) export function getMultiplayerWinner( state: MemoryPairsState, - activePlayers: Player[], + activePlayers: Player[] ): { - winners: Player[]; - winnerScore: number; - scores: { [playerId: string]: number }; - isTie: boolean; + winners: Player[] + winnerScore: number + scores: { [playerId: string]: number } + isTie: boolean } { - const { scores } = state; + const { scores } = state // Find the highest score - const maxScore = Math.max( - ...activePlayers.map((playerId) => scores[playerId] || 0), - ); + const maxScore = Math.max(...activePlayers.map((playerId) => scores[playerId] || 0)) // Find all players with the highest score - const winners = activePlayers.filter( - (playerId) => (scores[playerId] || 0) === maxScore, - ); + const winners = activePlayers.filter((playerId) => (scores[playerId] || 0) === maxScore) return { winners, winnerScore: maxScore, scores, isTie: winners.length > 1, - }; + } } diff --git a/apps/web/src/arcade-games/matching/utils/matchValidation.ts b/apps/web/src/arcade-games/matching/utils/matchValidation.ts index 2b8041b6..f706b490 100644 --- a/apps/web/src/arcade-games/matching/utils/matchValidation.ts +++ b/apps/web/src/arcade-games/matching/utils/matchValidation.ts @@ -1,145 +1,138 @@ -import type { GameCard, MatchValidationResult } from "../types"; +import type { GameCard, MatchValidationResult } from '../types' // Validate abacus-numeral match (abacus card matches with number card of same value) export function validateAbacusNumeralMatch( card1: GameCard, - card2: GameCard, + card2: GameCard ): MatchValidationResult { // Both cards must have the same number if (card1.number !== card2.number) { return { isValid: false, - reason: "Numbers do not match", - type: "invalid", - }; + reason: 'Numbers do not match', + type: 'invalid', + } } // Cards must be different types (one abacus, one number) if (card1.type === card2.type) { return { isValid: false, - reason: "Both cards are the same type", - type: "invalid", - }; + reason: 'Both cards are the same type', + type: 'invalid', + } } // One must be abacus, one must be number - const hasAbacus = card1.type === "abacus" || card2.type === "abacus"; - const hasNumber = card1.type === "number" || card2.type === "number"; + const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus' + const hasNumber = card1.type === 'number' || card2.type === 'number' if (!hasAbacus || !hasNumber) { return { isValid: false, - reason: "Must match abacus with number representation", - type: "invalid", - }; + reason: 'Must match abacus with number representation', + type: 'invalid', + } } // Neither should be complement type for this game mode - if (card1.type === "complement" || card2.type === "complement") { + if (card1.type === 'complement' || card2.type === 'complement') { return { isValid: false, - reason: "Complement cards not valid in abacus-numeral mode", - type: "invalid", - }; + reason: 'Complement cards not valid in abacus-numeral mode', + type: 'invalid', + } } return { isValid: true, - type: "abacus-numeral", - }; + type: 'abacus-numeral', + } } // Validate complement match (two numbers that add up to target sum) -export function validateComplementMatch( - card1: GameCard, - card2: GameCard, -): MatchValidationResult { +export function validateComplementMatch(card1: GameCard, card2: GameCard): MatchValidationResult { // Both cards must be complement type - if (card1.type !== "complement" || card2.type !== "complement") { + if (card1.type !== 'complement' || card2.type !== 'complement') { return { isValid: false, - reason: "Both cards must be complement type", - type: "invalid", - }; + reason: 'Both cards must be complement type', + type: 'invalid', + } } // Both cards must have the same target sum if (card1.targetSum !== card2.targetSum) { return { isValid: false, - reason: "Cards have different target sums", - type: "invalid", - }; + reason: 'Cards have different target sums', + type: 'invalid', + } } // Check if the numbers are actually complements if (!card1.complement || !card2.complement) { return { isValid: false, - reason: "Complement information missing", - type: "invalid", - }; + reason: 'Complement information missing', + type: 'invalid', + } } // Verify the complement relationship if (card1.number !== card2.complement || card2.number !== card1.complement) { return { isValid: false, - reason: "Numbers are not complements of each other", - type: "invalid", - }; + reason: 'Numbers are not complements of each other', + type: 'invalid', + } } // Verify the sum equals the target - const sum = card1.number + card2.number; + const sum = card1.number + card2.number if (sum !== card1.targetSum) { return { isValid: false, reason: `Sum ${sum} does not equal target ${card1.targetSum}`, - type: "invalid", - }; + type: 'invalid', + } } return { isValid: true, - type: "complement", - }; + type: 'complement', + } } // Main validation function that determines which validation to use -export function validateMatch( - card1: GameCard, - card2: GameCard, -): MatchValidationResult { +export function validateMatch(card1: GameCard, card2: GameCard): MatchValidationResult { // Cannot match the same card with itself if (card1.id === card2.id) { return { isValid: false, - reason: "Cannot match card with itself", - type: "invalid", - }; + reason: 'Cannot match card with itself', + type: 'invalid', + } } // Cannot match already matched cards if (card1.matched || card2.matched) { return { isValid: false, - reason: "Cannot match already matched cards", - type: "invalid", - }; + reason: 'Cannot match already matched cards', + type: 'invalid', + } } // Determine which type of match to validate based on card types - const hasComplement = - card1.type === "complement" || card2.type === "complement"; + const hasComplement = card1.type === 'complement' || card2.type === 'complement' if (hasComplement) { // If either card is complement type, use complement validation - return validateComplementMatch(card1, card2); + return validateComplementMatch(card1, card2) } else { // Otherwise, use abacus-numeral validation - return validateAbacusNumeralMatch(card1, card2); + return validateAbacusNumeralMatch(card1, card2) } } @@ -147,40 +140,40 @@ export function validateMatch( export function canFlipCard( card: GameCard, flippedCards: GameCard[], - isProcessingMove: boolean, + isProcessingMove: boolean ): boolean { // Cannot flip if processing a move - if (isProcessingMove) return false; + if (isProcessingMove) return false // Cannot flip already matched cards - if (card.matched) return false; + if (card.matched) return false // Cannot flip if already flipped - if (flippedCards.some((c) => c.id === card.id)) return false; + if (flippedCards.some((c) => c.id === card.id)) return false // Cannot flip if two cards are already flipped - if (flippedCards.length >= 2) return false; + if (flippedCards.length >= 2) return false - return true; + return true } // Get hint for what kind of match the player should look for export function getMatchHint(card: GameCard): string { switch (card.type) { - case "abacus": - return `Find the number ${card.number}`; + case 'abacus': + return `Find the number ${card.number}` - case "number": - return `Find the abacus showing ${card.number}`; + case 'number': + return `Find the abacus showing ${card.number}` - case "complement": + case 'complement': if (card.complement !== undefined && card.targetSum !== undefined) { - return `Find ${card.complement} to make ${card.targetSum}`; + return `Find ${card.complement} to make ${card.targetSum}` } - return "Find the matching complement"; + return 'Find the matching complement' default: - return "Find the matching card"; + return 'Find the matching card' } } @@ -188,13 +181,13 @@ export function getMatchHint(card: GameCard): string { export function calculateMatchScore( difficulty: number, timeForMatch: number, - isComplementMatch: boolean, + isComplementMatch: boolean ): number { - const baseScore = isComplementMatch ? 15 : 10; // Complement matches worth more - const difficultyMultiplier = difficulty / 6; // Scale with difficulty - const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000); // Bonus for speed + const baseScore = isComplementMatch ? 15 : 10 // Complement matches worth more + const difficultyMultiplier = difficulty / 6 // Scale with difficulty + const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000) // Bonus for speed - return Math.round(baseScore * difficultyMultiplier + timeBonus); + return Math.round(baseScore * difficultyMultiplier + timeBonus) } // Analyze game performance @@ -202,29 +195,28 @@ export function analyzeGamePerformance( totalMoves: number, matchedPairs: number, totalPairs: number, - gameTime: number, + gameTime: number ): { - accuracy: number; - efficiency: number; - averageTimePerMove: number; - grade: "A" | "B" | "C" | "D" | "F"; + accuracy: number + efficiency: number + averageTimePerMove: number + grade: 'A' | 'B' | 'C' | 'D' | 'F' } { - const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0; - const efficiency = - totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0; // Ideal is 100% (each pair found in 2 moves) - const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0; + const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0 + const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0 // Ideal is 100% (each pair found in 2 moves) + const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0 // Calculate grade based on accuracy and efficiency - let grade: "A" | "B" | "C" | "D" | "F" = "F"; - if (accuracy >= 90 && efficiency >= 80) grade = "A"; - else if (accuracy >= 80 && efficiency >= 70) grade = "B"; - else if (accuracy >= 70 && efficiency >= 60) grade = "C"; - else if (accuracy >= 60 && efficiency >= 50) grade = "D"; + let grade: 'A' | 'B' | 'C' | 'D' | 'F' = 'F' + if (accuracy >= 90 && efficiency >= 80) grade = 'A' + else if (accuracy >= 80 && efficiency >= 70) grade = 'B' + else if (accuracy >= 70 && efficiency >= 60) grade = 'C' + else if (accuracy >= 60 && efficiency >= 50) grade = 'D' return { accuracy, efficiency, averageTimePerMove, grade, - }; + } } diff --git a/apps/web/src/arcade-games/memory-quiz/Provider.tsx b/apps/web/src/arcade-games/memory-quiz/Provider.tsx index 68b35372..c3a98d9c 100644 --- a/apps/web/src/arcade-games/memory-quiz/Provider.tsx +++ b/apps/web/src/arcade-games/memory-quiz/Provider.tsx @@ -1,86 +1,73 @@ -"use client"; +'use client' -import type { ReactNode } from "react"; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { useGameMode } from "@/contexts/GameModeContext"; -import { useArcadeSession } from "@/hooks/useArcadeSession"; -import { useRoomData, useUpdateGameConfig } from "@/hooks/useRoomData"; -import { useViewerId } from "@/hooks/useViewerId"; +import type { ReactNode } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { useGameMode } from '@/contexts/GameModeContext' +import { useArcadeSession } from '@/hooks/useArcadeSession' +import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData' +import { useViewerId } from '@/hooks/useViewerId' import { buildPlayerMetadata as buildPlayerMetadataUtil, buildPlayerOwnershipFromRoomData, -} from "@/lib/arcade/player-ownership.client"; -import { TEAM_MOVE } from "@/lib/arcade/validation/types"; -import type { QuizCard, MemoryQuizState, MemoryQuizMove } from "./types"; +} from '@/lib/arcade/player-ownership.client' +import { TEAM_MOVE } from '@/lib/arcade/validation/types' +import type { QuizCard, MemoryQuizState, MemoryQuizMove } from './types' -import type { GameMove } from "@/lib/arcade/validation"; +import type { GameMove } from '@/lib/arcade/validation' /** * Optimistic move application (client-side prediction) * The server will validate and send back the authoritative state */ -function applyMoveOptimistically( - state: MemoryQuizState, - move: GameMove, -): MemoryQuizState { - const typedMove = move as MemoryQuizMove; +function applyMoveOptimistically(state: MemoryQuizState, move: GameMove): MemoryQuizState { + const typedMove = move as MemoryQuizMove switch (typedMove.type) { - case "START_QUIZ": { + case 'START_QUIZ': { // Handle both client-generated moves (with quizCards) and server-generated moves (with numbers only) - const clientQuizCards = typedMove.data.quizCards; - const serverNumbers = typedMove.data.numbers; + const clientQuizCards = typedMove.data.quizCards + const serverNumbers = typedMove.data.numbers - let quizCards: QuizCard[]; - let correctAnswers: number[]; + let quizCards: QuizCard[] + let correctAnswers: number[] if (clientQuizCards) { // Client-side optimistic update: use the full quizCards with React components - quizCards = clientQuizCards; - correctAnswers = clientQuizCards.map((card: QuizCard) => card.number); + quizCards = clientQuizCards + correctAnswers = clientQuizCards.map((card: QuizCard) => card.number) } else if (serverNumbers) { // Server update: create minimal quizCards from numbers quizCards = serverNumbers.map((number: number) => ({ number, svgComponent: null, element: null, - })); - correctAnswers = serverNumbers; + })) + correctAnswers = serverNumbers } else { - quizCards = state.quizCards; - correctAnswers = state.correctAnswers; + quizCards = state.quizCards + correctAnswers = state.correctAnswers } - const cardCount = quizCards.length; + const cardCount = quizCards.length // Initialize player scores for all active players (by userId) - const activePlayers = typedMove.data.activePlayers || []; - const playerMetadata = typedMove.data.playerMetadata || {}; + const activePlayers = typedMove.data.activePlayers || [] + const playerMetadata = typedMove.data.playerMetadata || {} - const uniqueUserIds = new Set(); + const uniqueUserIds = new Set() for (const playerId of activePlayers) { - const metadata = playerMetadata[playerId]; + const metadata = playerMetadata[playerId] if (metadata?.userId) { - uniqueUserIds.add(metadata.userId); + uniqueUserIds.add(metadata.userId) } } const playerScores = Array.from(uniqueUserIds).reduce( - ( - acc: Record, - userId: string, - ) => { - acc[userId] = { correct: 0, incorrect: 0 }; - return acc; + (acc: Record, userId: string) => { + acc[userId] = { correct: 0, incorrect: 0 } + return acc }, - {}, - ); + {} + ) return { ...state, @@ -89,156 +76,156 @@ function applyMoveOptimistically( currentCardIndex: 0, foundNumbers: [], guessesRemaining: cardCount + Math.floor(cardCount / 2), - gamePhase: "display", + gamePhase: 'display', incorrectGuesses: 0, - currentInput: "", + currentInput: '', wrongGuessAnimations: [], prefixAcceptanceTimeout: null, activePlayers, playerMetadata, playerScores, numberFoundBy: {}, - }; + } } - case "NEXT_CARD": + case 'NEXT_CARD': return { ...state, currentCardIndex: state.currentCardIndex + 1, - }; + } - case "SHOW_INPUT_PHASE": + case 'SHOW_INPUT_PHASE': return { ...state, - gamePhase: "input", - }; + gamePhase: 'input', + } - case "ACCEPT_NUMBER": { - const playerScores = state.playerScores || {}; - const foundNumbers = state.foundNumbers || []; - const numberFoundBy = state.numberFoundBy || {}; + case 'ACCEPT_NUMBER': { + const playerScores = state.playerScores || {} + const foundNumbers = state.foundNumbers || [] + const numberFoundBy = state.numberFoundBy || {} - const newPlayerScores = { ...playerScores }; - const newNumberFoundBy = { ...numberFoundBy }; + const newPlayerScores = { ...playerScores } + const newNumberFoundBy = { ...numberFoundBy } if (typedMove.userId) { const currentScore = newPlayerScores[typedMove.userId] || { correct: 0, incorrect: 0, - }; + } newPlayerScores[typedMove.userId] = { ...currentScore, correct: currentScore.correct + 1, - }; - newNumberFoundBy[typedMove.data.number] = typedMove.userId; + } + newNumberFoundBy[typedMove.data.number] = typedMove.userId } return { ...state, foundNumbers: [...foundNumbers, typedMove.data.number], - currentInput: "", + currentInput: '', playerScores: newPlayerScores, numberFoundBy: newNumberFoundBy, - }; + } } - case "REJECT_NUMBER": { - const playerScores = state.playerScores || {}; - const newPlayerScores = { ...playerScores }; + case 'REJECT_NUMBER': { + const playerScores = state.playerScores || {} + const newPlayerScores = { ...playerScores } if (typedMove.userId) { const currentScore = newPlayerScores[typedMove.userId] || { correct: 0, incorrect: 0, - }; + } newPlayerScores[typedMove.userId] = { ...currentScore, incorrect: currentScore.incorrect + 1, - }; + } } return { ...state, guessesRemaining: state.guessesRemaining - 1, incorrectGuesses: state.incorrectGuesses + 1, - currentInput: "", + currentInput: '', playerScores: newPlayerScores, - }; + } } - case "SET_INPUT": + case 'SET_INPUT': return { ...state, currentInput: typedMove.data.input, - }; + } - case "SHOW_RESULTS": + case 'SHOW_RESULTS': return { ...state, - gamePhase: "results", - }; + gamePhase: 'results', + } - case "RESET_QUIZ": + case 'RESET_QUIZ': return { ...state, - gamePhase: "setup", + gamePhase: 'setup', quizCards: [], correctAnswers: [], currentCardIndex: 0, foundNumbers: [], guessesRemaining: 0, - currentInput: "", + currentInput: '', incorrectGuesses: 0, wrongGuessAnimations: [], prefixAcceptanceTimeout: null, finishButtonsBound: false, - }; + } - case "SET_CONFIG": { - const { field, value } = typedMove.data; + case 'SET_CONFIG': { + const { field, value } = typedMove.data return { ...state, [field]: value, - }; + } } default: - return state; + return state } } // Context interface export interface MemoryQuizContextValue { - state: MemoryQuizState; - isGameActive: boolean; - isRoomCreator: boolean; - resetGame: () => void; - exitSession?: () => void; - startQuiz: (quizCards: QuizCard[]) => void; - nextCard: () => void; - showInputPhase: () => void; - acceptNumber: (number: number) => void; - rejectNumber: () => void; - setInput: (input: string) => void; - showResults: () => void; + state: MemoryQuizState + isGameActive: boolean + isRoomCreator: boolean + resetGame: () => void + exitSession?: () => void + startQuiz: (quizCards: QuizCard[]) => void + nextCard: () => void + showInputPhase: () => void + acceptNumber: (number: number) => void + rejectNumber: () => void + setInput: (input: string) => void + showResults: () => void setConfig: ( - field: "selectedCount" | "displayTime" | "selectedDifficulty" | "playMode", - value: unknown, - ) => void; + field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', + value: unknown + ) => void // Legacy dispatch for UI-only actions (to be migrated to local state) - dispatch: (action: unknown) => void; + dispatch: (action: unknown) => void } // Create context -const MemoryQuizContext = createContext(null); +const MemoryQuizContext = createContext(null) // Hook to use the context export function useMemoryQuiz(): MemoryQuizContextValue { - const context = useContext(MemoryQuizContext); + const context = useContext(MemoryQuizContext) if (!context) { - throw new Error("useMemoryQuiz must be used within MemoryQuizProvider"); + throw new Error('useMemoryQuiz must be used within MemoryQuizProvider') } - return context; + return context } /** @@ -248,27 +235,21 @@ export function useMemoryQuiz(): MemoryQuizContextValue { * All state changes are sent as moves and validated on the server. */ export function MemoryQuizProvider({ children }: { children: ReactNode }) { - const { data: viewerId } = useViewerId(); - const { roomData } = useRoomData(); - const { activePlayers: activePlayerIds, players } = useGameMode(); - const { mutate: updateGameConfig } = useUpdateGameConfig(); + const { data: viewerId } = useViewerId() + const { roomData } = useRoomData() + const { activePlayers: activePlayerIds, players } = useGameMode() + const { mutate: updateGameConfig } = useUpdateGameConfig() - const activePlayers = Array.from(activePlayerIds); + const activePlayers = Array.from(activePlayerIds) // LOCAL-ONLY state for current input (not synced over network) - const [localCurrentInput, setLocalCurrentInput] = useState(""); + const [localCurrentInput, setLocalCurrentInput] = useState('') // Merge saved game config from room with default initial state const mergedInitialState = useMemo(() => { - const gameConfig = roomData?.gameConfig as - | Record - | null - | undefined; + const gameConfig = roomData?.gameConfig as Record | null | undefined - const savedConfig = gameConfig?.["memory-quiz"] as - | Record - | null - | undefined; + const savedConfig = gameConfig?.['memory-quiz'] as Record | null | undefined // Default initial state const defaultState: MemoryQuizState = { @@ -278,290 +259,279 @@ export function MemoryQuizProvider({ children }: { children: ReactNode }) { currentCardIndex: 0, displayTime: 2.0, selectedCount: 5, - selectedDifficulty: "easy", + selectedDifficulty: 'easy', foundNumbers: [], guessesRemaining: 0, - currentInput: "", + currentInput: '', incorrectGuesses: 0, activePlayers: [], playerMetadata: {}, playerScores: {}, - playMode: "cooperative", + playMode: 'cooperative', numberFoundBy: {}, - gamePhase: "setup", + gamePhase: 'setup', prefixAcceptanceTimeout: null, finishButtonsBound: false, wrongGuessAnimations: [], hasPhysicalKeyboard: null, testingMode: false, showOnScreenKeyboard: false, - }; + } if (!savedConfig) { - return defaultState; + return defaultState } return { ...defaultState, selectedCount: - (savedConfig.selectedCount as 2 | 5 | 8 | 12 | 15) ?? - defaultState.selectedCount, - displayTime: - (savedConfig.displayTime as number) ?? defaultState.displayTime, + (savedConfig.selectedCount as 2 | 5 | 8 | 12 | 15) ?? defaultState.selectedCount, + displayTime: (savedConfig.displayTime as number) ?? defaultState.displayTime, selectedDifficulty: - (savedConfig.selectedDifficulty as MemoryQuizState["selectedDifficulty"]) ?? + (savedConfig.selectedDifficulty as MemoryQuizState['selectedDifficulty']) ?? defaultState.selectedDifficulty, - playMode: - (savedConfig.playMode as "cooperative" | "competitive") ?? - defaultState.playMode, - }; - }, [roomData?.gameConfig]); + playMode: (savedConfig.playMode as 'cooperative' | 'competitive') ?? defaultState.playMode, + } + }, [roomData?.gameConfig]) // Arcade session integration const { state, sendMove, exitSession } = useArcadeSession({ - userId: viewerId || "", + userId: viewerId || '', roomId: roomData?.id || undefined, initialState: mergedInitialState, applyMove: applyMoveOptimistically, - }); + }) // Clear local input when game phase changes useEffect(() => { - if (state.gamePhase !== "input") { - setLocalCurrentInput(""); + if (state.gamePhase !== 'input') { + setLocalCurrentInput('') } - }, [state.gamePhase]); + }, [state.gamePhase]) // Cleanup timeouts on unmount useEffect(() => { return () => { if (state.prefixAcceptanceTimeout) { - clearTimeout(state.prefixAcceptanceTimeout); + clearTimeout(state.prefixAcceptanceTimeout) } - }; - }, [state.prefixAcceptanceTimeout]); + } + }, [state.prefixAcceptanceTimeout]) // Detect state corruption const hasStateCorruption = !state.quizCards || !state.correctAnswers || !state.foundNumbers || - !Array.isArray(state.quizCards); + !Array.isArray(state.quizCards) // Computed values - const isGameActive = - state.gamePhase === "display" || state.gamePhase === "input"; + const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input' // Build player metadata const buildPlayerMetadata = useCallback(() => { - const playerOwnership = buildPlayerOwnershipFromRoomData(roomData); + const playerOwnership = buildPlayerOwnershipFromRoomData(roomData) const metadata = buildPlayerMetadataUtil( activePlayers, playerOwnership, players, - viewerId || undefined, - ); - return metadata; - }, [activePlayers, players, roomData, viewerId]); + viewerId || undefined + ) + return metadata + }, [activePlayers, players, roomData, viewerId]) // Action creators const startQuiz = useCallback( (quizCards: QuizCard[]) => { - const numbers = quizCards.map((card) => card.number); - const playerMetadata = buildPlayerMetadata(); + const numbers = quizCards.map((card) => card.number) + const playerMetadata = buildPlayerMetadata() sendMove({ - type: "START_QUIZ", + type: 'START_QUIZ', playerId: TEAM_MOVE, - userId: viewerId || "", + userId: viewerId || '', data: { numbers, quizCards, activePlayers, playerMetadata, }, - }); + }) }, - [viewerId, sendMove, activePlayers, buildPlayerMetadata], - ); + [viewerId, sendMove, activePlayers, buildPlayerMetadata] + ) const nextCard = useCallback(() => { sendMove({ - type: "NEXT_CARD", + type: 'NEXT_CARD', playerId: TEAM_MOVE, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [viewerId, sendMove]); + }) + }, [viewerId, sendMove]) const showInputPhase = useCallback(() => { sendMove({ - type: "SHOW_INPUT_PHASE", + type: 'SHOW_INPUT_PHASE', playerId: TEAM_MOVE, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [viewerId, sendMove]); + }) + }, [viewerId, sendMove]) const acceptNumber = useCallback( (number: number) => { - setLocalCurrentInput(""); + setLocalCurrentInput('') sendMove({ - type: "ACCEPT_NUMBER", + type: 'ACCEPT_NUMBER', playerId: TEAM_MOVE, - userId: viewerId || "", + userId: viewerId || '', data: { number }, - }); + }) }, - [viewerId, sendMove], - ); + [viewerId, sendMove] + ) const rejectNumber = useCallback(() => { - setLocalCurrentInput(""); + setLocalCurrentInput('') sendMove({ - type: "REJECT_NUMBER", + type: 'REJECT_NUMBER', playerId: TEAM_MOVE, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [viewerId, sendMove]); + }) + }, [viewerId, sendMove]) const setInput = useCallback((input: string) => { // LOCAL ONLY - no network sync for instant typing - setLocalCurrentInput(input); - }, []); + setLocalCurrentInput(input) + }, []) const showResults = useCallback(() => { sendMove({ - type: "SHOW_RESULTS", + type: 'SHOW_RESULTS', playerId: TEAM_MOVE, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [viewerId, sendMove]); + }) + }, [viewerId, sendMove]) const resetGame = useCallback(() => { sendMove({ - type: "RESET_QUIZ", + type: 'RESET_QUIZ', playerId: TEAM_MOVE, - userId: viewerId || "", + userId: viewerId || '', data: {}, - }); - }, [viewerId, sendMove]); + }) + }, [viewerId, sendMove]) const setConfig = useCallback( ( - field: - | "selectedCount" - | "displayTime" - | "selectedDifficulty" - | "playMode", - value: unknown, + field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', + value: unknown ) => { sendMove({ - type: "SET_CONFIG", + type: 'SET_CONFIG', playerId: TEAM_MOVE, - userId: viewerId || "", + userId: viewerId || '', data: { field, value }, - }); + }) // Save to room config for persistence if (roomData?.id) { - const currentGameConfig = - (roomData.gameConfig as Record) || {}; + const currentGameConfig = (roomData.gameConfig as Record) || {} const currentMemoryQuizConfig = - (currentGameConfig["memory-quiz"] as Record) || {}; + (currentGameConfig['memory-quiz'] as Record) || {} updateGameConfig({ roomId: roomData.id, gameConfig: { ...currentGameConfig, - "memory-quiz": { + 'memory-quiz': { ...currentMemoryQuizConfig, [field]: value, }, }, - }); + }) } }, - [viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig], - ); + [viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig] + ) // Legacy dispatch stub for UI-only actions // TODO: Migrate these to local component state const dispatch = useCallback((action: unknown) => { console.warn( - "[MemoryQuizProvider] dispatch() is deprecated for UI-only actions. These should be migrated to local component state:", - action, - ); + '[MemoryQuizProvider] dispatch() is deprecated for UI-only actions. These should be migrated to local component state:', + action + ) // No-op - UI-only state changes should be handled locally - }, []); + }, []) // Merge network state with local input const mergedState = { ...state, currentInput: localCurrentInput, - }; + } // Determine if current user is room creator const isRoomCreator = - roomData?.members.find((member) => member.userId === viewerId)?.isCreator || - false; + roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false // Handle state corruption if (hasStateCorruption) { return (
-
⚠️
+
⚠️

Game State Mismatch

- There's a mismatch between game types in this room. This usually - happens when room members are playing different games. + There's a mismatch between game types in this room. This usually happens when room members + are playing different games.

- ); + ) } const contextValue: MemoryQuizContextValue = { @@ -579,11 +549,7 @@ export function MemoryQuizProvider({ children }: { children: ReactNode }) { showResults, setConfig, dispatch, - }; + } - return ( - - {children} - - ); + return {children} } diff --git a/apps/web/src/arcade-games/memory-quiz/Validator.ts b/apps/web/src/arcade-games/memory-quiz/Validator.ts index 8ad3fb7d..87a773f0 100644 --- a/apps/web/src/arcade-games/memory-quiz/Validator.ts +++ b/apps/web/src/arcade-games/memory-quiz/Validator.ts @@ -3,84 +3,75 @@ * Validates all game moves and state transitions */ -import type { GameValidator, ValidationResult } from "@/lib/arcade/game-sdk"; +import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk' import type { MemoryQuizConfig, MemoryQuizState, MemoryQuizMove, MemoryQuizSetConfigMove, -} from "./types"; +} from './types' -export class MemoryQuizGameValidator - implements GameValidator -{ +export class MemoryQuizGameValidator implements GameValidator { validateMove( state: MemoryQuizState, move: MemoryQuizMove, - context?: { userId?: string; playerOwnership?: Record }, + context?: { userId?: string; playerOwnership?: Record } ): ValidationResult { switch (move.type) { - case "START_QUIZ": - return this.validateStartQuiz(state, move.data); + case 'START_QUIZ': + return this.validateStartQuiz(state, move.data) - case "NEXT_CARD": - return this.validateNextCard(state); + case 'NEXT_CARD': + return this.validateNextCard(state) - case "SHOW_INPUT_PHASE": - return this.validateShowInputPhase(state); + case 'SHOW_INPUT_PHASE': + return this.validateShowInputPhase(state) - case "ACCEPT_NUMBER": - return this.validateAcceptNumber(state, move.data.number, move.userId); + case 'ACCEPT_NUMBER': + return this.validateAcceptNumber(state, move.data.number, move.userId) - case "REJECT_NUMBER": - return this.validateRejectNumber(state, move.userId); + case 'REJECT_NUMBER': + return this.validateRejectNumber(state, move.userId) - case "SET_INPUT": - return this.validateSetInput(state, move.data.input); + case 'SET_INPUT': + return this.validateSetInput(state, move.data.input) - case "SHOW_RESULTS": - return this.validateShowResults(state); + case 'SHOW_RESULTS': + return this.validateShowResults(state) - case "RESET_QUIZ": - return this.validateResetQuiz(state); + case 'RESET_QUIZ': + return this.validateResetQuiz(state) - case "SET_CONFIG": { - const configMove = move as MemoryQuizSetConfigMove; - return this.validateSetConfig( - state, - configMove.data.field, - configMove.data.value, - ); + case 'SET_CONFIG': { + const configMove = move as MemoryQuizSetConfigMove + return this.validateSetConfig(state, configMove.data.field, configMove.data.value) } default: return { valid: false, error: `Unknown move type: ${(move as any).type}`, - }; + } } } - private validateStartQuiz( - state: MemoryQuizState, - data: any, - ): ValidationResult { + private validateStartQuiz(state: MemoryQuizState, data: any): ValidationResult { // Can start quiz from setup or results phase - if (state.gamePhase !== "setup" && state.gamePhase !== "results") { + if (state.gamePhase !== 'setup' && state.gamePhase !== 'results') { return { valid: false, - error: "Can only start quiz from setup or results phase", - }; + error: 'Can only start quiz from setup or results phase', + } } // Accept either numbers array (from network) or quizCards (from client) - const numbers = data.numbers || data.quizCards?.map((c: any) => c.number); + const numbers = data.numbers || data.quizCards?.map((c: any) => c.number) if (!numbers || numbers.length === 0) { return { valid: false, - error: "Quiz numbers are required", - }; + error: 'Quiz numbers are required', + } } // Create minimal quiz cards from numbers (server-side doesn't need React components) @@ -88,28 +79,25 @@ export class MemoryQuizGameValidator number, svgComponent: null, // Not needed server-side element: null, - })); + })) // Extract multiplayer data from move - const activePlayers = data.activePlayers || state.activePlayers || []; - const playerMetadata = data.playerMetadata || state.playerMetadata || {}; + const activePlayers = data.activePlayers || state.activePlayers || [] + const playerMetadata = data.playerMetadata || state.playerMetadata || {} // Initialize player scores for all active players (by userId) - const uniqueUserIds = new Set(); + const uniqueUserIds = new Set() for (const playerId of activePlayers) { - const metadata = playerMetadata[playerId]; + const metadata = playerMetadata[playerId] if (metadata?.userId) { - uniqueUserIds.add(metadata.userId); + uniqueUserIds.add(metadata.userId) } } - const playerScores = Array.from(uniqueUserIds).reduce( - (acc: any, userId: string) => { - acc[userId] = { correct: 0, incorrect: 0 }; - return acc; - }, - {}, - ); + const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => { + acc[userId] = { correct: 0, incorrect: 0 } + return acc + }, {}) const newState: MemoryQuizState = { ...state, @@ -118,9 +106,9 @@ export class MemoryQuizGameValidator currentCardIndex: 0, foundNumbers: [], guessesRemaining: numbers.length + Math.floor(numbers.length / 2), - gamePhase: "display", + gamePhase: 'display', incorrectGuesses: 0, - currentInput: "", + currentInput: '', wrongGuessAnimations: [], prefixAcceptanceTimeout: null, // Multiplayer state @@ -128,32 +116,32 @@ export class MemoryQuizGameValidator playerMetadata, playerScores, numberFoundBy: {}, - }; + } return { valid: true, newState, - }; + } } private validateNextCard(state: MemoryQuizState): ValidationResult { // Must be in display phase - if (state.gamePhase !== "display") { + if (state.gamePhase !== 'display') { return { valid: false, - error: "NEXT_CARD only valid in display phase", - }; + error: 'NEXT_CARD only valid in display phase', + } } const newState: MemoryQuizState = { ...state, currentCardIndex: state.currentCardIndex + 1, - }; + } return { valid: true, newState, - }; + } } private validateShowInputPhase(state: MemoryQuizState): ValidationResult { @@ -161,249 +149,243 @@ export class MemoryQuizGameValidator if (state.currentCardIndex < state.quizCards.length) { return { valid: false, - error: "All cards must be shown before input phase", - }; + error: 'All cards must be shown before input phase', + } } const newState: MemoryQuizState = { ...state, - gamePhase: "input", - }; + gamePhase: 'input', + } return { valid: true, newState, - }; + } } private validateAcceptNumber( state: MemoryQuizState, number: number, - userId?: string, + userId?: string ): ValidationResult { // Must be in input phase - if (state.gamePhase !== "input") { + if (state.gamePhase !== 'input') { return { valid: false, - error: "ACCEPT_NUMBER only valid in input phase", - }; + error: 'ACCEPT_NUMBER only valid in input phase', + } } // Number must be in correct answers if (!state.correctAnswers.includes(number)) { return { valid: false, - error: "Number is not a correct answer", - }; + error: 'Number is not a correct answer', + } } // Number must not be already found if (state.foundNumbers.includes(number)) { return { valid: false, - error: "Number already found", - }; + error: 'Number already found', + } } // Update player scores (track by userId) - const playerScores = state.playerScores || {}; - const newPlayerScores = { ...playerScores }; - const numberFoundBy = state.numberFoundBy || {}; - const newNumberFoundBy = { ...numberFoundBy }; + const playerScores = state.playerScores || {} + const newPlayerScores = { ...playerScores } + const numberFoundBy = state.numberFoundBy || {} + const newNumberFoundBy = { ...numberFoundBy } if (userId) { const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0, - }; + } newPlayerScores[userId] = { ...currentScore, correct: currentScore.correct + 1, - }; + } // Track who found this number - newNumberFoundBy[number] = userId; + newNumberFoundBy[number] = userId } const newState: MemoryQuizState = { ...state, foundNumbers: [...state.foundNumbers, number], - currentInput: "", + currentInput: '', playerScores: newPlayerScores, numberFoundBy: newNumberFoundBy, - }; + } return { valid: true, newState, - }; + } } - private validateRejectNumber( - state: MemoryQuizState, - userId?: string, - ): ValidationResult { + private validateRejectNumber(state: MemoryQuizState, userId?: string): ValidationResult { // Must be in input phase - if (state.gamePhase !== "input") { + if (state.gamePhase !== 'input') { return { valid: false, - error: "REJECT_NUMBER only valid in input phase", - }; + error: 'REJECT_NUMBER only valid in input phase', + } } // Must have guesses remaining if (state.guessesRemaining <= 0) { return { valid: false, - error: "No guesses remaining", - }; + error: 'No guesses remaining', + } } // Update player scores (track by userId) - const playerScores = state.playerScores || {}; - const newPlayerScores = { ...playerScores }; + const playerScores = state.playerScores || {} + const newPlayerScores = { ...playerScores } if (userId) { const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0, - }; + } newPlayerScores[userId] = { ...currentScore, incorrect: currentScore.incorrect + 1, - }; + } } const newState: MemoryQuizState = { ...state, guessesRemaining: state.guessesRemaining - 1, incorrectGuesses: state.incorrectGuesses + 1, - currentInput: "", + currentInput: '', playerScores: newPlayerScores, - }; + } return { valid: true, newState, - }; + } } - private validateSetInput( - state: MemoryQuizState, - input: string, - ): ValidationResult { + private validateSetInput(state: MemoryQuizState, input: string): ValidationResult { // Must be in input phase - if (state.gamePhase !== "input") { + if (state.gamePhase !== 'input') { return { valid: false, - error: "SET_INPUT only valid in input phase", - }; + error: 'SET_INPUT only valid in input phase', + } } // Input must be numeric if (input && !/^\d+$/.test(input)) { return { valid: false, - error: "Input must be numeric", - }; + error: 'Input must be numeric', + } } const newState: MemoryQuizState = { ...state, currentInput: input, - }; + } return { valid: true, newState, - }; + } } private validateShowResults(state: MemoryQuizState): ValidationResult { // Can show results from input phase - if (state.gamePhase !== "input") { + if (state.gamePhase !== 'input') { return { valid: false, - error: "SHOW_RESULTS only valid from input phase", - }; + error: 'SHOW_RESULTS only valid from input phase', + } } const newState: MemoryQuizState = { ...state, - gamePhase: "results", - }; + gamePhase: 'results', + } return { valid: true, newState, - }; + } } private validateResetQuiz(state: MemoryQuizState): ValidationResult { // Can reset from any phase const newState: MemoryQuizState = { ...state, - gamePhase: "setup", + gamePhase: 'setup', quizCards: [], correctAnswers: [], currentCardIndex: 0, foundNumbers: [], guessesRemaining: 0, - currentInput: "", + currentInput: '', incorrectGuesses: 0, wrongGuessAnimations: [], prefixAcceptanceTimeout: null, finishButtonsBound: false, - }; + } return { valid: true, newState, - }; + } } private validateSetConfig( state: MemoryQuizState, - field: "selectedCount" | "displayTime" | "selectedDifficulty" | "playMode", - value: any, + field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', + value: any ): ValidationResult { // Can only change config during setup phase - if (state.gamePhase !== "setup") { + if (state.gamePhase !== 'setup') { return { valid: false, - error: "Cannot change configuration outside of setup phase", - }; + error: 'Cannot change configuration outside of setup phase', + } } // Validate field-specific values switch (field) { - case "selectedCount": + case 'selectedCount': if (![2, 5, 8, 12, 15].includes(value)) { - return { valid: false, error: `Invalid selectedCount: ${value}` }; + return { valid: false, error: `Invalid selectedCount: ${value}` } } - break; + break - case "displayTime": - if (typeof value !== "number" || value < 0.5 || value > 10) { - return { valid: false, error: `Invalid displayTime: ${value}` }; + case 'displayTime': + if (typeof value !== 'number' || value < 0.5 || value > 10) { + return { valid: false, error: `Invalid displayTime: ${value}` } } - break; + break - case "selectedDifficulty": - if (!["beginner", "easy", "medium", "hard", "expert"].includes(value)) { + case 'selectedDifficulty': + if (!['beginner', 'easy', 'medium', 'hard', 'expert'].includes(value)) { return { valid: false, error: `Invalid selectedDifficulty: ${value}`, - }; + } } - break; + break - case "playMode": - if (!["cooperative", "competitive"].includes(value)) { - return { valid: false, error: `Invalid playMode: ${value}` }; + case 'playMode': + if (!['cooperative', 'competitive'].includes(value)) { + return { valid: false, error: `Invalid playMode: ${value}` } } - break; + break default: - return { valid: false, error: `Unknown config field: ${field}` }; + return { valid: false, error: `Unknown config field: ${field}` } } // Apply the configuration change @@ -413,11 +395,11 @@ export class MemoryQuizGameValidator ...state, [field]: value, }, - }; + } } isGameComplete(state: MemoryQuizState): boolean { - return state.gamePhase === "results"; + return state.gamePhase === 'results' } getInitialState(config: MemoryQuizConfig): MemoryQuizState { @@ -431,25 +413,25 @@ export class MemoryQuizGameValidator selectedDifficulty: config.selectedDifficulty, foundNumbers: [], guessesRemaining: 0, - currentInput: "", + currentInput: '', incorrectGuesses: 0, // Multiplayer state activePlayers: [], playerMetadata: {}, playerScores: {}, - playMode: config.playMode || "cooperative", + playMode: config.playMode || 'cooperative', numberFoundBy: {}, // UI state - gamePhase: "setup", + gamePhase: 'setup', prefixAcceptanceTimeout: null, finishButtonsBound: false, wrongGuessAnimations: [], hasPhysicalKeyboard: null, testingMode: false, showOnScreenKeyboard: false, - }; + } } } // Singleton instance -export const memoryQuizGameValidator = new MemoryQuizGameValidator(); +export const memoryQuizGameValidator = new MemoryQuizGameValidator() diff --git a/apps/web/src/arcade-games/memory-quiz/components/CardGrid.tsx b/apps/web/src/arcade-games/memory-quiz/components/CardGrid.tsx index a4b46d88..365ed4b6 100644 --- a/apps/web/src/arcade-games/memory-quiz/components/CardGrid.tsx +++ b/apps/web/src/arcade-games/memory-quiz/components/CardGrid.tsx @@ -1,57 +1,57 @@ -import { AbacusReact } from "@soroban/abacus-react"; -import type { MemoryQuizState } from "../types"; +import { AbacusReact } from '@soroban/abacus-react' +import type { MemoryQuizState } from '../types' interface CardGridProps { - state: MemoryQuizState; + state: MemoryQuizState } export function CardGrid({ state }: CardGridProps) { - if (state.quizCards.length === 0) return null; + if (state.quizCards.length === 0) return null // Calculate optimal grid layout based on number of cards - const cardCount = state.quizCards.length; + const cardCount = state.quizCards.length // Define static grid classes that Panda can generate const getGridClass = (count: number) => { - if (count <= 2) return "repeat(2, 1fr)"; - if (count <= 4) return "repeat(2, 1fr)"; - if (count <= 6) return "repeat(3, 1fr)"; - if (count <= 9) return "repeat(3, 1fr)"; - if (count <= 12) return "repeat(4, 1fr)"; - return "repeat(5, 1fr)"; - }; + if (count <= 2) return 'repeat(2, 1fr)' + if (count <= 4) return 'repeat(2, 1fr)' + if (count <= 6) return 'repeat(3, 1fr)' + if (count <= 9) return 'repeat(3, 1fr)' + if (count <= 12) return 'repeat(4, 1fr)' + return 'repeat(5, 1fr)' + } const getCardSize = (count: number) => { - if (count <= 2) return { minSize: "180px", cardHeight: "160px" }; - if (count <= 4) return { minSize: "160px", cardHeight: "150px" }; - if (count <= 6) return { minSize: "140px", cardHeight: "140px" }; - if (count <= 9) return { minSize: "120px", cardHeight: "130px" }; - if (count <= 12) return { minSize: "110px", cardHeight: "120px" }; - return { minSize: "100px", cardHeight: "110px" }; - }; + if (count <= 2) return { minSize: '180px', cardHeight: '160px' } + if (count <= 4) return { minSize: '160px', cardHeight: '150px' } + if (count <= 6) return { minSize: '140px', cardHeight: '140px' } + if (count <= 9) return { minSize: '120px', cardHeight: '130px' } + if (count <= 12) return { minSize: '110px', cardHeight: '120px' } + return { minSize: '100px', cardHeight: '110px' } + } - const gridClass = getGridClass(cardCount); - const cardSize = getCardSize(cardCount); + const gridClass = getGridClass(cardCount) + const cardSize = getCardSize(cardCount) return (

Cards you saw ({cardCount}): @@ -59,55 +59,55 @@ export function CardGrid({ state }: CardGridProps) {
{state.quizCards.map((card, index) => { - const isRevealed = state.foundNumbers.includes(card.number); + const isRevealed = state.foundNumbers.includes(card.number) return (
{/* Card back (hidden state) */}
?
@@ -116,38 +116,38 @@ export function CardGrid({ state }: CardGridProps) { {/* Card front (revealed state) */}
- ); + ) })}
@@ -174,26 +174,24 @@ export function CardGrid({ state }: CardGridProps) { {cardCount > 8 && (
- {state.foundNumbers.length} of{" "} - {cardCount} cards found + {state.foundNumbers.length} of {cardCount} cards found {state.foundNumbers.length > 0 && ( - - ({Math.round((state.foundNumbers.length / cardCount) * 100)}% - complete) + + ({Math.round((state.foundNumbers.length / cardCount) * 100)}% complete) )}
)}
- ); + ) } diff --git a/apps/web/src/arcade-games/memory-quiz/components/DisplayPhase.tsx b/apps/web/src/arcade-games/memory-quiz/components/DisplayPhase.tsx index cb904244..84d20c12 100644 --- a/apps/web/src/arcade-games/memory-quiz/components/DisplayPhase.tsx +++ b/apps/web/src/arcade-games/memory-quiz/components/DisplayPhase.tsx @@ -1,126 +1,121 @@ -import { AbacusReact, useAbacusConfig } from "@soroban/abacus-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useMemoryQuiz } from "../Provider"; -import type { QuizCard } from "../types"; +import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemoryQuiz } from '../Provider' +import type { QuizCard } from '../types' // Calculate maximum columns needed for a set of numbers function calculateMaxColumns(numbers: number[]): number { - if (numbers.length === 0) return 1; - const maxNumber = Math.max(...numbers); - if (maxNumber === 0) return 1; - return Math.floor(Math.log10(maxNumber)) + 1; + if (numbers.length === 0) return 1 + const maxNumber = Math.max(...numbers) + if (maxNumber === 0) return 1 + return Math.floor(Math.log10(maxNumber)) + 1 } export function DisplayPhase() { - const { state, nextCard, showInputPhase, resetGame, isRoomCreator } = - useMemoryQuiz(); - const [currentCard, setCurrentCard] = useState(null); - const [isTransitioning, setIsTransitioning] = useState(false); - const isDisplayPhaseActive = state.currentCardIndex < state.quizCards.length; - const isProcessingRef = useRef(false); - const lastProcessedIndexRef = useRef(-1); - const appConfig = useAbacusConfig(); + const { state, nextCard, showInputPhase, resetGame, isRoomCreator } = useMemoryQuiz() + const [currentCard, setCurrentCard] = useState(null) + const [isTransitioning, setIsTransitioning] = useState(false) + const isDisplayPhaseActive = state.currentCardIndex < state.quizCards.length + const isProcessingRef = useRef(false) + const lastProcessedIndexRef = useRef(-1) + const appConfig = useAbacusConfig() // In multiplayer room mode, only the room creator controls card timing // In local mode (isRoomCreator === undefined), allow timing control - const shouldControlTiming = - isRoomCreator === undefined || isRoomCreator === true; + const shouldControlTiming = isRoomCreator === undefined || isRoomCreator === true // Calculate maximum columns needed for this quiz set const maxColumns = useMemo(() => { - const allNumbers = state.quizCards.map((card) => card.number); - return calculateMaxColumns(allNumbers); - }, [state.quizCards]); + const allNumbers = state.quizCards.map((card) => card.number) + return calculateMaxColumns(allNumbers) + }, [state.quizCards]) // Calculate adaptive animation duration const flashDuration = useMemo(() => { - const displayTimeMs = state.displayTime * 1000; - return Math.min(Math.max(displayTimeMs * 0.3, 150), 600) / 1000; // Convert to seconds for CSS - }, [state.displayTime]); + const displayTimeMs = state.displayTime * 1000 + return Math.min(Math.max(displayTimeMs * 0.3, 150), 600) / 1000 // Convert to seconds for CSS + }, [state.displayTime]) - const progressPercentage = - (state.currentCardIndex / state.quizCards.length) * 100; + const progressPercentage = (state.currentCardIndex / state.quizCards.length) * 100 useEffect(() => { // Prevent processing the same card index multiple times // This prevents race conditions from optimistic updates if (state.currentCardIndex === lastProcessedIndexRef.current) { console.log( - `DisplayPhase: Skipping duplicate processing of index ${state.currentCardIndex} (lastProcessed: ${lastProcessedIndexRef.current})`, - ); - return; + `DisplayPhase: Skipping duplicate processing of index ${state.currentCardIndex} (lastProcessed: ${lastProcessedIndexRef.current})` + ) + return } if (state.currentCardIndex >= state.quizCards.length) { // Only the room creator (or local mode) triggers phase transitions if (shouldControlTiming) { console.log( - `DisplayPhase: All cards shown (${state.quizCards.length}), transitioning to input phase`, - ); - showInputPhase?.(); + `DisplayPhase: All cards shown (${state.quizCards.length}), transitioning to input phase` + ) + showInputPhase?.() } - return; + return } // Prevent multiple concurrent executions if (isProcessingRef.current) { console.log( - `DisplayPhase: Already processing, skipping (index: ${state.currentCardIndex}, lastProcessed: ${lastProcessedIndexRef.current})`, - ); - return; + `DisplayPhase: Already processing, skipping (index: ${state.currentCardIndex}, lastProcessed: ${lastProcessedIndexRef.current})` + ) + return } // Mark this index as being processed - lastProcessedIndexRef.current = state.currentCardIndex; + lastProcessedIndexRef.current = state.currentCardIndex const showNextCard = async () => { - isProcessingRef.current = true; - const card = state.quizCards[state.currentCardIndex]; + isProcessingRef.current = true + const card = state.quizCards[state.currentCardIndex] console.log( - `DisplayPhase: Showing card ${state.currentCardIndex + 1}/${state.quizCards.length}, number: ${card.number} (isRoomCreator: ${isRoomCreator}, shouldControlTiming: ${shouldControlTiming})`, - ); + `DisplayPhase: Showing card ${state.currentCardIndex + 1}/${state.quizCards.length}, number: ${card.number} (isRoomCreator: ${isRoomCreator}, shouldControlTiming: ${shouldControlTiming})` + ) // Calculate adaptive timing based on display speed - const displayTimeMs = state.displayTime * 1000; - const flashDuration = Math.min(Math.max(displayTimeMs * 0.3, 150), 600); // 30% of display time, between 150ms-600ms - const transitionPause = Math.min(Math.max(displayTimeMs * 0.1, 50), 200); // 10% of display time, between 50ms-200ms + const displayTimeMs = state.displayTime * 1000 + const flashDuration = Math.min(Math.max(displayTimeMs * 0.3, 150), 600) // 30% of display time, between 150ms-600ms + const transitionPause = Math.min(Math.max(displayTimeMs * 0.1, 50), 200) // 10% of display time, between 50ms-200ms // Trigger adaptive transition effect - setIsTransitioning(true); - setCurrentCard(card); + setIsTransitioning(true) + setCurrentCard(card) // Reset transition effect with adaptive duration - setTimeout(() => setIsTransitioning(false), flashDuration); + setTimeout(() => setIsTransitioning(false), flashDuration) console.log( - `DisplayPhase: Card ${state.currentCardIndex + 1} now visible (flash: ${flashDuration}ms, pause: ${transitionPause}ms)`, - ); + `DisplayPhase: Card ${state.currentCardIndex + 1} now visible (flash: ${flashDuration}ms, pause: ${transitionPause}ms)` + ) // Only the room creator (or local mode) controls the timing if (shouldControlTiming) { // Display card for specified time with adaptive transition pause - await new Promise((resolve) => - setTimeout(resolve, displayTimeMs - transitionPause), - ); + await new Promise((resolve) => setTimeout(resolve, displayTimeMs - transitionPause)) // Don't hide the abacus - just advance to next card for smooth transition console.log( - `DisplayPhase: Card ${state.currentCardIndex + 1} transitioning to next (controlled by ${isRoomCreator === undefined ? "local mode" : "room creator"})`, - ); - await new Promise((resolve) => setTimeout(resolve, transitionPause)); // Adaptive pause for visual transition + `DisplayPhase: Card ${state.currentCardIndex + 1} transitioning to next (controlled by ${isRoomCreator === undefined ? 'local mode' : 'room creator'})` + ) + await new Promise((resolve) => setTimeout(resolve, transitionPause)) // Adaptive pause for visual transition - isProcessingRef.current = false; - nextCard?.(); + isProcessingRef.current = false + nextCard?.() } else { // Non-creator players just display the card, don't control timing console.log( - `DisplayPhase: Non-creator player displaying card ${state.currentCardIndex + 1}, waiting for creator to advance`, - ); - isProcessingRef.current = false; + `DisplayPhase: Non-creator player displaying card ${state.currentCardIndex + 1}, waiting for creator to advance` + ) + isProcessingRef.current = false } - }; + } - showNextCard(); + showNextCard() }, [ state.currentCardIndex, state.displayTime, @@ -129,76 +124,74 @@ export function DisplayPhase() { showInputPhase, shouldControlTiming, isRoomCreator, - ]); + ]) return (
Card {state.currentCardIndex + 1} of {state.quizCards.length}
-
+
- ); + ) } diff --git a/apps/web/src/arcade-games/memory-quiz/components/InputPhase.tsx b/apps/web/src/arcade-games/memory-quiz/components/InputPhase.tsx index b661c1d4..8fe9cf01 100644 --- a/apps/web/src/arcade-games/memory-quiz/components/InputPhase.tsx +++ b/apps/web/src/arcade-games/memory-quiz/components/InputPhase.tsx @@ -1,163 +1,147 @@ -import { useCallback, useEffect, useState } from "react"; -import { isPrefix } from "@/lib/memory-quiz-utils"; -import { useMemoryQuiz } from "../Provider"; -import { useViewport } from "@/contexts/ViewportContext"; -import { CardGrid } from "./CardGrid"; +import { useCallback, useEffect, useState } from 'react' +import { isPrefix } from '@/lib/memory-quiz-utils' +import { useMemoryQuiz } from '../Provider' +import { useViewport } from '@/contexts/ViewportContext' +import { CardGrid } from './CardGrid' export function InputPhase() { - const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = - useMemoryQuiz(); - const viewport = useViewport(); - const [displayFeedback, setDisplayFeedback] = useState< - "neutral" | "correct" | "incorrect" - >("neutral"); + const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = useMemoryQuiz() + const viewport = useViewport() + const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>( + 'neutral' + ) // Use keyboard state from parent state instead of local state - const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state; + const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state // Debug: Log state changes and detect what's causing re-renders useEffect(() => { - console.log("🔍 Keyboard state changed:", { + console.log('🔍 Keyboard state changed:', { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard, - }); - console.trace("🔍 State change trace:"); - }, [hasPhysicalKeyboard, testingMode, showOnScreenKeyboard]); + }) + console.trace('🔍 State change trace:') + }, [hasPhysicalKeyboard, testingMode, showOnScreenKeyboard]) // Debug: Monitor for unexpected state resets useEffect(() => { if (showOnScreenKeyboard) { const timer = setTimeout(() => { if (!showOnScreenKeyboard) { - console.error("🚨 Keyboard was unexpectedly hidden!"); + console.error('🚨 Keyboard was unexpectedly hidden!') } - }, 1000); - return () => clearTimeout(timer); + }, 1000) + return () => clearTimeout(timer) } - }, [showOnScreenKeyboard]); + }, [showOnScreenKeyboard]) // Detect physical keyboard availability (disabled when testing mode is active) useEffect(() => { // Skip keyboard detection entirely when testing mode is enabled if (testingMode) { - console.log("🧪 Testing mode enabled - skipping keyboard detection"); - return; + console.log('🧪 Testing mode enabled - skipping keyboard detection') + return } - let detectionTimer: NodeJS.Timeout | null = null; + let detectionTimer: NodeJS.Timeout | null = null const detectKeyboard = () => { // Method 1: Check if device supports keyboard via media queries const hasKeyboardSupport = - window.matchMedia("(pointer: fine)").matches && - window.matchMedia("(hover: hover)").matches; + window.matchMedia('(pointer: fine)').matches && window.matchMedia('(hover: hover)').matches // Method 2: Check if device is likely touch-only const isTouchDevice = - "ontouchstart" in window || + 'ontouchstart' in window || navigator.maxTouchPoints > 0 || - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) // Method 3: Check viewport characteristics for mobile devices - const isMobileViewport = viewport.width <= 768 && viewport.height <= 1024; + const isMobileViewport = viewport.width <= 768 && viewport.height <= 1024 // Combined heuristic: assume no physical keyboard if: // - It's a touch device AND has mobile viewport AND lacks precise pointer - const likelyNoKeyboard = - isTouchDevice && isMobileViewport && !hasKeyboardSupport; + const likelyNoKeyboard = isTouchDevice && isMobileViewport && !hasKeyboardSupport - console.log("⌨️ Keyboard detection result:", !likelyNoKeyboard); + console.log('⌨️ Keyboard detection result:', !likelyNoKeyboard) dispatch({ - type: "SET_PHYSICAL_KEYBOARD", + type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: !likelyNoKeyboard, - }); - }; + }) + } // Test for actual keyboard input within 3 seconds - let keyboardDetected = false; + let keyboardDetected = false const handleFirstKeyPress = (e: KeyboardEvent) => { if (/^[0-9]$/.test(e.key)) { - console.log("⌨️ Physical keyboard detected via keypress"); - keyboardDetected = true; - dispatch({ type: "SET_PHYSICAL_KEYBOARD", hasKeyboard: true }); - document.removeEventListener("keypress", handleFirstKeyPress); - if (detectionTimer) clearTimeout(detectionTimer); + console.log('⌨️ Physical keyboard detected via keypress') + keyboardDetected = true + dispatch({ type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: true }) + document.removeEventListener('keypress', handleFirstKeyPress) + if (detectionTimer) clearTimeout(detectionTimer) } - }; + } // Start detection - document.addEventListener("keypress", handleFirstKeyPress); + document.addEventListener('keypress', handleFirstKeyPress) // Fallback to heuristic detection after 3 seconds detectionTimer = setTimeout(() => { if (!keyboardDetected) { - console.log("⌨️ Using fallback keyboard detection"); - detectKeyboard(); + console.log('⌨️ Using fallback keyboard detection') + detectKeyboard() } - document.removeEventListener("keypress", handleFirstKeyPress); - }, 3000); + document.removeEventListener('keypress', handleFirstKeyPress) + }, 3000) // Initial heuristic detection (but don't commit to it yet) - const initialDetection = setTimeout(detectKeyboard, 100); + const initialDetection = setTimeout(detectKeyboard, 100) return () => { - document.removeEventListener("keypress", handleFirstKeyPress); - if (detectionTimer) clearTimeout(detectionTimer); - clearTimeout(initialDetection); - }; - }, [testingMode, dispatch]); + document.removeEventListener('keypress', handleFirstKeyPress) + if (detectionTimer) clearTimeout(detectionTimer) + clearTimeout(initialDetection) + } + }, [testingMode, dispatch]) const acceptCorrectNumber = useCallback( (number: number) => { - acceptNumber?.(number); + acceptNumber?.(number) // setInput('') is called inside acceptNumber action creator - setDisplayFeedback("correct"); + setDisplayFeedback('correct') - setTimeout(() => setDisplayFeedback("neutral"), 500); + setTimeout(() => setDisplayFeedback('neutral'), 500) // Auto-finish if all found if (state.foundNumbers.length + 1 === state.correctAnswers.length) { - setTimeout(() => showResults?.(), 1000); + setTimeout(() => showResults?.(), 1000) } }, - [ - acceptNumber, - showResults, - state.foundNumbers.length, - state.correctAnswers.length, - ], - ); + [acceptNumber, showResults, state.foundNumbers.length, state.correctAnswers.length] + ) const handleIncorrectGuess = useCallback(() => { - const wrongNumber = parseInt(state.currentInput, 10); + const wrongNumber = parseInt(state.currentInput, 10) if (!Number.isNaN(wrongNumber)) { - dispatch({ type: "ADD_WRONG_GUESS_ANIMATION", number: wrongNumber }); + dispatch({ type: 'ADD_WRONG_GUESS_ANIMATION', number: wrongNumber }) // Clear wrong guess animations after explosion setTimeout(() => { - dispatch({ type: "CLEAR_WRONG_GUESS_ANIMATIONS" }); - }, 1500); + dispatch({ type: 'CLEAR_WRONG_GUESS_ANIMATIONS' }) + }, 1500) } - rejectNumber?.(); + rejectNumber?.() // setInput('') is called inside rejectNumber action creator - setDisplayFeedback("incorrect"); + setDisplayFeedback('incorrect') - setTimeout(() => setDisplayFeedback("neutral"), 500); + setTimeout(() => setDisplayFeedback('neutral'), 500) // Auto-finish if out of guesses if (state.guessesRemaining - 1 === 0) { - setTimeout(() => showResults?.(), 1000); + setTimeout(() => showResults?.(), 1000) } - }, [ - state.currentInput, - dispatch, - rejectNumber, - showResults, - state.guessesRemaining, - ]); + }, [state.currentInput, dispatch, rejectNumber, showResults, state.guessesRemaining]) // Simple keyboard event handlers that will be defined after callbacks const handleKeyboardInput = useCallback( @@ -165,52 +149,43 @@ export function InputPhase() { // Handle number input if (/^[0-9]$/.test(key)) { // Only handle if input phase is active and guesses remain - if (state.guessesRemaining === 0) return; + if (state.guessesRemaining === 0) return // Update input with new key - const newInput = state.currentInput + key; - setInput?.(newInput); + const newInput = state.currentInput + key + setInput?.(newInput) // Clear any existing timeout if (state.prefixAcceptanceTimeout) { - clearTimeout(state.prefixAcceptanceTimeout); - dispatch({ type: "SET_PREFIX_TIMEOUT", timeout: null }); + clearTimeout(state.prefixAcceptanceTimeout) + dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout: null }) } - setDisplayFeedback("neutral"); + setDisplayFeedback('neutral') - const number = parseInt(newInput, 10); - if (Number.isNaN(number)) return; + const number = parseInt(newInput, 10) + if (Number.isNaN(number)) return // Check if correct and not already found - if ( - state.correctAnswers.includes(number) && - !state.foundNumbers.includes(number) - ) { + if (state.correctAnswers.includes(number) && !state.foundNumbers.includes(number)) { if (!isPrefix(newInput, state.correctAnswers, state.foundNumbers)) { - acceptCorrectNumber(number); + acceptCorrectNumber(number) } else { const timeout = setTimeout(() => { - acceptCorrectNumber(number); - }, 500); - dispatch({ type: "SET_PREFIX_TIMEOUT", timeout }); + acceptCorrectNumber(number) + }, 500) + dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout }) } } else { // Check if this input could be a valid prefix or complete number - const couldBePrefix = state.correctAnswers.some((n) => - n.toString().startsWith(newInput), - ); - const isCompleteWrongNumber = - !state.correctAnswers.includes(number) && !couldBePrefix; + const couldBePrefix = state.correctAnswers.some((n) => n.toString().startsWith(newInput)) + const isCompleteWrongNumber = !state.correctAnswers.includes(number) && !couldBePrefix // Trigger explosion if: // 1. It's a complete wrong number (length >= 2 or can't be a prefix) // 2. It's a single digit that can't possibly be a prefix of any target - if ( - (newInput.length >= 2 || isCompleteWrongNumber) && - state.guessesRemaining > 0 - ) { - handleIncorrectGuess(); + if ((newInput.length >= 2 || isCompleteWrongNumber) && state.guessesRemaining > 0) { + handleIncorrectGuess() } } } @@ -225,117 +200,116 @@ export function InputPhase() { setInput, acceptCorrectNumber, handleIncorrectGuess, - ], - ); + ] + ) const handleKeyboardBackspace = useCallback(() => { if (state.currentInput.length > 0) { - const newInput = state.currentInput.slice(0, -1); - setInput?.(newInput); + const newInput = state.currentInput.slice(0, -1) + setInput?.(newInput) // Clear any existing timeout if (state.prefixAcceptanceTimeout) { - clearTimeout(state.prefixAcceptanceTimeout); - dispatch({ type: "SET_PREFIX_TIMEOUT", timeout: null }); + clearTimeout(state.prefixAcceptanceTimeout) + dispatch({ type: 'SET_PREFIX_TIMEOUT', timeout: null }) } - setDisplayFeedback("neutral"); + setDisplayFeedback('neutral') } - }, [state.currentInput, state.prefixAcceptanceTimeout, dispatch, setInput]); + }, [state.currentInput, state.prefixAcceptanceTimeout, dispatch, setInput]) // Set up global keyboard listeners useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle backspace/delete on keydown to prevent repetition - if (e.key === "Backspace" || e.key === "Delete") { - e.preventDefault(); - handleKeyboardBackspace(); + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault() + handleKeyboardBackspace() } - }; + } const handleKeyPressEvent = (e: KeyboardEvent) => { // Handle number input if (/^[0-9]$/.test(e.key)) { - handleKeyboardInput(e.key); + handleKeyboardInput(e.key) } - }; + } - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("keypress", handleKeyPressEvent); + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keypress', handleKeyPressEvent) return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("keypress", handleKeyPressEvent); - }; - }, [handleKeyboardInput, handleKeyboardBackspace]); + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('keypress', handleKeyPressEvent) + } + }, [handleKeyboardInput, handleKeyboardBackspace]) - const hasFoundSome = state.foundNumbers.length > 0; - const hasFoundAll = state.foundNumbers.length === state.correctAnswers.length; - const outOfGuesses = state.guessesRemaining === 0; - const showFinishButtons = hasFoundAll || outOfGuesses || hasFoundSome; + const hasFoundSome = state.foundNumbers.length > 0 + const hasFoundAll = state.foundNumbers.length === state.correctAnswers.length + const outOfGuesses = state.guessesRemaining === 0 + const showFinishButtons = hasFoundAll || outOfGuesses || hasFoundSome return (
0 - ? "100px" - : "12px", // Add space for keyboard - maxWidth: "800px", - margin: "0 auto", - height: "100%", - display: "flex", - flexDirection: "column", - justifyContent: "flex-start", + (hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 + ? '100px' + : '12px', // Add space for keyboard + maxWidth: '800px', + margin: '0 auto', + height: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', }} >

Enter the Numbers You Remember

Cards shown: {state.quizCards.length} @@ -343,26 +317,26 @@ export function InputPhase() {
Guesses left: {state.guessesRemaining} @@ -370,26 +344,26 @@ export function InputPhase() {
Found: {state.foundNumbers.length} @@ -398,34 +372,34 @@ export function InputPhase() {
{/* Live Scoreboard - Competitive Mode Only */} - {state.playMode === "competitive" && + {state.playMode === 'competitive' && state.activePlayers && state.activePlayers.length > 1 && (
🏆 LIVE SCOREBOARD
{(() => { @@ -433,30 +407,27 @@ export function InputPhase() { const userTeams = new Map< string, { - userId: string; - players: any[]; - score: { correct: number; incorrect: number }; + userId: string + players: any[] + score: { correct: number; incorrect: number } } - >(); + >() - console.log("📊 [InputPhase] Building scoreboard:", { + console.log('📊 [InputPhase] Building scoreboard:', { activePlayers: state.activePlayers, playerMetadata: state.playerMetadata, playerScores: state.playerScores, - }); + }) for (const playerId of state.activePlayers) { - const metadata = state.playerMetadata?.[playerId]; - const userId = metadata?.userId; - console.log( - "📊 [InputPhase] Processing player for scoreboard:", - { - playerId, - metadata, - userId, - }, - ); - if (!userId) continue; + const metadata = state.playerMetadata?.[playerId] + const userId = metadata?.userId + console.log('📊 [InputPhase] Processing player for scoreboard:', { + playerId, + metadata, + userId, + }) + if (!userId) continue if (!userTeams.has(userId)) { userTeams.set(userId, { @@ -466,63 +437,59 @@ export function InputPhase() { correct: 0, incorrect: 0, }, - }); + }) } - userTeams.get(userId)!.players.push(metadata); + userTeams.get(userId)!.players.push(metadata) } - console.log("📊 [InputPhase] UserTeams created:", { + console.log('📊 [InputPhase] UserTeams created:', { count: userTeams.size, teams: Array.from(userTeams.entries()), - }); + }) // Sort teams by score return Array.from(userTeams.values()) .sort((a, b) => { - const aScore = a.score.correct - a.score.incorrect * 0.5; - const bScore = b.score.correct - b.score.incorrect * 0.5; - return bScore - aScore; + const aScore = a.score.correct - a.score.incorrect * 0.5 + const bScore = b.score.correct - b.score.incorrect * 0.5 + return bScore - aScore }) .map((team, index) => { - const netScore = - team.score.correct - team.score.incorrect * 0.5; + const netScore = team.score.correct - team.score.incorrect * 0.5 return (
{/* Team header with rank and stats */}
- - {index === 0 ? "👑" : `${index + 1}.`} + + {index === 0 ? '👑' : `${index + 1}.`} Score: {netScore.toFixed(1)} @@ -530,20 +497,16 @@ export function InputPhase() {
- + ✓{team.score.correct} - + ✗{team.score.incorrect}
@@ -551,29 +514,27 @@ export function InputPhase() { {/* Players list */}
{team.players.map((player, i) => (
- - {player?.emoji || "🎮"} - + {player?.emoji || '🎮'} {player?.name || `Player ${i + 1}`} @@ -582,8 +543,8 @@ export function InputPhase() { ))}
- ); - }); + ) + }) })()}
@@ -591,38 +552,38 @@ export function InputPhase() {
{state.guessesRemaining === 0 - ? "🚫 No more guesses available" - : "⌨️ Type the numbers you remember"} + ? '🚫 No more guesses available' + : '⌨️ Type the numbers you remember'}
{/* Testing control - remove in production */}
-
- Keyboard detected:{" "} - {hasPhysicalKeyboard === null - ? "detecting..." - : hasPhysicalKeyboard - ? "yes" - : "no"} +
+ Keyboard detected:{' '} + {hasPhysicalKeyboard === null ? 'detecting...' : hasPhysicalKeyboard ? 'yes' : 'no'}
- + {state.guessesRemaining === 0 - ? "🔒 Game Over" + ? '🔒 Game Over' : state.currentInput || ( 💭 Think & Type @@ -706,14 +663,14 @@ export function InputPhase() { {state.currentInput && ( )} @@ -724,10 +681,10 @@ export function InputPhase() { {/* Visual card grid showing cards the user was shown */}
@@ -736,12 +693,12 @@ export function InputPhase() { {/* Wrong guess explosion animations */}
@@ -749,15 +706,15 @@ export function InputPhase() {
{animation.number} @@ -766,136 +723,133 @@ export function InputPhase() {
{/* Simple fixed keyboard bar - appears when needed, no hiding of game elements */} - {(hasPhysicalKeyboard === false || testingMode) && - state.guessesRemaining > 0 && ( -
- {[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((digit) => ( - - ))} + {(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 && ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((digit) => ( -
- )} + ))} + +
+ )} {showFinishButtons && (
{hasFoundSome && !hasFoundAll && !outOfGuesses && (
)}
- ); + ) } diff --git a/apps/web/src/arcade-games/memory-quiz/components/MemoryQuizGame.tsx b/apps/web/src/arcade-games/memory-quiz/components/MemoryQuizGame.tsx index 30b551b4..db19f8c2 100644 --- a/apps/web/src/arcade-games/memory-quiz/components/MemoryQuizGame.tsx +++ b/apps/web/src/arcade-games/memory-quiz/components/MemoryQuizGame.tsx @@ -1,14 +1,14 @@ -"use client"; +'use client' -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { PageWithNav } from "@/components/PageWithNav"; -import { css } from "../../../../styled-system/css"; -import { useMemoryQuiz } from "../Provider"; -import { DisplayPhase } from "./DisplayPhase"; -import { InputPhase } from "./InputPhase"; -import { ResultsPhase } from "./ResultsPhase"; -import { SetupPhase } from "./SetupPhase"; +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { PageWithNav } from '@/components/PageWithNav' +import { css } from '../../../../styled-system/css' +import { useMemoryQuiz } from '../Provider' +import { DisplayPhase } from './DisplayPhase' +import { InputPhase } from './InputPhase' +import { ResultsPhase } from './ResultsPhase' +import { SetupPhase } from './SetupPhase' // CSS animations that need to be global const globalAnimations = ` @@ -59,23 +59,23 @@ const globalAnimations = ` transform: translateX(-50%) translateY(0); } } -`; +` export function MemoryQuizGame() { - const router = useRouter(); - const { state, exitSession, resetGame } = useMemoryQuiz(); + const router = useRouter() + const { state, exitSession, resetGame } = useMemoryQuiz() return ( { - exitSession?.(); - router.push("/arcade"); + exitSession?.() + router.push('/arcade') }} onNewGame={() => { - resetGame?.(); + resetGame?.() }} >
- ); + ) } // Export wrapper component with provider @@ -2069,5 +1920,5 @@ export function TutorialPlayer(props: TutorialPlayerProps) { - ); + ) } diff --git a/apps/web/src/components/tutorial/TutorialUI.stories.tsx b/apps/web/src/components/tutorial/TutorialUI.stories.tsx index 8fdf3a8f..c167ea10 100644 --- a/apps/web/src/components/tutorial/TutorialUI.stories.tsx +++ b/apps/web/src/components/tutorial/TutorialUI.stories.tsx @@ -1,65 +1,50 @@ -import * as HoverCard from "@radix-ui/react-hover-card"; -import type { Meta, StoryObj } from "@storybook/react"; -import type React from "react"; -import { useState } from "react"; -import { CoachBar } from "./CoachBar/CoachBar"; -import type { PedagogicalSegment } from "./DecompositionWithReasons"; -import { ReasonTooltip } from "./ReasonTooltip"; -import { TutorialUIProvider, useTutorialUI } from "./TutorialUIContext"; -import "./CoachBar/coachbar.css"; -import "./reason-tooltip.css"; +import * as HoverCard from '@radix-ui/react-hover-card' +import type { Meta, StoryObj } from '@storybook/react' +import type React from 'react' +import { useState } from 'react' +import { CoachBar } from './CoachBar/CoachBar' +import type { PedagogicalSegment } from './DecompositionWithReasons' +import { ReasonTooltip } from './ReasonTooltip' +import { TutorialUIProvider, useTutorialUI } from './TutorialUIContext' +import './CoachBar/coachbar.css' +import './reason-tooltip.css' // Enhanced demo components -function MockBeadTooltip({ - children, - content, -}: { - children: React.ReactNode; - content: string; -}) { - const [isOpen, setIsOpen] = useState(false); - const ui = useTutorialUI(); +function MockBeadTooltip({ children, content }: { children: React.ReactNode; content: string }) { + const [isOpen, setIsOpen] = useState(false) + const ui = useTutorialUI() const handleOpenChange = (open: boolean) => { if (open) { - const granted = ui.requestFocus("bead"); + const granted = ui.requestFocus('bead') if (granted) { - setIsOpen(true); + setIsOpen(true) } } else { - ui.releaseFocus("bead"); - setIsOpen(false); + ui.releaseFocus('bead') + setIsOpen(false) } - }; + } return ( - +
{children} @@ -69,12 +54,12 @@ function MockBeadTooltip({
- 🟢 - Bead Movement + 🟢 + Bead Movement
-

- {content} -

+

{content}

- +
- ); + ) } function MockTermWithTooltip({ @@ -107,202 +90,185 @@ function MockTermWithTooltip({ segment, termIndex, }: { - children: React.ReactNode; - segment: PedagogicalSegment; - termIndex: number; + children: React.ReactNode + segment: PedagogicalSegment + termIndex: number }) { return ( {children} - ); + ) } -const createMockSegment = ( - overrides: Partial = {}, -): PedagogicalSegment => ({ - id: "demo-segment", +const createMockSegment = (overrides: Partial = {}): PedagogicalSegment => ({ + id: 'demo-segment', place: 1, digit: 5, a: 0, L: 0, U: 0, - goal: "Add 5 to tens place", + goal: 'Add 5 to tens place', plan: [ { - rule: "Direct", - conditions: ["L+d <= 4"], - explanation: ["Simple addition"], + rule: 'Direct', + conditions: ['L+d <= 4'], + explanation: ['Simple addition'], }, ], - expression: "50", + expression: '50', termIndices: [0], termRange: { startIndex: 0, endIndex: 2 }, readable: { - title: "Direct Move", - subtitle: "Heaven bead helps", - summary: "Add 5 to the tens place using the heaven bead.", + title: 'Direct Move', + subtitle: 'Heaven bead helps', + summary: 'Add 5 to the tens place using the heaven bead.', chips: [ - { label: "Digit being added", value: "5" }, - { label: "Target place", value: "tens" }, - { label: "Rod shows", value: "0 (empty)" }, + { label: 'Digit being added', value: '5' }, + { label: 'Target place', value: 'tens' }, + { label: 'Rod shows', value: '0 (empty)' }, ], - stepsFriendly: ["Press the heaven bead down in tens column"], + stepsFriendly: ['Press the heaven bead down in tens column'], validation: { ok: true, issues: [] }, }, ...overrides, -}); +}) function IntegratedDemo() { - const ui = useTutorialUI(); - const [currentSegment, setCurrentSegment] = useState(createMockSegment()); + const ui = useTutorialUI() + const [currentSegment, setCurrentSegment] = useState(createMockSegment()) const segments = [ createMockSegment({ - id: "step-1", + id: 'step-1', readable: { - title: "Step 1: Direct Move", - subtitle: "Simple bead movement", - summary: - "Add 3 to the ones place. It fits here, so just move 3 lower beads.", + title: 'Step 1: Direct Move', + subtitle: 'Simple bead movement', + summary: 'Add 3 to the ones place. It fits here, so just move 3 lower beads.', chips: [ - { label: "Digit", value: "3" }, - { label: "Place", value: "ones" }, + { label: 'Digit', value: '3' }, + { label: 'Place', value: 'ones' }, ], - stepsFriendly: ["Move 3 earth beads down"], + stepsFriendly: ['Move 3 earth beads down'], validation: { ok: true, issues: [] }, }, }), createMockSegment({ - id: "step-2", + id: 'step-2', readable: { - title: "Step 2: Five Friend", + title: 'Step 2: Five Friend', subtitle: "Using 5's friend", - summary: - "Add 7 to the ones place using 5's friend: press the heaven bead (5) and lift 2.", + summary: "Add 7 to the ones place using 5's friend: press the heaven bead (5) and lift 2.", chips: [ - { label: "Target", value: "7" }, - { label: "Strategy", value: "5 + 2" }, + { label: 'Target', value: '7' }, + { label: 'Strategy', value: '5 + 2' }, ], - stepsFriendly: ["Press heaven bead", "Lift 2 earth beads"], + stepsFriendly: ['Press heaven bead', 'Lift 2 earth beads'], validation: { ok: true, issues: [] }, }, }), createMockSegment({ - id: "step-3", + id: 'step-3', readable: { - title: "Step 3: Ten Friend", + title: 'Step 3: Ten Friend', subtitle: "Using 10's friend", - summary: - "Add 8 to the ones place to make 10. Carry to tens place, then take 2 here.", + summary: 'Add 8 to the ones place to make 10. Carry to tens place, then take 2 here.', chips: [ - { label: "Target", value: "8" }, - { label: "Strategy", value: "10 - 2" }, + { label: 'Target', value: '8' }, + { label: 'Strategy', value: '10 - 2' }, ], - stepsFriendly: ["Add 1 to tens", "Subtract 2 from ones"], + stepsFriendly: ['Add 1 to tens', 'Subtract 2 from ones'], validation: { ok: true, issues: [] }, }, }), - ]; + ] // Update the active segment when buttons are clicked const handleStepChange = (segment: PedagogicalSegment) => { - setCurrentSegment(segment); - ui.setActiveSegment(segment); - }; + setCurrentSegment(segment) + ui.setActiveSegment(segment) + } return ( -
+
-
+

Tutorial UI Integration Demo

-

- This demonstrates the Coach Bar and single-owner tooltip gate working - together. -

+

This demonstrates the Coach Bar and single-owner tooltip gate working together.

{/* Focus status indicator */}
Current Focus: {ui.hintFocus} - {ui.hintFocus !== "none" && ( - + {ui.hintFocus !== 'none' && ( + (Only {ui.hintFocus} tooltips can open) )}
{/* Step navigation */} -
+

Tutorial Steps

-
+
{segments.map((segment, index) => ( ))}
-

- Click the steps above to see how the Coach Bar updates with - different content. +

+ Click the steps above to see how the Coach Bar updates with different content.

{/* Mathematical expression with term tooltips */} -
+

Mathematical Expression (Term Tooltips)

27 + @@ -315,32 +281,30 @@ function IntegratedDemo() { = ?
-

+

Hover over the blue terms to see pedagogical tooltips (term focus).

{/* Abacus representation with bead tooltips */} -
+

Abacus Representation (Bead Tooltips)

-
+
-

- Hundreds -

+

Hundreds

@@ -348,10 +312,10 @@ function IntegratedDemo() {
@@ -364,12 +328,12 @@ function IntegratedDemo() {
-

Tens

+

Tens

@@ -377,10 +341,10 @@ function IntegratedDemo() {
@@ -393,12 +357,12 @@ function IntegratedDemo() {
-

Ones

+

Ones

@@ -406,10 +370,10 @@ function IntegratedDemo() {
@@ -422,7 +386,7 @@ function IntegratedDemo() {
-

+

Hover over the beads to see movement instructions (bead focus).

@@ -430,39 +394,34 @@ function IntegratedDemo() { {/* Testing instructions */}
-

- 🧪 Test the Single-Owner Gate: -

-
    +

    🧪 Test the Single-Owner Gate:

    +
    1. Hover over a blue mathematical term → term tooltip opens
    2. - While keeping mouse over the term, try hovering a green bead → - bead tooltip is blocked + While keeping mouse over the term, try hovering a green bead → bead tooltip is blocked
    3. Move mouse away from term → term tooltip closes
    4. -
    5. - Now hover over a green bead → bead tooltip opens successfully -
    6. +
    7. Now hover over a green bead → bead tooltip opens successfully
    8. Try hovering between beads → focus stays with bead tooltips
    9. Switch tutorial steps → Coach Bar updates with new content
- ); + ) } const meta: Meta = { - title: "Tutorial/Complete Tutorial UI", + title: 'Tutorial/Complete Tutorial UI', component: TutorialUIProvider, parameters: { - layout: "fullscreen", + layout: 'fullscreen', docs: { description: { component: ` @@ -478,10 +437,10 @@ This story demonstrates: }, }, }, -}; +} -export default meta; -type Story = StoryObj; +export default meta +type Story = StoryObj export const Complete: Story = { render: () => ( @@ -489,37 +448,37 @@ export const Complete: Story = { ), -}; +} export const CoachBarOnly: Story = { render: () => ( -
+
-
+

Coach Bar Only Demo

This shows just the Coach Bar without the tooltip interactions.

Tutorial content would go here...

@@ -528,4 +487,4 @@ export const CoachBarOnly: Story = {
), -}; +} diff --git a/apps/web/src/components/tutorial/TutorialUIContext.stories.tsx b/apps/web/src/components/tutorial/TutorialUIContext.stories.tsx index 2471fd53..7e54e0ca 100644 --- a/apps/web/src/components/tutorial/TutorialUIContext.stories.tsx +++ b/apps/web/src/components/tutorial/TutorialUIContext.stories.tsx @@ -1,8 +1,8 @@ -import * as HoverCard from "@radix-ui/react-hover-card"; -import type { Meta, StoryObj } from "@storybook/react"; -import type React from "react"; -import { useState } from "react"; -import { TutorialUIProvider, useTutorialUI } from "./TutorialUIContext"; +import * as HoverCard from '@radix-ui/react-hover-card' +import type { Meta, StoryObj } from '@storybook/react' +import type React from 'react' +import { useState } from 'react' +import { TutorialUIProvider, useTutorialUI } from './TutorialUIContext' // Demo tooltip component that uses the focus gate function DemoTooltip({ @@ -10,50 +10,44 @@ function DemoTooltip({ content, hintType, }: { - children: React.ReactNode; - content: string; - hintType: "term" | "bead"; + children: React.ReactNode + content: string + hintType: 'term' | 'bead' }) { - const [isOpen, setIsOpen] = useState(false); - const ui = useTutorialUI(); + const [isOpen, setIsOpen] = useState(false) + const ui = useTutorialUI() const handleOpenChange = (open: boolean) => { if (open) { - const granted = ui.requestFocus(hintType); + const granted = ui.requestFocus(hintType) if (granted) { - setIsOpen(true); + setIsOpen(true) } } else { - ui.releaseFocus(hintType); - setIsOpen(false); + ui.releaseFocus(hintType) + setIsOpen(false) } - }; + } - const isActive = ui.hintFocus === hintType; - const bgColor = hintType === "term" ? "#3b82f6" : "#10b981"; - const activeBg = hintType === "term" ? "#1d4ed8" : "#047857"; + const isActive = ui.hintFocus === hintType + const bgColor = hintType === 'term' ? '#3b82f6' : '#10b981' + const activeBg = hintType === 'term' ? '#1d4ed8' : '#047857' return ( - +
-
+

Interactive Tooltips

Term Tooltip @@ -396,39 +354,39 @@ export const FocusDebugger: Story = {

Focus Event Log

{logs.length === 0 ? ( - No events yet... + No events yet... ) : ( logs.map((log, i) =>
{log}
) )}
- ); + ) } return ( - ); + ) }, parameters: { docs: { description: { story: - "Debug panel for testing focus management programmatically. Use the buttons to manually request/release focus and observe the behavior.", + 'Debug panel for testing focus management programmatically. Use the buttons to manually request/release focus and observe the behavior.', }, }, }, -}; +} diff --git a/apps/web/src/components/tutorial/TutorialUIContext.tsx b/apps/web/src/components/tutorial/TutorialUIContext.tsx index 5ae2c491..a19e4215 100644 --- a/apps/web/src/components/tutorial/TutorialUIContext.tsx +++ b/apps/web/src/components/tutorial/TutorialUIContext.tsx @@ -1,42 +1,40 @@ -"use client"; +'use client' -import type React from "react"; -import { createContext, useContext, useMemo, useState } from "react"; -import type { PedagogicalSegment } from "./DecompositionWithReasons"; +import type React from 'react' +import { createContext, useContext, useMemo, useState } from 'react' +import type { PedagogicalSegment } from './DecompositionWithReasons' -type HintFocus = "none" | "term" | "bead"; +type HintFocus = 'none' | 'term' | 'bead' interface TutorialUIState { - showCoachBar: boolean; - setShowCoachBar: (v: boolean) => void; - canHideCoachBar: boolean; + showCoachBar: boolean + setShowCoachBar: (v: boolean) => void + canHideCoachBar: boolean // Single-owner tooltip gate (tutorial-only) - hintFocus: HintFocus; - requestFocus: (who: HintFocus) => boolean; // returns true if granted - releaseFocus: (who: HintFocus) => void; + hintFocus: HintFocus + requestFocus: (who: HintFocus) => boolean // returns true if granted + releaseFocus: (who: HintFocus) => void // Currently active segment for Coach Bar - activeSegment: PedagogicalSegment | null; - setActiveSegment: (seg: PedagogicalSegment | null) => void; + activeSegment: PedagogicalSegment | null + setActiveSegment: (seg: PedagogicalSegment | null) => void } -const TutorialUIContext = createContext(undefined); +const TutorialUIContext = createContext(undefined) export function TutorialUIProvider({ children, initialSegment = null, canHideCoachBar = true, }: { - children: React.ReactNode; - initialSegment?: PedagogicalSegment | null; - canHideCoachBar?: boolean; + children: React.ReactNode + initialSegment?: PedagogicalSegment | null + canHideCoachBar?: boolean }) { - const [showCoachBar, setShowCoachBar] = useState(true); - const [hintFocus, setHintFocus] = useState("none"); - const [activeSegment, setActiveSegment] = useState( - initialSegment, - ); + const [showCoachBar, setShowCoachBar] = useState(true) + const [hintFocus, setHintFocus] = useState('none') + const [activeSegment, setActiveSegment] = useState(initialSegment) const value: TutorialUIState = useMemo( () => ({ @@ -45,39 +43,31 @@ export function TutorialUIProvider({ canHideCoachBar, hintFocus, requestFocus: (who: HintFocus) => { - if (hintFocus === "none" || hintFocus === who) { - setHintFocus(who); - return true; + if (hintFocus === 'none' || hintFocus === who) { + setHintFocus(who) + return true } - if (process.env.NODE_ENV !== "production") { - console.debug( - `[tutorial-ui] focus denied: ${who}, owned by ${hintFocus}`, - ); + if (process.env.NODE_ENV !== 'production') { + console.debug(`[tutorial-ui] focus denied: ${who}, owned by ${hintFocus}`) } - return false; + return false }, releaseFocus: (who: HintFocus) => { - if (hintFocus === who) setHintFocus("none"); + if (hintFocus === who) setHintFocus('none') }, activeSegment, setActiveSegment, }), - [showCoachBar, canHideCoachBar, hintFocus, activeSegment], - ); + [showCoachBar, canHideCoachBar, hintFocus, activeSegment] + ) - return ( - - {children} - - ); + return {children} } export function useTutorialUI(): TutorialUIState { - const ctx = useContext(TutorialUIContext); + const ctx = useContext(TutorialUIContext) if (!ctx) { - throw new Error( - "useTutorialUI must be used within (tutorial routes)", - ); + throw new Error('useTutorialUI must be used within (tutorial routes)') } - return ctx; + return ctx } diff --git a/apps/web/src/components/tutorial/__tests__/CelebrationTooltip.unit.test.ts b/apps/web/src/components/tutorial/__tests__/CelebrationTooltip.unit.test.ts index 9696bc7e..dd7c98fb 100644 --- a/apps/web/src/components/tutorial/__tests__/CelebrationTooltip.unit.test.ts +++ b/apps/web/src/components/tutorial/__tests__/CelebrationTooltip.unit.test.ts @@ -1,170 +1,170 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest' /** * Unit tests for celebration tooltip logic * These test the core business logic without rendering components */ -describe("Celebration Tooltip Logic", () => { +describe('Celebration Tooltip Logic', () => { // Helper function that mimics the tooltip visibility logic from TutorialPlayer function shouldShowCelebrationTooltip( isStepCompleted: boolean, currentValue: number, targetValue: number, - hasStepInstructions: boolean, + hasStepInstructions: boolean ) { - const showCelebration = isStepCompleted && currentValue === targetValue; - const showInstructions = !showCelebration && hasStepInstructions; + const showCelebration = isStepCompleted && currentValue === targetValue + const showInstructions = !showCelebration && hasStepInstructions return { showCelebration, showInstructions, visible: showCelebration || showInstructions, - }; + } } - describe("Celebration visibility logic", () => { - it("should show celebration when step is completed and at target value", () => { + describe('Celebration visibility logic', () => { + it('should show celebration when step is completed and at target value', () => { const result = shouldShowCelebrationTooltip( true, // isStepCompleted 5, // currentValue 5, // targetValue - false, // hasStepInstructions - ); + false // hasStepInstructions + ) - expect(result.showCelebration).toBe(true); - expect(result.showInstructions).toBe(false); - expect(result.visible).toBe(true); - }); + expect(result.showCelebration).toBe(true) + expect(result.showInstructions).toBe(false) + expect(result.visible).toBe(true) + }) - it("should not show celebration when step completed but not at target", () => { + it('should not show celebration when step completed but not at target', () => { const result = shouldShowCelebrationTooltip( true, // isStepCompleted 6, // currentValue (moved away from target) 5, // targetValue - true, // hasStepInstructions - ); + true // hasStepInstructions + ) - expect(result.showCelebration).toBe(false); - expect(result.showInstructions).toBe(true); - expect(result.visible).toBe(true); - }); + expect(result.showCelebration).toBe(false) + expect(result.showInstructions).toBe(true) + expect(result.visible).toBe(true) + }) - it("should not show celebration when not completed even if at target", () => { + it('should not show celebration when not completed even if at target', () => { const result = shouldShowCelebrationTooltip( false, // isStepCompleted 5, // currentValue 5, // targetValue - true, // hasStepInstructions - ); + true // hasStepInstructions + ) - expect(result.showCelebration).toBe(false); - expect(result.showInstructions).toBe(true); - expect(result.visible).toBe(true); - }); + expect(result.showCelebration).toBe(false) + expect(result.showInstructions).toBe(true) + expect(result.visible).toBe(true) + }) - it("should show instructions when not completed and has instructions", () => { + it('should show instructions when not completed and has instructions', () => { const result = shouldShowCelebrationTooltip( false, // isStepCompleted 3, // currentValue 5, // targetValue - true, // hasStepInstructions - ); + true // hasStepInstructions + ) - expect(result.showCelebration).toBe(false); - expect(result.showInstructions).toBe(true); - expect(result.visible).toBe(true); - }); + expect(result.showCelebration).toBe(false) + expect(result.showInstructions).toBe(true) + expect(result.visible).toBe(true) + }) - it("should not show anything when not completed and no instructions", () => { + it('should not show anything when not completed and no instructions', () => { const result = shouldShowCelebrationTooltip( false, // isStepCompleted 3, // currentValue 5, // targetValue - false, // hasStepInstructions - ); + false // hasStepInstructions + ) - expect(result.showCelebration).toBe(false); - expect(result.showInstructions).toBe(false); - expect(result.visible).toBe(false); - }); - }); + expect(result.showCelebration).toBe(false) + expect(result.showInstructions).toBe(false) + expect(result.visible).toBe(false) + }) + }) - describe("State transition scenarios", () => { - it("should transition from instruction to celebration when reaching target", () => { + describe('State transition scenarios', () => { + it('should transition from instruction to celebration when reaching target', () => { // Initially showing instructions - const initial = shouldShowCelebrationTooltip(false, 3, 5, true); - expect(initial.showInstructions).toBe(true); - expect(initial.showCelebration).toBe(false); + const initial = shouldShowCelebrationTooltip(false, 3, 5, true) + expect(initial.showInstructions).toBe(true) + expect(initial.showCelebration).toBe(false) // After completing and reaching target - const completed = shouldShowCelebrationTooltip(true, 5, 5, true); - expect(completed.showCelebration).toBe(true); - expect(completed.showInstructions).toBe(false); - }); + const completed = shouldShowCelebrationTooltip(true, 5, 5, true) + expect(completed.showCelebration).toBe(true) + expect(completed.showInstructions).toBe(false) + }) - it("should transition from celebration to instructions when moving away", () => { + it('should transition from celebration to instructions when moving away', () => { // Initially celebrating - const celebrating = shouldShowCelebrationTooltip(true, 5, 5, true); - expect(celebrating.showCelebration).toBe(true); - expect(celebrating.showInstructions).toBe(false); + const celebrating = shouldShowCelebrationTooltip(true, 5, 5, true) + expect(celebrating.showCelebration).toBe(true) + expect(celebrating.showInstructions).toBe(false) // After moving away from target - const movedAway = shouldShowCelebrationTooltip(true, 6, 5, true); - expect(movedAway.showCelebration).toBe(false); - expect(movedAway.showInstructions).toBe(true); - }); + const movedAway = shouldShowCelebrationTooltip(true, 6, 5, true) + expect(movedAway.showCelebration).toBe(false) + expect(movedAway.showInstructions).toBe(true) + }) - it("should return to celebration when returning to target value", () => { + it('should return to celebration when returning to target value', () => { // Start celebrating - const initial = shouldShowCelebrationTooltip(true, 5, 5, true); - expect(initial.showCelebration).toBe(true); + const initial = shouldShowCelebrationTooltip(true, 5, 5, true) + expect(initial.showCelebration).toBe(true) // Move away - const away = shouldShowCelebrationTooltip(true, 6, 5, true); - expect(away.showCelebration).toBe(false); - expect(away.showInstructions).toBe(true); + const away = shouldShowCelebrationTooltip(true, 6, 5, true) + expect(away.showCelebration).toBe(false) + expect(away.showInstructions).toBe(true) // Return to target - const returned = shouldShowCelebrationTooltip(true, 5, 5, true); - expect(returned.showCelebration).toBe(true); - expect(returned.showInstructions).toBe(false); - }); - }); + const returned = shouldShowCelebrationTooltip(true, 5, 5, true) + expect(returned.showCelebration).toBe(true) + expect(returned.showInstructions).toBe(false) + }) + }) - describe("Edge cases", () => { - it("should handle negative values correctly", () => { - const result = shouldShowCelebrationTooltip(true, -3, -3, false); - expect(result.showCelebration).toBe(true); - }); + describe('Edge cases', () => { + it('should handle negative values correctly', () => { + const result = shouldShowCelebrationTooltip(true, -3, -3, false) + expect(result.showCelebration).toBe(true) + }) - it("should handle zero values correctly", () => { - const result = shouldShowCelebrationTooltip(true, 0, 0, false); - expect(result.showCelebration).toBe(true); - }); + it('should handle zero values correctly', () => { + const result = shouldShowCelebrationTooltip(true, 0, 0, false) + expect(result.showCelebration).toBe(true) + }) - it("should handle large values correctly", () => { - const result = shouldShowCelebrationTooltip(true, 99999, 99999, false); - expect(result.showCelebration).toBe(true); - }); + it('should handle large values correctly', () => { + const result = shouldShowCelebrationTooltip(true, 99999, 99999, false) + expect(result.showCelebration).toBe(true) + }) - it("should prioritize celebration over instructions", () => { + it('should prioritize celebration over instructions', () => { // When both conditions could be true, celebration takes priority - const result = shouldShowCelebrationTooltip(true, 5, 5, true); - expect(result.showCelebration).toBe(true); - expect(result.showInstructions).toBe(false); - }); - }); -}); + const result = shouldShowCelebrationTooltip(true, 5, 5, true) + expect(result.showCelebration).toBe(true) + expect(result.showInstructions).toBe(false) + }) + }) +}) /** * Tests for last moved bead tracking logic */ -describe("Last Moved Bead Tracking", () => { +describe('Last Moved Bead Tracking', () => { interface StepBeadHighlight { - placeValue: number; - beadType: "earth" | "heaven"; - position: number; - direction: string; + placeValue: number + beadType: 'earth' | 'heaven' + position: number + direction: string } // Helper function that mimics the bead selection logic @@ -172,115 +172,110 @@ describe("Last Moved Bead Tracking", () => { showCelebration: boolean, showInstructions: boolean, currentStepBeads: StepBeadHighlight[] | null, - lastMovedBead: StepBeadHighlight | null, + lastMovedBead: StepBeadHighlight | null ): StepBeadHighlight | null { if (showCelebration) { // For celebration, use last moved bead or fallback if (lastMovedBead) { - return lastMovedBead; + return lastMovedBead } else { // Fallback to ones place heaven bead return { placeValue: 0, - beadType: "heaven", + beadType: 'heaven', position: 0, - direction: "none", - }; + direction: 'none', + } } } else if (showInstructions && currentStepBeads?.length) { // For instructions, use first bead with arrows - return currentStepBeads.find((bead) => bead.direction !== "none") || null; + return currentStepBeads.find((bead) => bead.direction !== 'none') || null } - return null; + return null } - describe("Bead selection for celebration", () => { - it("should use last moved bead when available", () => { + describe('Bead selection for celebration', () => { + it('should use last moved bead when available', () => { const lastMoved: StepBeadHighlight = { placeValue: 2, - beadType: "earth", + beadType: 'earth', position: 1, - direction: "down", - }; + direction: 'down', + } - const result = selectTooltipBead(true, false, null, lastMoved); - expect(result).toEqual(lastMoved); - }); + const result = selectTooltipBead(true, false, null, lastMoved) + expect(result).toEqual(lastMoved) + }) - it("should use fallback when no last moved bead", () => { - const result = selectTooltipBead(true, false, null, null); + it('should use fallback when no last moved bead', () => { + const result = selectTooltipBead(true, false, null, null) expect(result).toEqual({ placeValue: 0, - beadType: "heaven", + beadType: 'heaven', position: 0, - direction: "none", - }); - }); + direction: 'none', + }) + }) - it("should use instruction bead when showing instructions", () => { + it('should use instruction bead when showing instructions', () => { const instructionBeads: StepBeadHighlight[] = [ { placeValue: 1, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', }, - ]; + ] - const result = selectTooltipBead(false, true, instructionBeads, null); - expect(result).toEqual(instructionBeads[0]); - }); + const result = selectTooltipBead(false, true, instructionBeads, null) + expect(result).toEqual(instructionBeads[0]) + }) - it("should return null when no conditions met", () => { - const result = selectTooltipBead(false, false, null, null); - expect(result).toBeNull(); - }); - }); + it('should return null when no conditions met', () => { + const result = selectTooltipBead(false, false, null, null) + expect(result).toBeNull() + }) + }) - describe("Bead priority logic", () => { - it("should prefer last moved bead over instruction bead for celebration", () => { + describe('Bead priority logic', () => { + it('should prefer last moved bead over instruction bead for celebration', () => { const lastMoved: StepBeadHighlight = { placeValue: 3, - beadType: "heaven", + beadType: 'heaven', position: 0, - direction: "up", - }; + direction: 'up', + } const instructionBeads: StepBeadHighlight[] = [ { placeValue: 1, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "down", + direction: 'down', }, - ]; + ] - const result = selectTooltipBead( - true, - false, - instructionBeads, - lastMoved, - ); - expect(result).toEqual(lastMoved); - }); + const result = selectTooltipBead(true, false, instructionBeads, lastMoved) + expect(result).toEqual(lastMoved) + }) - it("should use fallback only when no last moved bead available", () => { + it('should use fallback only when no last moved bead available', () => { const instructionBeads: StepBeadHighlight[] = [ { placeValue: 1, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "down", + direction: 'down', }, - ]; + ] - const result = selectTooltipBead(true, false, instructionBeads, null); + const result = selectTooltipBead(true, false, instructionBeads, null) // Should use fallback, not instruction bead - expect(result?.placeValue).toBe(0); - expect(result?.beadType).toBe("heaven"); - }); - }); -}); + expect(result?.placeValue).toBe(0) + expect(result?.beadType).toBe('heaven') + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/DecompositionWithReasons.provenance.test.tsx b/apps/web/src/components/tutorial/__tests__/DecompositionWithReasons.provenance.test.tsx index 14cd610a..471776d3 100644 --- a/apps/web/src/components/tutorial/__tests__/DecompositionWithReasons.provenance.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/DecompositionWithReasons.provenance.test.tsx @@ -1,21 +1,15 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import type React from "react"; -import { describe, expect, it, vi } from "vitest"; -import { generateUnifiedInstructionSequence } from "../../../utils/unifiedStepGenerator"; -import { DecompositionWithReasons } from "../DecompositionWithReasons"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { generateUnifiedInstructionSequence } from '../../../utils/unifiedStepGenerator' +import { DecompositionWithReasons } from '../DecompositionWithReasons' // Mock Radix Tooltip so it renders content immediately -vi.mock("@radix-ui/react-tooltip", () => ({ +vi.mock('@radix-ui/react-tooltip', () => ({ Provider: ({ children }: { children: React.ReactNode }) => (
{children}
), - Root: ({ - children, - open = true, - }: { - children: React.ReactNode; - open?: boolean; - }) => ( + Root: ({ children, open = true }: { children: React.ReactNode; open?: boolean }) => (
{children}
@@ -26,19 +20,13 @@ vi.mock("@radix-ui/react-tooltip", () => ({ Portal: ({ children }: { children: React.ReactNode }) => (
{children}
), - Content: ({ - children, - ...props - }: { - children: React.ReactNode; - [key: string]: any; - }) => ( + Content: ({ children, ...props }: { children: React.ReactNode; [key: string]: any }) => (
{children}
), Arrow: (props: any) =>
, -})); +})) // Mock the tutorial context const mockTutorialContext = { @@ -47,30 +35,30 @@ const mockTutorialContext = { // other state properties as needed }, // other context methods -}; +} -vi.mock("../TutorialContext", () => ({ +vi.mock('../TutorialContext', () => ({ useTutorialContext: () => mockTutorialContext, -})); +})) -describe("DecompositionWithReasons Provenance Test", () => { - it("should render provenance information in tooltip for 3475 + 25 = 3500 example", async () => { +describe('DecompositionWithReasons Provenance Test', () => { + it('should render provenance information in tooltip for 3475 + 25 = 3500 example', async () => { // Generate the actual data for 3475 + 25 = 3500 - const result = generateUnifiedInstructionSequence(3475, 3500); + const result = generateUnifiedInstructionSequence(3475, 3500) - console.log("Generated result:", { + console.log('Generated result:', { fullDecomposition: result.fullDecomposition, stepsCount: result.steps.length, segmentsCount: result.segments.length, - }); + }) - console.log("Steps with provenance:"); + console.log('Steps with provenance:') result.steps.forEach((step, i) => { console.log( `Step ${i}: ${step.mathematicalTerm}`, - step.provenance ? "HAS PROVENANCE" : "NO PROVENANCE", - ); - }); + step.provenance ? 'HAS PROVENANCE' : 'NO PROVENANCE' + ) + }) // Render the DecompositionWithReasons component render( @@ -79,151 +67,140 @@ describe("DecompositionWithReasons Provenance Test", () => { termPositions={result.steps.map((step) => step.termPosition)} segments={result.segments} steps={result.steps} - />, - ); + /> + ) // The decomposition should be rendered expect( - screen.getByText(/3475 \+ 25 = 3475 \+ 20 \+ \(100 - 90 - 5\) = 3500/), - ).toBeInTheDocument(); + screen.getByText(/3475 \+ 25 = 3475 \+ 20 \+ \(100 - 90 - 5\) = 3500/) + ).toBeInTheDocument() // Find the "20" term - const twentyElement = screen.getByText("20"); - expect(twentyElement).toBeInTheDocument(); + const twentyElement = screen.getByText('20') + expect(twentyElement).toBeInTheDocument() // Simulate mouse enter to trigger tooltip - fireEvent.mouseEnter(twentyElement); + fireEvent.mouseEnter(twentyElement) // Wait for tooltip content to appear await waitFor(() => { - const tooltipContent = screen.getByTestId("tooltip-content"); - expect(tooltipContent).toBeInTheDocument(); - }); + const tooltipContent = screen.getByTestId('tooltip-content') + expect(tooltipContent).toBeInTheDocument() + }) // Check if the enhanced provenance title appears await waitFor(() => { - const provenanceTitle = screen.queryByText( - "Add the tens digit — 2 tens (20)", - ); + const provenanceTitle = screen.queryByText('Add the tens digit — 2 tens (20)') if (provenanceTitle) { - expect(provenanceTitle).toBeInTheDocument(); - console.log("✅ Found provenance title!"); + expect(provenanceTitle).toBeInTheDocument() + console.log('✅ Found provenance title!') } else { - console.log("❌ Provenance title not found"); + console.log('❌ Provenance title not found') // Log what we actually got - const tooltipContent = screen.getByTestId("tooltip-content"); - console.log("Actual tooltip content:", tooltipContent.textContent); + const tooltipContent = screen.getByTestId('tooltip-content') + console.log('Actual tooltip content:', tooltipContent.textContent) } - }); + }) // Check for the provenance subtitle - const provenanceSubtitle = screen.queryByText("From addend 25"); + const provenanceSubtitle = screen.queryByText('From addend 25') if (provenanceSubtitle) { - expect(provenanceSubtitle).toBeInTheDocument(); - console.log("✅ Found provenance subtitle!"); + expect(provenanceSubtitle).toBeInTheDocument() + console.log('✅ Found provenance subtitle!') } else { - console.log("❌ Provenance subtitle not found"); + console.log('❌ Provenance subtitle not found') } // Check for the enhanced explanation - const provenanceExplanation = screen.queryByText( - /We're adding the tens digit of 25 → 2 tens/, - ); + const provenanceExplanation = screen.queryByText(/We're adding the tens digit of 25 → 2 tens/) if (provenanceExplanation) { - expect(provenanceExplanation).toBeInTheDocument(); - console.log("✅ Found provenance explanation!"); + expect(provenanceExplanation).toBeInTheDocument() + console.log('✅ Found provenance explanation!') } else { - console.log("❌ Provenance explanation not found"); + console.log('❌ Provenance explanation not found') } - }); + }) - it("should pass provenance data from steps to ReasonTooltip", () => { + it('should pass provenance data from steps to ReasonTooltip', () => { // Generate test data - const result = generateUnifiedInstructionSequence(3475, 3500); - const twentyStep = result.steps.find( - (step) => step.mathematicalTerm === "20", - ); + const result = generateUnifiedInstructionSequence(3475, 3500) + const twentyStep = result.steps.find((step) => step.mathematicalTerm === '20') // Verify the step has provenance - expect(twentyStep).toBeDefined(); - expect(twentyStep?.provenance).toBeDefined(); + expect(twentyStep).toBeDefined() + expect(twentyStep?.provenance).toBeDefined() if (twentyStep?.provenance) { - console.log("✅ Step has provenance data:", twentyStep.provenance); + console.log('✅ Step has provenance data:', twentyStep.provenance) // Verify the provenance data is correct - expect(twentyStep.provenance.rhs).toBe(25); - expect(twentyStep.provenance.rhsDigit).toBe(2); - expect(twentyStep.provenance.rhsPlaceName).toBe("tens"); - expect(twentyStep.provenance.rhsValue).toBe(20); + expect(twentyStep.provenance.rhs).toBe(25) + expect(twentyStep.provenance.rhsDigit).toBe(2) + expect(twentyStep.provenance.rhsPlaceName).toBe('tens') + expect(twentyStep.provenance.rhsValue).toBe(20) - console.log("✅ Provenance data is correct!"); + console.log('✅ Provenance data is correct!') } else { - console.log("❌ Step does not have provenance data"); + console.log('❌ Step does not have provenance data') } // Find the corresponding segment const tensSegment = result.segments.find((seg) => - seg.stepIndices.includes(twentyStep!.stepIndex), - ); - expect(tensSegment).toBeDefined(); + seg.stepIndices.includes(twentyStep!.stepIndex) + ) + expect(tensSegment).toBeDefined() if (tensSegment) { - console.log("✅ Found corresponding segment:", { + console.log('✅ Found corresponding segment:', { id: tensSegment.id, rule: tensSegment.plan[0]?.rule, stepIndices: tensSegment.stepIndices, - }); + }) } - }); + }) - it("should debug the actual data flow", () => { - const result = generateUnifiedInstructionSequence(3475, 3500); + it('should debug the actual data flow', () => { + const result = generateUnifiedInstructionSequence(3475, 3500) - console.log("\n=== DEBUGGING DATA FLOW ==="); - console.log("Full decomposition:", result.fullDecomposition); + console.log('\n=== DEBUGGING DATA FLOW ===') + console.log('Full decomposition:', result.fullDecomposition) - console.log("\nSteps:"); + console.log('\nSteps:') result.steps.forEach((step, i) => { console.log( ` ${i}: ${step.mathematicalTerm} - segmentId: ${step.segmentId} - provenance:`, - !!step.provenance, - ); + !!step.provenance + ) if (step.provenance) { console.log( - ` -> rhs: ${step.provenance.rhs}, digit: ${step.provenance.rhsDigit}, place: ${step.provenance.rhsPlaceName}`, - ); + ` -> rhs: ${step.provenance.rhs}, digit: ${step.provenance.rhsDigit}, place: ${step.provenance.rhsPlaceName}` + ) } - }); + }) - console.log("\nSegments:"); + console.log('\nSegments:') result.segments.forEach((segment, i) => { console.log( - ` ${i}: ${segment.id} - place: ${segment.place}, digit: ${segment.digit}, rule: ${segment.plan[0]?.rule}`, - ); - console.log(` -> stepIndices: [${segment.stepIndices.join(", ")}]`); - console.log(` -> readable title: "${segment.readable?.title}"`); - }); + ` ${i}: ${segment.id} - place: ${segment.place}, digit: ${segment.digit}, rule: ${segment.plan[0]?.rule}` + ) + console.log(` -> stepIndices: [${segment.stepIndices.join(', ')}]`) + console.log(` -> readable title: "${segment.readable?.title}"`) + }) // The key insight: when DecompositionWithReasons renders a SegmentGroup, // it should pass the provenance from the first step in that segment to ReasonTooltip - const twentyStep = result.steps.find( - (step) => step.mathematicalTerm === "20", - ); + const twentyStep = result.steps.find((step) => step.mathematicalTerm === '20') const tensSegment = result.segments.find((seg) => - seg.stepIndices.includes(twentyStep!.stepIndex), - ); + seg.stepIndices.includes(twentyStep!.stepIndex) + ) if (twentyStep && tensSegment) { - console.log("\n=== TOOLTIP DATA FLOW ==="); - console.log("Step provenance:", twentyStep.provenance); - console.log("Segment readable:", tensSegment.readable); - console.log( - "Expected to show enhanced content:", - !!twentyStep.provenance, - ); + console.log('\n=== TOOLTIP DATA FLOW ===') + console.log('Step provenance:', twentyStep.provenance) + console.log('Segment readable:', tensSegment.readable) + console.log('Expected to show enhanced content:', !!twentyStep.provenance) } - expect(true).toBe(true); // This test just logs information - }); -}); + expect(true).toBe(true) // This test just logs information + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/ReasonTooltip.provenance.test.tsx b/apps/web/src/components/tutorial/__tests__/ReasonTooltip.provenance.test.tsx index 17f0d70f..1da6c31d 100644 --- a/apps/web/src/components/tutorial/__tests__/ReasonTooltip.provenance.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/ReasonTooltip.provenance.test.tsx @@ -1,93 +1,88 @@ -import { render, screen } from "@testing-library/react"; -import type React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { - PedagogicalSegment, - TermProvenance, -} from "../../../utils/unifiedStepGenerator"; -import { ReasonTooltip } from "../ReasonTooltip"; +import { render, screen } from '@testing-library/react' +import type React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PedagogicalSegment, TermProvenance } from '../../../utils/unifiedStepGenerator' +import { ReasonTooltip } from '../ReasonTooltip' // Mock the Radix Tooltip to make testing easier const MockTooltipProvider = ({ children }: { children: React.ReactNode }) => (
{children}
-); +) const MockTooltipRoot = ({ children, open = true, }: { - children: React.ReactNode; - open?: boolean; + children: React.ReactNode + open?: boolean }) => (
{children}
-); +) const MockTooltipTrigger = ({ children, asChild, }: { - children: React.ReactNode; - asChild?: boolean; -}) =>
{children}
; + children: React.ReactNode + asChild?: boolean +}) =>
{children}
const MockTooltipPortal = ({ children }: { children: React.ReactNode }) => (
{children}
-); +) const MockTooltipContent = ({ children, ...props }: { - children: React.ReactNode; - [key: string]: any; + children: React.ReactNode + [key: string]: any }) => (
{children}
-); +) -const MockTooltipArrow = (props: any) => ( -
-); +const MockTooltipArrow = (props: any) =>
// Mock Radix UI components -vi.mock("@radix-ui/react-tooltip", () => ({ +vi.mock('@radix-ui/react-tooltip', () => ({ Provider: MockTooltipProvider, Root: MockTooltipRoot, Trigger: MockTooltipTrigger, Portal: MockTooltipPortal, Content: MockTooltipContent, Arrow: MockTooltipArrow, -})); +})) -describe("ReasonTooltip with Provenance", () => { +describe('ReasonTooltip with Provenance', () => { const mockProvenance: TermProvenance = { rhs: 25, rhsDigit: 2, rhsPlace: 1, - rhsPlaceName: "tens", + rhsPlaceName: 'tens', rhsDigitIndex: 0, rhsValue: 20, - }; + } const mockSegment: PedagogicalSegment = { - id: "place-1-digit-2", + id: 'place-1-digit-2', place: 1, digit: 2, a: 7, L: 2, U: 0, - goal: "Increase tens by 2 without carry", + goal: 'Increase tens by 2 without carry', plan: [ { - rule: "Direct", - conditions: ["a+d=7+2=9 ≤ 9"], - explanation: ["Fits inside this place; add earth beads directly."], + rule: 'Direct', + conditions: ['a+d=7+2=9 ≤ 9'], + explanation: ['Fits inside this place; add earth beads directly.'], }, ], - expression: "20", + expression: '20', stepIndices: [0], termIndices: [0], termRange: { startIndex: 10, endIndex: 12 }, @@ -96,16 +91,16 @@ describe("ReasonTooltip with Provenance", () => { startState: {}, endState: {}, readable: { - title: "Direct Add — tens", - subtitle: "Simple bead movement", + title: 'Direct Add — tens', + subtitle: 'Simple bead movement', chips: [ - { label: "This rod shows", value: "7" }, - { label: "We're adding", value: "2" }, + { label: 'This rod shows', value: '7' }, + { label: "We're adding", value: '2' }, ], - why: ["We can add beads directly to this rod."], - stepsFriendly: ["Add 2 earth beads in tens column"], + why: ['We can add beads directly to this rod.'], + stepsFriendly: ['Add 2 earth beads in tens column'], }, - }; + } const defaultProps = { termIndex: 0, @@ -113,95 +108,87 @@ describe("ReasonTooltip with Provenance", () => { open: true, onOpenChange: vi.fn(), provenance: mockProvenance, - }; + } beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - it("should display enhanced title with provenance", () => { + it('should display enhanced title with provenance', () => { render( 20 - , - ); + + ) // Should show the enhanced title format - expect( - screen.getByText("Add the tens digit — 2 tens (20)"), - ).toBeInTheDocument(); - }); + expect(screen.getByText('Add the tens digit — 2 tens (20)')).toBeInTheDocument() + }) - it("should display enhanced subtitle with provenance", () => { + it('should display enhanced subtitle with provenance', () => { render( 20 - , - ); + + ) // Should show the enhanced subtitle - expect(screen.getByText("From addend 25")).toBeInTheDocument(); - }); + expect(screen.getByText('From addend 25')).toBeInTheDocument() + }) - it("should display enhanced breadcrumb chips", () => { + it('should display enhanced breadcrumb chips', () => { render( 20 - , - ); + + ) // Should show enhanced chips - expect( - screen.getByText(/Digit we're using: 2 \(tens\)/), - ).toBeInTheDocument(); - expect(screen.getByText(/This rod shows: 7/)).toBeInTheDocument(); - expect( - screen.getByText(/So we add here: \+2 tens → 20/), - ).toBeInTheDocument(); - }); + expect(screen.getByText(/Digit we're using: 2 \(tens\)/)).toBeInTheDocument() + expect(screen.getByText(/This rod shows: 7/)).toBeInTheDocument() + expect(screen.getByText(/So we add here: \+2 tens → 20/)).toBeInTheDocument() + }) - it("should display provenance-based explanation for Direct rule", () => { + it('should display provenance-based explanation for Direct rule', () => { render( 20 - , - ); + + ) // Should show the enhanced explanation - expect( - screen.getByText(/We're adding the tens digit of 25 → 2 tens/), - ).toBeInTheDocument(); - }); + expect(screen.getByText(/We're adding the tens digit of 25 → 2 tens/)).toBeInTheDocument() + }) - it("should handle complement operations with group ID", () => { + it('should handle complement operations with group ID', () => { const complementProvenance: TermProvenance = { rhs: 25, rhsDigit: 5, rhsPlace: 0, - rhsPlaceName: "ones", + rhsPlaceName: 'ones', rhsDigitIndex: 1, rhsValue: 5, - groupId: "10comp-0-5", - }; + groupId: '10comp-0-5', + } const complementSegment: PedagogicalSegment = { ...mockSegment, - id: "place-0-digit-5", + id: 'place-0-digit-5', place: 0, digit: 5, plan: [ { - rule: "TenComplement", - conditions: ["a+d=5+5=10 ≥ 10"], - explanation: ["Need a carry to the next higher place."], + rule: 'TenComplement', + conditions: ['a+d=5+5=10 ≥ 10'], + explanation: ['Need a carry to the next higher place.'], }, ], readable: { ...mockSegment.readable, - title: "Make 10 — ones", - subtitle: "Using pairs that make 10", + title: 'Make 10 — ones', + subtitle: 'Using pairs that make 10', }, - }; + } render( { provenance={complementProvenance} > 100 - , - ); + + ) // Should show the enhanced title for complement operations - expect( - screen.getByText("Add the ones digit — 5 ones (5)"), - ).toBeInTheDocument(); - expect(screen.getByText("From addend 25")).toBeInTheDocument(); - }); + expect(screen.getByText('Add the ones digit — 5 ones (5)')).toBeInTheDocument() + expect(screen.getByText('From addend 25')).toBeInTheDocument() + }) - it("should fallback to readable content when provenance is not available", () => { + it('should fallback to readable content when provenance is not available', () => { render( 20 - , - ); + + ) // Should show the fallback title and content - expect(screen.getByText("Direct Add — tens")).toBeInTheDocument(); - expect(screen.getByText("Simple bead movement")).toBeInTheDocument(); - expect( - screen.getByText(/We can add beads directly to this rod/), - ).toBeInTheDocument(); - }); + expect(screen.getByText('Direct Add — tens')).toBeInTheDocument() + expect(screen.getByText('Simple bead movement')).toBeInTheDocument() + expect(screen.getByText(/We can add beads directly to this rod/)).toBeInTheDocument() + }) - it("should not render enhanced content when no rule is provided", () => { + it('should not render enhanced content when no rule is provided', () => { const segmentWithoutRule = { ...mockSegment, plan: [], - }; + } render( 20 - , - ); + + ) // Should just render the children without any tooltip - expect(screen.getByText("20")).toBeInTheDocument(); - expect(screen.queryByTestId("tooltip-content")).not.toBeInTheDocument(); - }); + expect(screen.getByText('20')).toBeInTheDocument() + expect(screen.queryByTestId('tooltip-content')).not.toBeInTheDocument() + }) - it("should log debug information when provenance is provided", () => { - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + it('should log debug information when provenance is provided', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) render( 20 - , - ); + + ) // Should log debug information + expect(consoleSpy).toHaveBeenCalledWith('ReasonTooltip - provenance data:', mockProvenance) + expect(consoleSpy).toHaveBeenCalledWith('ReasonTooltip - rule:', 'Direct') expect(consoleSpy).toHaveBeenCalledWith( - "ReasonTooltip - provenance data:", - mockProvenance, - ); - expect(consoleSpy).toHaveBeenCalledWith("ReasonTooltip - rule:", "Direct"); - expect(consoleSpy).toHaveBeenCalledWith( - "ReasonTooltip - enhancedContent:", + 'ReasonTooltip - enhancedContent:', expect.objectContaining({ - title: "Add the tens digit — 2 tens (20)", - subtitle: "From addend 25", + title: 'Add the tens digit — 2 tens (20)', + subtitle: 'From addend 25', chips: expect.arrayContaining([ expect.objectContaining({ label: "Digit we're using", - value: "2 (tens)", + value: '2 (tens)', }), ]), - }), - ); + }) + ) - consoleSpy.mockRestore(); - }); + consoleSpy.mockRestore() + }) - it("should handle the exact 3475 + 25 = 3500 example", () => { + it('should handle the exact 3475 + 25 = 3500 example', () => { // Test with the exact provenance data from our example const exactProvenance: TermProvenance = { rhs: 25, rhsDigit: 2, rhsPlace: 1, - rhsPlaceName: "tens", + rhsPlaceName: 'tens', rhsDigitIndex: 0, // '2' is the first digit in '25' rhsValue: 20, - }; + } render( 20 - , - ); + + ) // Verify all the expected enhanced content - expect( - screen.getByText("Add the tens digit — 2 tens (20)"), - ).toBeInTheDocument(); - expect(screen.getByText("From addend 25")).toBeInTheDocument(); - expect( - screen.getByText(/We're adding the tens digit of 25 → 2 tens/), - ).toBeInTheDocument(); + expect(screen.getByText('Add the tens digit — 2 tens (20)')).toBeInTheDocument() + expect(screen.getByText('From addend 25')).toBeInTheDocument() + expect(screen.getByText(/We're adding the tens digit of 25 → 2 tens/)).toBeInTheDocument() // Verify the chips show the digit transformation clearly - expect( - screen.getByText(/Digit we're using: 2 \(tens\)/), - ).toBeInTheDocument(); - expect( - screen.getByText(/So we add here: \+2 tens → 20/), - ).toBeInTheDocument(); - }); -}); + expect(screen.getByText(/Digit we're using: 2 \(tens\)/)).toBeInTheDocument() + expect(screen.getByText(/So we add here: \+2 tens → 20/)).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/ReasonTooltip.simple.test.tsx b/apps/web/src/components/tutorial/__tests__/ReasonTooltip.simple.test.tsx index 99852083..c0ef4346 100644 --- a/apps/web/src/components/tutorial/__tests__/ReasonTooltip.simple.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/ReasonTooltip.simple.test.tsx @@ -1,35 +1,35 @@ -import { describe, expect, it } from "vitest"; -import type { TermProvenance } from "../../../utils/unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import type { TermProvenance } from '../../../utils/unifiedStepGenerator' // Simple unit test for the tooltip content generation logic -describe("ReasonTooltip Provenance Logic", () => { - it("should generate correct enhanced title from provenance", () => { +describe('ReasonTooltip Provenance Logic', () => { + it('should generate correct enhanced title from provenance', () => { const provenance: TermProvenance = { rhs: 25, rhsDigit: 2, rhsPlace: 1, - rhsPlaceName: "tens", + rhsPlaceName: 'tens', rhsDigitIndex: 0, rhsValue: 20, - }; + } // This is the logic from getEnhancedTooltipContent - const title = `Add the ${provenance.rhsPlaceName} digit — ${provenance.rhsDigit} ${provenance.rhsPlaceName} (${provenance.rhsValue})`; - const subtitle = `From addend ${provenance.rhs}`; + const title = `Add the ${provenance.rhsPlaceName} digit — ${provenance.rhsDigit} ${provenance.rhsPlaceName} (${provenance.rhsValue})` + const subtitle = `From addend ${provenance.rhs}` - expect(title).toBe("Add the tens digit — 2 tens (20)"); - expect(subtitle).toBe("From addend 25"); - }); + expect(title).toBe('Add the tens digit — 2 tens (20)') + expect(subtitle).toBe('From addend 25') + }) - it("should generate correct breadcrumb chips from provenance", () => { + it('should generate correct breadcrumb chips from provenance', () => { const provenance: TermProvenance = { rhs: 25, rhsDigit: 2, rhsPlace: 1, - rhsPlaceName: "tens", + rhsPlaceName: 'tens', rhsDigitIndex: 0, rhsValue: 20, - }; + } // This is the logic from getEnhancedTooltipContent const enhancedChips = [ @@ -38,98 +38,98 @@ describe("ReasonTooltip Provenance Logic", () => { value: `${provenance.rhsDigit} (${provenance.rhsPlaceName})`, }, { - label: "So we add here", + label: 'So we add here', value: `+${provenance.rhsDigit} ${provenance.rhsPlaceName} → ${provenance.rhsValue}`, }, - ]; + ] expect(enhancedChips[0]).toEqual({ label: "Digit we're using", - value: "2 (tens)", - }); + value: '2 (tens)', + }) expect(enhancedChips[1]).toEqual({ - label: "So we add here", - value: "+2 tens → 20", - }); - }); + label: 'So we add here', + value: '+2 tens → 20', + }) + }) - it("should generate correct explanation text for Direct rule", () => { + it('should generate correct explanation text for Direct rule', () => { const provenance: TermProvenance = { rhs: 25, rhsDigit: 2, rhsPlace: 1, - rhsPlaceName: "tens", + rhsPlaceName: 'tens', rhsDigitIndex: 0, rhsValue: 20, - }; + } // This is the logic from the "Why this step" section - const explanationText = `We're adding the ${provenance.rhsPlaceName} digit of ${provenance.rhs} → ${provenance.rhsDigit} ${provenance.rhsPlaceName}.`; + const explanationText = `We're adding the ${provenance.rhsPlaceName} digit of ${provenance.rhs} → ${provenance.rhsDigit} ${provenance.rhsPlaceName}.` - expect(explanationText).toBe("We're adding the tens digit of 25 → 2 tens."); - }); + expect(explanationText).toBe("We're adding the tens digit of 25 → 2 tens.") + }) - it("should handle ones digit provenance correctly", () => { + it('should handle ones digit provenance correctly', () => { const onesProvenance: TermProvenance = { rhs: 25, rhsDigit: 5, rhsPlace: 0, - rhsPlaceName: "ones", + rhsPlaceName: 'ones', rhsDigitIndex: 1, // '5' is the second digit in '25' rhsValue: 5, - }; + } - const title = `Add the ${onesProvenance.rhsPlaceName} digit — ${onesProvenance.rhsDigit} ${onesProvenance.rhsPlaceName} (${onesProvenance.rhsValue})`; - const subtitle = `From addend ${onesProvenance.rhs}`; + const title = `Add the ${onesProvenance.rhsPlaceName} digit — ${onesProvenance.rhsDigit} ${onesProvenance.rhsPlaceName} (${onesProvenance.rhsValue})` + const subtitle = `From addend ${onesProvenance.rhs}` - expect(title).toBe("Add the ones digit — 5 ones (5)"); - expect(subtitle).toBe("From addend 25"); - }); + expect(title).toBe('Add the ones digit — 5 ones (5)') + expect(subtitle).toBe('From addend 25') + }) - it("should handle complement operations with group ID", () => { + it('should handle complement operations with group ID', () => { const complementProvenance: TermProvenance = { rhs: 25, rhsDigit: 5, rhsPlace: 0, - rhsPlaceName: "ones", + rhsPlaceName: 'ones', rhsDigitIndex: 1, rhsValue: 5, - groupId: "10comp-0-5", - }; + groupId: '10comp-0-5', + } // All terms in a complement group should trace back to the same source digit - expect(complementProvenance.groupId).toBe("10comp-0-5"); - expect(complementProvenance.rhsDigit).toBe(5); - expect(complementProvenance.rhs).toBe(25); + expect(complementProvenance.groupId).toBe('10comp-0-5') + expect(complementProvenance.rhsDigit).toBe(5) + expect(complementProvenance.rhs).toBe(25) // The title should still show the source digit correctly - const title = `Add the ${complementProvenance.rhsPlaceName} digit — ${complementProvenance.rhsDigit} ${complementProvenance.rhsPlaceName} (${complementProvenance.rhsValue})`; - expect(title).toBe("Add the ones digit — 5 ones (5)"); - }); + const title = `Add the ${complementProvenance.rhsPlaceName} digit — ${complementProvenance.rhsDigit} ${complementProvenance.rhsPlaceName} (${complementProvenance.rhsValue})` + expect(title).toBe('Add the ones digit — 5 ones (5)') + }) - it("should handle the exact 3475 + 25 = 3500 example", () => { + it('should handle the exact 3475 + 25 = 3500 example', () => { // Test the exact scenario from the user's request const tensDigitProvenance: TermProvenance = { rhs: 25, rhsDigit: 2, rhsPlace: 1, - rhsPlaceName: "tens", + rhsPlaceName: 'tens', rhsDigitIndex: 0, // '2' is the first character in '25' rhsValue: 20, // 2 * 10^1 = 20 - }; + } // This should generate the exact text the user is expecting - const title = `Add the ${tensDigitProvenance.rhsPlaceName} digit — ${tensDigitProvenance.rhsDigit} ${tensDigitProvenance.rhsPlaceName} (${tensDigitProvenance.rhsValue})`; - const subtitle = `From addend ${tensDigitProvenance.rhs}`; - const explanation = `We're adding the ${tensDigitProvenance.rhsPlaceName} digit of ${tensDigitProvenance.rhs} → ${tensDigitProvenance.rhsDigit} ${tensDigitProvenance.rhsPlaceName}.`; + const title = `Add the ${tensDigitProvenance.rhsPlaceName} digit — ${tensDigitProvenance.rhsDigit} ${tensDigitProvenance.rhsPlaceName} (${tensDigitProvenance.rhsValue})` + const subtitle = `From addend ${tensDigitProvenance.rhs}` + const explanation = `We're adding the ${tensDigitProvenance.rhsPlaceName} digit of ${tensDigitProvenance.rhs} → ${tensDigitProvenance.rhsDigit} ${tensDigitProvenance.rhsPlaceName}.` - expect(title).toBe("Add the tens digit — 2 tens (20)"); - expect(subtitle).toBe("From addend 25"); - expect(explanation).toBe("We're adding the tens digit of 25 → 2 tens."); + expect(title).toBe('Add the tens digit — 2 tens (20)') + expect(subtitle).toBe('From addend 25') + expect(explanation).toBe("We're adding the tens digit of 25 → 2 tens.") // The key insight: the "20" pill now explicitly shows it came from the "2" in "25" - expect(tensDigitProvenance.rhsDigitIndex).toBe(0); // Points to the '2' in '25' - expect(tensDigitProvenance.rhsValue).toBe(20); // Shows the transformation 2 → 20 - }); -}); + expect(tensDigitProvenance.rhsDigitIndex).toBe(0) // Points to the '2' in '25' + expect(tensDigitProvenance.rhsValue).toBe(20) // Shows the transformation 2 → 20 + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialCelebrationTooltip.e2e.test.ts b/apps/web/src/components/tutorial/__tests__/TutorialCelebrationTooltip.e2e.test.ts index 0a653fad..b91e92b7 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialCelebrationTooltip.e2e.test.ts +++ b/apps/web/src/components/tutorial/__tests__/TutorialCelebrationTooltip.e2e.test.ts @@ -1,287 +1,271 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from '@playwright/test' -test.describe("Tutorial Celebration Tooltip E2E", () => { +test.describe('Tutorial Celebration Tooltip E2E', () => { test.beforeEach(async ({ page }) => { // Navigate to tutorial editor with a simple addition problem - await page.goto("/tutorial-editor"); + await page.goto('/tutorial-editor') // Wait for page to load - await page.waitForLoadState("networkidle"); + await page.waitForLoadState('networkidle') // Create a simple tutorial for testing await page.evaluate(() => { const tutorial = { - id: "celebration-e2e-test", - title: "Celebration E2E Test", - description: "Testing celebration tooltip in real browser", + id: 'celebration-e2e-test', + title: 'Celebration E2E Test', + description: 'Testing celebration tooltip in real browser', steps: [ { - id: "step-1", - title: "Add Two", - problem: "3 + 2", - description: "Add 2 to the starting value of 3", + id: 'step-1', + title: 'Add Two', + problem: '3 + 2', + description: 'Add 2 to the starting value of 3', startValue: 3, targetValue: 5, }, ], - }; + } // Store in localStorage for the tutorial player - localStorage.setItem("current-tutorial", JSON.stringify(tutorial)); - }); + localStorage.setItem('current-tutorial', JSON.stringify(tutorial)) + }) // Reload to pick up the tutorial - await page.reload(); - await page.waitForLoadState("networkidle"); - }); + await page.reload() + await page.waitForLoadState('networkidle') + }) - test("celebration tooltip appears when reaching target value", async ({ - page, - }) => { + test('celebration tooltip appears when reaching target value', async ({ page }) => { // Wait for tutorial to load - await expect(page.locator("text=3 + 2")).toBeVisible({ timeout: 10000 }); + await expect(page.locator('text=3 + 2')).toBeVisible({ timeout: 10000 }) // Look for the abacus SVG - const abacus = page.locator("svg").first(); - await expect(abacus).toBeVisible(); + const abacus = page.locator('svg').first() + await expect(abacus).toBeVisible() // We need to interact with specific beads to change value from 3 to 5 // Look for earth beads in the ones column (rightmost) - const earthBeads = page.locator('svg circle[data-bead-type="earth"]'); + const earthBeads = page.locator('svg circle[data-bead-type="earth"]') // Click on earth beads to add 2 (getting from 3 to 5) // This might require multiple clicks depending on the current state - const earthBeadCount = await earthBeads.count(); + const earthBeadCount = await earthBeads.count() if (earthBeadCount > 0) { // Try clicking the first available earth bead - await earthBeads.first().click(); + await earthBeads.first().click() // Wait a bit for the value to update - await page.waitForTimeout(500); + await page.waitForTimeout(500) // Click another earth bead if needed if (earthBeadCount > 1) { - await earthBeads.nth(1).click(); - await page.waitForTimeout(500); + await earthBeads.nth(1).click() + await page.waitForTimeout(500) } } // Look for celebration tooltip with "Excellent work!" - await expect(page.locator("text=🎉")).toBeVisible({ timeout: 5000 }); - await expect(page.locator("text=Excellent work!")).toBeVisible({ + await expect(page.locator('text=🎉')).toBeVisible({ timeout: 5000 }) + await expect(page.locator('text=Excellent work!')).toBeVisible({ timeout: 5000, - }); - }); + }) + }) - test("celebration tooltip disappears when moving away from target", async ({ - page, - }) => { + test('celebration tooltip disappears when moving away from target', async ({ page }) => { // Wait for tutorial to load - await expect(page.locator("text=3 + 2")).toBeVisible({ timeout: 10000 }); + await expect(page.locator('text=3 + 2')).toBeVisible({ timeout: 10000 }) - const abacus = page.locator("svg").first(); - await expect(abacus).toBeVisible(); + const abacus = page.locator('svg').first() + await expect(abacus).toBeVisible() // First, reach the target value (5) - const earthBeads = page.locator('svg circle[data-bead-type="earth"]'); + const earthBeads = page.locator('svg circle[data-bead-type="earth"]') if ((await earthBeads.count()) > 0) { - await earthBeads.first().click(); - await page.waitForTimeout(300); + await earthBeads.first().click() + await page.waitForTimeout(300) if ((await earthBeads.count()) > 1) { - await earthBeads.nth(1).click(); - await page.waitForTimeout(300); + await earthBeads.nth(1).click() + await page.waitForTimeout(300) } } // Verify celebration appears - await expect(page.locator("text=🎉")).toBeVisible({ timeout: 3000 }); - await expect(page.locator("text=Excellent work!")).toBeVisible(); + await expect(page.locator('text=🎉')).toBeVisible({ timeout: 3000 }) + await expect(page.locator('text=Excellent work!')).toBeVisible() // Now move away from target by clicking another bead (add more) - const heavenBead = page - .locator('svg circle[data-bead-type="heaven"]') - .first(); + const heavenBead = page.locator('svg circle[data-bead-type="heaven"]').first() if (await heavenBead.isVisible()) { - await heavenBead.click(); - await page.waitForTimeout(500); + await heavenBead.click() + await page.waitForTimeout(500) } // Celebration tooltip should disappear - await expect(page.locator("text=🎉")).not.toBeVisible({ timeout: 2000 }); - await expect(page.locator("text=Excellent work!")).not.toBeVisible(); - }); + await expect(page.locator('text=🎉')).not.toBeVisible({ timeout: 2000 }) + await expect(page.locator('text=Excellent work!')).not.toBeVisible() + }) - test("celebration tooltip shows instruction mode when moved away", async ({ - page, - }) => { + test('celebration tooltip shows instruction mode when moved away', async ({ page }) => { // Wait for tutorial to load - await expect(page.locator("text=3 + 2")).toBeVisible({ timeout: 10000 }); + await expect(page.locator('text=3 + 2')).toBeVisible({ timeout: 10000 }) - const abacus = page.locator("svg").first(); - await expect(abacus).toBeVisible(); + const abacus = page.locator('svg').first() + await expect(abacus).toBeVisible() // Reach target value first - const earthBeads = page.locator('svg circle[data-bead-type="earth"]'); + const earthBeads = page.locator('svg circle[data-bead-type="earth"]') if ((await earthBeads.count()) > 0) { - await earthBeads.first().click(); - await page.waitForTimeout(300); + await earthBeads.first().click() + await page.waitForTimeout(300) if ((await earthBeads.count()) > 1) { - await earthBeads.nth(1).click(); - await page.waitForTimeout(300); + await earthBeads.nth(1).click() + await page.waitForTimeout(300) } } // Verify celebration - await expect(page.locator("text=🎉")).toBeVisible({ timeout: 3000 }); + await expect(page.locator('text=🎉')).toBeVisible({ timeout: 3000 }) // Move away from target (subtract by clicking active earth bead) - const activeEarthBeads = page.locator( - 'svg circle[data-bead-type="earth"][data-active="true"]', - ); + const activeEarthBeads = page.locator('svg circle[data-bead-type="earth"][data-active="true"]') if ((await activeEarthBeads.count()) > 0) { - await activeEarthBeads.first().click(); - await page.waitForTimeout(500); + await activeEarthBeads.first().click() + await page.waitForTimeout(500) } // Should no longer show celebration - await expect(page.locator("text=🎉")).not.toBeVisible({ timeout: 2000 }); + await expect(page.locator('text=🎉')).not.toBeVisible({ timeout: 2000 }) // Should show instruction tooltip (look for lightbulb or guidance text) const instructionTooltip = page - .locator("text=💡") - .or(page.locator("[data-radix-popper-content-wrapper]")); + .locator('text=💡') + .or(page.locator('[data-radix-popper-content-wrapper]')) // There might be instruction tooltips visible if ((await instructionTooltip.count()) > 0) { - await expect(instructionTooltip.first()).toBeVisible(); + await expect(instructionTooltip.first()).toBeVisible() } - }); + }) - test("celebration tooltip positioned at correct bead", async ({ page }) => { + test('celebration tooltip positioned at correct bead', async ({ page }) => { // Wait for tutorial to load - await expect(page.locator("text=3 + 2")).toBeVisible({ timeout: 10000 }); + await expect(page.locator('text=3 + 2')).toBeVisible({ timeout: 10000 }) - const abacus = page.locator("svg").first(); - await expect(abacus).toBeVisible(); + const abacus = page.locator('svg').first() + await expect(abacus).toBeVisible() // Interact with specific bead to track last moved bead - const targetEarthBead = page - .locator('svg circle[data-bead-type="earth"]') - .first(); + const targetEarthBead = page.locator('svg circle[data-bead-type="earth"]').first() if (await targetEarthBead.isVisible()) { // Get the position of the bead we're clicking - const _beadBox = await targetEarthBead.boundingBox(); + const _beadBox = await targetEarthBead.boundingBox() // Click the bead to move toward target - await targetEarthBead.click(); - await page.waitForTimeout(300); + await targetEarthBead.click() + await page.waitForTimeout(300) // Continue clicking beads until we reach target - const earthBeads = page.locator('svg circle[data-bead-type="earth"]'); - const beadCount = await earthBeads.count(); + const earthBeads = page.locator('svg circle[data-bead-type="earth"]') + const beadCount = await earthBeads.count() for (let i = 1; i < Math.min(beadCount, 3); i++) { - await earthBeads.nth(i).click(); - await page.waitForTimeout(200); + await earthBeads.nth(i).click() + await page.waitForTimeout(200) } } // Wait for celebration tooltip - await expect(page.locator("text=🎉")).toBeVisible({ timeout: 3000 }); + await expect(page.locator('text=🎉')).toBeVisible({ timeout: 3000 }) // Verify tooltip is positioned near where we last clicked - const tooltip = page.locator("[data-radix-popper-content-wrapper]").first(); + const tooltip = page.locator('[data-radix-popper-content-wrapper]').first() if (await tooltip.isVisible()) { - const tooltipBox = await tooltip.boundingBox(); - const abacusBox = await abacus.boundingBox(); + const tooltipBox = await tooltip.boundingBox() + const abacusBox = await abacus.boundingBox() // Tooltip should be positioned within reasonable proximity to the abacus - expect(tooltipBox?.x).toBeGreaterThan((abacusBox?.x ?? 0) - 200); - expect(tooltipBox?.x).toBeLessThan( - (abacusBox?.x ?? 0) + (abacusBox?.width ?? 0) + 200, - ); + expect(tooltipBox?.x).toBeGreaterThan((abacusBox?.x ?? 0) - 200) + expect(tooltipBox?.x).toBeLessThan((abacusBox?.x ?? 0) + (abacusBox?.width ?? 0) + 200) } - }); + }) - test("celebration tooltip resets on step navigation", async ({ page }) => { + test('celebration tooltip resets on step navigation', async ({ page }) => { // Create a multi-step tutorial await page.evaluate(() => { const tutorial = { - id: "multi-step-celebration-test", - title: "Multi-Step Celebration Test", - description: "Testing celebration across steps", + id: 'multi-step-celebration-test', + title: 'Multi-Step Celebration Test', + description: 'Testing celebration across steps', steps: [ { - id: "step-1", - title: "First Addition", - problem: "2 + 3", - description: "Add 3 to 2", + id: 'step-1', + title: 'First Addition', + problem: '2 + 3', + description: 'Add 3 to 2', startValue: 2, targetValue: 5, }, { - id: "step-2", - title: "Second Addition", - problem: "1 + 4", - description: "Add 4 to 1", + id: 'step-2', + title: 'Second Addition', + problem: '1 + 4', + description: 'Add 4 to 1', startValue: 1, targetValue: 5, }, ], - }; - localStorage.setItem("current-tutorial", JSON.stringify(tutorial)); - }); + } + localStorage.setItem('current-tutorial', JSON.stringify(tutorial)) + }) - await page.reload(); - await page.waitForLoadState("networkidle"); + await page.reload() + await page.waitForLoadState('networkidle') // Complete first step - await expect(page.locator("text=2 + 3")).toBeVisible({ timeout: 10000 }); + await expect(page.locator('text=2 + 3')).toBeVisible({ timeout: 10000 }) - const _abacus = page.locator("svg").first(); - const earthBeads = page.locator('svg circle[data-bead-type="earth"]'); + const _abacus = page.locator('svg').first() + const earthBeads = page.locator('svg circle[data-bead-type="earth"]') // Reach target for first step (from 2 to 5, need to add 3) if ((await earthBeads.count()) > 0) { for (let i = 0; i < 3 && i < (await earthBeads.count()); i++) { - await earthBeads.nth(i).click(); - await page.waitForTimeout(200); + await earthBeads.nth(i).click() + await page.waitForTimeout(200) } } // Verify celebration for first step - await expect(page.locator("text=🎉")).toBeVisible({ timeout: 3000 }); + await expect(page.locator('text=🎉')).toBeVisible({ timeout: 3000 }) // Navigate to next step - const nextButton = page - .locator("text=Next") - .or(page.locator('button:has-text("Next")')); + const nextButton = page.locator('text=Next').or(page.locator('button:has-text("Next")')) if (await nextButton.isVisible()) { - await nextButton.click(); + await nextButton.click() } // Wait for second step to load - await expect(page.locator("text=1 + 4")).toBeVisible({ timeout: 5000 }); + await expect(page.locator('text=1 + 4')).toBeVisible({ timeout: 5000 }) // Complete second step (from 1 to 5, need to add 4) - const newEarthBeads = page.locator('svg circle[data-bead-type="earth"]'); + const newEarthBeads = page.locator('svg circle[data-bead-type="earth"]') if ((await newEarthBeads.count()) > 0) { for (let i = 0; i < 4 && i < (await newEarthBeads.count()); i++) { - await newEarthBeads.nth(i).click(); - await page.waitForTimeout(200); + await newEarthBeads.nth(i).click() + await page.waitForTimeout(200) } } // Should show celebration for second step - await expect(page.locator("text=🎉")).toBeVisible({ timeout: 3000 }); - await expect(page.locator("text=Excellent work!")).toBeVisible(); - }); -}); + await expect(page.locator('text=🎉')).toBeVisible({ timeout: 3000 }) + await expect(page.locator('text=Excellent work!')).toBeVisible() + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialCelebrationTooltip.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialCelebrationTooltip.test.tsx index d30c8f0b..324a7262 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialCelebrationTooltip.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialCelebrationTooltip.test.tsx @@ -1,299 +1,285 @@ -import { AbacusDisplayProvider } from "@soroban/abacus-react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { vi } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { TutorialProvider } from "../TutorialContext"; -import { TutorialPlayer } from "../TutorialPlayer"; +import { AbacusDisplayProvider } from '@soroban/abacus-react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { TutorialProvider } from '../TutorialContext' +import { TutorialPlayer } from '../TutorialPlayer' // Mock tutorial data const mockTutorial: Tutorial = { - id: "celebration-test-tutorial", - title: "Celebration Tooltip Test", - description: "Testing celebration tooltip behavior", + id: 'celebration-test-tutorial', + title: 'Celebration Tooltip Test', + description: 'Testing celebration tooltip behavior', steps: [ { - id: "step-1", - title: "Simple Addition", - problem: "3 + 2", - description: "Add 2 to 3", + id: 'step-1', + title: 'Simple Addition', + problem: '3 + 2', + description: 'Add 2 to 3', startValue: 3, targetValue: 5, }, ], -}; +} // Test component that exposes internal state for testing const TestCelebrationComponent = ({ tutorial }: { tutorial: Tutorial }) => { return ( - + - ); -}; + ) +} -describe("TutorialPlayer Celebration Tooltip", () => { +describe('TutorialPlayer Celebration Tooltip', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("Celebration Tooltip Visibility", () => { - it("should show celebration tooltip when step is completed and at target value", async () => { - render(); + describe('Celebration Tooltip Visibility', () => { + it('should show celebration tooltip when step is completed and at target value', async () => { + render() // Wait for initial render with start value (3) await waitFor(() => { - expect(screen.getByText("3 + 2")).toBeInTheDocument(); - }); + expect(screen.getByText('3 + 2')).toBeInTheDocument() + }) // Simulate reaching target value (5) by finding and clicking appropriate beads // We need to add 2 to get from 3 to 5 // Look for earth beads in the ones place that we can activate const abacusContainer = - screen.getByRole("img", { hidden: true }) || - document.querySelector("svg"); + screen.getByRole('img', { hidden: true }) || document.querySelector('svg') if (abacusContainer) { // Simulate clicking beads to reach value 5 - fireEvent.click(abacusContainer); + fireEvent.click(abacusContainer) // In a real scenario, we'd need to trigger the actual value change // For testing, we'll use a more direct approach - const valueChangeEvent = new CustomEvent("valueChange", { + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 5 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Wait for celebration tooltip to appear await waitFor( () => { - const celebrationElements = screen.queryAllByText(/excellent work/i); - expect(celebrationElements.length).toBeGreaterThan(0); + const celebrationElements = screen.queryAllByText(/excellent work/i) + expect(celebrationElements.length).toBeGreaterThan(0) }, - { timeout: 3000 }, - ); - }); + { timeout: 3000 } + ) + }) - it("should hide celebration tooltip when user moves away from target value", async () => { - render(); + it('should hide celebration tooltip when user moves away from target value', async () => { + render() // Wait for initial render await waitFor(() => { - expect(screen.getByText("3 + 2")).toBeInTheDocument(); - }); + expect(screen.getByText('3 + 2')).toBeInTheDocument() + }) // First, complete the step (reach target value 5) - const abacusContainer = document.querySelector("svg"); + const abacusContainer = document.querySelector('svg') if (abacusContainer) { - const valueChangeEvent = new CustomEvent("valueChange", { + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 5 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Verify celebration appears await waitFor( () => { - const celebrationElements = screen.queryAllByText(/excellent work/i); - expect(celebrationElements.length).toBeGreaterThan(0); + const celebrationElements = screen.queryAllByText(/excellent work/i) + expect(celebrationElements.length).toBeGreaterThan(0) }, - { timeout: 2000 }, - ); + { timeout: 2000 } + ) // Now move away from target value (change to 6) if (abacusContainer) { - const valueChangeEvent = new CustomEvent("valueChange", { + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 6 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Verify celebration tooltip disappears await waitFor( () => { - const celebrationElements = screen.queryAllByText(/excellent work/i); - expect(celebrationElements.length).toBe(0); + const celebrationElements = screen.queryAllByText(/excellent work/i) + expect(celebrationElements.length).toBe(0) }, - { timeout: 2000 }, - ); - }); + { timeout: 2000 } + ) + }) - it("should return to instruction tooltip when moved away from target", async () => { - render(); + it('should return to instruction tooltip when moved away from target', async () => { + render() // Wait for initial render await waitFor(() => { - expect(screen.getByText("3 + 2")).toBeInTheDocument(); - }); + expect(screen.getByText('3 + 2')).toBeInTheDocument() + }) // Complete step (reach target value 5) - const abacusContainer = document.querySelector("svg"); + const abacusContainer = document.querySelector('svg') if (abacusContainer) { - fireEvent.click(abacusContainer); - const valueChangeEvent = new CustomEvent("valueChange", { + fireEvent.click(abacusContainer) + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 5 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Wait for celebration await waitFor(() => { - expect(screen.queryAllByText(/excellent work/i).length).toBeGreaterThan( - 0, - ); - }); + expect(screen.queryAllByText(/excellent work/i).length).toBeGreaterThan(0) + }) // Move away from target (to value 4) if (abacusContainer) { - const valueChangeEvent = new CustomEvent("valueChange", { + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 4 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Should show instruction tooltip instead of celebration await waitFor(() => { - expect(screen.queryAllByText(/excellent work/i).length).toBe(0); + expect(screen.queryAllByText(/excellent work/i).length).toBe(0) // Look for instruction indicators (lightbulb emoji or guidance text) - const instructionElements = screen.queryAllByText(/💡/i); - expect(instructionElements.length).toBeGreaterThanOrEqual(0); // May not always have instructions - }); - }); - }); + const instructionElements = screen.queryAllByText(/💡/i) + expect(instructionElements.length).toBeGreaterThanOrEqual(0) // May not always have instructions + }) + }) + }) - describe("Celebration Tooltip Positioning", () => { - it("should position celebration tooltip at last moved bead when available", async () => { - const onStepComplete = vi.fn(); + describe('Celebration Tooltip Positioning', () => { + it('should position celebration tooltip at last moved bead when available', async () => { + const onStepComplete = vi.fn() render( - + - , - ); + + ) // Wait for initial render await waitFor(() => { - expect(screen.getByText("3 + 2")).toBeInTheDocument(); - }); + expect(screen.getByText('3 + 2')).toBeInTheDocument() + }) // Simulate completing the step - const abacusContainer = document.querySelector("svg"); + const abacusContainer = document.querySelector('svg') if (abacusContainer) { - fireEvent.click(abacusContainer); - const valueChangeEvent = new CustomEvent("valueChange", { + fireEvent.click(abacusContainer) + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 5 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Wait for step completion callback await waitFor( () => { - expect(onStepComplete).toHaveBeenCalled(); + expect(onStepComplete).toHaveBeenCalled() }, - { timeout: 3000 }, - ); + { timeout: 3000 } + ) // Verify celebration tooltip is positioned (should be visible in DOM) - const tooltipPortal = document.querySelector( - "[data-radix-popper-content-wrapper]", - ); - expect(tooltipPortal).toBeTruthy(); - }); + const tooltipPortal = document.querySelector('[data-radix-popper-content-wrapper]') + expect(tooltipPortal).toBeTruthy() + }) - it("should use fallback position when no last moved bead available", async () => { - render(); + it('should use fallback position when no last moved bead available', async () => { + render() // Directly trigger completion without tracking a moved bead - const abacusContainer = document.querySelector("svg"); + const abacusContainer = document.querySelector('svg') if (abacusContainer) { // Skip the gradual movement and go straight to target - const valueChangeEvent = new CustomEvent("valueChange", { + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 5 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Should still show celebration tooltip with fallback positioning await waitFor( () => { - const celebrationElements = screen.queryAllByText(/excellent work/i); - expect(celebrationElements.length).toBeGreaterThan(0); + const celebrationElements = screen.queryAllByText(/excellent work/i) + expect(celebrationElements.length).toBeGreaterThan(0) }, - { timeout: 2000 }, - ); - }); - }); + { timeout: 2000 } + ) + }) + }) - describe("Tooltip State Management", () => { - it("should reset last moved bead when navigating to new step", async () => { + describe('Tooltip State Management', () => { + it('should reset last moved bead when navigating to new step', async () => { const multiStepTutorial: Tutorial = { ...mockTutorial, steps: [ mockTutorial.steps[0], { - id: "step-2", - title: "Another Addition", - problem: "2 + 3", - description: "Add 3 to 2", + id: 'step-2', + title: 'Another Addition', + problem: '2 + 3', + description: 'Add 3 to 2', startValue: 2, targetValue: 5, }, ], - }; + } - render(); + render() // Complete first step - const abacusContainer = document.querySelector("svg"); + const abacusContainer = document.querySelector('svg') if (abacusContainer) { - const valueChangeEvent = new CustomEvent("valueChange", { + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 5 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Navigate to next step - const nextButton = screen.getByText(/next/i); - fireEvent.click(nextButton); + const nextButton = screen.getByText(/next/i) + fireEvent.click(nextButton) // Wait for step change await waitFor(() => { - expect(screen.getByText("2 + 3")).toBeInTheDocument(); - }); + expect(screen.getByText('2 + 3')).toBeInTheDocument() + }) // Complete second step - should use appropriate positioning if (abacusContainer) { - const valueChangeEvent = new CustomEvent("valueChange", { + const valueChangeEvent = new CustomEvent('valueChange', { detail: { newValue: 5 }, - }); - abacusContainer.dispatchEvent(valueChangeEvent); + }) + abacusContainer.dispatchEvent(valueChangeEvent) } // Celebration should appear for second step await waitFor( () => { - expect( - screen.queryAllByText(/excellent work/i).length, - ).toBeGreaterThan(0); + expect(screen.queryAllByText(/excellent work/i).length).toBeGreaterThan(0) }, - { timeout: 2000 }, - ); - }); - }); -}); + { timeout: 2000 } + ) + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialContext.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialContext.test.tsx index 1b2123ac..5f22fed7 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialContext.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialContext.test.tsx @@ -1,41 +1,41 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import React from "react"; -import { vi } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { TutorialProvider, useTutorialContext } from "../TutorialContext"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { vi } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { TutorialProvider, useTutorialContext } from '../TutorialContext' // Mock tutorial data const mockTutorial: Tutorial = { - id: "test-tutorial", - title: "Test Tutorial", - description: "A test tutorial", + id: 'test-tutorial', + title: 'Test Tutorial', + description: 'A test tutorial', steps: [ { - id: "step-1", - title: "Step 1", - problem: "5 + 3", - description: "Add 3 to 5", + id: 'step-1', + title: 'Step 1', + problem: '5 + 3', + description: 'Add 3 to 5', startValue: 5, targetValue: 8, }, { - id: "step-2", - title: "Step 2", - problem: "10 - 2", - description: "Subtract 2 from 10", + id: 'step-2', + title: 'Step 2', + problem: '10 - 2', + description: 'Subtract 2 from 10', startValue: 10, targetValue: 8, }, { - id: "step-3", - title: "Step 3", - problem: "15 + 7", - description: "Add 7 to 15", + id: 'step-3', + title: 'Step 3', + problem: '15 + 7', + description: 'Add 7 to 15', startValue: 15, targetValue: 22, }, ], -}; +} // Test component that uses the context const TestComponent = () => { @@ -49,7 +49,7 @@ const TestComponent = () => { advanceMultiStep, previousMultiStep, resetMultiStep, - } = useTutorialContext(); + } = useTutorialContext() return (
@@ -83,326 +83,316 @@ const TestComponent = () => { Reset Multi-Step
- ); -}; + ) +} -describe("TutorialContext", () => { +describe('TutorialContext', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("Step Initialization", () => { - it("should initialize with first step and correct startValue", async () => { + describe('Step Initialization', () => { + it('should initialize with first step and correct startValue', async () => { render( - , - ); + + ) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("0"); - expect(screen.getByTestId("current-value")).toHaveTextContent("5"); - expect(screen.getByTestId("step-title")).toHaveTextContent("Step 1"); - expect(screen.getByTestId("start-value")).toHaveTextContent("5"); - expect(screen.getByTestId("target-value")).toHaveTextContent("8"); - }); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('0') + expect(screen.getByTestId('current-value')).toHaveTextContent('5') + expect(screen.getByTestId('step-title')).toHaveTextContent('Step 1') + expect(screen.getByTestId('start-value')).toHaveTextContent('5') + expect(screen.getByTestId('target-value')).toHaveTextContent('8') + }) + }) - it("should initialize with custom initial step index", async () => { + it('should initialize with custom initial step index', async () => { render( - , - ); + + ) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("1"); - expect(screen.getByTestId("current-value")).toHaveTextContent("10"); - expect(screen.getByTestId("step-title")).toHaveTextContent("Step 2"); - expect(screen.getByTestId("start-value")).toHaveTextContent("10"); - }); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('1') + expect(screen.getByTestId('current-value')).toHaveTextContent('10') + expect(screen.getByTestId('step-title')).toHaveTextContent('Step 2') + expect(screen.getByTestId('start-value')).toHaveTextContent('10') + }) + }) - it("should call onStepChange callback during initialization", async () => { - const onStepChange = vi.fn(); + it('should call onStepChange callback during initialization', async () => { + const onStepChange = vi.fn() render( - , - ); + + ) await waitFor(() => { - expect(onStepChange).toHaveBeenCalledWith(0, mockTutorial.steps[0]); - }); - }); - }); + expect(onStepChange).toHaveBeenCalledWith(0, mockTutorial.steps[0]) + }) + }) + }) - describe("Step Navigation", () => { - it("should navigate to specific step with correct startValue", async () => { + describe('Step Navigation', () => { + it('should navigate to specific step with correct startValue', async () => { render( - , - ); + + ) // Wait for initial render await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("0"); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('0') + }) // Navigate to step 2 - fireEvent.click(screen.getByTestId("go-to-step-2")); + fireEvent.click(screen.getByTestId('go-to-step-2')) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("1"); - expect(screen.getByTestId("current-value")).toHaveTextContent("10"); - expect(screen.getByTestId("step-title")).toHaveTextContent("Step 2"); - expect(screen.getByTestId("is-completed")).toHaveTextContent("false"); - }); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('1') + expect(screen.getByTestId('current-value')).toHaveTextContent('10') + expect(screen.getByTestId('step-title')).toHaveTextContent('Step 2') + expect(screen.getByTestId('is-completed')).toHaveTextContent('false') + }) + }) - it("should navigate to next step", async () => { + it('should navigate to next step', async () => { render( - , - ); + + ) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("0"); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('0') + }) - fireEvent.click(screen.getByTestId("go-next")); + fireEvent.click(screen.getByTestId('go-next')) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("1"); - expect(screen.getByTestId("current-value")).toHaveTextContent("10"); - }); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('1') + expect(screen.getByTestId('current-value')).toHaveTextContent('10') + }) + }) - it("should navigate to previous step", async () => { + it('should navigate to previous step', async () => { render( - , - ); + + ) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("1"); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('1') + }) - fireEvent.click(screen.getByTestId("go-prev")); + fireEvent.click(screen.getByTestId('go-prev')) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("0"); - expect(screen.getByTestId("current-value")).toHaveTextContent("5"); - }); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('0') + expect(screen.getByTestId('current-value')).toHaveTextContent('5') + }) + }) - it("should reset multi-step index when navigating between steps", async () => { + it('should reset multi-step index when navigating between steps', async () => { render( - , - ); + + ) // Advance multi-step - fireEvent.click(screen.getByTestId("advance-multi")); + fireEvent.click(screen.getByTestId('advance-multi')) await waitFor(() => { - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("1"); - }); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('1') + }) // Navigate to next step - fireEvent.click(screen.getByTestId("go-next")); + fireEvent.click(screen.getByTestId('go-next')) await waitFor(() => { // Multi-step should reset to 0 - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("0"); - }); - }); - }); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('0') + }) + }) + }) - describe("Value Changes", () => { - it("should update current value when user changes it", async () => { + describe('Value Changes', () => { + it('should update current value when user changes it', async () => { const TestWithValueChange = () => { - const { handleValueChange, state } = useTutorialContext(); + const { handleValueChange, state } = useTutorialContext() return (
-
- ); - }; + ) + } render( - , - ); + + ) await waitFor(() => { - expect(screen.getByTestId("current-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('current-value')).toHaveTextContent('5') + }) - fireEvent.click(screen.getByTestId("change-value-directly")); + fireEvent.click(screen.getByTestId('change-value-directly')) await waitFor(() => { - expect(screen.getByTestId("current-value")).toHaveTextContent("42"); - }); - }); + expect(screen.getByTestId('current-value')).toHaveTextContent('42') + }) + }) - it("should complete step when target value is reached", async () => { + it('should complete step when target value is reached', async () => { const TestWithCompletion = () => { - const { handleValueChange } = useTutorialContext(); + const { handleValueChange } = useTutorialContext() React.useEffect(() => { // Simulate reaching target value after initialization - const timer = setTimeout(() => handleValueChange(8), 100); - return () => clearTimeout(timer); - }, [handleValueChange]); + const timer = setTimeout(() => handleValueChange(8), 100) + return () => clearTimeout(timer) + }, [handleValueChange]) - return ; - }; + return + } render( - , - ); + + ) await waitFor( () => { - expect(screen.getByTestId("is-completed")).toHaveTextContent("true"); + expect(screen.getByTestId('is-completed')).toHaveTextContent('true') }, - { timeout: 2000 }, - ); - }); + { timeout: 2000 } + ) + }) - it("should call onStepComplete when step is completed", async () => { - const onStepComplete = vi.fn(); + it('should call onStepComplete when step is completed', async () => { + const onStepComplete = vi.fn() const TestWithCallback = () => { - const { handleValueChange } = useTutorialContext(); + const { handleValueChange } = useTutorialContext() React.useEffect(() => { // Simulate reaching target value - setTimeout(() => handleValueChange(8), 100); - }, [handleValueChange]); + setTimeout(() => handleValueChange(8), 100) + }, [handleValueChange]) - return ; - }; + return + } render( - + - , - ); + + ) await waitFor( () => { - expect(onStepComplete).toHaveBeenCalledWith( - 0, - mockTutorial.steps[0], - true, - ); + expect(onStepComplete).toHaveBeenCalledWith(0, mockTutorial.steps[0], true) }, - { timeout: 2000 }, - ); - }); - }); + { timeout: 2000 } + ) + }) + }) - describe("Multi-Step Navigation", () => { - it("should advance multi-step index", async () => { + describe('Multi-Step Navigation', () => { + it('should advance multi-step index', async () => { render( - , - ); + + ) - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("0"); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('0') - fireEvent.click(screen.getByTestId("advance-multi")); + fireEvent.click(screen.getByTestId('advance-multi')) await waitFor(() => { - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("1"); - }); - }); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('1') + }) + }) - it("should go to previous multi-step", async () => { + it('should go to previous multi-step', async () => { render( - , - ); + + ) // First advance to step 1 - fireEvent.click(screen.getByTestId("advance-multi")); + fireEvent.click(screen.getByTestId('advance-multi')) await waitFor(() => { - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("1"); - }); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('1') + }) // Then go back - fireEvent.click(screen.getByTestId("prev-multi")); + fireEvent.click(screen.getByTestId('prev-multi')) await waitFor(() => { - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("0"); - }); - }); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('0') + }) + }) - it("should reset multi-step to 0", async () => { + it('should reset multi-step to 0', async () => { render( - , - ); + + ) // Advance a few steps - fireEvent.click(screen.getByTestId("advance-multi")); - fireEvent.click(screen.getByTestId("advance-multi")); + fireEvent.click(screen.getByTestId('advance-multi')) + fireEvent.click(screen.getByTestId('advance-multi')) await waitFor(() => { - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("2"); - }); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('2') + }) // Reset - fireEvent.click(screen.getByTestId("reset-multi")); + fireEvent.click(screen.getByTestId('reset-multi')) await waitFor(() => { - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("0"); - }); - }); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('0') + }) + }) - it("should not allow previous multi-step below 0", async () => { + it('should not allow previous multi-step below 0', async () => { render( - , - ); + + ) - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("0"); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('0') // Try to go to previous (should stay at 0) - fireEvent.click(screen.getByTestId("prev-multi")); + fireEvent.click(screen.getByTestId('prev-multi')) await waitFor(() => { - expect(screen.getByTestId("current-multi-step")).toHaveTextContent("0"); - }); - }); - }); + expect(screen.getByTestId('current-multi-step')).toHaveTextContent('0') + }) + }) + }) - describe("Error Handling", () => { - it("should handle invalid step indices gracefully", async () => { + describe('Error Handling', () => { + it('should handle invalid step indices gracefully', async () => { const TestWithInvalidStep = () => { - const { goToStep } = useTutorialContext(); + const { goToStep } = useTutorialContext() return (
@@ -411,51 +401,49 @@ describe("TutorialContext", () => { Invalid Step
- ); - }; + ) + } render( - , - ); + + ) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("0"); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('0') + }) // Click invalid step - should not crash or change step - fireEvent.click(screen.getByTestId("invalid-step")); + fireEvent.click(screen.getByTestId('invalid-step')) await waitFor(() => { - expect(screen.getByTestId("current-step-index")).toHaveTextContent("0"); - }); - }); + expect(screen.getByTestId('current-step-index')).toHaveTextContent('0') + }) + }) - it("should handle empty tutorial steps", async () => { + it('should handle empty tutorial steps', async () => { const emptyTutorial: Tutorial = { - id: "empty", - title: "Empty Tutorial", - description: "No steps", + id: 'empty', + title: 'Empty Tutorial', + description: 'No steps', steps: [], - }; + } - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) // For empty tutorial, we expect the context to handle gracefully // but the component might not render completely render(
Empty tutorial test
-
, - ); + + ) // Should at least render without crashing - expect(screen.getByTestId("empty-tutorial-test")).toBeInTheDocument(); + expect(screen.getByTestId('empty-tutorial-test')).toBeInTheDocument() - consoleSpy.mockRestore(); - }); - }); -}); + consoleSpy.mockRestore() + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialEditor.integration.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialEditor.integration.test.tsx index a230b59b..fecede6f 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialEditor.integration.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialEditor.integration.test.tsx @@ -1,51 +1,51 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DevAccessProvider } from "../../../hooks/useAccessControl"; -import type { Tutorial, TutorialValidation } from "../../../types/tutorial"; -import { getTutorialForEditor } from "../../../utils/tutorialConverter"; -import { TutorialEditor } from "../TutorialEditor"; -import { TutorialPlayer } from "../TutorialPlayer"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DevAccessProvider } from '../../../hooks/useAccessControl' +import type { Tutorial, TutorialValidation } from '../../../types/tutorial' +import { getTutorialForEditor } from '../../../utils/tutorialConverter' +import { TutorialEditor } from '../TutorialEditor' +import { TutorialPlayer } from '../TutorialPlayer' // Mock the AbacusReact component for integration tests -vi.mock("@soroban/abacus-react", () => ({ +vi.mock('@soroban/abacus-react', () => ({ AbacusReact: ({ value, onValueChange, callbacks }: any) => (
{value}
), -})); +})) -describe("Tutorial Editor Integration Tests", () => { - let mockTutorial: Tutorial; - let mockOnSave: ReturnType; - let mockOnValidate: ReturnType; - let mockOnPreview: ReturnType; +describe('Tutorial Editor Integration Tests', () => { + let mockTutorial: Tutorial + let mockOnSave: ReturnType + let mockOnValidate: ReturnType + let mockOnPreview: ReturnType beforeEach(() => { - vi.clearAllMocks(); - mockTutorial = getTutorialForEditor(); - mockOnSave = vi.fn(); + vi.clearAllMocks() + mockTutorial = getTutorialForEditor() + mockOnSave = vi.fn() mockOnValidate = vi.fn().mockResolvedValue({ isValid: true, errors: [], warnings: [], - } as TutorialValidation); - mockOnPreview = vi.fn(); - }); + } as TutorialValidation) + mockOnPreview = vi.fn() + }) const renderTutorialEditor = () => { return render( @@ -56,190 +56,186 @@ describe("Tutorial Editor Integration Tests", () => { onValidate={mockOnValidate} onPreview={mockOnPreview} /> - , - ); - }; + + ) + } - describe("Complete Tutorial Editing Workflow", () => { - it("supports complete tutorial editing workflow from start to finish", async () => { - renderTutorialEditor(); + describe('Complete Tutorial Editing Workflow', () => { + it('supports complete tutorial editing workflow from start to finish', async () => { + renderTutorialEditor() // 1. Initial state - read-only mode - expect(screen.getByText("Guided Addition Tutorial")).toBeInTheDocument(); - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - expect(screen.queryByText("Save Changes")).not.toBeInTheDocument(); + expect(screen.getByText('Guided Addition Tutorial')).toBeInTheDocument() + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + expect(screen.queryByText('Save Changes')).not.toBeInTheDocument() // 2. Enter edit mode - fireEvent.click(screen.getByText("Edit Tutorial")); - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - expect(screen.getByText("Cancel")).toBeInTheDocument(); + fireEvent.click(screen.getByText('Edit Tutorial')) + expect(screen.getByText('Save Changes')).toBeInTheDocument() + expect(screen.getByText('Cancel')).toBeInTheDocument() // 3. Edit tutorial metadata - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') fireEvent.change(titleInput, { - target: { value: "Advanced Addition Tutorial" }, - }); - expect(titleInput).toHaveValue("Advanced Addition Tutorial"); + target: { value: 'Advanced Addition Tutorial' }, + }) + expect(titleInput).toHaveValue('Advanced Addition Tutorial') - const descriptionInput = screen.getByDisplayValue(/Learn basic addition/); + const descriptionInput = screen.getByDisplayValue(/Learn basic addition/) fireEvent.change(descriptionInput, { - target: { value: "Master advanced addition techniques" }, - }); - expect(descriptionInput).toHaveValue( - "Master advanced addition techniques", - ); + target: { value: 'Master advanced addition techniques' }, + }) + expect(descriptionInput).toHaveValue('Master advanced addition techniques') // 4. Expand and edit a step - const firstStep = screen.getByText(/1\. .+/); - fireEvent.click(firstStep); + const firstStep = screen.getByText(/1\. .+/) + fireEvent.click(firstStep) // Find step editing form - const stepTitleInputs = screen.getAllByDisplayValue(/.*/); + const stepTitleInputs = screen.getAllByDisplayValue(/.*/) const stepTitleInput = stepTitleInputs.find( (input) => - (input as HTMLInputElement).value.includes("Basic") || - (input as HTMLInputElement).value.includes("Introduction"), - ); + (input as HTMLInputElement).value.includes('Basic') || + (input as HTMLInputElement).value.includes('Introduction') + ) if (stepTitleInput) { fireEvent.change(stepTitleInput, { - target: { value: "Advanced Introduction Step" }, - }); - expect(stepTitleInput).toHaveValue("Advanced Introduction Step"); + target: { value: 'Advanced Introduction Step' }, + }) + expect(stepTitleInput).toHaveValue('Advanced Introduction Step') } // 5. Add a new step - const addStepButton = screen.getByText("+ Add Step"); - const initialStepCount = screen.getAllByText(/^\d+\./).length; - fireEvent.click(addStepButton); + const addStepButton = screen.getByText('+ Add Step') + const initialStepCount = screen.getAllByText(/^\d+\./).length + fireEvent.click(addStepButton) await waitFor(() => { - const newStepCount = screen.getAllByText(/^\d+\./).length; - expect(newStepCount).toBe(initialStepCount + 1); - }); + const newStepCount = screen.getAllByText(/^\d+\./).length + expect(newStepCount).toBe(initialStepCount + 1) + }) // 6. Preview functionality - const previewButtons = screen.getAllByText(/Preview/); + const previewButtons = screen.getAllByText(/Preview/) if (previewButtons.length > 0) { - fireEvent.click(previewButtons[0]); - expect(mockOnPreview).toHaveBeenCalled(); + fireEvent.click(previewButtons[0]) + expect(mockOnPreview).toHaveBeenCalled() } // 7. Save changes - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(mockOnValidate).toHaveBeenCalled(); + expect(mockOnValidate).toHaveBeenCalled() expect(mockOnSave).toHaveBeenCalledWith( expect.objectContaining({ - title: "Advanced Addition Tutorial", - description: "Master advanced addition techniques", - }), - ); - }); - }); + title: 'Advanced Addition Tutorial', + description: 'Master advanced addition techniques', + }) + ) + }) + }) - it("handles step management operations correctly", async () => { - renderTutorialEditor(); + it('handles step management operations correctly', async () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) // Get initial step count - const initialSteps = screen.getAllByText(/^\d+\./); - const initialCount = initialSteps.length; + const initialSteps = screen.getAllByText(/^\d+\./) + const initialCount = initialSteps.length // Add a step - fireEvent.click(screen.getByText("+ Add Step")); + fireEvent.click(screen.getByText('+ Add Step')) await waitFor(() => { - expect(screen.getAllByText(/^\d+\./).length).toBe(initialCount + 1); - }); + expect(screen.getAllByText(/^\d+\./).length).toBe(initialCount + 1) + }) // Expand the first step and duplicate it - fireEvent.click(screen.getByText(/1\. .+/)); + fireEvent.click(screen.getByText(/1\. .+/)) - const duplicateButton = screen.queryByText("Duplicate"); + const duplicateButton = screen.queryByText('Duplicate') if (duplicateButton) { - fireEvent.click(duplicateButton); + fireEvent.click(duplicateButton) await waitFor(() => { - expect(screen.getAllByText(/^\d+\./).length).toBe(initialCount + 2); - }); + expect(screen.getAllByText(/^\d+\./).length).toBe(initialCount + 2) + }) } // Try to delete a step (but not if it's the last one) - const deleteButton = screen.queryByText("Delete"); - if (deleteButton && !deleteButton.hasAttribute("disabled")) { - const currentCount = screen.getAllByText(/^\d+\./).length; - fireEvent.click(deleteButton); + const deleteButton = screen.queryByText('Delete') + if (deleteButton && !deleteButton.hasAttribute('disabled')) { + const currentCount = screen.getAllByText(/^\d+\./).length + fireEvent.click(deleteButton) await waitFor(() => { - expect(screen.getAllByText(/^\d+\./).length).toBe(currentCount - 1); - }); + expect(screen.getAllByText(/^\d+\./).length).toBe(currentCount - 1) + }) } - }); + }) - it("validates tutorial data before saving", async () => { + it('validates tutorial data before saving', async () => { // Set up validation to fail mockOnValidate.mockResolvedValueOnce({ isValid: false, errors: [ { - stepId: "", - field: "title", - message: "Title cannot be empty", - severity: "error" as const, + stepId: '', + field: 'title', + message: 'Title cannot be empty', + severity: 'error' as const, }, ], warnings: [], - }); + }) - renderTutorialEditor(); + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) // Clear the title to trigger validation error - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); - fireEvent.change(titleInput, { target: { value: "" } }); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') + fireEvent.change(titleInput, { target: { value: '' } }) // Try to save - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(mockOnValidate).toHaveBeenCalled(); - expect(mockOnSave).not.toHaveBeenCalled(); - expect(screen.getByText("Title cannot be empty")).toBeInTheDocument(); - }); - }); + expect(mockOnValidate).toHaveBeenCalled() + expect(mockOnSave).not.toHaveBeenCalled() + expect(screen.getByText('Title cannot be empty')).toBeInTheDocument() + }) + }) - it("shows validation warnings without blocking save", async () => { + it('shows validation warnings without blocking save', async () => { // Set up validation with warnings only mockOnValidate.mockResolvedValueOnce({ isValid: true, errors: [], warnings: [ { - stepId: "", - field: "description", - message: "Description could be more detailed", - severity: "warning" as const, + stepId: '', + field: 'description', + message: 'Description could be more detailed', + severity: 'warning' as const, }, ], - }); + }) - renderTutorialEditor(); + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(mockOnValidate).toHaveBeenCalled(); - expect(mockOnSave).toHaveBeenCalled(); - expect( - screen.getByText("Description could be more detailed"), - ).toBeInTheDocument(); - }); - }); - }); + expect(mockOnValidate).toHaveBeenCalled() + expect(mockOnSave).toHaveBeenCalled() + expect(screen.getByText('Description could be more detailed')).toBeInTheDocument() + }) + }) + }) - describe("Tutorial Player Integration", () => { + describe('Tutorial Player Integration', () => { const renderTutorialPlayer = () => { return render( @@ -251,69 +247,69 @@ describe("Tutorial Editor Integration Tests", () => { onTutorialComplete={vi.fn()} onEvent={vi.fn()} /> - , - ); - }; + + ) + } - it("integrates tutorial player for preview functionality", async () => { - renderTutorialPlayer(); + it('integrates tutorial player for preview functionality', async () => { + renderTutorialPlayer() // Check that tutorial loads correctly - expect(screen.getByText("Guided Addition Tutorial")).toBeInTheDocument(); + expect(screen.getByText('Guided Addition Tutorial')).toBeInTheDocument() // Check that first step is displayed - const stepInfo = screen.getByText(/Step 1 of/); - expect(stepInfo).toBeInTheDocument(); + const stepInfo = screen.getByText(/Step 1 of/) + expect(stepInfo).toBeInTheDocument() // Check that abacus is rendered - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() // Check debug features are available - expect(screen.getByText("Debug")).toBeInTheDocument(); - expect(screen.getByText("Steps")).toBeInTheDocument(); + expect(screen.getByText('Debug')).toBeInTheDocument() + expect(screen.getByText('Steps')).toBeInTheDocument() // Test step navigation - const nextButton = screen.getByText("Next →"); - expect(nextButton).toBeDisabled(); // Should be disabled until step is completed + const nextButton = screen.getByText('Next →') + expect(nextButton).toBeDisabled() // Should be disabled until step is completed // Complete a step by interacting with abacus - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) // Check that step completion is handled await waitFor(() => { - const completionMessage = screen.queryByText(/Great! You completed/); + const completionMessage = screen.queryByText(/Great! You completed/) if (completionMessage) { - expect(completionMessage).toBeInTheDocument(); + expect(completionMessage).toBeInTheDocument() } - }); - }); + }) + }) - it("supports debug panel and step jumping", async () => { - renderTutorialPlayer(); + it('supports debug panel and step jumping', async () => { + renderTutorialPlayer() // Open step list - const stepsButton = screen.getByText("Steps"); - fireEvent.click(stepsButton); + const stepsButton = screen.getByText('Steps') + fireEvent.click(stepsButton) // Check that step list is displayed - expect(screen.getByText("Tutorial Steps")).toBeInTheDocument(); + expect(screen.getByText('Tutorial Steps')).toBeInTheDocument() // Check that steps are listed - const stepListItems = screen.getAllByText(/^\d+\./); - expect(stepListItems.length).toBeGreaterThan(0); + const stepListItems = screen.getAllByText(/^\d+\./) + expect(stepListItems.length).toBeGreaterThan(0) // Test auto-advance toggle - const autoAdvanceCheckbox = screen.getByLabelText("Auto-advance"); - expect(autoAdvanceCheckbox).toBeInTheDocument(); + const autoAdvanceCheckbox = screen.getByLabelText('Auto-advance') + expect(autoAdvanceCheckbox).toBeInTheDocument() - fireEvent.click(autoAdvanceCheckbox); - expect(autoAdvanceCheckbox).toBeChecked(); - }); - }); + fireEvent.click(autoAdvanceCheckbox) + expect(autoAdvanceCheckbox).toBeChecked() + }) + }) - describe("Access Control Integration", () => { - it("enforces access control for editor features", () => { + describe('Access Control Integration', () => { + it('enforces access control for editor features', () => { // Test that editor is wrapped in access control render( @@ -323,14 +319,14 @@ describe("Tutorial Editor Integration Tests", () => { onValidate={mockOnValidate} onPreview={mockOnPreview} /> - , - ); + + ) // Should render editor when access is granted (DevAccessProvider grants access) - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - }); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + }) - it("handles read-only mode when save is not provided", () => { + it('handles read-only mode when save is not provided', () => { render( { onValidate={mockOnValidate} onPreview={mockOnPreview} /> - , - ); + + ) // Should not show edit button when onSave is not provided - expect(screen.queryByText("Edit Tutorial")).not.toBeInTheDocument(); - expect(screen.getByText("Guided Addition Tutorial")).toBeInTheDocument(); - }); - }); + expect(screen.queryByText('Edit Tutorial')).not.toBeInTheDocument() + expect(screen.getByText('Guided Addition Tutorial')).toBeInTheDocument() + }) + }) - describe("Error Handling and Edge Cases", () => { - it("handles tutorial with no steps gracefully", () => { - const emptyTutorial = { ...mockTutorial, steps: [] }; + describe('Error Handling and Edge Cases', () => { + it('handles tutorial with no steps gracefully', () => { + const emptyTutorial = { ...mockTutorial, steps: [] } expect(() => { render( @@ -360,114 +356,112 @@ describe("Tutorial Editor Integration Tests", () => { onValidate={mockOnValidate} onPreview={mockOnPreview} /> - , - ); - }).not.toThrow(); + + ) + }).not.toThrow() - expect(screen.getByText("Guided Addition Tutorial")).toBeInTheDocument(); - }); + expect(screen.getByText('Guided Addition Tutorial')).toBeInTheDocument() + }) - it("handles async validation errors gracefully", async () => { - mockOnValidate.mockRejectedValueOnce( - new Error("Validation service unavailable"), - ); + it('handles async validation errors gracefully', async () => { + mockOnValidate.mockRejectedValueOnce(new Error('Validation service unavailable')) - renderTutorialEditor(); + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(mockOnValidate).toHaveBeenCalled(); - expect(mockOnSave).not.toHaveBeenCalled(); - }); - }); + expect(mockOnValidate).toHaveBeenCalled() + expect(mockOnSave).not.toHaveBeenCalled() + }) + }) - it("handles save operation failures gracefully", async () => { - mockOnSave.mockRejectedValueOnce(new Error("Save failed")); + it('handles save operation failures gracefully', async () => { + mockOnSave.mockRejectedValueOnce(new Error('Save failed')) - renderTutorialEditor(); + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(mockOnSave).toHaveBeenCalled(); - }); - }); + expect(mockOnSave).toHaveBeenCalled() + }) + }) - it("preserves unsaved changes when canceling edit mode", () => { - renderTutorialEditor(); + it('preserves unsaved changes when canceling edit mode', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) // Make some changes - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); - fireEvent.change(titleInput, { target: { value: "Modified Title" } }); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') + fireEvent.change(titleInput, { target: { value: 'Modified Title' } }) // Cancel editing - fireEvent.click(screen.getByText("Cancel")); + fireEvent.click(screen.getByText('Cancel')) // Should return to read-only mode with original title - expect(screen.getByText("Guided Addition Tutorial")).toBeInTheDocument(); - expect(screen.queryByText("Modified Title")).not.toBeInTheDocument(); - }); - }); + expect(screen.getByText('Guided Addition Tutorial')).toBeInTheDocument() + expect(screen.queryByText('Modified Title')).not.toBeInTheDocument() + }) + }) - describe("Performance and User Experience", () => { - it("provides immediate feedback for user actions", async () => { - renderTutorialEditor(); + describe('Performance and User Experience', () => { + it('provides immediate feedback for user actions', async () => { + renderTutorialEditor() // Test immediate response to mode toggle - fireEvent.click(screen.getByText("Edit Tutorial")); - expect(screen.getByText("Save Changes")).toBeInTheDocument(); + fireEvent.click(screen.getByText('Edit Tutorial')) + expect(screen.getByText('Save Changes')).toBeInTheDocument() // Test immediate response to form changes - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); - fireEvent.change(titleInput, { target: { value: "New Title" } }); - expect(titleInput).toHaveValue("New Title"); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') + fireEvent.change(titleInput, { target: { value: 'New Title' } }) + expect(titleInput).toHaveValue('New Title') // Test immediate response to step expansion - const firstStep = screen.getByText(/1\. .+/); - fireEvent.click(firstStep); + const firstStep = screen.getByText(/1\. .+/) + fireEvent.click(firstStep) // Should show step editing controls immediately await waitFor(() => { - expect(screen.queryByText("Preview")).toBeInTheDocument(); - }); - }); + expect(screen.queryByText('Preview')).toBeInTheDocument() + }) + }) - it("maintains consistent state across operations", async () => { - renderTutorialEditor(); + it('maintains consistent state across operations', async () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) // Make multiple changes - const titleInput = screen.getByDisplayValue("Guided Addition Tutorial"); - fireEvent.change(titleInput, { target: { value: "Updated Tutorial" } }); + const titleInput = screen.getByDisplayValue('Guided Addition Tutorial') + fireEvent.change(titleInput, { target: { value: 'Updated Tutorial' } }) // Add a step - const initialCount = screen.getAllByText(/^\d+\./).length; - fireEvent.click(screen.getByText("+ Add Step")); + const initialCount = screen.getAllByText(/^\d+\./).length + fireEvent.click(screen.getByText('+ Add Step')) await waitFor(() => { - expect(screen.getAllByText(/^\d+\./).length).toBe(initialCount + 1); - }); + expect(screen.getAllByText(/^\d+\./).length).toBe(initialCount + 1) + }) // Verify title change is still preserved - expect(screen.getByDisplayValue("Updated Tutorial")).toBeInTheDocument(); + expect(screen.getByDisplayValue('Updated Tutorial')).toBeInTheDocument() // Save and verify both changes are included - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { expect(mockOnSave).toHaveBeenCalledWith( expect.objectContaining({ - title: "Updated Tutorial", + title: 'Updated Tutorial', steps: expect.arrayContaining([expect.any(Object)]), - }), - ); - }); - }); - }); -}); + }) + ) + }) + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialEditor.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialEditor.test.tsx index c8b778a3..95591339 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialEditor.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialEditor.test.tsx @@ -1,596 +1,584 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DevAccessProvider } from "../../../hooks/useAccessControl"; -import type { Tutorial, TutorialValidation } from "../../../types/tutorial"; -import { TutorialEditor } from "../TutorialEditor"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DevAccessProvider } from '../../../hooks/useAccessControl' +import type { Tutorial, TutorialValidation } from '../../../types/tutorial' +import { TutorialEditor } from '../TutorialEditor' const mockTutorial: Tutorial = { - id: "test-tutorial", - title: "Test Tutorial", - description: "A test tutorial for editing", - category: "test", - difficulty: "beginner", + id: 'test-tutorial', + title: 'Test Tutorial', + description: 'A test tutorial for editing', + category: 'test', + difficulty: 'beginner', estimatedDuration: 15, steps: [ { - id: "step-1", - title: "Step 1", - problem: "0 + 1", - description: "Add one", + id: 'step-1', + title: 'Step 1', + problem: '0 + 1', + description: 'Add one', startValue: 0, targetValue: 1, - highlightBeads: [{ columnIndex: 4, beadType: "earth", position: 0 }], - expectedAction: "add", - actionDescription: "Click the first bead", + highlightBeads: [{ columnIndex: 4, beadType: 'earth', position: 0 }], + expectedAction: 'add', + actionDescription: 'Click the first bead', tooltip: { - content: "Test tooltip", - explanation: "Test explanation", + content: 'Test tooltip', + explanation: 'Test explanation', }, errorMessages: { - wrongBead: "Wrong bead clicked", - wrongAction: "Wrong action", - hint: "Test hint", + wrongBead: 'Wrong bead clicked', + wrongAction: 'Wrong action', + hint: 'Test hint', }, }, { - id: "step-2", - title: "Step 2", - problem: "1 + 1", - description: "Add another one", + id: 'step-2', + title: 'Step 2', + problem: '1 + 1', + description: 'Add another one', startValue: 1, targetValue: 2, - expectedAction: "add", - actionDescription: "Click the second bead", + expectedAction: 'add', + actionDescription: 'Click the second bead', tooltip: { - content: "Second tooltip", - explanation: "Second explanation", + content: 'Second tooltip', + explanation: 'Second explanation', }, errorMessages: { - wrongBead: "Wrong bead for step 2", - wrongAction: "Wrong action for step 2", - hint: "Step 2 hint", + wrongBead: 'Wrong bead for step 2', + wrongAction: 'Wrong action for step 2', + hint: 'Step 2 hint', }, }, ], - tags: ["test"], - author: "Test Author", - version: "1.0.0", + tags: ['test'], + author: 'Test Author', + version: '1.0.0', createdAt: new Date(), updatedAt: new Date(), isPublished: false, -}; +} const mockValidationResult: TutorialValidation = { isValid: true, errors: [], warnings: [], -}; +} -const renderTutorialEditor = ( - props: Partial> = {}, -) => { +const renderTutorialEditor = (props: Partial> = {}) => { const defaultProps = { tutorial: mockTutorial, onSave: vi.fn(), onValidate: vi.fn().mockResolvedValue(mockValidationResult), onPreview: vi.fn(), - }; + } return render( - , - ); -}; + + ) +} -describe("TutorialEditor", () => { +describe('TutorialEditor', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("Initial Rendering", () => { - it("renders tutorial information in read-only mode by default", () => { - renderTutorialEditor(); + describe('Initial Rendering', () => { + it('renders tutorial information in read-only mode by default', () => { + renderTutorialEditor() - expect(screen.getByText("Test Tutorial")).toBeInTheDocument(); - expect( - screen.getByText("A test tutorial for editing"), - ).toBeInTheDocument(); - expect(screen.getByText("test")).toBeInTheDocument(); - expect(screen.getByText("beginner")).toBeInTheDocument(); - expect(screen.getByText("15 minutes")).toBeInTheDocument(); - }); + expect(screen.getByText('Test Tutorial')).toBeInTheDocument() + expect(screen.getByText('A test tutorial for editing')).toBeInTheDocument() + expect(screen.getByText('test')).toBeInTheDocument() + expect(screen.getByText('beginner')).toBeInTheDocument() + expect(screen.getByText('15 minutes')).toBeInTheDocument() + }) - it("shows edit tutorial button when onSave is provided", () => { - renderTutorialEditor(); + it('shows edit tutorial button when onSave is provided', () => { + renderTutorialEditor() - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - }); + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + }) - it("does not show edit button when onSave is not provided", () => { - renderTutorialEditor({ onSave: undefined }); + it('does not show edit button when onSave is not provided', () => { + renderTutorialEditor({ onSave: undefined }) - expect(screen.queryByText("Edit Tutorial")).not.toBeInTheDocument(); - }); + expect(screen.queryByText('Edit Tutorial')).not.toBeInTheDocument() + }) - it("displays tutorial steps in collapsed state", () => { - renderTutorialEditor(); + it('displays tutorial steps in collapsed state', () => { + renderTutorialEditor() - expect(screen.getByText("1. Step 1")).toBeInTheDocument(); - expect(screen.getByText("2. Step 2")).toBeInTheDocument(); - expect(screen.getByText("0 + 1")).toBeInTheDocument(); - expect(screen.getByText("1 + 1")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('1. Step 1')).toBeInTheDocument() + expect(screen.getByText('2. Step 2')).toBeInTheDocument() + expect(screen.getByText('0 + 1')).toBeInTheDocument() + expect(screen.getByText('1 + 1')).toBeInTheDocument() + }) + }) - describe("Edit Mode Toggle", () => { - it("enters edit mode when edit button is clicked", () => { - renderTutorialEditor(); + describe('Edit Mode Toggle', () => { + it('enters edit mode when edit button is clicked', () => { + renderTutorialEditor() - const editButton = screen.getByText("Edit Tutorial"); - fireEvent.click(editButton); + const editButton = screen.getByText('Edit Tutorial') + fireEvent.click(editButton) - expect(screen.getByText("Save Changes")).toBeInTheDocument(); - expect(screen.getByText("Cancel")).toBeInTheDocument(); - expect(screen.queryByText("Edit Tutorial")).not.toBeInTheDocument(); - }); + expect(screen.getByText('Save Changes')).toBeInTheDocument() + expect(screen.getByText('Cancel')).toBeInTheDocument() + expect(screen.queryByText('Edit Tutorial')).not.toBeInTheDocument() + }) - it("exits edit mode when cancel button is clicked", () => { - renderTutorialEditor(); + it('exits edit mode when cancel button is clicked', () => { + renderTutorialEditor() // Enter edit mode - fireEvent.click(screen.getByText("Edit Tutorial")); - expect(screen.getByText("Save Changes")).toBeInTheDocument(); + fireEvent.click(screen.getByText('Edit Tutorial')) + expect(screen.getByText('Save Changes')).toBeInTheDocument() // Exit edit mode - fireEvent.click(screen.getByText("Cancel")); - expect(screen.getByText("Edit Tutorial")).toBeInTheDocument(); - expect(screen.queryByText("Save Changes")).not.toBeInTheDocument(); - }); + fireEvent.click(screen.getByText('Cancel')) + expect(screen.getByText('Edit Tutorial')).toBeInTheDocument() + expect(screen.queryByText('Save Changes')).not.toBeInTheDocument() + }) - it("shows form inputs in edit mode", () => { - renderTutorialEditor(); + it('shows form inputs in edit mode', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) // Check for tutorial metadata inputs - expect(screen.getByDisplayValue("Test Tutorial")).toBeInTheDocument(); - expect( - screen.getByDisplayValue("A test tutorial for editing"), - ).toBeInTheDocument(); - expect(screen.getByDisplayValue("test")).toBeInTheDocument(); - }); - }); + expect(screen.getByDisplayValue('Test Tutorial')).toBeInTheDocument() + expect(screen.getByDisplayValue('A test tutorial for editing')).toBeInTheDocument() + expect(screen.getByDisplayValue('test')).toBeInTheDocument() + }) + }) - describe("Tutorial Metadata Editing", () => { - it("allows editing tutorial title", () => { - renderTutorialEditor(); + describe('Tutorial Metadata Editing', () => { + it('allows editing tutorial title', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const titleInput = screen.getByDisplayValue("Test Tutorial"); + const titleInput = screen.getByDisplayValue('Test Tutorial') fireEvent.change(titleInput, { - target: { value: "Updated Tutorial Title" }, - }); + target: { value: 'Updated Tutorial Title' }, + }) - expect(titleInput).toHaveValue("Updated Tutorial Title"); - }); + expect(titleInput).toHaveValue('Updated Tutorial Title') + }) - it("allows editing tutorial description", () => { - renderTutorialEditor(); + it('allows editing tutorial description', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const descriptionInput = screen.getByDisplayValue( - "A test tutorial for editing", - ); + const descriptionInput = screen.getByDisplayValue('A test tutorial for editing') fireEvent.change(descriptionInput, { - target: { value: "Updated description" }, - }); + target: { value: 'Updated description' }, + }) - expect(descriptionInput).toHaveValue("Updated description"); - }); + expect(descriptionInput).toHaveValue('Updated description') + }) - it("allows editing category and difficulty", () => { - renderTutorialEditor(); + it('allows editing category and difficulty', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const categoryInput = screen.getByDisplayValue("test"); - const difficultySelect = screen.getByDisplayValue("beginner"); + const categoryInput = screen.getByDisplayValue('test') + const difficultySelect = screen.getByDisplayValue('beginner') - fireEvent.change(categoryInput, { target: { value: "advanced" } }); - fireEvent.change(difficultySelect, { target: { value: "intermediate" } }); + fireEvent.change(categoryInput, { target: { value: 'advanced' } }) + fireEvent.change(difficultySelect, { target: { value: 'intermediate' } }) - expect(categoryInput).toHaveValue("advanced"); - expect(difficultySelect).toHaveValue("intermediate"); - }); + expect(categoryInput).toHaveValue('advanced') + expect(difficultySelect).toHaveValue('intermediate') + }) - it("allows editing estimated duration", () => { - renderTutorialEditor(); + it('allows editing estimated duration', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const durationInput = screen.getByDisplayValue("15"); - fireEvent.change(durationInput, { target: { value: "20" } }); + const durationInput = screen.getByDisplayValue('15') + fireEvent.change(durationInput, { target: { value: '20' } }) - expect(durationInput).toHaveValue("20"); - }); + expect(durationInput).toHaveValue('20') + }) - it("allows editing tags", () => { - renderTutorialEditor(); + it('allows editing tags', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const tagsInput = screen.getByDisplayValue("test"); + const tagsInput = screen.getByDisplayValue('test') fireEvent.change(tagsInput, { - target: { value: "test, advanced, math" }, - }); + target: { value: 'test, advanced, math' }, + }) - expect(tagsInput).toHaveValue("test, advanced, math"); - }); - }); + expect(tagsInput).toHaveValue('test, advanced, math') + }) + }) - describe("Step Management", () => { - it("expands step editing form when step is clicked", () => { - renderTutorialEditor(); + describe('Step Management', () => { + it('expands step editing form when step is clicked', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const stepButton = screen.getByText("1. Step 1"); - fireEvent.click(stepButton); + const stepButton = screen.getByText('1. Step 1') + fireEvent.click(stepButton) // Check for step editing inputs - expect(screen.getByDisplayValue("Step 1")).toBeInTheDocument(); - expect(screen.getByDisplayValue("0 + 1")).toBeInTheDocument(); - expect(screen.getByDisplayValue("Add one")).toBeInTheDocument(); - }); + expect(screen.getByDisplayValue('Step 1')).toBeInTheDocument() + expect(screen.getByDisplayValue('0 + 1')).toBeInTheDocument() + expect(screen.getByDisplayValue('Add one')).toBeInTheDocument() + }) - it("allows editing step properties", () => { - renderTutorialEditor(); + it('allows editing step properties', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("1. Step 1")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('1. Step 1')) - const stepTitleInput = screen.getByDisplayValue("Step 1"); - const problemInput = screen.getByDisplayValue("0 + 1"); - const descriptionInput = screen.getByDisplayValue("Add one"); + const stepTitleInput = screen.getByDisplayValue('Step 1') + const problemInput = screen.getByDisplayValue('0 + 1') + const descriptionInput = screen.getByDisplayValue('Add one') fireEvent.change(stepTitleInput, { - target: { value: "Updated Step Title" }, - }); - fireEvent.change(problemInput, { target: { value: "2 + 2" } }); + target: { value: 'Updated Step Title' }, + }) + fireEvent.change(problemInput, { target: { value: '2 + 2' } }) fireEvent.change(descriptionInput, { - target: { value: "Updated description" }, - }); + target: { value: 'Updated description' }, + }) - expect(stepTitleInput).toHaveValue("Updated Step Title"); - expect(problemInput).toHaveValue("2 + 2"); - expect(descriptionInput).toHaveValue("Updated description"); - }); + expect(stepTitleInput).toHaveValue('Updated Step Title') + expect(problemInput).toHaveValue('2 + 2') + expect(descriptionInput).toHaveValue('Updated description') + }) - it("shows add step button in edit mode", () => { - renderTutorialEditor(); + it('shows add step button in edit mode', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - expect(screen.getByText("+ Add Step")).toBeInTheDocument(); - }); + expect(screen.getByText('+ Add Step')).toBeInTheDocument() + }) - it("adds new step when add button is clicked", () => { - renderTutorialEditor(); + it('adds new step when add button is clicked', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const initialStepCount = screen.getAllByText(/^\d+\./).length; - fireEvent.click(screen.getByText("+ Add Step")); + const initialStepCount = screen.getAllByText(/^\d+\./).length + fireEvent.click(screen.getByText('+ Add Step')) - const newStepCount = screen.getAllByText(/^\d+\./).length; - expect(newStepCount).toBe(initialStepCount + 1); - }); + const newStepCount = screen.getAllByText(/^\d+\./).length + expect(newStepCount).toBe(initialStepCount + 1) + }) - it("shows step action buttons when step is expanded", () => { - renderTutorialEditor(); + it('shows step action buttons when step is expanded', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("1. Step 1")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('1. Step 1')) - expect(screen.getByText("Preview")).toBeInTheDocument(); - expect(screen.getByText("Duplicate")).toBeInTheDocument(); - expect(screen.getByText("Delete")).toBeInTheDocument(); - }); + expect(screen.getByText('Preview')).toBeInTheDocument() + expect(screen.getByText('Duplicate')).toBeInTheDocument() + expect(screen.getByText('Delete')).toBeInTheDocument() + }) - it("duplicates step when duplicate button is clicked", () => { - renderTutorialEditor(); + it('duplicates step when duplicate button is clicked', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("1. Step 1")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('1. Step 1')) - const initialStepCount = screen.getAllByText(/^\d+\./).length; - fireEvent.click(screen.getByText("Duplicate")); + const initialStepCount = screen.getAllByText(/^\d+\./).length + fireEvent.click(screen.getByText('Duplicate')) - const newStepCount = screen.getAllByText(/^\d+\./).length; - expect(newStepCount).toBe(initialStepCount + 1); - }); + const newStepCount = screen.getAllByText(/^\d+\./).length + expect(newStepCount).toBe(initialStepCount + 1) + }) - it("removes step when delete button is clicked", () => { - renderTutorialEditor(); + it('removes step when delete button is clicked', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("1. Step 1")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('1. Step 1')) - const initialStepCount = screen.getAllByText(/^\d+\./).length; - fireEvent.click(screen.getByText("Delete")); + const initialStepCount = screen.getAllByText(/^\d+\./).length + fireEvent.click(screen.getByText('Delete')) - const newStepCount = screen.getAllByText(/^\d+\./).length; - expect(newStepCount).toBe(initialStepCount - 1); - }); - }); + const newStepCount = screen.getAllByText(/^\d+\./).length + expect(newStepCount).toBe(initialStepCount - 1) + }) + }) - describe("Preview Functionality", () => { - it("calls onPreview when step preview button is clicked", () => { - const onPreview = vi.fn(); - renderTutorialEditor({ onPreview }); + describe('Preview Functionality', () => { + it('calls onPreview when step preview button is clicked', () => { + const onPreview = vi.fn() + renderTutorialEditor({ onPreview }) - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("1. Step 1")); - fireEvent.click(screen.getByText("Preview")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('1. Step 1')) + fireEvent.click(screen.getByText('Preview')) - expect(onPreview).toHaveBeenCalledWith(expect.any(Object), 0); - }); + expect(onPreview).toHaveBeenCalledWith(expect.any(Object), 0) + }) - it("calls onPreview when global preview button is clicked", () => { - const onPreview = vi.fn(); - renderTutorialEditor({ onPreview }); + it('calls onPreview when global preview button is clicked', () => { + const onPreview = vi.fn() + renderTutorialEditor({ onPreview }) - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const previewButtons = screen.getAllByText("Preview Tutorial"); - fireEvent.click(previewButtons[0]); + const previewButtons = screen.getAllByText('Preview Tutorial') + fireEvent.click(previewButtons[0]) - expect(onPreview).toHaveBeenCalledWith(expect.any(Object), 0); - }); - }); + expect(onPreview).toHaveBeenCalledWith(expect.any(Object), 0) + }) + }) - describe("Save Functionality", () => { - it("calls onSave when save button is clicked", async () => { - const onSave = vi.fn(); - renderTutorialEditor({ onSave }); + describe('Save Functionality', () => { + it('calls onSave when save button is clicked', async () => { + const onSave = vi.fn() + renderTutorialEditor({ onSave }) - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) // Make a change - const titleInput = screen.getByDisplayValue("Test Tutorial"); - fireEvent.change(titleInput, { target: { value: "Updated Title" } }); + const titleInput = screen.getByDisplayValue('Test Tutorial') + fireEvent.change(titleInput, { target: { value: 'Updated Title' } }) - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ - title: "Updated Title", - }), - ); - }); - }); + title: 'Updated Title', + }) + ) + }) + }) - it("calls validation before saving", async () => { - const onValidate = vi.fn().mockResolvedValue(mockValidationResult); - const onSave = vi.fn(); - renderTutorialEditor({ onSave, onValidate }); + it('calls validation before saving', async () => { + const onValidate = vi.fn().mockResolvedValue(mockValidationResult) + const onSave = vi.fn() + renderTutorialEditor({ onSave, onValidate }) - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(onValidate).toHaveBeenCalled(); - }); - }); + expect(onValidate).toHaveBeenCalled() + }) + }) - it("prevents saving when validation fails", async () => { + it('prevents saving when validation fails', async () => { const onValidate = vi.fn().mockResolvedValue({ isValid: false, errors: [ { - stepId: "", - field: "title", - message: "Title required", - severity: "error", + stepId: '', + field: 'title', + message: 'Title required', + severity: 'error', }, ], warnings: [], - }); - const onSave = vi.fn(); - renderTutorialEditor({ onSave, onValidate }); + }) + const onSave = vi.fn() + renderTutorialEditor({ onSave, onValidate }) - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(onValidate).toHaveBeenCalled(); - expect(onSave).not.toHaveBeenCalled(); - }); - }); - }); + expect(onValidate).toHaveBeenCalled() + expect(onSave).not.toHaveBeenCalled() + }) + }) + }) - describe("Validation Display", () => { - it("shows validation errors when validation fails", async () => { + describe('Validation Display', () => { + it('shows validation errors when validation fails', async () => { const onValidate = vi.fn().mockResolvedValue({ isValid: false, errors: [ { - stepId: "", - field: "title", - message: "Title is required", - severity: "error", + stepId: '', + field: 'title', + message: 'Title is required', + severity: 'error', }, ], warnings: [], - }); - renderTutorialEditor({ onValidate }); + }) + renderTutorialEditor({ onValidate }) - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(screen.getByText("Title is required")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('Title is required')).toBeInTheDocument() + }) + }) - it("shows validation warnings", async () => { + it('shows validation warnings', async () => { const onValidate = vi.fn().mockResolvedValue({ isValid: true, errors: [], warnings: [ { - stepId: "", - field: "description", - message: "Description could be longer", - severity: "warning", + stepId: '', + field: 'description', + message: 'Description could be longer', + severity: 'warning', }, ], - }); - renderTutorialEditor({ onValidate }); + }) + renderTutorialEditor({ onValidate }) - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect( - screen.getByText("Description could be longer"), - ).toBeInTheDocument(); - }); - }); + expect(screen.getByText('Description could be longer')).toBeInTheDocument() + }) + }) - it("displays step-specific validation errors", async () => { + it('displays step-specific validation errors', async () => { const onValidate = vi.fn().mockResolvedValue({ isValid: false, errors: [ { - stepId: "step-1", - field: "problem", - message: "Problem is required", - severity: "error", + stepId: 'step-1', + field: 'problem', + message: 'Problem is required', + severity: 'error', }, ], warnings: [], - }); - renderTutorialEditor({ onValidate }); + }) + renderTutorialEditor({ onValidate }) - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("1. Step 1")); - fireEvent.click(screen.getByText("Save Changes")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('1. Step 1')) + fireEvent.click(screen.getByText('Save Changes')) await waitFor(() => { - expect(screen.getByText("Problem is required")).toBeInTheDocument(); - }); - }); - }); + expect(screen.getByText('Problem is required')).toBeInTheDocument() + }) + }) + }) - describe("Step Reordering", () => { - it("shows move up/down buttons for steps", () => { - renderTutorialEditor(); + describe('Step Reordering', () => { + it('shows move up/down buttons for steps', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("1. Step 1")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('1. Step 1')) // Step 1 should have move down button but no move up - expect(screen.getByLabelText(/Move.*down/i)).toBeInTheDocument(); - expect(screen.queryByLabelText(/Move.*up/i)).not.toBeInTheDocument(); - }); + expect(screen.getByLabelText(/Move.*down/i)).toBeInTheDocument() + expect(screen.queryByLabelText(/Move.*up/i)).not.toBeInTheDocument() + }) - it("enables both move buttons for middle steps", () => { + it('enables both move buttons for middle steps', () => { const tutorialWithMoreSteps = { ...mockTutorial, steps: [ ...mockTutorial.steps, { - id: "step-3", - title: "Step 3", - problem: "2 + 1", - description: "Add one more", + id: 'step-3', + title: 'Step 3', + problem: '2 + 1', + description: 'Add one more', startValue: 2, targetValue: 3, - expectedAction: "add", - actionDescription: "Click the third bead", + expectedAction: 'add', + actionDescription: 'Click the third bead', tooltip: { - content: "Third tooltip", - explanation: "Third explanation", + content: 'Third tooltip', + explanation: 'Third explanation', }, errorMessages: { - wrongBead: "Wrong bead", - wrongAction: "Wrong action", - hint: "Hint", + wrongBead: 'Wrong bead', + wrongAction: 'Wrong action', + hint: 'Hint', }, }, ], - }; + } - renderTutorialEditor({ tutorial: tutorialWithMoreSteps }); + renderTutorialEditor({ tutorial: tutorialWithMoreSteps }) - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("2. Step 2")); + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('2. Step 2')) // Middle step should have both buttons - expect(screen.getByLabelText(/Move.*up/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/Move.*down/i)).toBeInTheDocument(); - }); - }); + expect(screen.getByLabelText(/Move.*up/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Move.*down/i)).toBeInTheDocument() + }) + }) - describe("Accessibility", () => { - it("has proper ARIA attributes for form controls", () => { - renderTutorialEditor(); + describe('Accessibility', () => { + it('has proper ARIA attributes for form controls', () => { + renderTutorialEditor() - fireEvent.click(screen.getByText("Edit Tutorial")); + fireEvent.click(screen.getByText('Edit Tutorial')) - const titleInput = screen.getByDisplayValue("Test Tutorial"); - expect(titleInput).toHaveAttribute("aria-label"); - }); + const titleInput = screen.getByDisplayValue('Test Tutorial') + expect(titleInput).toHaveAttribute('aria-label') + }) - it("has proper heading structure", () => { - renderTutorialEditor(); + it('has proper heading structure', () => { + renderTutorialEditor() - expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( - "Test Tutorial", - ); - }); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Tutorial') + }) - it("has proper button roles and labels", () => { - renderTutorialEditor(); + it('has proper button roles and labels', () => { + renderTutorialEditor() - const editButton = screen.getByText("Edit Tutorial"); - expect(editButton).toHaveAttribute("type", "button"); - }); - }); + const editButton = screen.getByText('Edit Tutorial') + expect(editButton).toHaveAttribute('type', 'button') + }) + }) - describe("Edge Cases", () => { - it("handles empty tutorial gracefully", () => { + describe('Edge Cases', () => { + it('handles empty tutorial gracefully', () => { const emptyTutorial = { ...mockTutorial, steps: [], - title: "", - description: "", - }; + title: '', + description: '', + } expect(() => { - renderTutorialEditor({ tutorial: emptyTutorial }); - }).not.toThrow(); - }); + renderTutorialEditor({ tutorial: emptyTutorial }) + }).not.toThrow() + }) - it("handles tutorial with single step", () => { + it('handles tutorial with single step', () => { const singleStepTutorial = { ...mockTutorial, steps: [mockTutorial.steps[0]], - }; + } - renderTutorialEditor({ tutorial: singleStepTutorial }); - fireEvent.click(screen.getByText("Edit Tutorial")); + renderTutorialEditor({ tutorial: singleStepTutorial }) + fireEvent.click(screen.getByText('Edit Tutorial')) - expect(screen.getByText("1. Step 1")).toBeInTheDocument(); - expect(screen.queryByText("2.")).not.toBeInTheDocument(); - }); + expect(screen.getByText('1. Step 1')).toBeInTheDocument() + expect(screen.queryByText('2.')).not.toBeInTheDocument() + }) - it("handles invalid step data gracefully", () => { + it('handles invalid step data gracefully', () => { const invalidStepTutorial = { ...mockTutorial, steps: [ @@ -600,45 +588,45 @@ describe("TutorialEditor", () => { targetValue: -1, }, ], - }; + } expect(() => { - renderTutorialEditor({ tutorial: invalidStepTutorial }); - }).not.toThrow(); - }); + renderTutorialEditor({ tutorial: invalidStepTutorial }) + }).not.toThrow() + }) - it("prevents deleting the last step", () => { + it('prevents deleting the last step', () => { const singleStepTutorial = { ...mockTutorial, steps: [mockTutorial.steps[0]], - }; + } - renderTutorialEditor({ tutorial: singleStepTutorial }); - fireEvent.click(screen.getByText("Edit Tutorial")); - fireEvent.click(screen.getByText("1. Step 1")); + renderTutorialEditor({ tutorial: singleStepTutorial }) + fireEvent.click(screen.getByText('Edit Tutorial')) + fireEvent.click(screen.getByText('1. Step 1')) - const deleteButton = screen.getByText("Delete"); - expect(deleteButton).toBeDisabled(); - }); - }); + const deleteButton = screen.getByText('Delete') + expect(deleteButton).toBeDisabled() + }) + }) - describe("Read-only Mode", () => { - it("does not show edit controls when onSave is not provided", () => { - renderTutorialEditor({ onSave: undefined }); + describe('Read-only Mode', () => { + it('does not show edit controls when onSave is not provided', () => { + renderTutorialEditor({ onSave: undefined }) - expect(screen.queryByText("Edit Tutorial")).not.toBeInTheDocument(); - expect(screen.getByText("Test Tutorial")).toBeInTheDocument(); - expect(screen.getByText("1. Step 1")).toBeInTheDocument(); - }); + expect(screen.queryByText('Edit Tutorial')).not.toBeInTheDocument() + expect(screen.getByText('Test Tutorial')).toBeInTheDocument() + expect(screen.getByText('1. Step 1')).toBeInTheDocument() + }) - it("allows clicking steps in read-only mode for viewing", () => { - renderTutorialEditor({ onSave: undefined }); + it('allows clicking steps in read-only mode for viewing', () => { + renderTutorialEditor({ onSave: undefined }) - fireEvent.click(screen.getByText("1. Step 1")); + fireEvent.click(screen.getByText('1. Step 1')) // Should show step details but no edit controls - expect(screen.getByText("0 + 1")).toBeInTheDocument(); - expect(screen.queryByDisplayValue("Step 1")).not.toBeInTheDocument(); - }); - }); -}); + expect(screen.getByText('0 + 1')).toBeInTheDocument() + expect(screen.queryByDisplayValue('Step 1')).not.toBeInTheDocument() + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialPlayer.integration.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialPlayer.integration.test.tsx index 74e3628a..829faa95 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialPlayer.integration.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialPlayer.integration.test.tsx @@ -1,17 +1,14 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { vi } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { TutorialPlayer } from "../TutorialPlayer"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { TutorialPlayer } from '../TutorialPlayer' // Mock the AbacusReact component for integration tests -vi.mock("@soroban/abacus-react", () => ({ +vi.mock('@soroban/abacus-react', () => ({ AbacusReact: ({ value, onValueChange, onClick }: any) => (
{value}
-
), - AbacusOverlay: ({ children }: any) => ( -
{children}
- ), -})); + AbacusOverlay: ({ children }: any) =>
{children}
, +})) // Mock tutorial data with multi-step instructions const mockTutorial: Tutorial = { - id: "integration-test", - title: "Integration Test Tutorial", - description: "Testing TutorialPlayer integration", + id: 'integration-test', + title: 'Integration Test Tutorial', + description: 'Testing TutorialPlayer integration', steps: [ { - id: "step-1", - title: "First Step", - problem: "5 + 3 = ?", - description: "Add 3 to 5", + id: 'step-1', + title: 'First Step', + problem: '5 + 3 = ?', + description: 'Add 3 to 5', startValue: 5, targetValue: 8, multiStepInstructions: [ - "First, show 5 on the abacus", - "Then add 3 more", - "Result should be 8", + 'First, show 5 on the abacus', + 'Then add 3 more', + 'Result should be 8', ], }, { - id: "step-2", - title: "Second Step", - problem: "10 - 2 = ?", - description: "Subtract 2 from 10", + id: 'step-2', + title: 'Second Step', + problem: '10 - 2 = ?', + description: 'Subtract 2 from 10', startValue: 10, targetValue: 8, }, ], -}; +} -describe("TutorialPlayer Integration", () => { +describe('TutorialPlayer Integration', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("AbacusReact Integration", () => { - it("should pass correct startValue to AbacusReact on step initialization", async () => { - render(); + describe('AbacusReact Integration', () => { + it('should pass correct startValue to AbacusReact on step initialization', async () => { + render() await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) + }) - it("should update AbacusReact value when navigating between steps", async () => { - render(); + it('should update AbacusReact value when navigating between steps', async () => { + render() // Initial step should show startValue = 5 await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Navigate to next step - const nextButton = screen.getByText("Next →"); - fireEvent.click(nextButton); + const nextButton = screen.getByText('Next →') + fireEvent.click(nextButton) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("10"); - }); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('10') + }) + }) - it("should handle user interactions with AbacusReact", async () => { - render(); + it('should handle user interactions with AbacusReact', async () => { + render() await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // User clicks a bead - fireEvent.click(screen.getByTestId("bead-click")); + fireEvent.click(screen.getByTestId('bead-click')) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("6"); - }); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('6') + }) + }) - it("should complete step when user reaches target value", async () => { - render(); + it('should complete step when user reaches target value', async () => { + render() // Initial state await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Simulate user changing abacus to target value (8) // We'll click the bead 3 times to go from 5 to 8 - fireEvent.click(screen.getByTestId("bead-click")); // 6 - fireEvent.click(screen.getByTestId("bead-click")); // 7 - fireEvent.click(screen.getByTestId("bead-click")); // 8 + fireEvent.click(screen.getByTestId('bead-click')) // 6 + fireEvent.click(screen.getByTestId('bead-click')) // 7 + fireEvent.click(screen.getByTestId('bead-click')) // 8 await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("8"); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('8') // Should show success feedback - expect(screen.getByText(/excellent/i)).toBeInTheDocument(); - }); - }); - }); + expect(screen.getByText(/excellent/i)).toBeInTheDocument() + }) + }) + }) - describe("Multi-Step Progression", () => { - it("should show multi-step instructions for steps that have them", async () => { - render(); + describe('Multi-Step Progression', () => { + it('should show multi-step instructions for steps that have them', async () => { + render() await waitFor(() => { // Should show multi-step navigation controls - expect( - screen.getByText("First, show 5 on the abacus"), - ).toBeInTheDocument(); - }); - }); + expect(screen.getByText('First, show 5 on the abacus')).toBeInTheDocument() + }) + }) - it("should advance multi-steps automatically when user reaches expected values", async () => { + it('should advance multi-steps automatically when user reaches expected values', async () => { const tutorialWithExpectedSteps: Tutorial = { ...mockTutorial, steps: [ @@ -144,17 +137,17 @@ describe("TutorialPlayer Integration", () => { // For testing, we'll simulate the expected intermediate values }, ], - }; + } - render(); + render() await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Simulate reaching an intermediate target that should advance multi-step // This would be based on the unified step generator's expected values - fireEvent.click(screen.getByTestId("bead-click")); + fireEvent.click(screen.getByTestId('bead-click')) // The multi-step should advance after a delay await waitFor( @@ -163,32 +156,32 @@ describe("TutorialPlayer Integration", () => { // Since we don't have the exact expected values in this test, // we're testing the integration pattern rather than specific values }, - { timeout: 2000 }, - ); - }); + { timeout: 2000 } + ) + }) - it("should allow manual multi-step navigation", async () => { - render(); + it('should allow manual multi-step navigation', async () => { + render() // Should show multi-step controls await waitFor(() => { - expect(screen.getByText("⏪ Prev")).toBeInTheDocument(); - expect(screen.getByText("Next ⏩")).toBeInTheDocument(); - }); + expect(screen.getByText('⏪ Prev')).toBeInTheDocument() + expect(screen.getByText('Next ⏩')).toBeInTheDocument() + }) // Navigate to next multi-step - fireEvent.click(screen.getByText("Next ⏩")); + fireEvent.click(screen.getByText('Next ⏩')) await waitFor(() => { - expect(screen.getByText("Then add 3 more")).toBeInTheDocument(); - }); - }); - }); + expect(screen.getByText('Then add 3 more')).toBeInTheDocument() + }) + }) + }) - describe("Context State Management", () => { - it("should maintain consistent state across components", async () => { - const onStepChange = vi.fn(); - const onStepComplete = vi.fn(); + describe('Context State Management', () => { + it('should maintain consistent state across components', async () => { + const onStepChange = vi.fn() + const onStepComplete = vi.fn() render( { onStepChange={onStepChange} onStepComplete={onStepComplete} showDebugPanel={true} - />, - ); + /> + ) // Initial step change should be called await waitFor(() => { - expect(onStepChange).toHaveBeenCalledWith(0, mockTutorial.steps[0]); - }); + expect(onStepChange).toHaveBeenCalledWith(0, mockTutorial.steps[0]) + }) // Navigate to next step - fireEvent.click(screen.getByText("Next →")); + fireEvent.click(screen.getByText('Next →')) await waitFor(() => { - expect(onStepChange).toHaveBeenCalledWith(1, mockTutorial.steps[1]); - expect(screen.getByTestId("abacus-value")).toHaveTextContent("10"); - }); - }); + expect(onStepChange).toHaveBeenCalledWith(1, mockTutorial.steps[1]) + expect(screen.getByTestId('abacus-value')).toHaveTextContent('10') + }) + }) - it("should prevent feedback loops between AbacusReact and context", async () => { - const _onValueChange = vi.fn(); + it('should prevent feedback loops between AbacusReact and context', async () => { + const _onValueChange = vi.fn() - render(); + render() // Initial value should be set without causing loops await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Navigate between steps rapidly - fireEvent.click(screen.getByText("Next →")); - fireEvent.click(screen.getByText("← Prev")); - fireEvent.click(screen.getByText("Next →")); + fireEvent.click(screen.getByText('Next →')) + fireEvent.click(screen.getByText('← Prev')) + fireEvent.click(screen.getByText('Next →')) // Should handle rapid navigation without getting stuck await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("10"); - }); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('10') + }) + }) - it("should handle programmatic changes correctly", async () => { - render(); + it('should handle programmatic changes correctly', async () => { + render() // Initial programmatic setting await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // User change - fireEvent.click(screen.getByTestId("bead-click")); + fireEvent.click(screen.getByTestId('bead-click')) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("6"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('6') + }) // Navigate to new step (programmatic change) - fireEvent.click(screen.getByText("Next →")); + fireEvent.click(screen.getByText('Next →')) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("10"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('10') + }) // User should still be able to interact after programmatic change - fireEvent.click(screen.getByTestId("bead-click")); + fireEvent.click(screen.getByTestId('bead-click')) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("11"); - }); - }); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('11') + }) + }) + }) - describe("Error Recovery", () => { - it("should handle AbacusReact errors gracefully", async () => { - const consoleSpy = vi - .spyOn(console, "error") - .mockImplementation(() => {}); + describe('Error Recovery', () => { + it('should handle AbacusReact errors gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) // Mock AbacusReact to throw an error const ErrorAbacus = () => { - throw new Error("AbacusReact error"); - }; + throw new Error('AbacusReact error') + } - vi.mocked(require("@soroban/abacus-react")).AbacusReact = ErrorAbacus; + vi.mocked(require('@soroban/abacus-react')).AbacusReact = ErrorAbacus expect(() => { - render(); - }).not.toThrow(); + render() + }).not.toThrow() - consoleSpy.mockRestore(); - }); + consoleSpy.mockRestore() + }) - it("should recover from invalid state transitions", async () => { - render(); + it('should recover from invalid state transitions', async () => { + render() await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Try to set an invalid value - fireEvent.click(screen.getByTestId("set-target")); + fireEvent.click(screen.getByTestId('set-target')) // Should still function normally await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("42"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('42') + }) // Navigation should still work - fireEvent.click(screen.getByText("Next →")); + fireEvent.click(screen.getByText('Next →')) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("10"); - }); - }); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('10') + }) + }) + }) - describe("Performance", () => { - it("should not cause excessive re-renders", async () => { - let renderCount = 0; + describe('Performance', () => { + it('should not cause excessive re-renders', async () => { + let renderCount = 0 const TestWrapper = () => { - renderCount++; - return ; - }; + renderCount++ + return + } - render(); + render() - const initialRenderCount = renderCount; + const initialRenderCount = renderCount // Perform various interactions await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) - fireEvent.click(screen.getByTestId("bead-click")); - fireEvent.click(screen.getByText("Next →")); - fireEvent.click(screen.getByText("← Prev")); + fireEvent.click(screen.getByTestId('bead-click')) + fireEvent.click(screen.getByText('Next →')) + fireEvent.click(screen.getByText('← Prev')) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Should not cause excessive re-renders - expect(renderCount).toBeLessThan(initialRenderCount + 10); - }); - }); -}); + expect(renderCount).toBeLessThan(initialRenderCount + 10) + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialPlayer.provenance.e2e.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialPlayer.provenance.e2e.test.tsx index 3a12345c..6ef636fa 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialPlayer.provenance.e2e.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialPlayer.provenance.e2e.test.tsx @@ -1,7 +1,7 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { TutorialPlayer } from "../TutorialPlayer"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { TutorialPlayer } from '../TutorialPlayer' // Mock the AbacusDisplayContext const mockAbacusDisplay = { @@ -14,43 +14,40 @@ const mockAbacusDisplay = { animationSpeed: 1000, }, updateConfig: () => {}, -}; +} // Mock the context -vi.mock("@/contexts/AbacusDisplayContext", () => ({ +vi.mock('@/contexts/AbacusDisplayContext', () => ({ useAbacusDisplay: () => mockAbacusDisplay, -})); +})) -describe("TutorialPlayer Provenance E2E Test", () => { +describe('TutorialPlayer Provenance E2E Test', () => { const provenanceTestTutorial: Tutorial = { - id: "provenance-test", - title: "Provenance Test Tutorial", - description: "Testing provenance information in tooltips", + id: 'provenance-test', + title: 'Provenance Test Tutorial', + description: 'Testing provenance information in tooltips', steps: [ { - id: "provenance-step", - title: "Test 3475 + 25 = 3500", - problem: "3475 + 25", - description: "Add 25 to 3475 to get 3500", + id: 'provenance-step', + title: 'Test 3475 + 25 = 3500', + problem: '3475 + 25', + description: 'Add 25 to 3475 to get 3500', startValue: 3475, targetValue: 3500, - expectedAction: "multi-step" as const, - actionDescription: "Follow the decomposition steps", + expectedAction: 'multi-step' as const, + actionDescription: 'Follow the decomposition steps', tooltip: { - content: "Adding 25 to 3475", - explanation: "This will show the provenance information", + content: 'Adding 25 to 3475', + explanation: 'This will show the provenance information', }, - multiStepInstructions: [ - "Add 2 tens (20)", - "Add 5 ones using ten-complement", - ], + multiStepInstructions: ['Add 2 tens (20)', 'Add 5 ones using ten-complement'], }, ], createdAt: new Date(), updatedAt: new Date(), - }; + } - let _container: HTMLElement; + let _container: HTMLElement beforeEach(() => { const renderResult = render( @@ -60,191 +57,181 @@ describe("TutorialPlayer Provenance E2E Test", () => { showDebugPanel={false} onEvent={() => {}} onTutorialComplete={() => {}} - />, - ); - _container = renderResult.container; - }); + /> + ) + _container = renderResult.container + }) it('should show provenance information in tooltip for the "20" term', async () => { // Wait for the tutorial to load and show the decomposition await waitFor( () => { - expect(screen.getByText("3475 + 25")).toBeInTheDocument(); + expect(screen.getByText('3475 + 25')).toBeInTheDocument() }, - { timeout: 5000 }, - ); + { timeout: 5000 } + ) // Look for the full decomposition string await waitFor( () => { // The decomposition should be: 3475 + 25 = 3475 + 20 + (100 - 90 - 5) = 3500 const decompositionElement = screen.getByText( - /3475 \+ 25 = 3475 \+ 20 \+ \(100 - 90 - 5\) = 3500/, - ); - expect(decompositionElement).toBeInTheDocument(); + /3475 \+ 25 = 3475 \+ 20 \+ \(100 - 90 - 5\) = 3500/ + ) + expect(decompositionElement).toBeInTheDocument() }, - { timeout: 5000 }, - ); + { timeout: 5000 } + ) // Find the "20" term in the decomposition - const twentyTerm = screen.getByText("20"); - expect(twentyTerm).toBeInTheDocument(); + const twentyTerm = screen.getByText('20') + expect(twentyTerm).toBeInTheDocument() // Hover over the "20" term to trigger the tooltip - fireEvent.mouseEnter(twentyTerm); + fireEvent.mouseEnter(twentyTerm) // Wait for the tooltip to appear await waitFor( () => { // Look for the enhanced provenance-based title - const provenanceTitle = screen.getByText( - "Add the tens digit — 2 tens (20)", - ); - expect(provenanceTitle).toBeInTheDocument(); + const provenanceTitle = screen.getByText('Add the tens digit — 2 tens (20)') + expect(provenanceTitle).toBeInTheDocument() }, - { timeout: 3000 }, - ); + { timeout: 3000 } + ) // Check for the enhanced subtitle await waitFor(() => { - const provenanceSubtitle = screen.getByText("From addend 25"); - expect(provenanceSubtitle).toBeInTheDocument(); - }); + const provenanceSubtitle = screen.getByText('From addend 25') + expect(provenanceSubtitle).toBeInTheDocument() + }) // Check for the enhanced explanation text await waitFor(() => { - const provenanceExplanation = screen.getByText( - /We're adding the tens digit of 25 → 2 tens/, - ); - expect(provenanceExplanation).toBeInTheDocument(); - }); + const provenanceExplanation = screen.getByText(/We're adding the tens digit of 25 → 2 tens/) + expect(provenanceExplanation).toBeInTheDocument() + }) // Check for the enhanced breadcrumb chips await waitFor(() => { - const digitChip = screen.getByText(/Digit we're using: 2 \(tens\)/); - expect(digitChip).toBeInTheDocument(); - }); + const digitChip = screen.getByText(/Digit we're using: 2 \(tens\)/) + expect(digitChip).toBeInTheDocument() + }) await waitFor(() => { - const addChip = screen.getByText(/So we add here: \+2 tens → 20/); - expect(addChip).toBeInTheDocument(); - }); - }); + const addChip = screen.getByText(/So we add here: \+2 tens → 20/) + expect(addChip).toBeInTheDocument() + }) + }) - it("should NOT show the old generic tooltip text", async () => { + it('should NOT show the old generic tooltip text', async () => { // Wait for the tutorial to load await waitFor(() => { - expect(screen.getByText("3475 + 25")).toBeInTheDocument(); - }); + expect(screen.getByText('3475 + 25')).toBeInTheDocument() + }) // Find and hover over the "20" term - const twentyTerm = screen.getByText("20"); - fireEvent.mouseEnter(twentyTerm); + const twentyTerm = screen.getByText('20') + fireEvent.mouseEnter(twentyTerm) // Wait a moment for tooltip to appear await waitFor(() => { - const provenanceTitle = screen.getByText( - "Add the tens digit — 2 tens (20)", - ); - expect(provenanceTitle).toBeInTheDocument(); - }); + const provenanceTitle = screen.getByText('Add the tens digit — 2 tens (20)') + expect(provenanceTitle).toBeInTheDocument() + }) // The old generic text should NOT be present when provenance is available - expect(screen.queryByText("Direct Move")).not.toBeInTheDocument(); - expect(screen.queryByText("Simple bead movement")).not.toBeInTheDocument(); + expect(screen.queryByText('Direct Move')).not.toBeInTheDocument() + expect(screen.queryByText('Simple bead movement')).not.toBeInTheDocument() // Instead we should see the provenance-enhanced content - expect(screen.getByText("From addend 25")).toBeInTheDocument(); - }); + expect(screen.getByText('From addend 25')).toBeInTheDocument() + }) - it("should show correct provenance for complement operations in the same example", async () => { + it('should show correct provenance for complement operations in the same example', async () => { // Wait for the tutorial to load await waitFor(() => { - expect(screen.getByText("3475 + 25")).toBeInTheDocument(); - }); + expect(screen.getByText('3475 + 25')).toBeInTheDocument() + }) // Find the "100" term (part of the ten-complement for the ones digit) - const hundredTerm = screen.getByText("100"); - expect(hundredTerm).toBeInTheDocument(); + const hundredTerm = screen.getByText('100') + expect(hundredTerm).toBeInTheDocument() // Hover over the "100" term - fireEvent.mouseEnter(hundredTerm); + fireEvent.mouseEnter(hundredTerm) // This should show provenance pointing back to the ones digit "5" from "25" await waitFor(() => { - const provenanceTitle = screen.getByText( - "Add the ones digit — 5 ones (5)", - ); - expect(provenanceTitle).toBeInTheDocument(); - }); + const provenanceTitle = screen.getByText('Add the ones digit — 5 ones (5)') + expect(provenanceTitle).toBeInTheDocument() + }) await waitFor(() => { - const provenanceSubtitle = screen.getByText("From addend 25"); - expect(provenanceSubtitle).toBeInTheDocument(); - }); - }); + const provenanceSubtitle = screen.getByText('From addend 25') + expect(provenanceSubtitle).toBeInTheDocument() + }) + }) - it("should provide data for addend digit highlighting", async () => { + it('should provide data for addend digit highlighting', async () => { // Wait for the tutorial to load await waitFor(() => { - expect(screen.getByText("3475 + 25")).toBeInTheDocument(); - }); + expect(screen.getByText('3475 + 25')).toBeInTheDocument() + }) // The equation anchors should be available in the component // We can't directly test highlighting without more complex setup, // but we can verify the equation has the right structure for highlighting - const fullEquation = screen.getByText( - /3475 \+ 25 = 3475 \+ 20 \+ \(100 - 90 - 5\) = 3500/, - ); - expect(fullEquation).toBeInTheDocument(); + const fullEquation = screen.getByText(/3475 \+ 25 = 3475 \+ 20 \+ \(100 - 90 - 5\) = 3500/) + expect(fullEquation).toBeInTheDocument() // The "25" should be present and ready for highlighting - const addendText = screen.getByText("25"); - expect(addendText).toBeInTheDocument(); - }); + const addendText = screen.getByText('25') + expect(addendText).toBeInTheDocument() + }) - it("should show working on bubble with provenance information", async () => { + it('should show working on bubble with provenance information', async () => { // Wait for the tutorial to load await waitFor(() => { - expect(screen.getByText("3475 + 25")).toBeInTheDocument(); - }); + expect(screen.getByText('3475 + 25')).toBeInTheDocument() + }) // If there's a "working on" indicator, it should use provenance // The exact implementation might vary, but it should reference the source digit // Look for any element that might show "Working on: tens digit of 25 → 2 tens (20)" - const workingOnElements = screen.queryAllByText(/Working on/); + const workingOnElements = screen.queryAllByText(/Working on/) if (workingOnElements.length > 0) { - const workingOnText = workingOnElements[0].textContent; - expect(workingOnText).toMatch(/tens digit of 25|2 tens/); + const workingOnText = workingOnElements[0].textContent + expect(workingOnText).toMatch(/tens digit of 25|2 tens/) } - }); + }) - it("should debug log provenance information", async () => { - const consoleSpy = vi.spyOn(console, "log"); + it('should debug log provenance information', async () => { + const consoleSpy = vi.spyOn(console, 'log') // Wait for the tutorial to load await waitFor(() => { - expect(screen.getByText("3475 + 25")).toBeInTheDocument(); - }); + expect(screen.getByText('3475 + 25')).toBeInTheDocument() + }) // Hover over the "20" term to trigger tooltip rendering - const twentyTerm = screen.getByText("20"); - fireEvent.mouseEnter(twentyTerm); + const twentyTerm = screen.getByText('20') + fireEvent.mouseEnter(twentyTerm) // The ReasonTooltip component should log the provenance data await waitFor(() => { expect(consoleSpy).toHaveBeenCalledWith( - "ReasonTooltip - provenance data:", + 'ReasonTooltip - provenance data:', expect.objectContaining({ rhs: 25, rhsDigit: 2, rhsPlace: 1, - rhsPlaceName: "tens", + rhsPlaceName: 'tens', rhsValue: 20, - }), - ); - }); + }) + ) + }) - consoleSpy.mockRestore(); - }); -}); + consoleSpy.mockRestore() + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialPlayer.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialPlayer.test.tsx index 2da865de..ced5a667 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialPlayer.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialPlayer.test.tsx @@ -1,398 +1,386 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DevAccessProvider } from "../../../hooks/useAccessControl"; -import type { Tutorial } from "../../../types/tutorial"; -import { TutorialPlayer } from "../TutorialPlayer"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DevAccessProvider } from '../../../hooks/useAccessControl' +import type { Tutorial } from '../../../types/tutorial' +import { TutorialPlayer } from '../TutorialPlayer' // Mock the AbacusReact component -vi.mock("@soroban/abacus-react", () => ({ +vi.mock('@soroban/abacus-react', () => ({ AbacusReact: ({ value, onValueChange, callbacks }: any) => (
{value}
), -})); +})) const mockTutorial: Tutorial = { - id: "test-tutorial", - title: "Test Tutorial", - description: "A test tutorial", - category: "test", - difficulty: "beginner", + id: 'test-tutorial', + title: 'Test Tutorial', + description: 'A test tutorial', + category: 'test', + difficulty: 'beginner', estimatedDuration: 10, steps: [ { - id: "step-1", - title: "Step 1", - problem: "0 + 1", - description: "Add one", + id: 'step-1', + title: 'Step 1', + problem: '0 + 1', + description: 'Add one', startValue: 0, targetValue: 1, - highlightBeads: [{ columnIndex: 4, beadType: "earth", position: 0 }], - expectedAction: "add", - actionDescription: "Click the first bead", + highlightBeads: [{ columnIndex: 4, beadType: 'earth', position: 0 }], + expectedAction: 'add', + actionDescription: 'Click the first bead', tooltip: { - content: "Test tooltip", - explanation: "Test explanation", + content: 'Test tooltip', + explanation: 'Test explanation', }, errorMessages: { - wrongBead: "Wrong bead clicked", - wrongAction: "Wrong action", - hint: "Test hint", + wrongBead: 'Wrong bead clicked', + wrongAction: 'Wrong action', + hint: 'Test hint', }, }, { - id: "step-2", - title: "Step 2", - problem: "1 + 1", - description: "Add another one", + id: 'step-2', + title: 'Step 2', + problem: '1 + 1', + description: 'Add another one', startValue: 1, targetValue: 2, - expectedAction: "add", - actionDescription: "Click the second bead", + expectedAction: 'add', + actionDescription: 'Click the second bead', tooltip: { - content: "Second tooltip", - explanation: "Second explanation", + content: 'Second tooltip', + explanation: 'Second explanation', }, errorMessages: { - wrongBead: "Wrong bead for step 2", - wrongAction: "Wrong action for step 2", - hint: "Step 2 hint", + wrongBead: 'Wrong bead for step 2', + wrongAction: 'Wrong action for step 2', + hint: 'Step 2 hint', }, }, ], - tags: ["test"], - author: "Test Author", - version: "1.0.0", + tags: ['test'], + author: 'Test Author', + version: '1.0.0', createdAt: new Date(), updatedAt: new Date(), isPublished: true, -}; +} -const renderTutorialPlayer = ( - props: Partial> = {}, -) => { +const renderTutorialPlayer = (props: Partial> = {}) => { const defaultProps = { tutorial: mockTutorial, initialStepIndex: 0, isDebugMode: false, showDebugPanel: false, - }; + } return render( - , - ); -}; + + ) +} -describe("TutorialPlayer", () => { +describe('TutorialPlayer', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("Basic Rendering", () => { - it("renders tutorial title and current step information", () => { - renderTutorialPlayer(); + describe('Basic Rendering', () => { + it('renders tutorial title and current step information', () => { + renderTutorialPlayer() - expect(screen.getByText("Test Tutorial")).toBeInTheDocument(); - expect(screen.getByText(/Step 1 of 2: Step 1/)).toBeInTheDocument(); - expect(screen.getByText("0 + 1")).toBeInTheDocument(); - expect(screen.getByText("Add one")).toBeInTheDocument(); - }); + expect(screen.getByText('Test Tutorial')).toBeInTheDocument() + expect(screen.getByText(/Step 1 of 2: Step 1/)).toBeInTheDocument() + expect(screen.getByText('0 + 1')).toBeInTheDocument() + expect(screen.getByText('Add one')).toBeInTheDocument() + }) - it("renders the abacus component", () => { - renderTutorialPlayer(); + it('renders the abacus component', () => { + renderTutorialPlayer() - expect(screen.getByTestId("mock-abacus")).toBeInTheDocument(); - expect(screen.getByTestId("abacus-value")).toHaveTextContent("0"); - }); + expect(screen.getByTestId('mock-abacus')).toBeInTheDocument() + expect(screen.getByTestId('abacus-value')).toHaveTextContent('0') + }) - it("shows tooltip information", () => { - renderTutorialPlayer(); + it('shows tooltip information', () => { + renderTutorialPlayer() - expect(screen.getByText("Test tooltip")).toBeInTheDocument(); - expect(screen.getByText("Test explanation")).toBeInTheDocument(); - }); + expect(screen.getByText('Test tooltip')).toBeInTheDocument() + expect(screen.getByText('Test explanation')).toBeInTheDocument() + }) - it("shows progress bar", () => { - renderTutorialPlayer(); + it('shows progress bar', () => { + renderTutorialPlayer() const progressBar = - screen.getByRole("progressbar", { hidden: true }) || - document.querySelector('[style*="width"]'); - expect(progressBar).toBeInTheDocument(); - }); - }); + screen.getByRole('progressbar', { hidden: true }) || + document.querySelector('[style*="width"]') + expect(progressBar).toBeInTheDocument() + }) + }) - describe("Navigation", () => { - it("disables previous button on first step", () => { - renderTutorialPlayer(); + describe('Navigation', () => { + it('disables previous button on first step', () => { + renderTutorialPlayer() - const prevButton = screen.getByText("← Previous"); - expect(prevButton).toBeDisabled(); - }); + const prevButton = screen.getByText('← Previous') + expect(prevButton).toBeDisabled() + }) - it("enables next button when step is completed", async () => { - const onStepComplete = vi.fn(); - renderTutorialPlayer({ onStepComplete }); + it('enables next button when step is completed', async () => { + const onStepComplete = vi.fn() + renderTutorialPlayer({ onStepComplete }) // Complete the step by clicking the mock bead - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) await waitFor(() => { - const nextButton = screen.getByText("Next →"); - expect(nextButton).not.toBeDisabled(); - }); - }); + const nextButton = screen.getByText('Next →') + expect(nextButton).not.toBeDisabled() + }) + }) - it("navigates to next step when next button is clicked", async () => { - const onStepChange = vi.fn(); - renderTutorialPlayer({ onStepChange }); + it('navigates to next step when next button is clicked', async () => { + const onStepChange = vi.fn() + renderTutorialPlayer({ onStepChange }) // Complete first step - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) await waitFor(() => { - const nextButton = screen.getByText("Next →"); - fireEvent.click(nextButton); - }); + const nextButton = screen.getByText('Next →') + fireEvent.click(nextButton) + }) - expect(onStepChange).toHaveBeenCalledWith(1, mockTutorial.steps[1]); - }); + expect(onStepChange).toHaveBeenCalledWith(1, mockTutorial.steps[1]) + }) it('shows "Complete Tutorial" button on last step', () => { - renderTutorialPlayer({ initialStepIndex: 1 }); + renderTutorialPlayer({ initialStepIndex: 1 }) - expect(screen.getByText("Complete Tutorial")).toBeInTheDocument(); - }); - }); + expect(screen.getByText('Complete Tutorial')).toBeInTheDocument() + }) + }) - describe("Step Completion", () => { - it("marks step as completed when target value is reached", async () => { - const onStepComplete = vi.fn(); - renderTutorialPlayer({ onStepComplete }); + describe('Step Completion', () => { + it('marks step as completed when target value is reached', async () => { + const onStepComplete = vi.fn() + renderTutorialPlayer({ onStepComplete }) - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) await waitFor(() => { - expect( - screen.getByText(/Great! You completed this step correctly/), - ).toBeInTheDocument(); - expect(onStepComplete).toHaveBeenCalledWith( - 0, - mockTutorial.steps[0], - true, - ); - }); - }); + expect(screen.getByText(/Great! You completed this step correctly/)).toBeInTheDocument() + expect(onStepComplete).toHaveBeenCalledWith(0, mockTutorial.steps[0], true) + }) + }) - it("calls onTutorialComplete when tutorial is finished", async () => { - const onTutorialComplete = vi.fn(); + it('calls onTutorialComplete when tutorial is finished', async () => { + const onTutorialComplete = vi.fn() renderTutorialPlayer({ initialStepIndex: 1, // Start on last step onTutorialComplete, - }); + }) // Complete the last step - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) await waitFor(() => { - const completeButton = screen.getByText("Complete Tutorial"); - fireEvent.click(completeButton); - }); + const completeButton = screen.getByText('Complete Tutorial') + fireEvent.click(completeButton) + }) - expect(onTutorialComplete).toHaveBeenCalled(); - }); - }); + expect(onTutorialComplete).toHaveBeenCalled() + }) + }) - describe("Debug Mode", () => { - it("shows debug controls when debug mode is enabled", () => { - renderTutorialPlayer({ isDebugMode: true }); + describe('Debug Mode', () => { + it('shows debug controls when debug mode is enabled', () => { + renderTutorialPlayer({ isDebugMode: true }) - expect(screen.getByText("Debug")).toBeInTheDocument(); - expect(screen.getByText("Steps")).toBeInTheDocument(); - expect(screen.getByLabelText("Auto-advance")).toBeInTheDocument(); - }); + expect(screen.getByText('Debug')).toBeInTheDocument() + expect(screen.getByText('Steps')).toBeInTheDocument() + expect(screen.getByLabelText('Auto-advance')).toBeInTheDocument() + }) - it("shows debug panel when enabled", () => { - renderTutorialPlayer({ isDebugMode: true, showDebugPanel: true }); + it('shows debug panel when enabled', () => { + renderTutorialPlayer({ isDebugMode: true, showDebugPanel: true }) - expect(screen.getByText("Debug Panel")).toBeInTheDocument(); - expect(screen.getByText("Current State")).toBeInTheDocument(); - expect(screen.getByText("Event Log")).toBeInTheDocument(); - }); + expect(screen.getByText('Debug Panel')).toBeInTheDocument() + expect(screen.getByText('Current State')).toBeInTheDocument() + expect(screen.getByText('Event Log')).toBeInTheDocument() + }) - it("shows step list sidebar when enabled", () => { - renderTutorialPlayer({ isDebugMode: true }); + it('shows step list sidebar when enabled', () => { + renderTutorialPlayer({ isDebugMode: true }) - const stepsButton = screen.getByText("Steps"); - fireEvent.click(stepsButton); + const stepsButton = screen.getByText('Steps') + fireEvent.click(stepsButton) - expect(screen.getByText("Tutorial Steps")).toBeInTheDocument(); - expect(screen.getByText("1. Step 1")).toBeInTheDocument(); - expect(screen.getByText("2. Step 2")).toBeInTheDocument(); - }); + expect(screen.getByText('Tutorial Steps')).toBeInTheDocument() + expect(screen.getByText('1. Step 1')).toBeInTheDocument() + expect(screen.getByText('2. Step 2')).toBeInTheDocument() + }) - it("allows jumping to specific step from step list", () => { - const onStepChange = vi.fn(); - renderTutorialPlayer({ isDebugMode: true, onStepChange }); + it('allows jumping to specific step from step list', () => { + const onStepChange = vi.fn() + renderTutorialPlayer({ isDebugMode: true, onStepChange }) - const stepsButton = screen.getByText("Steps"); - fireEvent.click(stepsButton); + const stepsButton = screen.getByText('Steps') + fireEvent.click(stepsButton) - const step2Button = screen.getByText("2. Step 2"); - fireEvent.click(step2Button); + const step2Button = screen.getByText('2. Step 2') + fireEvent.click(step2Button) - expect(onStepChange).toHaveBeenCalledWith(1, mockTutorial.steps[1]); - }); - }); + expect(onStepChange).toHaveBeenCalledWith(1, mockTutorial.steps[1]) + }) + }) - describe("Event Logging", () => { - it("logs events when onEvent callback is provided", () => { - const onEvent = vi.fn(); - renderTutorialPlayer({ onEvent }); + describe('Event Logging', () => { + it('logs events when onEvent callback is provided', () => { + const onEvent = vi.fn() + renderTutorialPlayer({ onEvent }) - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ - type: "BEAD_CLICKED", + type: 'BEAD_CLICKED', timestamp: expect.any(Date), - }), - ); - }); + }) + ) + }) - it("logs step started event on mount", () => { - const onEvent = vi.fn(); - renderTutorialPlayer({ onEvent }); + it('logs step started event on mount', () => { + const onEvent = vi.fn() + renderTutorialPlayer({ onEvent }) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ - type: "STEP_STARTED", - stepId: "step-1", + type: 'STEP_STARTED', + stepId: 'step-1', timestamp: expect.any(Date), - }), - ); - }); + }) + ) + }) - it("logs value changed events", () => { - const onEvent = vi.fn(); - renderTutorialPlayer({ onEvent }); + it('logs value changed events', () => { + const onEvent = vi.fn() + renderTutorialPlayer({ onEvent }) - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) expect(onEvent).toHaveBeenCalledWith( expect.objectContaining({ - type: "VALUE_CHANGED", + type: 'VALUE_CHANGED', oldValue: 0, newValue: 1, timestamp: expect.any(Date), - }), - ); - }); - }); + }) + ) + }) + }) - describe("Error Handling", () => { - it("shows error message for wrong bead clicks", async () => { - renderTutorialPlayer(); + describe('Error Handling', () => { + it('shows error message for wrong bead clicks', async () => { + renderTutorialPlayer() // Mock a wrong bead click by directly calling the callback // In real usage, this would come from the AbacusReact component const _wrongBeadClick = { columnIndex: 1, // Wrong column - beadType: "earth" as const, + beadType: 'earth' as const, position: 0, active: false, - }; + } // Simulate wrong bead click through the mock - const _mockAbacus = screen.getByTestId("mock-abacus"); + const _mockAbacus = screen.getByTestId('mock-abacus') // We need to trigger this through the component's callback system // For now, we'll test the error display indirectly - }); - }); + }) + }) - describe("Auto-advance Feature", () => { - it("enables auto-advance when checkbox is checked", () => { - renderTutorialPlayer({ isDebugMode: true }); + describe('Auto-advance Feature', () => { + it('enables auto-advance when checkbox is checked', () => { + renderTutorialPlayer({ isDebugMode: true }) - const autoAdvanceCheckbox = screen.getByLabelText("Auto-advance"); - fireEvent.click(autoAdvanceCheckbox); + const autoAdvanceCheckbox = screen.getByLabelText('Auto-advance') + fireEvent.click(autoAdvanceCheckbox) - expect(autoAdvanceCheckbox).toBeChecked(); - }); - }); + expect(autoAdvanceCheckbox).toBeChecked() + }) + }) - describe("Accessibility", () => { - it("has proper ARIA attributes", () => { - renderTutorialPlayer(); + describe('Accessibility', () => { + it('has proper ARIA attributes', () => { + renderTutorialPlayer() // Check for proper heading structure - expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( - "Test Tutorial", - ); - expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( - "0 + 1", - ); - }); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Test Tutorial') + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('0 + 1') + }) - it("has keyboard navigation support", () => { - renderTutorialPlayer(); + it('has keyboard navigation support', () => { + renderTutorialPlayer() - const nextButton = screen.getByText("Next →"); - const prevButton = screen.getByText("← Previous"); + const nextButton = screen.getByText('Next →') + const prevButton = screen.getByText('← Previous') - expect(nextButton).toHaveAttribute("type", "button"); - expect(prevButton).toHaveAttribute("type", "button"); - }); - }); + expect(nextButton).toHaveAttribute('type', 'button') + expect(prevButton).toHaveAttribute('type', 'button') + }) + }) - describe("Edge Cases", () => { - it("handles empty tutorial gracefully", () => { - const emptyTutorial = { ...mockTutorial, steps: [] }; + describe('Edge Cases', () => { + it('handles empty tutorial gracefully', () => { + const emptyTutorial = { ...mockTutorial, steps: [] } expect(() => { - renderTutorialPlayer({ tutorial: emptyTutorial }); - }).not.toThrow(); - }); + renderTutorialPlayer({ tutorial: emptyTutorial }) + }).not.toThrow() + }) - it("handles invalid initial step index", () => { + it('handles invalid initial step index', () => { expect(() => { - renderTutorialPlayer({ initialStepIndex: 999 }); - }).not.toThrow(); - }); + renderTutorialPlayer({ initialStepIndex: 999 }) + }).not.toThrow() + }) - it("handles tutorial with single step", () => { + it('handles tutorial with single step', () => { const singleStepTutorial = { ...mockTutorial, steps: [mockTutorial.steps[0]], - }; + } - renderTutorialPlayer({ tutorial: singleStepTutorial }); + renderTutorialPlayer({ tutorial: singleStepTutorial }) - expect(screen.getByText("Complete Tutorial")).toBeInTheDocument(); - expect(screen.getByText("← Previous")).toBeDisabled(); - }); - }); -}); + expect(screen.getByText('Complete Tutorial')).toBeInTheDocument() + expect(screen.getByText('← Previous')).toBeDisabled() + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialPlayerCelebration.integration.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialPlayerCelebration.integration.test.tsx index 76defdb5..426c1eab 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialPlayerCelebration.integration.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialPlayerCelebration.integration.test.tsx @@ -1,33 +1,27 @@ -import "./test-setup"; -import { AbacusDisplayProvider } from "@soroban/abacus-react"; -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import React from "react"; -import { vi } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { TutorialProvider } from "../TutorialContext"; -import { TutorialPlayer } from "../TutorialPlayer"; +import './test-setup' +import { AbacusDisplayProvider } from '@soroban/abacus-react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import { vi } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { TutorialProvider } from '../TutorialContext' +import { TutorialPlayer } from '../TutorialPlayer' // Mock the AbacusReact component to make testing easier -vi.mock("@soroban/abacus-react", () => ({ +vi.mock('@soroban/abacus-react', () => ({ AbacusReact: ({ value, onValueChange, overlays }: any) => { - const [currentValue, setCurrentValue] = React.useState(value); + const [currentValue, setCurrentValue] = React.useState(value) // Sync with prop changes React.useEffect(() => { - setCurrentValue(value); - }, [value]); + setCurrentValue(value) + }, [value]) const handleClick = () => { - const newValue = currentValue === 3 ? 5 : currentValue === 5 ? 6 : 3; - setCurrentValue(newValue); - onValueChange?.(newValue); - }; + const newValue = currentValue === 3 ? 5 : currentValue === 5 ? 6 : 3 + setCurrentValue(newValue) + onValueChange?.(newValue) + } return (
@@ -42,38 +36,38 @@ vi.mock("@soroban/abacus-react", () => ({
))}
- ); + ) }, StepBeadHighlight: {}, -})); +})) const mockTutorial: Tutorial = { - id: "integration-test-tutorial", - title: "Integration Test Tutorial", - description: "Testing celebration tooltip integration", + id: 'integration-test-tutorial', + title: 'Integration Test Tutorial', + description: 'Testing celebration tooltip integration', steps: [ { - id: "step-1", - title: "Add Two", - problem: "3 + 2", - description: "Add 2 to 3 to get 5", + id: 'step-1', + title: 'Add Two', + problem: '3 + 2', + description: 'Add 2 to 3 to get 5', startValue: 3, targetValue: 5, - expectedAction: "add", - actionDescription: "3 + 2 = 5", + expectedAction: 'add', + actionDescription: '3 + 2 = 5', tooltip: { - content: "Add 2 to reach 5", - explanation: "Move two earth beads up to add 2", + content: 'Add 2 to reach 5', + explanation: 'Move two earth beads up to add 2', }, - multiStepInstructions: ["Move two earth beads up"], + multiStepInstructions: ['Move two earth beads up'], }, ], -}; +} -describe("TutorialPlayer Celebration Integration", () => { +describe('TutorialPlayer Celebration Integration', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) const renderTutorialPlayer = (tutorial = mockTutorial, props = {}) => { return render( @@ -81,352 +75,352 @@ describe("TutorialPlayer Celebration Integration", () => { - , - ); - }; + + ) + } - describe("Celebration Tooltip Behavior", () => { - it("should show celebration tooltip when target value is reached", async () => { - const onStepComplete = vi.fn(); - renderTutorialPlayer(mockTutorial, { onStepComplete }); + describe('Celebration Tooltip Behavior', () => { + it('should show celebration tooltip when target value is reached', async () => { + const onStepComplete = vi.fn() + renderTutorialPlayer(mockTutorial, { onStepComplete }) // Wait for tutorial to load with initial value await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("3"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('3') + }) // Change value to target (5) - const changeBtn = screen.getByTestId("change-value-btn"); + const changeBtn = screen.getByTestId('change-value-btn') await act(async () => { - fireEvent.click(changeBtn); - }); + fireEvent.click(changeBtn) + }) // Wait for value to change to 5 await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Wait for step completion and celebration tooltip await waitFor( () => { - expect(onStepComplete).toHaveBeenCalled(); + expect(onStepComplete).toHaveBeenCalled() }, - { timeout: 5000 }, - ); + { timeout: 5000 } + ) // Look for celebration content in overlays await waitFor( () => { - const celebration = screen.queryByText("🎉"); - const excellentWork = screen.queryByText("Excellent work!"); - expect(celebration || excellentWork).toBeTruthy(); + const celebration = screen.queryByText('🎉') + const excellentWork = screen.queryByText('Excellent work!') + expect(celebration || excellentWork).toBeTruthy() }, - { timeout: 3000 }, - ); - }); + { timeout: 3000 } + ) + }) - it("should hide celebration tooltip when user moves away from target", async () => { - const onStepComplete = vi.fn(); - renderTutorialPlayer(mockTutorial, { onStepComplete }); + it('should hide celebration tooltip when user moves away from target', async () => { + const onStepComplete = vi.fn() + renderTutorialPlayer(mockTutorial, { onStepComplete }) // Wait for initial load await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("3"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('3') + }) - const changeBtn = screen.getByTestId("change-value-btn"); + const changeBtn = screen.getByTestId('change-value-btn') // First reach target value (5) await act(async () => { - fireEvent.click(changeBtn); - }); + fireEvent.click(changeBtn) + }) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Wait for celebration to appear await waitFor( () => { - const celebration = screen.queryByText("🎉"); - const excellentWork = screen.queryByText("Excellent work!"); - expect(celebration || excellentWork).toBeTruthy(); + const celebration = screen.queryByText('🎉') + const excellentWork = screen.queryByText('Excellent work!') + expect(celebration || excellentWork).toBeTruthy() }, - { timeout: 3000 }, - ); + { timeout: 3000 } + ) // Now move away from target (to 6) await act(async () => { - fireEvent.click(changeBtn); - }); + fireEvent.click(changeBtn) + }) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("6"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('6') + }) // Celebration should disappear await waitFor( () => { - const celebration = screen.queryByText("🎉"); - const excellentWork = screen.queryByText("Excellent work!"); - expect(celebration).toBeFalsy(); - expect(excellentWork).toBeFalsy(); + const celebration = screen.queryByText('🎉') + const excellentWork = screen.queryByText('Excellent work!') + expect(celebration).toBeFalsy() + expect(excellentWork).toBeFalsy() }, - { timeout: 2000 }, - ); - }); + { timeout: 2000 } + ) + }) - it("should return celebration when user goes back to target value", async () => { - renderTutorialPlayer(mockTutorial); + it('should return celebration when user goes back to target value', async () => { + renderTutorialPlayer(mockTutorial) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("3"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('3') + }) - const changeBtn = screen.getByTestId("change-value-btn"); + const changeBtn = screen.getByTestId('change-value-btn') // Reach target (5) await act(async () => { - fireEvent.click(changeBtn); - }); + fireEvent.click(changeBtn) + }) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Verify celebration appears await waitFor( () => { - const celebration = screen.queryByText("🎉"); - const excellentWork = screen.queryByText("Excellent work!"); - expect(celebration || excellentWork).toBeTruthy(); + const celebration = screen.queryByText('🎉') + const excellentWork = screen.queryByText('Excellent work!') + expect(celebration || excellentWork).toBeTruthy() }, - { timeout: 3000 }, - ); + { timeout: 3000 } + ) // Move away (to 6) await act(async () => { - fireEvent.click(changeBtn); - }); + fireEvent.click(changeBtn) + }) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("6"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('6') + }) // Celebration should be gone await waitFor(() => { - expect(screen.queryByText("🎉")).toBeFalsy(); - expect(screen.queryByText("Excellent work!")).toBeFalsy(); - }); + expect(screen.queryByText('🎉')).toBeFalsy() + expect(screen.queryByText('Excellent work!')).toBeFalsy() + }) // Go back to start (3) then back to target (5) await act(async () => { - fireEvent.click(changeBtn); // 6 -> 3 - }); + fireEvent.click(changeBtn) // 6 -> 3 + }) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("3"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('3') + }) await act(async () => { - fireEvent.click(changeBtn); // 3 -> 5 - }); + fireEvent.click(changeBtn) // 3 -> 5 + }) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Celebration should return await waitFor( () => { - const celebration = screen.queryByText("🎉"); - const excellentWork = screen.queryByText("Excellent work!"); - expect(celebration || excellentWork).toBeTruthy(); + const celebration = screen.queryByText('🎉') + const excellentWork = screen.queryByText('Excellent work!') + expect(celebration || excellentWork).toBeTruthy() }, - { timeout: 3000 }, - ); - }); + { timeout: 3000 } + ) + }) - it("should handle multiple step navigation with celebration tooltips", async () => { + it('should handle multiple step navigation with celebration tooltips', async () => { const multiStepTutorial: Tutorial = { ...mockTutorial, steps: [ mockTutorial.steps[0], { - id: "step-2", - title: "Add One", - problem: "4 + 1", - description: "Add 1 to 4 to get 5", + id: 'step-2', + title: 'Add One', + problem: '4 + 1', + description: 'Add 1 to 4 to get 5', startValue: 4, targetValue: 5, - expectedAction: "add", - actionDescription: "4 + 1 = 5", + expectedAction: 'add', + actionDescription: '4 + 1 = 5', tooltip: { - content: "Add 1 to reach 5", - explanation: "Move one earth bead up to add 1", + content: 'Add 1 to reach 5', + explanation: 'Move one earth bead up to add 1', }, - multiStepInstructions: ["Move one earth bead up"], + multiStepInstructions: ['Move one earth bead up'], }, ], - }; + } - renderTutorialPlayer(multiStepTutorial); + renderTutorialPlayer(multiStepTutorial) // Complete first step await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("3"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('3') + }) - const changeBtn = screen.getByTestId("change-value-btn"); + const changeBtn = screen.getByTestId('change-value-btn') await act(async () => { - fireEvent.click(changeBtn); // 3 -> 5 - }); + fireEvent.click(changeBtn) // 3 -> 5 + }) // Wait for celebration await waitFor( () => { - const celebration = screen.queryByText("🎉"); - const excellentWork = screen.queryByText("Excellent work!"); - expect(celebration || excellentWork).toBeTruthy(); + const celebration = screen.queryByText('🎉') + const excellentWork = screen.queryByText('Excellent work!') + expect(celebration || excellentWork).toBeTruthy() }, - { timeout: 3000 }, - ); + { timeout: 3000 } + ) // Navigate to next step - const nextButton = screen.getByText(/Next/); + const nextButton = screen.getByText(/Next/) await act(async () => { - fireEvent.click(nextButton); - }); + fireEvent.click(nextButton) + }) // Wait for step 2 to load await waitFor(() => { - expect(screen.getByText("4 + 1")).toBeInTheDocument(); - expect(screen.getByTestId("abacus-value")).toHaveTextContent("4"); - }); + expect(screen.getByText('4 + 1')).toBeInTheDocument() + expect(screen.getByTestId('abacus-value')).toHaveTextContent('4') + }) // Complete second step await act(async () => { - fireEvent.click(changeBtn); // Should go from 4 to 5 - }); + fireEvent.click(changeBtn) // Should go from 4 to 5 + }) // Celebration should appear for second step too await waitFor( () => { - const celebration = screen.queryByText("🎉"); - const excellentWork = screen.queryByText("Excellent work!"); - expect(celebration || excellentWork).toBeTruthy(); + const celebration = screen.queryByText('🎉') + const excellentWork = screen.queryByText('Excellent work!') + expect(celebration || excellentWork).toBeTruthy() }, - { timeout: 3000 }, - ); - }); + { timeout: 3000 } + ) + }) - it("should properly reset celebration state between steps", async () => { + it('should properly reset celebration state between steps', async () => { const multiStepTutorial: Tutorial = { ...mockTutorial, steps: [ mockTutorial.steps[0], { - id: "step-2", - title: "Different Target", - problem: "2 + 4", - description: "Add 4 to 2 to get 6", + id: 'step-2', + title: 'Different Target', + problem: '2 + 4', + description: 'Add 4 to 2 to get 6', startValue: 2, targetValue: 6, - expectedAction: "add", - actionDescription: "2 + 4 = 6", + expectedAction: 'add', + actionDescription: '2 + 4 = 6', tooltip: { - content: "Add 4 to reach 6", - explanation: "Move four earth beads up to add 4", + content: 'Add 4 to reach 6', + explanation: 'Move four earth beads up to add 4', }, - multiStepInstructions: ["Move four earth beads up"], + multiStepInstructions: ['Move four earth beads up'], }, ], - }; + } - renderTutorialPlayer(multiStepTutorial); + renderTutorialPlayer(multiStepTutorial) // Complete first step (target 5) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("3"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('3') + }) - const changeBtn = screen.getByTestId("change-value-btn"); + const changeBtn = screen.getByTestId('change-value-btn') await act(async () => { - fireEvent.click(changeBtn); // 3 -> 5 - }); + fireEvent.click(changeBtn) // 3 -> 5 + }) // Wait for celebration await waitFor(() => { - const celebration = screen.queryByText("🎉"); - const excellentWork = screen.queryByText("Excellent work!"); - expect(celebration || excellentWork).toBeTruthy(); - }); + const celebration = screen.queryByText('🎉') + const excellentWork = screen.queryByText('Excellent work!') + expect(celebration || excellentWork).toBeTruthy() + }) // Navigate to step 2 - const nextButton = screen.getByText(/Next/); + const nextButton = screen.getByText(/Next/) await act(async () => { - fireEvent.click(nextButton); - }); + fireEvent.click(nextButton) + }) // Step 2 should start fresh (no celebration initially) await waitFor(() => { - expect(screen.getByText("2 + 4")).toBeInTheDocument(); - expect(screen.getByTestId("abacus-value")).toHaveTextContent("2"); - }); + expect(screen.getByText('2 + 4')).toBeInTheDocument() + expect(screen.getByTestId('abacus-value')).toHaveTextContent('2') + }) // Should not show celebration initially for new step - expect(screen.queryByText("🎉")).toBeFalsy(); - expect(screen.queryByText("Excellent work!")).toBeFalsy(); - }); - }); + expect(screen.queryByText('🎉')).toBeFalsy() + expect(screen.queryByText('Excellent work!')).toBeFalsy() + }) + }) - describe("Tooltip Content and Styling", () => { - it("should show correct celebration content and styling", async () => { - renderTutorialPlayer(mockTutorial); + describe('Tooltip Content and Styling', () => { + it('should show correct celebration content and styling', async () => { + renderTutorialPlayer(mockTutorial) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("3"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('3') + }) - const changeBtn = screen.getByTestId("change-value-btn"); + const changeBtn = screen.getByTestId('change-value-btn') // Reach target value await act(async () => { - fireEvent.click(changeBtn); - }); + fireEvent.click(changeBtn) + }) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("5"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('5') + }) // Verify both celebration elements appear await waitFor( () => { - expect(screen.queryByText("🎉")).toBeTruthy(); - expect(screen.queryByText("Excellent work!")).toBeTruthy(); + expect(screen.queryByText('🎉')).toBeTruthy() + expect(screen.queryByText('Excellent work!')).toBeTruthy() }, - { timeout: 3000 }, - ); + { timeout: 3000 } + ) // The overlay should have celebration styling - const overlay = screen.queryByTestId("overlay-0"); - expect(overlay).toBeTruthy(); - }); + const overlay = screen.queryByTestId('overlay-0') + expect(overlay).toBeTruthy() + }) - it("should show instruction content when not at target", async () => { - renderTutorialPlayer(mockTutorial); + it('should show instruction content when not at target', async () => { + renderTutorialPlayer(mockTutorial) // Initially should show instructions (not celebration) await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("3"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('3') + }) // Should not show celebration initially - expect(screen.queryByText("🎉")).toBeFalsy(); - expect(screen.queryByText("Excellent work!")).toBeFalsy(); - }); - }); -}); + expect(screen.queryByText('🎉')).toBeFalsy() + expect(screen.queryByText('Excellent work!')).toBeFalsy() + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialPlayerHelpers.test.ts b/apps/web/src/components/tutorial/__tests__/TutorialPlayerHelpers.test.ts index 1c2452a0..d66b1dff 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialPlayerHelpers.test.ts +++ b/apps/web/src/components/tutorial/__tests__/TutorialPlayerHelpers.test.ts @@ -1,6 +1,6 @@ -import type { StepBeadHighlight } from "@soroban/abacus-react"; -import type React from "react"; -import { vi } from "vitest"; +import type { StepBeadHighlight } from '@soroban/abacus-react' +import type React from 'react' +import { vi } from 'vitest' // Import the helper functions from the module (we'll need to extract these) // For now, let's define them locally for testing @@ -8,13 +8,13 @@ import { vi } from "vitest"; // Helper function to compute problem string from start and target values function computeProblemString(startValue: number, targetValue: number): string { if (targetValue > startValue) { - const difference = targetValue - startValue; - return `${startValue} + ${difference} = ${targetValue}`; + const difference = targetValue - startValue + return `${startValue} + ${difference} = ${targetValue}` } else if (targetValue < startValue) { - const difference = startValue - targetValue; - return `${startValue} - ${difference} = ${targetValue}`; + const difference = startValue - targetValue + return `${startValue} - ${difference} = ${targetValue}` } else { - return `${startValue} (no change)`; + return `${startValue} (no change)` } } @@ -22,29 +22,29 @@ function computeProblemString(startValue: number, targetValue: number): string { function calculateBeadPosition( bead: StepBeadHighlight, beadRefs: React.MutableRefObject>, - abacusContainer: HTMLElement | null, + abacusContainer: HTMLElement | null ): { x: number; y: number } | null { - if (!abacusContainer) return null; + if (!abacusContainer) return null - const key = `${bead.placeValue}-${bead.beadType}-${bead.position || 0}`; - const beadElement = beadRefs.current.get(key); + const key = `${bead.placeValue}-${bead.beadType}-${bead.position || 0}` + const beadElement = beadRefs.current.get(key) - if (!beadElement) return null; + if (!beadElement) return null - const beadRect = beadElement.getBoundingClientRect(); - const containerRect = abacusContainer.getBoundingClientRect(); + const beadRect = beadElement.getBoundingClientRect() + const containerRect = abacusContainer.getBoundingClientRect() return { x: beadRect.left + beadRect.width / 2 - containerRect.left, y: beadRect.top + beadRect.height / 2 - containerRect.top, - }; + } } // Helper function to find the topmost bead with arrows function findTopmostBeadWithArrows( - stepBeadHighlights: StepBeadHighlight[] | undefined, + stepBeadHighlights: StepBeadHighlight[] | undefined ): StepBeadHighlight | null { - if (!stepBeadHighlights || stepBeadHighlights.length === 0) return null; + if (!stepBeadHighlights || stepBeadHighlights.length === 0) return null // Sort by place value (highest first, since place value 4 = leftmost = highest value) // Then by bead type (heaven beads are higher than earth beads) @@ -52,195 +52,195 @@ function findTopmostBeadWithArrows( const sortedBeads = [...stepBeadHighlights].sort((a, b) => { // First sort by place value (higher place value = more significant = topmost priority) if (a.placeValue !== b.placeValue) { - return b.placeValue - a.placeValue; + return b.placeValue - a.placeValue } // If same place value, heaven beads come before earth beads if (a.beadType !== b.beadType) { - return a.beadType === "heaven" ? -1 : 1; + return a.beadType === 'heaven' ? -1 : 1 } // If both earth beads in same column, lower position number = higher on abacus - if (a.beadType === "earth" && b.beadType === "earth") { - return (a.position || 0) - (b.position || 0); + if (a.beadType === 'earth' && b.beadType === 'earth') { + return (a.position || 0) - (b.position || 0) } - return 0; - }); + return 0 + }) - return sortedBeads[0] || null; + return sortedBeads[0] || null } -describe("TutorialPlayer Helper Functions", () => { - describe("computeProblemString", () => { - it("should handle addition problems correctly", () => { - expect(computeProblemString(0, 5)).toBe("0 + 5 = 5"); - expect(computeProblemString(3, 7)).toBe("3 + 4 = 7"); - expect(computeProblemString(10, 15)).toBe("10 + 5 = 15"); - }); +describe('TutorialPlayer Helper Functions', () => { + describe('computeProblemString', () => { + it('should handle addition problems correctly', () => { + expect(computeProblemString(0, 5)).toBe('0 + 5 = 5') + expect(computeProblemString(3, 7)).toBe('3 + 4 = 7') + expect(computeProblemString(10, 15)).toBe('10 + 5 = 15') + }) - it("should handle subtraction problems correctly", () => { - expect(computeProblemString(5, 0)).toBe("5 - 5 = 0"); - expect(computeProblemString(7, 3)).toBe("7 - 4 = 3"); - expect(computeProblemString(15, 10)).toBe("15 - 5 = 10"); - }); + it('should handle subtraction problems correctly', () => { + expect(computeProblemString(5, 0)).toBe('5 - 5 = 0') + expect(computeProblemString(7, 3)).toBe('7 - 4 = 3') + expect(computeProblemString(15, 10)).toBe('15 - 5 = 10') + }) - it("should handle no change problems correctly", () => { - expect(computeProblemString(0, 0)).toBe("0 (no change)"); - expect(computeProblemString(5, 5)).toBe("5 (no change)"); - expect(computeProblemString(42, 42)).toBe("42 (no change)"); - }); + it('should handle no change problems correctly', () => { + expect(computeProblemString(0, 0)).toBe('0 (no change)') + expect(computeProblemString(5, 5)).toBe('5 (no change)') + expect(computeProblemString(42, 42)).toBe('42 (no change)') + }) - it("should handle edge cases", () => { - expect(computeProblemString(-5, 0)).toBe("-5 + 5 = 0"); - expect(computeProblemString(0, -3)).toBe("0 - 3 = -3"); - expect(computeProblemString(-2, -5)).toBe("-2 - 3 = -5"); - }); - }); + it('should handle edge cases', () => { + expect(computeProblemString(-5, 0)).toBe('-5 + 5 = 0') + expect(computeProblemString(0, -3)).toBe('0 - 3 = -3') + expect(computeProblemString(-2, -5)).toBe('-2 - 3 = -5') + }) + }) - describe("findTopmostBeadWithArrows", () => { - it("should return null for empty or undefined input", () => { - expect(findTopmostBeadWithArrows(undefined)).toBeNull(); - expect(findTopmostBeadWithArrows([])).toBeNull(); - }); + describe('findTopmostBeadWithArrows', () => { + it('should return null for empty or undefined input', () => { + expect(findTopmostBeadWithArrows(undefined)).toBeNull() + expect(findTopmostBeadWithArrows([])).toBeNull() + }) - it("should return the only bead when there is one", () => { + it('should return the only bead when there is one', () => { const singleBead: StepBeadHighlight = { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, - }; + } - expect(findTopmostBeadWithArrows([singleBead])).toEqual(singleBead); - }); + expect(findTopmostBeadWithArrows([singleBead])).toEqual(singleBead) + }) - it("should prioritize higher place values", () => { + it('should prioritize higher place values', () => { const beads: StepBeadHighlight[] = [ { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, }, { placeValue: 2, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, }, { placeValue: 1, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, }, - ]; + ] - const result = findTopmostBeadWithArrows(beads); - expect(result?.placeValue).toBe(2); - }); + const result = findTopmostBeadWithArrows(beads) + expect(result?.placeValue).toBe(2) + }) - it("should prioritize heaven beads over earth beads in same column", () => { + it('should prioritize heaven beads over earth beads in same column', () => { const beads: StepBeadHighlight[] = [ { placeValue: 1, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, }, - { placeValue: 1, beadType: "heaven", direction: "down", stepIndex: 0 }, - ]; + { placeValue: 1, beadType: 'heaven', direction: 'down', stepIndex: 0 }, + ] - const result = findTopmostBeadWithArrows(beads); - expect(result?.beadType).toBe("heaven"); - }); + const result = findTopmostBeadWithArrows(beads) + expect(result?.beadType).toBe('heaven') + }) - it("should prioritize lower position earth beads (higher on abacus)", () => { + it('should prioritize lower position earth beads (higher on abacus)', () => { const beads: StepBeadHighlight[] = [ { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 2, - direction: "up", + direction: 'up', stepIndex: 0, }, { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, }, { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 1, - direction: "up", + direction: 'up', stepIndex: 0, }, - ]; + ] - const result = findTopmostBeadWithArrows(beads); - expect(result?.position).toBe(0); - }); + const result = findTopmostBeadWithArrows(beads) + expect(result?.position).toBe(0) + }) - it("should handle complex mixed scenarios correctly", () => { + it('should handle complex mixed scenarios correctly', () => { const beads: StepBeadHighlight[] = [ { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 1, - direction: "up", + direction: 'up', stepIndex: 0, }, { placeValue: 1, - beadType: "earth", + beadType: 'earth', position: 3, - direction: "down", + direction: 'down', stepIndex: 0, }, - { placeValue: 2, beadType: "heaven", direction: "down", stepIndex: 0 }, - { placeValue: 1, beadType: "heaven", direction: "up", stepIndex: 0 }, - ]; + { placeValue: 2, beadType: 'heaven', direction: 'down', stepIndex: 0 }, + { placeValue: 1, beadType: 'heaven', direction: 'up', stepIndex: 0 }, + ] - const result = findTopmostBeadWithArrows(beads); + const result = findTopmostBeadWithArrows(beads) // Should pick place value 2 (highest), heaven bead - expect(result?.placeValue).toBe(2); - expect(result?.beadType).toBe("heaven"); - }); + expect(result?.placeValue).toBe(2) + expect(result?.beadType).toBe('heaven') + }) - it("should handle undefined positions correctly", () => { + it('should handle undefined positions correctly', () => { const beads: StepBeadHighlight[] = [ - { placeValue: 0, beadType: "earth", direction: "up", stepIndex: 0 }, // No position + { placeValue: 0, beadType: 'earth', direction: 'up', stepIndex: 0 }, // No position { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 1, - direction: "up", + direction: 'up', stepIndex: 0, }, - ]; + ] - const result = findTopmostBeadWithArrows(beads); + const result = findTopmostBeadWithArrows(beads) // Should pick position 0 (undefined defaults to 0, which is higher) - expect(result?.position).toBeUndefined(); - }); - }); + expect(result?.position).toBeUndefined() + }) + }) - describe("calculateBeadPosition", () => { - let mockBeadRefs: React.MutableRefObject>; - let mockAbacusContainer: HTMLElement; + describe('calculateBeadPosition', () => { + let mockBeadRefs: React.MutableRefObject> + let mockAbacusContainer: HTMLElement beforeEach(() => { // Mock getBoundingClientRect for SVG elements and container - const mockGetBoundingClientRect = vi.fn(); + const mockGetBoundingClientRect = vi.fn() // Mock bead element const mockBeadElement = { @@ -250,7 +250,7 @@ describe("TutorialPlayer Helper Functions", () => { width: 20, height: 20, }), - } as unknown as SVGElement; + } as unknown as SVGElement // Mock container element mockAbacusContainer = { @@ -260,117 +260,101 @@ describe("TutorialPlayer Helper Functions", () => { width: 400, height: 300, }), - } as unknown as HTMLElement; + } as unknown as HTMLElement // Create mock beadRefs - const beadMap = new Map(); - beadMap.set("0-earth-0", mockBeadElement); - beadMap.set("1-heaven-0", mockBeadElement); + const beadMap = new Map() + beadMap.set('0-earth-0', mockBeadElement) + beadMap.set('1-heaven-0', mockBeadElement) - mockBeadRefs = { current: beadMap }; - }); + mockBeadRefs = { current: beadMap } + }) - it("should return null when abacusContainer is null", () => { + it('should return null when abacusContainer is null', () => { const bead: StepBeadHighlight = { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, - }; + } - const result = calculateBeadPosition(bead, mockBeadRefs, null); - expect(result).toBeNull(); - }); + const result = calculateBeadPosition(bead, mockBeadRefs, null) + expect(result).toBeNull() + }) - it("should return null when bead element is not found in refs", () => { + it('should return null when bead element is not found in refs', () => { const bead: StepBeadHighlight = { placeValue: 5, // Not in our mock refs - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, - }; + } - const result = calculateBeadPosition( - bead, - mockBeadRefs, - mockAbacusContainer, - ); - expect(result).toBeNull(); - }); + const result = calculateBeadPosition(bead, mockBeadRefs, mockAbacusContainer) + expect(result).toBeNull() + }) - it("should calculate correct relative position for earth bead", () => { + it('should calculate correct relative position for earth bead', () => { const bead: StepBeadHighlight = { placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "up", + direction: 'up', stepIndex: 0, - }; + } - const result = calculateBeadPosition( - bead, - mockBeadRefs, - mockAbacusContainer, - ); + const result = calculateBeadPosition(bead, mockBeadRefs, mockAbacusContainer) expect(result).toEqual({ x: 60, // (100 + 20/2) - 50 = 110 - 50 = 60 y: 40, // (50 + 20/2) - 20 = 60 - 20 = 40 - }); - }); + }) + }) - it("should calculate correct relative position for heaven bead", () => { + it('should calculate correct relative position for heaven bead', () => { const bead: StepBeadHighlight = { placeValue: 1, - beadType: "heaven", - direction: "down", + beadType: 'heaven', + direction: 'down', stepIndex: 0, - }; + } - const result = calculateBeadPosition( - bead, - mockBeadRefs, - mockAbacusContainer, - ); + const result = calculateBeadPosition(bead, mockBeadRefs, mockAbacusContainer) expect(result).toEqual({ x: 60, // Same calculation as above y: 40, - }); - }); + }) + }) - it("should use position 0 as default when position is undefined", () => { + it('should use position 0 as default when position is undefined', () => { const bead: StepBeadHighlight = { placeValue: 0, - beadType: "earth", + beadType: 'earth', // position is undefined - direction: "up", + direction: 'up', stepIndex: 0, - }; + } // This should look for key '0-earth-0' which exists in our mock - const result = calculateBeadPosition( - bead, - mockBeadRefs, - mockAbacusContainer, - ); + const result = calculateBeadPosition(bead, mockBeadRefs, mockAbacusContainer) expect(result).toEqual({ x: 60, y: 40, - }); - }); + }) + }) - it("should generate correct key format for bead lookup", () => { + it('should generate correct key format for bead lookup', () => { const bead: StepBeadHighlight = { placeValue: 2, - beadType: "earth", + beadType: 'earth', position: 3, - direction: "up", + direction: 'up', stepIndex: 0, - }; + } // Add this specific bead to our refs const mockElement = { @@ -380,20 +364,16 @@ describe("TutorialPlayer Helper Functions", () => { width: 25, height: 25, }), - } as unknown as SVGElement; + } as unknown as SVGElement - mockBeadRefs.current.set("2-earth-3", mockElement); + mockBeadRefs.current.set('2-earth-3', mockElement) - const result = calculateBeadPosition( - bead, - mockBeadRefs, - mockAbacusContainer, - ); + const result = calculateBeadPosition(bead, mockBeadRefs, mockAbacusContainer) expect(result).toEqual({ x: 162.5, // (200 + 25/2) - 50 = 212.5 - 50 = 162.5 y: 92.5, // (100 + 25/2) - 20 = 112.5 - 20 = 92.5 - }); - }); - }); -}); + }) + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialPlayerLayout.integration.test.tsx b/apps/web/src/components/tutorial/__tests__/TutorialPlayerLayout.integration.test.tsx index 45a22c38..b96038eb 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialPlayerLayout.integration.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/TutorialPlayerLayout.integration.test.tsx @@ -1,32 +1,25 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { getTutorialForEditor } from "../../../utils/tutorialConverter"; -import { TutorialPlayer } from "../TutorialPlayer"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { getTutorialForEditor } from '../../../utils/tutorialConverter' +import { TutorialPlayer } from '../TutorialPlayer' // Mock the AbacusReact component for integration tests -vi.mock("@soroban/abacus-react", () => ({ - AbacusReact: ({ - value, - onValueChange, - callbacks, - stepBeadHighlights, - }: any) => ( +vi.mock('@soroban/abacus-react', () => ({ + AbacusReact: ({ value, onValueChange, callbacks, stepBeadHighlights }: any) => (
{value}
-
- {stepBeadHighlights?.length || 0} arrows -
+
{stepBeadHighlights?.length || 0} arrows
), -})); +})) -describe("TutorialPlayer New Layout Integration Tests", () => { - let mockTutorial: Tutorial; - let mockOnStepChange: ReturnType; - let mockOnStepComplete: ReturnType; - let mockOnEvent: ReturnType; +describe('TutorialPlayer New Layout Integration Tests', () => { + let mockTutorial: Tutorial + let mockOnStepChange: ReturnType + let mockOnStepComplete: ReturnType + let mockOnEvent: ReturnType beforeEach(() => { - vi.clearAllMocks(); - mockTutorial = getTutorialForEditor(); - mockOnStepChange = vi.fn(); - mockOnStepComplete = vi.fn(); - mockOnEvent = vi.fn(); - }); + vi.clearAllMocks() + mockTutorial = getTutorialForEditor() + mockOnStepChange = vi.fn() + mockOnStepComplete = vi.fn() + mockOnEvent = vi.fn() + }) const renderTutorialPlayer = (props = {}) => { return render( @@ -70,172 +63,163 @@ describe("TutorialPlayer New Layout Integration Tests", () => { onStepComplete={mockOnStepComplete} onEvent={mockOnEvent} {...props} - />, - ); - }; + /> + ) + } - describe("New Layout Structure", () => { - it("should render tutorial step title instead of problem field", () => { - renderTutorialPlayer(); + describe('New Layout Structure', () => { + it('should render tutorial step title instead of problem field', () => { + renderTutorialPlayer() - const firstStep = mockTutorial.steps[0]; - expect(screen.getByText(firstStep.title)).toBeInTheDocument(); + const firstStep = mockTutorial.steps[0] + expect(screen.getByText(firstStep.title)).toBeInTheDocument() // Should NOT display the raw problem field - expect(screen.queryByText(firstStep.problem)).not.toBeInTheDocument(); - }); + expect(screen.queryByText(firstStep.problem)).not.toBeInTheDocument() + }) - it("should display computed problem string from start/target values", () => { - renderTutorialPlayer(); + it('should display computed problem string from start/target values', () => { + renderTutorialPlayer() - const firstStep = mockTutorial.steps[0]; - const expectedProblem = `${firstStep.startValue} + ${firstStep.targetValue - firstStep.startValue} = ${firstStep.targetValue}`; + const firstStep = mockTutorial.steps[0] + const expectedProblem = `${firstStep.startValue} + ${firstStep.targetValue - firstStep.startValue} = ${firstStep.targetValue}` - expect(screen.getByText(expectedProblem)).toBeInTheDocument(); - }); + expect(screen.getByText(expectedProblem)).toBeInTheDocument() + }) - it("should show inline guidance with fixed height layout", () => { - renderTutorialPlayer(); + it('should show inline guidance with fixed height layout', () => { + renderTutorialPlayer() // Should show current instruction in inline guidance area - const _firstStep = mockTutorial.steps[0]; - expect( - screen.getByText("Click the earth bead to add 1"), - ).toBeInTheDocument(); - }); + const _firstStep = mockTutorial.steps[0] + expect(screen.getByText('Click the earth bead to add 1')).toBeInTheDocument() + }) - it("should keep abacus always visible and centered", () => { - renderTutorialPlayer(); + it('should keep abacus always visible and centered', () => { + renderTutorialPlayer() - const abacus = screen.getByTestId("mock-abacus"); - expect(abacus).toBeInTheDocument(); + const abacus = screen.getByTestId('mock-abacus') + expect(abacus).toBeInTheDocument() // Abacus should be present and visible - expect(abacus).toBeVisible(); - }); + expect(abacus).toBeVisible() + }) - it("should show bead diff tooltip instead of error messages", async () => { - renderTutorialPlayer(); + it('should show bead diff tooltip instead of error messages', async () => { + renderTutorialPlayer() // With new design, no error toasts are shown - only bead diff tooltip - const abacus = screen.getByTestId("mock-abacus"); - expect(abacus).toBeInTheDocument(); + const abacus = screen.getByTestId('mock-abacus') + expect(abacus).toBeInTheDocument() // Bead diff tooltip should appear when there are highlights - const highlights = screen.getByTestId("step-bead-highlights"); - expect(highlights).toHaveTextContent("1 arrows"); - }); + const highlights = screen.getByTestId('step-bead-highlights') + expect(highlights).toHaveTextContent('1 arrows') + }) - it("should display success message as toast when step completed", async () => { - renderTutorialPlayer(); + it('should display success message as toast when step completed', async () => { + renderTutorialPlayer() - const firstStep = mockTutorial.steps[0]; - const targetValue = firstStep.targetValue; + const firstStep = mockTutorial.steps[0] + const targetValue = firstStep.targetValue // Simulate correct interaction to complete step for (let i = 0; i < targetValue; i++) { - const bead = screen.getByTestId("mock-bead-0"); - fireEvent.click(bead); + const bead = screen.getByTestId('mock-bead-0') + fireEvent.click(bead) } // Should show success message as toast await waitFor(() => { - expect( - screen.getByText(/great! you completed this step correctly/i), - ).toBeInTheDocument(); - }); - }); - }); + expect(screen.getByText(/great! you completed this step correctly/i)).toBeInTheDocument() + }) + }) + }) - describe("Bead Tooltip Functionality", () => { - it("should show bead diff tooltip when user needs help", async () => { - renderTutorialPlayer(); + describe('Bead Tooltip Functionality', () => { + it('should show bead diff tooltip when user needs help', async () => { + renderTutorialPlayer() // Wait for help timer (8 seconds in real code, but we can test the logic) // Since we're mocking, we'll simulate the conditions - const abacus = screen.getByTestId("mock-abacus"); - expect(abacus).toBeInTheDocument(); + const abacus = screen.getByTestId('mock-abacus') + expect(abacus).toBeInTheDocument() // Tooltip should appear when there are step bead highlights - const highlights = screen.getByTestId("step-bead-highlights"); - expect(highlights).toBeInTheDocument(); - }); + const highlights = screen.getByTestId('step-bead-highlights') + expect(highlights).toBeInTheDocument() + }) - it("should position tooltip near topmost bead with arrows", () => { - renderTutorialPlayer(); + it('should position tooltip near topmost bead with arrows', () => { + renderTutorialPlayer() // This tests the integration of our helper functions // The tooltip positioning logic should work with mock abacus - const abacus = screen.getByTestId("mock-abacus"); - expect(abacus).toBeInTheDocument(); - }); - }); + const abacus = screen.getByTestId('mock-abacus') + expect(abacus).toBeInTheDocument() + }) + }) - describe("Navigation and Multi-step Flow", () => { - it("should maintain abacus position during navigation", async () => { - renderTutorialPlayer(); + describe('Navigation and Multi-step Flow', () => { + it('should maintain abacus position during navigation', async () => { + renderTutorialPlayer() - const abacus = screen.getByTestId("mock-abacus"); - const initialPosition = abacus.getBoundingClientRect(); + const abacus = screen.getByTestId('mock-abacus') + const initialPosition = abacus.getBoundingClientRect() // Navigate to next step - const nextButton = screen.getByText(/next/i); - fireEvent.click(nextButton); + const nextButton = screen.getByText(/next/i) + fireEvent.click(nextButton) await waitFor(() => { - const newPosition = abacus.getBoundingClientRect(); + const newPosition = abacus.getBoundingClientRect() // Abacus should remain in same position - expect(newPosition.top).toBe(initialPosition.top); - expect(newPosition.left).toBe(initialPosition.left); - }); - }); + expect(newPosition.top).toBe(initialPosition.top) + expect(newPosition.left).toBe(initialPosition.left) + }) + }) - it("should update guidance content during multi-step instructions", async () => { - renderTutorialPlayer(); + it('should update guidance content during multi-step instructions', async () => { + renderTutorialPlayer() - const firstStep = mockTutorial.steps[0]; - if ( - firstStep.multiStepInstructions && - firstStep.multiStepInstructions.length > 1 - ) { + const firstStep = mockTutorial.steps[0] + if (firstStep.multiStepInstructions && firstStep.multiStepInstructions.length > 1) { // Should show first instruction initially - expect( - screen.getByText(firstStep.multiStepInstructions[0]), - ).toBeInTheDocument(); + expect(screen.getByText(firstStep.multiStepInstructions[0])).toBeInTheDocument() // After user interaction, should advance to next instruction // (This would need proper multi-step interaction simulation) } - }); + }) - it("should show pedagogical decomposition with highlighting", () => { - renderTutorialPlayer(); + it('should show pedagogical decomposition with highlighting', () => { + renderTutorialPlayer() // Should show mathematical decomposition // This tests integration with the unified step generator - const firstStep = mockTutorial.steps[0]; + const firstStep = mockTutorial.steps[0] if (firstStep.startValue !== firstStep.targetValue) { // Should show some form of mathematical representation - const abacusValue = screen.getByTestId("abacus-value"); - expect(abacusValue).toBeInTheDocument(); + const abacusValue = screen.getByTestId('abacus-value') + expect(abacusValue).toBeInTheDocument() } - }); - }); + }) + }) - describe("Responsive Layout Behavior", () => { - it("should not require scrolling to see abacus", () => { - renderTutorialPlayer(); + describe('Responsive Layout Behavior', () => { + it('should not require scrolling to see abacus', () => { + renderTutorialPlayer() - const abacus = screen.getByTestId("mock-abacus"); - expect(abacus).toBeInTheDocument(); - expect(abacus).toBeVisible(); + const abacus = screen.getByTestId('mock-abacus') + expect(abacus).toBeInTheDocument() + expect(abacus).toBeVisible() // In a real e2e test, we'd check viewport constraints // Here we ensure abacus is always rendered - }); + }) - it("should handle guidance content overflow gracefully", () => { + it('should handle guidance content overflow gracefully', () => { // Test with a tutorial step that has very long instructions const longInstructionTutorial = { ...mockTutorial, @@ -243,11 +227,11 @@ describe("TutorialPlayer New Layout Integration Tests", () => { { ...mockTutorial.steps[0], multiStepInstructions: [ - "This is a very long instruction that should be handled gracefully within the fixed height guidance area without breaking the layout or causing the abacus to move from its fixed position", + 'This is a very long instruction that should be handled gracefully within the fixed height guidance area without breaking the layout or causing the abacus to move from its fixed position', ], }, ], - }; + } render( { onStepChange={mockOnStepChange} onStepComplete={mockOnStepComplete} onEvent={mockOnEvent} - />, - ); + /> + ) - const abacus = screen.getByTestId("mock-abacus"); - expect(abacus).toBeInTheDocument(); - expect(abacus).toBeVisible(); - }); - }); + const abacus = screen.getByTestId('mock-abacus') + expect(abacus).toBeInTheDocument() + expect(abacus).toBeVisible() + }) + }) - describe("Accessibility and UX", () => { - it("should maintain proper heading hierarchy", () => { - renderTutorialPlayer(); + describe('Accessibility and UX', () => { + it('should maintain proper heading hierarchy', () => { + renderTutorialPlayer() // Should have proper h1 for tutorial title - const tutorialTitle = screen.getByRole("heading", { level: 1 }); - expect(tutorialTitle).toBeInTheDocument(); + const tutorialTitle = screen.getByRole('heading', { level: 1 }) + expect(tutorialTitle).toBeInTheDocument() // Should have h2 for computed problem - const problemHeading = screen.getByRole("heading", { level: 2 }); - expect(problemHeading).toBeInTheDocument(); - }); + const problemHeading = screen.getByRole('heading', { level: 2 }) + expect(problemHeading).toBeInTheDocument() + }) - it("should provide clear visual feedback for user actions", async () => { - renderTutorialPlayer(); + it('should provide clear visual feedback for user actions', async () => { + renderTutorialPlayer() - const earthBead = screen.getByTestId("mock-bead-0"); - fireEvent.click(earthBead); + const earthBead = screen.getByTestId('mock-bead-0') + fireEvent.click(earthBead) // Should update abacus value await waitFor(() => { - expect(screen.getByTestId("abacus-value")).toHaveTextContent("1"); - }); + expect(screen.getByTestId('abacus-value')).toHaveTextContent('1') + }) // Should call event handlers - expect(mockOnEvent).toHaveBeenCalled(); - }); - }); -}); + expect(mockOnEvent).toHaveBeenCalled() + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/TutorialWorkflow.e2e.test.ts b/apps/web/src/components/tutorial/__tests__/TutorialWorkflow.e2e.test.ts index 6a452be5..29fc04af 100644 --- a/apps/web/src/components/tutorial/__tests__/TutorialWorkflow.e2e.test.ts +++ b/apps/web/src/components/tutorial/__tests__/TutorialWorkflow.e2e.test.ts @@ -1,335 +1,307 @@ -import { expect, type Page, test } from "@playwright/test"; +import { expect, type Page, test } from '@playwright/test' // Helper functions for tutorial testing const waitForTutorialLoad = async (page: Page) => { await page.waitForSelector('[data-testid="tutorial-player"]', { timeout: 10000, - }); + }) // Wait for abacus to load - await page.waitForSelector('[data-testid^="bead-place-"]', { timeout: 5000 }); -}; + await page.waitForSelector('[data-testid^="bead-place-"]', { timeout: 5000 }) +} const getAbacusValue = async (page: Page): Promise => { // This would depend on how the abacus displays its current value // For now, we'll use a data attribute or calculate from bead positions - const valueElement = await page - .locator('[data-testid="current-value"]') - .first(); + const valueElement = await page.locator('[data-testid="current-value"]').first() if (await valueElement.isVisible()) { - const text = await valueElement.textContent(); - return parseInt(text || "0", 10); + const text = await valueElement.textContent() + return parseInt(text || '0', 10) } - return 0; -}; + return 0 +} const clickBeadToIncrement = async (page: Page) => { // Click the first earth bead in the ones place to increment by 1 - await page.click('[data-testid="bead-place-0-earth-pos-0"]'); -}; + await page.click('[data-testid="bead-place-0-earth-pos-0"]') +} const _navigateToStep = async (page: Page, stepIndex: number) => { // Open step list if not already open - const stepListButton = page.locator('button:has-text("Steps")'); + const stepListButton = page.locator('button:has-text("Steps")') if (await stepListButton.isVisible()) { - await stepListButton.click(); + await stepListButton.click() } // Click on the specific step - await page.click(`[data-testid="step-${stepIndex}"]`); -}; + await page.click(`[data-testid="step-${stepIndex}"]`) +} -test.describe("Tutorial Workflow E2E", () => { +test.describe('Tutorial Workflow E2E', () => { test.beforeEach(async ({ page }) => { // Navigate to a tutorial page - await page.goto("/tutorial-editor"); // Adjust URL based on your routing - await waitForTutorialLoad(page); - }); + await page.goto('/tutorial-editor') // Adjust URL based on your routing + await waitForTutorialLoad(page) + }) - test("should initialize tutorial with correct start value", async ({ - page, - }) => { + test('should initialize tutorial with correct start value', async ({ page }) => { // Check that the tutorial loads with the expected start value - const currentValue = await getAbacusValue(page); - expect(currentValue).toBeGreaterThanOrEqual(0); + const currentValue = await getAbacusValue(page) + expect(currentValue).toBeGreaterThanOrEqual(0) // Check that step information is displayed - await expect(page.locator('[data-testid="step-title"]')).toBeVisible(); - await expect( - page.locator('[data-testid="step-description"]'), - ).toBeVisible(); - }); + await expect(page.locator('[data-testid="step-title"]')).toBeVisible() + await expect(page.locator('[data-testid="step-description"]')).toBeVisible() + }) - test("should allow user to interact with abacus beads", async ({ page }) => { - const initialValue = await getAbacusValue(page); + test('should allow user to interact with abacus beads', async ({ page }) => { + const initialValue = await getAbacusValue(page) // Click a bead to change the value - await clickBeadToIncrement(page); + await clickBeadToIncrement(page) // Wait for value to update - await page.waitForTimeout(500); + await page.waitForTimeout(500) - const newValue = await getAbacusValue(page); - expect(newValue).not.toBe(initialValue); - }); + const newValue = await getAbacusValue(page) + expect(newValue).not.toBe(initialValue) + }) - test("should navigate between tutorial steps and reset abacus value", async ({ - page, - }) => { + test('should navigate between tutorial steps and reset abacus value', async ({ page }) => { // Get initial step value - const step1Value = await getAbacusValue(page); + const step1Value = await getAbacusValue(page) // Navigate to next step - await page.click('button:has-text("Next")'); - await page.waitForTimeout(1000); + await page.click('button:has-text("Next")') + await page.waitForTimeout(1000) // Check that value changed (should be the new step's startValue) - const step2Value = await getAbacusValue(page); - expect(step2Value).not.toBe(step1Value); + const step2Value = await getAbacusValue(page) + expect(step2Value).not.toBe(step1Value) // Navigate back to first step - await page.click('button:has-text("Previous")'); - await page.waitForTimeout(1000); + await page.click('button:has-text("Previous")') + await page.waitForTimeout(1000) // Should return to original start value - const backToStep1Value = await getAbacusValue(page); - expect(backToStep1Value).toBe(step1Value); - }); + const backToStep1Value = await getAbacusValue(page) + expect(backToStep1Value).toBe(step1Value) + }) - test("should show success feedback when target value is reached", async ({ - page, - }) => { + test('should show success feedback when target value is reached', async ({ page }) => { // This test assumes we can determine the target value from the UI - const targetValueText = await page - .locator('[data-testid="target-value"]') - .textContent(); - const targetValue = parseInt(targetValueText || "0", 10); - const currentValue = await getAbacusValue(page); + const targetValueText = await page.locator('[data-testid="target-value"]').textContent() + const targetValue = parseInt(targetValueText || '0', 10) + const currentValue = await getAbacusValue(page) // Calculate how many clicks needed (simplified) - const clicksNeeded = Math.abs(targetValue - currentValue); + const clicksNeeded = Math.abs(targetValue - currentValue) // Click beads to reach target value for (let i = 0; i < clicksNeeded && i < 10; i++) { if (targetValue > currentValue) { - await clickBeadToIncrement(page); + await clickBeadToIncrement(page) } - await page.waitForTimeout(200); + await page.waitForTimeout(200) } // Check for success message - await expect(page.locator("text=/excellent|great|correct/i")).toBeVisible({ + await expect(page.locator('text=/excellent|great|correct/i')).toBeVisible({ timeout: 5000, - }); - }); + }) + }) - test("should handle multi-step tutorial progression", async ({ page }) => { + test('should handle multi-step tutorial progression', async ({ page }) => { // Check if this is a multi-step tutorial - const multiStepIndicator = page.locator( - '[data-testid="multi-step-indicator"]', - ); + const multiStepIndicator = page.locator('[data-testid="multi-step-indicator"]') if (await multiStepIndicator.isVisible()) { // Should show multi-step navigation - await expect(page.locator('button:has-text("Prev")')).toBeVisible(); - await expect(page.locator('button:has-text("Next")')).toBeVisible(); + await expect(page.locator('button:has-text("Prev")')).toBeVisible() + await expect(page.locator('button:has-text("Next")')).toBeVisible() // Should show current multi-step instruction - await expect( - page.locator('[data-testid="multi-step-instruction"]'), - ).toBeVisible(); + await expect(page.locator('[data-testid="multi-step-instruction"]')).toBeVisible() // Navigate through multi-steps - await page.click('button:has-text("Next ⏩")'); - await page.waitForTimeout(500); + await page.click('button:has-text("Next ⏩")') + await page.waitForTimeout(500) // Should show different instruction const instruction1 = await page .locator('[data-testid="multi-step-instruction"]') - .textContent(); + .textContent() - await page.click('button:has-text("Next ⏩")'); - await page.waitForTimeout(500); + await page.click('button:has-text("Next ⏩")') + await page.waitForTimeout(500) const instruction2 = await page .locator('[data-testid="multi-step-instruction"]') - .textContent(); - expect(instruction2).not.toBe(instruction1); + .textContent() + expect(instruction2).not.toBe(instruction1) } - }); + }) - test("should automatically advance multi-steps when target is reached", async ({ - page, - }) => { + test('should automatically advance multi-steps when target is reached', async ({ page }) => { // This test would need to be customized based on specific tutorial steps // that have known intermediate target values // Skip if not a multi-step tutorial - const multiStepIndicator = page.locator( - '[data-testid="multi-step-indicator"]', - ); + const multiStepIndicator = page.locator('[data-testid="multi-step-indicator"]') if (!(await multiStepIndicator.isVisible())) { - test.skip(); + test.skip() } const _initialInstruction = await page .locator('[data-testid="multi-step-instruction"]') - .textContent(); + .textContent() // Interact with abacus to potentially reach an intermediate target - await clickBeadToIncrement(page); - await clickBeadToIncrement(page); - await clickBeadToIncrement(page); + await clickBeadToIncrement(page) + await clickBeadToIncrement(page) + await clickBeadToIncrement(page) // Wait for potential auto-advancement (with timeout) - await page.waitForTimeout(2000); + await page.waitForTimeout(2000) const _newInstruction = await page .locator('[data-testid="multi-step-instruction"]') - .textContent(); + .textContent() // If auto-advancement happened, instruction should change // If not, that's also okay - depends on the specific tutorial values // This test verifies the system doesn't crash during the process - }); + }) - test("should maintain state consistency during rapid interactions", async ({ - page, - }) => { - const initialValue = await getAbacusValue(page); + test('should maintain state consistency during rapid interactions', async ({ page }) => { + const initialValue = await getAbacusValue(page) // Rapid clicking - await clickBeadToIncrement(page); - await clickBeadToIncrement(page); - await clickBeadToIncrement(page); + await clickBeadToIncrement(page) + await clickBeadToIncrement(page) + await clickBeadToIncrement(page) // Rapid navigation - await page.click('button:has-text("Next")'); - await page.waitForTimeout(100); - await page.click('button:has-text("Previous")'); - await page.waitForTimeout(100); + await page.click('button:has-text("Next")') + await page.waitForTimeout(100) + await page.click('button:has-text("Previous")') + await page.waitForTimeout(100) // Should return to a consistent state - const finalValue = await getAbacusValue(page); - expect(finalValue).toBe(initialValue); - }); + const finalValue = await getAbacusValue(page) + expect(finalValue).toBe(initialValue) + }) - test("should show tooltip guidance when available", async ({ page }) => { + test('should show tooltip guidance when available', async ({ page }) => { // Check if tooltips are present - const tooltip = page.locator('[data-testid="bead-tooltip"]'); + const tooltip = page.locator('[data-testid="bead-tooltip"]') if (await tooltip.isVisible()) { // Tooltip should contain helpful guidance - await expect(tooltip).toContainText(/working on|next|step/i); + await expect(tooltip).toContainText(/working on|next|step/i) // Tooltip should be positioned correctly (not covering important elements) - const tooltipBox = await tooltip.boundingBox(); - const abacusBox = await page - .locator('[data-testid="abacus-container"]') - .boundingBox(); + const tooltipBox = await tooltip.boundingBox() + const abacusBox = await page.locator('[data-testid="abacus-container"]').boundingBox() if (tooltipBox && abacusBox) { // Tooltip should not completely overlap the abacus expect(tooltipBox.x + tooltipBox.width).toBeLessThanOrEqual( - abacusBox.x + abacusBox.width + 50, - ); + abacusBox.x + abacusBox.width + 50 + ) } } - }); + }) - test("should handle keyboard navigation for accessibility", async ({ - page, - }) => { + test('should handle keyboard navigation for accessibility', async ({ page }) => { // Focus on abacus - await page.click('[data-testid="abacus-container"]'); + await page.click('[data-testid="abacus-container"]') // Test tab navigation - await page.keyboard.press("Tab"); - await page.waitForTimeout(200); + await page.keyboard.press('Tab') + await page.waitForTimeout(200) // Should move focus to next column const focusedElement = await page.evaluate(() => - document.activeElement?.getAttribute("data-testid"), - ); - expect(focusedElement).toMatch(/bead-place-/); + document.activeElement?.getAttribute('data-testid') + ) + expect(focusedElement).toMatch(/bead-place-/) // Test shift+tab navigation - await page.keyboard.press("Shift+Tab"); - await page.waitForTimeout(200); + await page.keyboard.press('Shift+Tab') + await page.waitForTimeout(200) // Should move focus in opposite direction const newFocusedElement = await page.evaluate(() => - document.activeElement?.getAttribute("data-testid"), - ); - expect(newFocusedElement).not.toBe(focusedElement); - }); + document.activeElement?.getAttribute('data-testid') + ) + expect(newFocusedElement).not.toBe(focusedElement) + }) - test("should dismiss success popup when clicked", async ({ page }) => { + test('should dismiss success popup when clicked', async ({ page }) => { // Simulate reaching a target value to show success popup // This would need to be customized based on the specific tutorial // For now, we'll test the general pattern - const successPopup = page.locator("text=/excellent|great|correct/i"); + const successPopup = page.locator('text=/excellent|great|correct/i') // If we can trigger a success state and the popup appears if (await successPopup.isVisible({ timeout: 1000 })) { // Click to dismiss - await successPopup.click(); + await successPopup.click() // Should disappear - await expect(successPopup).not.toBeVisible({ timeout: 2000 }); + await expect(successPopup).not.toBeVisible({ timeout: 2000 }) } - }); + }) - test("should handle tutorial completion", async ({ page }) => { + test('should handle tutorial completion', async ({ page }) => { // This test would navigate through all steps of a tutorial // and verify completion behavior - let currentStep = 0; - const maxSteps = 5; // Safety limit + let currentStep = 0 + const maxSteps = 5 // Safety limit while (currentStep < maxSteps) { // Check if Next button is available - const nextButton = page.locator('button:has-text("Next")'); + const nextButton = page.locator('button:has-text("Next")') if (!(await nextButton.isEnabled())) { - break; // Reached end or need to complete current step + break // Reached end or need to complete current step } - await nextButton.click(); - await page.waitForTimeout(1000); - currentStep++; + await nextButton.click() + await page.waitForTimeout(1000) + currentStep++ } // Should show some completion indicator or stay on final step // The exact behavior would depend on the tutorial implementation - }); + }) - test("should work on different screen sizes", async ({ page }) => { + test('should work on different screen sizes', async ({ page }) => { // Test mobile viewport - await page.setViewportSize({ width: 375, height: 667 }); - await page.reload(); - await waitForTutorialLoad(page); + await page.setViewportSize({ width: 375, height: 667 }) + await page.reload() + await waitForTutorialLoad(page) // Should still be functional - await expect(page.locator('[data-testid="tutorial-player"]')).toBeVisible(); - await expect( - page.locator('[data-testid^="bead-place-"]').first(), - ).toBeVisible(); + await expect(page.locator('[data-testid="tutorial-player"]')).toBeVisible() + await expect(page.locator('[data-testid^="bead-place-"]').first()).toBeVisible() // Test tablet viewport - await page.setViewportSize({ width: 768, height: 1024 }); - await page.reload(); - await waitForTutorialLoad(page); + await page.setViewportSize({ width: 768, height: 1024 }) + await page.reload() + await waitForTutorialLoad(page) // Should still be functional - await expect(page.locator('[data-testid="tutorial-player"]')).toBeVisible(); + await expect(page.locator('[data-testid="tutorial-player"]')).toBeVisible() // Test desktop viewport - await page.setViewportSize({ width: 1920, height: 1080 }); - await page.reload(); - await waitForTutorialLoad(page); + await page.setViewportSize({ width: 1920, height: 1080 }) + await page.reload() + await waitForTutorialLoad(page) // Should still be functional - await expect(page.locator('[data-testid="tutorial-player"]')).toBeVisible(); - }); -}); + await expect(page.locator('[data-testid="tutorial-player"]')).toBeVisible() + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/combinedTooltip.test.tsx b/apps/web/src/components/tutorial/__tests__/combinedTooltip.test.tsx index dc3cfae5..ecd898aa 100644 --- a/apps/web/src/components/tutorial/__tests__/combinedTooltip.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/combinedTooltip.test.tsx @@ -1,62 +1,48 @@ -import { render, screen } from "@testing-library/react"; -import type React from "react"; -import { describe, expect, it, vi } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { generateUnifiedInstructionSequence } from "../../../utils/unifiedStepGenerator"; -import { DecompositionWithReasons } from "../DecompositionWithReasons"; -import { TutorialProvider } from "../TutorialContext"; +import { render, screen } from '@testing-library/react' +import type React from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { generateUnifiedInstructionSequence } from '../../../utils/unifiedStepGenerator' +import { DecompositionWithReasons } from '../DecompositionWithReasons' +import { TutorialProvider } from '../TutorialContext' // Mock Radix Tooltip for testing -vi.mock("@radix-ui/react-tooltip", () => ({ - Provider: ({ children }: any) => ( -
{children}
- ), - Root: ({ children, open = true }: any) => ( -
{children}
- ), - Trigger: ({ children }: any) => ( -
{children}
- ), - Portal: ({ children }: any) => ( -
{children}
- ), +vi.mock('@radix-ui/react-tooltip', () => ({ + Provider: ({ children }: any) =>
{children}
, + Root: ({ children, open = true }: any) =>
{children}
, + Trigger: ({ children }: any) =>
{children}
, + Portal: ({ children }: any) =>
{children}
, Content: ({ children, className, ...props }: any) => (
{children}
), Arrow: (props: any) =>
, -})); +})) -describe("Combined Tooltip Content - Provenance + Why Explanations", () => { - const createTutorial = ( - startValue: number, - targetValue: number, - ): Tutorial => ({ +describe('Combined Tooltip Content - Provenance + Why Explanations', () => { + const createTutorial = (startValue: number, targetValue: number): Tutorial => ({ id: `test-${startValue}-${targetValue}`, title: `Test ${startValue} + ${targetValue - startValue}`, - description: "Testing combined tooltip content", + description: 'Testing combined tooltip content', steps: [ { - id: "test-step", + id: 'test-step', title: `${startValue} + ${targetValue - startValue} = ${targetValue}`, problem: `${startValue} + ${targetValue - startValue}`, description: `Add ${targetValue - startValue} to get ${targetValue}`, startValue, targetValue, - expectedAction: "multi-step" as const, - actionDescription: "Follow the steps", - tooltip: { content: "Test", explanation: "Test explanation" }, + expectedAction: 'multi-step' as const, + actionDescription: 'Follow the steps', + tooltip: { content: 'Test', explanation: 'Test explanation' }, }, ], createdAt: new Date(), updatedAt: new Date(), - }); + }) - function renderWithTutorialContext( - tutorial: Tutorial, - component: React.ReactElement, - ) { + function renderWithTutorialContext(tutorial: Tutorial, component: React.ReactElement) { return render( { onEvent={() => {}} > {component} - , - ); + + ) } - describe("Five Complement Operations", () => { - it("should show combined provenance + why explanations for 3 + 4 = 7", () => { - const result = generateUnifiedInstructionSequence(3, 7); - const tutorial = createTutorial(3, 7); + describe('Five Complement Operations', () => { + it('should show combined provenance + why explanations for 3 + 4 = 7', () => { + const result = generateUnifiedInstructionSequence(3, 7) + const tutorial = createTutorial(3, 7) renderWithTutorialContext( tutorial, @@ -80,33 +66,33 @@ describe("Combined Tooltip Content - Provenance + Why Explanations", () => { fullDecomposition={result.fullDecomposition} termPositions={result.steps.map((step) => step.termPosition)} segments={result.segments} - />, - ); + /> + ) // Find the five complement tooltip - const tooltipContent = screen.getAllByTestId("tooltip-content"); - let foundCombinedContent = false; + const tooltipContent = screen.getAllByTestId('tooltip-content') + let foundCombinedContent = false tooltipContent.forEach((tooltip) => { - const text = tooltip.textContent || ""; - if (text.includes("Make 5 — ones")) { - foundCombinedContent = true; + const text = tooltip.textContent || '' + if (text.includes('Make 5 — ones')) { + foundCombinedContent = true // Should have the semantic summary mentioning 5's friend - expect(text).toMatch(/5's friend/i); + expect(text).toMatch(/5's friend/i) // Should have semantic summary with core concepts - expect(text).toMatch(/Add 4/i); - expect(text).toMatch(/press 5/i); + expect(text).toMatch(/Add 4/i) + expect(text).toMatch(/press 5/i) } - }); + }) - expect(foundCombinedContent).toBe(true); - }); + expect(foundCombinedContent).toBe(true) + }) - it("should show combined content for 2 + 3 = 5", () => { - const result = generateUnifiedInstructionSequence(2, 5); - const tutorial = createTutorial(2, 5); + it('should show combined content for 2 + 3 = 5', () => { + const result = generateUnifiedInstructionSequence(2, 5) + const tutorial = createTutorial(2, 5) renderWithTutorialContext( tutorial, @@ -114,31 +100,31 @@ describe("Combined Tooltip Content - Provenance + Why Explanations", () => { fullDecomposition={result.fullDecomposition} termPositions={result.steps.map((step) => step.termPosition)} segments={result.segments} - />, - ); + /> + ) - const tooltipContent = screen.getAllByTestId("tooltip-content"); - let foundFiveComplement = false; + const tooltipContent = screen.getAllByTestId('tooltip-content') + let foundFiveComplement = false tooltipContent.forEach((tooltip) => { - const text = tooltip.textContent || ""; - if (text.includes("Make 5") && !text.includes("Direct")) { - foundFiveComplement = true; + const text = tooltip.textContent || '' + if (text.includes('Make 5') && !text.includes('Direct')) { + foundFiveComplement = true // Should have semantic summary with core concepts - expect(text).toMatch(/5's friend/i); - expect(text).toMatch(/Add 3/i); + expect(text).toMatch(/5's friend/i) + expect(text).toMatch(/Add 3/i) } - }); + }) - expect(foundFiveComplement).toBe(true); - }); - }); + expect(foundFiveComplement).toBe(true) + }) + }) - describe("Direct Operations", () => { - it("should show enhanced provenance content for direct operations like 3475 + 25", () => { - const result = generateUnifiedInstructionSequence(3475, 3500); - const tutorial = createTutorial(3475, 3500); + describe('Direct Operations', () => { + it('should show enhanced provenance content for direct operations like 3475 + 25', () => { + const result = generateUnifiedInstructionSequence(3475, 3500) + const tutorial = createTutorial(3475, 3500) renderWithTutorialContext( tutorial, @@ -146,39 +132,39 @@ describe("Combined Tooltip Content - Provenance + Why Explanations", () => { fullDecomposition={result.fullDecomposition} termPositions={result.steps.map((step) => step.termPosition)} segments={result.segments} - />, - ); + /> + ) - const tooltipContent = screen.getAllByTestId("tooltip-content"); - let foundDirectContent = false; + const tooltipContent = screen.getAllByTestId('tooltip-content') + let foundDirectContent = false tooltipContent.forEach((tooltip) => { - const text = tooltip.textContent || ""; + const text = tooltip.textContent || '' // Look for direct operation tooltip (should have enhanced provenance format) - if (text.includes("Add the tens digit — 2 tens (20)")) { - foundDirectContent = true; + if (text.includes('Add the tens digit — 2 tens (20)')) { + foundDirectContent = true // Should have enhanced title and subtitle - expect(text).toContain("From addend 25"); + expect(text).toContain('From addend 25') // Should have enhanced chips - expect(text).toContain("Digit we're using: 2 (tens)"); - expect(text).toContain("So we add here: +2 tens → 20"); + expect(text).toContain("Digit we're using: 2 (tens)") + expect(text).toContain('So we add here: +2 tens → 20') // Should have provenance explanation (new format) - expect(text).toContain("From addend 25: use the tens digit 2"); + expect(text).toContain('From addend 25: use the tens digit 2') } - }); + }) - expect(foundDirectContent).toBe(true); - }); - }); + expect(foundDirectContent).toBe(true) + }) + }) - describe("Ten Complement Operations", () => { - it("should show combined content for ten complement operations", () => { + describe('Ten Complement Operations', () => { + it('should show combined content for ten complement operations', () => { // Use a case that triggers ten complement (like adding to 9) - const result = generateUnifiedInstructionSequence(7, 12); // 7 + 5 may trigger ten complement - const tutorial = createTutorial(7, 12); + const result = generateUnifiedInstructionSequence(7, 12) // 7 + 5 may trigger ten complement + const tutorial = createTutorial(7, 12) renderWithTutorialContext( tutorial, @@ -186,43 +172,41 @@ describe("Combined Tooltip Content - Provenance + Why Explanations", () => { fullDecomposition={result.fullDecomposition} termPositions={result.steps.map((step) => step.termPosition)} segments={result.segments} - />, - ); + /> + ) - const tooltipContent = screen.getAllByTestId("tooltip-content"); - let foundTenComplement = false; + const tooltipContent = screen.getAllByTestId('tooltip-content') + let foundTenComplement = false tooltipContent.forEach((tooltip) => { - const text = tooltip.textContent || ""; - if (text.includes("Make 10") && !text.includes("Direct")) { - foundTenComplement = true; + const text = tooltip.textContent || '' + if (text.includes('Make 10') && !text.includes('Direct')) { + foundTenComplement = true // Should have enhanced subtitle with provenance - expect(text).toMatch(/From ones digit \d+ of \d+/); + expect(text).toMatch(/From ones digit \d+ of \d+/) // Should have source digit chip - expect(text).toMatch(/Source digit: \d+ from \d+ \(ones place\)/); + expect(text).toMatch(/Source digit: \d+ from \d+ \(ones place\)/) // Should have semantic summary for ten complement - expect(text).toMatch( - /Add \d+ to the ones to make 10.*carry.*take \d+ here/i, - ); + expect(text).toMatch(/Add \d+ to the ones to make 10.*carry.*take \d+ here/i) // Should have additional provenance context (new format) - expect(text).toMatch(/From addend \d+: use the ones digit \d+/); + expect(text).toMatch(/From addend \d+: use the ones digit \d+/) } - }); + }) // Ten complement might not always trigger, so we don't assert it must be found // This test documents the expected behavior when it does occur - console.log("Ten complement tooltip found:", foundTenComplement); - }); - }); + console.log('Ten complement tooltip found:', foundTenComplement) + }) + }) - describe("Content Structure Validation", () => { - it("should maintain proper content hierarchy in combined tooltips", () => { - const result = generateUnifiedInstructionSequence(3, 7); - const tutorial = createTutorial(3, 7); + describe('Content Structure Validation', () => { + it('should maintain proper content hierarchy in combined tooltips', () => { + const result = generateUnifiedInstructionSequence(3, 7) + const tutorial = createTutorial(3, 7) renderWithTutorialContext( tutorial, @@ -230,27 +214,27 @@ describe("Combined Tooltip Content - Provenance + Why Explanations", () => { fullDecomposition={result.fullDecomposition} termPositions={result.steps.map((step) => step.termPosition)} segments={result.segments} - />, - ); + /> + ) - const tooltip = screen.getAllByTestId("tooltip-content")[0]; - const html = tooltip.innerHTML; + const tooltip = screen.getAllByTestId('tooltip-content')[0] + const html = tooltip.innerHTML // Should have proper section order - const headerIndex = html.indexOf("reason-tooltip__header"); - const contextIndex = html.indexOf("reason-tooltip__context"); - const reasoningIndex = html.indexOf("reason-tooltip__reasoning"); - const formulaIndex = html.indexOf("reason-tooltip__formula"); + const headerIndex = html.indexOf('reason-tooltip__header') + const contextIndex = html.indexOf('reason-tooltip__context') + const reasoningIndex = html.indexOf('reason-tooltip__reasoning') + const formulaIndex = html.indexOf('reason-tooltip__formula') - expect(headerIndex).toBeGreaterThan(-1); - expect(contextIndex).toBeGreaterThan(headerIndex); - expect(reasoningIndex).toBeGreaterThan(contextIndex); - expect(formulaIndex).toBeGreaterThan(reasoningIndex); - }); + expect(headerIndex).toBeGreaterThan(-1) + expect(contextIndex).toBeGreaterThan(headerIndex) + expect(reasoningIndex).toBeGreaterThan(contextIndex) + expect(formulaIndex).toBeGreaterThan(reasoningIndex) + }) - it("should not duplicate content between sections", () => { - const result = generateUnifiedInstructionSequence(3, 7); - const tutorial = createTutorial(3, 7); + it('should not duplicate content between sections', () => { + const result = generateUnifiedInstructionSequence(3, 7) + const tutorial = createTutorial(3, 7) renderWithTutorialContext( tutorial, @@ -258,18 +242,18 @@ describe("Combined Tooltip Content - Provenance + Why Explanations", () => { fullDecomposition={result.fullDecomposition} termPositions={result.steps.map((step) => step.termPosition)} segments={result.segments} - />, - ); + /> + ) - const tooltip = screen.getAllByTestId("tooltip-content")[0]; - const text = tooltip.textContent || ""; + const tooltip = screen.getAllByTestId('tooltip-content')[0] + const text = tooltip.textContent || '' // Verify semantic content exists (simplified check) - const has5Friend = text.includes("5's friend"); - const hasAdd4 = text.includes("Add 4"); + const has5Friend = text.includes("5's friend") + const hasAdd4 = text.includes('Add 4') // Should have semantic content for this FiveComplement operation - expect(has5Friend || hasAdd4).toBe(true); - }); - }); -}); + expect(has5Friend || hasAdd4).toBe(true) + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/provenance.comprehensive.test.tsx b/apps/web/src/components/tutorial/__tests__/provenance.comprehensive.test.tsx index 32ee9c0b..85d15259 100644 --- a/apps/web/src/components/tutorial/__tests__/provenance.comprehensive.test.tsx +++ b/apps/web/src/components/tutorial/__tests__/provenance.comprehensive.test.tsx @@ -1,54 +1,46 @@ -import { render, screen } from "@testing-library/react"; -import type React from "react"; -import { describe, expect, it, vi } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { generateUnifiedInstructionSequence } from "../../../utils/unifiedStepGenerator"; -import { DecompositionWithReasons } from "../DecompositionWithReasons"; -import { TutorialProvider, useTutorialContext } from "../TutorialContext"; +import { render, screen } from '@testing-library/react' +import type React from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { generateUnifiedInstructionSequence } from '../../../utils/unifiedStepGenerator' +import { DecompositionWithReasons } from '../DecompositionWithReasons' +import { TutorialProvider, useTutorialContext } from '../TutorialContext' // Mock Radix Tooltip for reliable testing -vi.mock("@radix-ui/react-tooltip", () => ({ - Provider: ({ children }: any) => ( -
{children}
- ), - Root: ({ children, open = true }: any) => ( -
{children}
- ), - Trigger: ({ children }: any) => ( -
{children}
- ), - Portal: ({ children }: any) => ( -
{children}
- ), +vi.mock('@radix-ui/react-tooltip', () => ({ + Provider: ({ children }: any) =>
{children}
, + Root: ({ children, open = true }: any) =>
{children}
, + Trigger: ({ children }: any) =>
{children}
, + Portal: ({ children }: any) =>
{children}
, Content: ({ children, className, ...props }: any) => (
{children}
), Arrow: (props: any) =>
, -})); +})) -describe("Provenance System - Comprehensive Tests", () => { +describe('Provenance System - Comprehensive Tests', () => { const provenanceTutorial: Tutorial = { - id: "provenance-test", - title: "Provenance Test", - description: "Testing provenance system", + id: 'provenance-test', + title: 'Provenance Test', + description: 'Testing provenance system', steps: [ { - id: "test-step", - title: "3475 + 25 = 3500", - problem: "3475 + 25", - description: "Add 25 to get 3500", + id: 'test-step', + title: '3475 + 25 = 3500', + problem: '3475 + 25', + description: 'Add 25 to get 3500', startValue: 3475, targetValue: 3500, - expectedAction: "multi-step" as const, - actionDescription: "Follow the steps", - tooltip: { content: "Test", explanation: "Test explanation" }, + expectedAction: 'multi-step' as const, + actionDescription: 'Follow the steps', + tooltip: { content: 'Test', explanation: 'Test explanation' }, }, ], createdAt: new Date(), updatedAt: new Date(), - }; + } function renderWithTutorialContext(component: React.ReactElement) { return render( @@ -59,25 +51,23 @@ describe("Provenance System - Comprehensive Tests", () => { onEvent={() => {}} > {component} - , - ); + + ) } - describe("Unified Step Generator Provenance", () => { - it("should generate correct provenance data for 3475 + 25 = 3500", () => { - const result = generateUnifiedInstructionSequence(3475, 3500); + describe('Unified Step Generator Provenance', () => { + it('should generate correct provenance data for 3475 + 25 = 3500', () => { + const result = generateUnifiedInstructionSequence(3475, 3500) // Verify basic structure - expect(result.steps.length).toBeGreaterThan(0); - expect(result.segments.length).toBeGreaterThan(0); - expect(result.fullDecomposition).toContain("3475 + 25"); + expect(result.steps.length).toBeGreaterThan(0) + expect(result.segments.length).toBeGreaterThan(0) + expect(result.fullDecomposition).toContain('3475 + 25') // Find the "20" step (tens digit) - const twentyStep = result.steps.find( - (step) => step.mathematicalTerm === "20", - ); - expect(twentyStep).toBeDefined(); - expect(twentyStep?.provenance).toBeDefined(); + const twentyStep = result.steps.find((step) => step.mathematicalTerm === '20') + expect(twentyStep).toBeDefined() + expect(twentyStep?.provenance).toBeDefined() if (twentyStep?.provenance) { // Verify provenance data matches the specification exactly @@ -85,50 +75,50 @@ describe("Provenance System - Comprehensive Tests", () => { rhs: 25, // the addend rhsDigit: 2, // digit from tens place rhsPlace: 1, // tens = place 1 - rhsPlaceName: "tens", // human readable + rhsPlaceName: 'tens', // human readable rhsDigitIndex: 0, // '2' is first character in '25' rhsValue: 20, // 2 * 10^1 = 20 - }); + }) } // Verify ones digit complement group const complementSteps = result.steps.filter((step) => - step.provenance?.groupId?.includes("10comp-0-5"), - ); - expect(complementSteps.length).toBeGreaterThan(0); + step.provenance?.groupId?.includes('10comp-0-5') + ) + expect(complementSteps.length).toBeGreaterThan(0) // All complement steps should trace back to the same source digit complementSteps.forEach((step) => { - expect(step.provenance?.rhs).toBe(25); - expect(step.provenance?.rhsDigit).toBe(5); - expect(step.provenance?.rhsPlace).toBe(0); - expect(step.provenance?.rhsPlaceName).toBe("ones"); - }); + expect(step.provenance?.rhs).toBe(25) + expect(step.provenance?.rhsDigit).toBe(5) + expect(step.provenance?.rhsPlace).toBe(0) + expect(step.provenance?.rhsPlaceName).toBe('ones') + }) // Verify equation anchors for digit highlighting - expect(result.equationAnchors).toBeDefined(); - expect(result.equationAnchors?.differenceText).toBe("25"); - expect(result.equationAnchors?.rhsDigitPositions).toHaveLength(2); - }); - }); + expect(result.equationAnchors).toBeDefined() + expect(result.equationAnchors?.differenceText).toBe('25') + expect(result.equationAnchors?.rhsDigitPositions).toHaveLength(2) + }) + }) - describe("Tooltip Enhancement Logic", () => { - it("should generate correct enhanced tooltip content", () => { + describe('Tooltip Enhancement Logic', () => { + it('should generate correct enhanced tooltip content', () => { const provenance = { rhs: 25, rhsDigit: 2, rhsPlace: 1, - rhsPlaceName: "tens" as const, + rhsPlaceName: 'tens' as const, rhsDigitIndex: 0, rhsValue: 20, - }; + } // Test the exact logic from getEnhancedTooltipContent - const title = `Add the ${provenance.rhsPlaceName} digit — ${provenance.rhsDigit} ${provenance.rhsPlaceName} (${provenance.rhsValue})`; - const subtitle = `From addend ${provenance.rhs}`; + const title = `Add the ${provenance.rhsPlaceName} digit — ${provenance.rhsDigit} ${provenance.rhsPlaceName} (${provenance.rhsValue})` + const subtitle = `From addend ${provenance.rhs}` - expect(title).toBe("Add the tens digit — 2 tens (20)"); - expect(subtitle).toBe("From addend 25"); + expect(title).toBe('Add the tens digit — 2 tens (20)') + expect(subtitle).toBe('From addend 25') // Test breadcrumb chips const chips = [ @@ -137,65 +127,63 @@ describe("Provenance System - Comprehensive Tests", () => { value: `${provenance.rhsDigit} (${provenance.rhsPlaceName})`, }, { - label: "So we add here", + label: 'So we add here', value: `+${provenance.rhsDigit} ${provenance.rhsPlaceName} → ${provenance.rhsValue}`, }, - ]; + ] expect(chips[0]).toEqual({ label: "Digit we're using", - value: "2 (tens)", - }); + value: '2 (tens)', + }) expect(chips[1]).toEqual({ - label: "So we add here", - value: "+2 tens → 20", - }); + label: 'So we add here', + value: '+2 tens → 20', + }) // Test explanation text - const explanation = `We're adding the ${provenance.rhsPlaceName} digit of ${provenance.rhs} → ${provenance.rhsDigit} ${provenance.rhsPlaceName}.`; - expect(explanation).toBe("We're adding the tens digit of 25 → 2 tens."); - }); - }); + const explanation = `We're adding the ${provenance.rhsPlaceName} digit of ${provenance.rhs} → ${provenance.rhsDigit} ${provenance.rhsPlaceName}.` + expect(explanation).toBe("We're adding the tens digit of 25 → 2 tens.") + }) + }) - describe("Context Integration", () => { - it("should provide unified steps through tutorial context", () => { - let contextSteps: any = null; + describe('Context Integration', () => { + it('should provide unified steps through tutorial context', () => { + let contextSteps: any = null function TestComponent() { - const { unifiedSteps } = useTutorialContext(); - contextSteps = unifiedSteps; - return
Test
; + const { unifiedSteps } = useTutorialContext() + contextSteps = unifiedSteps + return
Test
} - renderWithTutorialContext(); + renderWithTutorialContext() // Context should provide steps with provenance - expect(contextSteps).toBeDefined(); - expect(Array.isArray(contextSteps)).toBe(true); - expect(contextSteps.length).toBeGreaterThan(0); + expect(contextSteps).toBeDefined() + expect(Array.isArray(contextSteps)).toBe(true) + expect(contextSteps.length).toBeGreaterThan(0) // Find the "20" step - const twentyStep = contextSteps.find( - (step: any) => step.mathematicalTerm === "20", - ); - expect(twentyStep).toBeDefined(); - expect(twentyStep.provenance).toBeDefined(); - expect(twentyStep.provenance.rhsValue).toBe(20); - }); - }); + const twentyStep = contextSteps.find((step: any) => step.mathematicalTerm === '20') + expect(twentyStep).toBeDefined() + expect(twentyStep.provenance).toBeDefined() + expect(twentyStep.provenance.rhsValue).toBe(20) + }) + }) - describe("DecompositionWithReasons Integration", () => { - it("should render enhanced tooltips with provenance information", () => { - const result = generateUnifiedInstructionSequence(3475, 3500); + describe('DecompositionWithReasons Integration', () => { + it('should render enhanced tooltips with provenance information', () => { + const result = generateUnifiedInstructionSequence(3475, 3500) renderWithTutorialContext( step.termPosition)} segments={result.segments} - />, - ); + /> + ) // Verify that enhanced provenance content exists in the DOM // The specific enhanced tooltip content we expect to see: @@ -206,18 +194,18 @@ describe("Provenance System - Comprehensive Tests", () => { // Check that the DOM contains at least one instance of our enhanced content // This proves the provenance system is working and generating enhanced tooltips const enhancedContent = [ - screen.queryAllByText("Add the tens digit — 2 tens (20)"), - screen.queryAllByText("From addend 25"), + screen.queryAllByText('Add the tens digit — 2 tens (20)'), + screen.queryAllByText('From addend 25'), screen.queryAllByText(/We're adding the tens digit of 25/), - ].flat(); + ].flat() // The provenance system should generate enhanced content for mathematical terms - expect(enhancedContent.length).toBeGreaterThan(0); - }); - }); + expect(enhancedContent.length).toBeGreaterThan(0) + }) + }) - describe("Regression Tests", () => { - it("should not break existing functionality without provenance", () => { + describe('Regression Tests', () => { + it('should not break existing functionality without provenance', () => { // Test with a simple case that might not generate provenance renderWithTutorialContext( { { startIndex: 8, endIndex: 10 }, ]} segments={[]} - />, - ); + /> + ) // Should still render without errors - expect(screen.getByText("7")).toBeInTheDocument(); - expect(screen.getByText("3")).toBeInTheDocument(); - expect(screen.getByText("10")).toBeInTheDocument(); - }); + expect(screen.getByText('7')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.getByText('10')).toBeInTheDocument() + }) - it("should handle empty or malformed data gracefully", () => { + it('should handle empty or malformed data gracefully', () => { renderWithTutorialContext( - , - ); + + ) // Should render without throwing - expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument(); - }); - }); + expect(screen.getByTestId('tooltip-provider')).toBeInTheDocument() + }) + }) - describe("End-to-End User Experience", () => { - it("should provide clear digit-to-pill connection for students", () => { - const result = generateUnifiedInstructionSequence(3475, 3500); + describe('End-to-End User Experience', () => { + it('should provide clear digit-to-pill connection for students', () => { + const result = generateUnifiedInstructionSequence(3475, 3500) // Verify that every step with provenance clearly indicates its source result.steps.forEach((step) => { if (step.provenance) { // Each step should know which addend digit it came from - expect(step.provenance.rhs).toBe(25); - expect([2, 5]).toContain(step.provenance.rhsDigit); - expect(["tens", "ones"]).toContain(step.provenance.rhsPlaceName); + expect(step.provenance.rhs).toBe(25) + expect([2, 5]).toContain(step.provenance.rhsDigit) + expect(['tens', 'ones']).toContain(step.provenance.rhsPlaceName) // The digit index should point to the correct character in "25" if (step.provenance.rhsDigit === 2) { - expect(step.provenance.rhsDigitIndex).toBe(0); // '2' is at index 0 + expect(step.provenance.rhsDigitIndex).toBe(0) // '2' is at index 0 } else if (step.provenance.rhsDigit === 5) { - expect(step.provenance.rhsDigitIndex).toBe(1); // '5' is at index 1 + expect(step.provenance.rhsDigitIndex).toBe(1) // '5' is at index 1 } } - }); + }) // Equation anchors should allow precise highlighting expect(result.equationAnchors?.rhsDigitPositions[0]).toEqual({ digitIndex: 0, startIndex: expect.any(Number), endIndex: expect.any(Number), - }); + }) expect(result.equationAnchors?.rhsDigitPositions[1]).toEqual({ digitIndex: 1, startIndex: expect.any(Number), endIndex: expect.any(Number), - }); - }); - }); -}); + }) + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/run-tutorial-tests.ts b/apps/web/src/components/tutorial/__tests__/run-tutorial-tests.ts index 3d0b0c46..ac3c3019 100644 --- a/apps/web/src/components/tutorial/__tests__/run-tutorial-tests.ts +++ b/apps/web/src/components/tutorial/__tests__/run-tutorial-tests.ts @@ -12,110 +12,108 @@ * 4. End-to-end tutorial workflow */ -import { execSync } from "child_process"; +import { execSync } from 'child_process' const TEST_CATEGORIES = [ { - name: "AbacusReact Controlled Input", - pattern: "**/AbacusReact.controlled-input.test.tsx", - description: - "Tests the React controlled input pattern implementation in AbacusReact", + name: 'AbacusReact Controlled Input', + pattern: '**/AbacusReact.controlled-input.test.tsx', + description: 'Tests the React controlled input pattern implementation in AbacusReact', }, { - name: "TutorialContext State Management", - pattern: "**/TutorialContext.test.tsx", - description: - "Tests step initialization, navigation, and multi-step functionality", + name: 'TutorialContext State Management', + pattern: '**/TutorialContext.test.tsx', + description: 'Tests step initialization, navigation, and multi-step functionality', }, { - name: "TutorialPlayer Integration", - pattern: "**/TutorialPlayer.integration.test.tsx", - description: "Tests integration between TutorialPlayer and context state", + name: 'TutorialPlayer Integration', + pattern: '**/TutorialPlayer.integration.test.tsx', + description: 'Tests integration between TutorialPlayer and context state', }, { - name: "End-to-End Workflow", - pattern: "**/TutorialWorkflow.e2e.test.ts", - description: "Tests complete tutorial workflow from user perspective", + name: 'End-to-End Workflow', + pattern: '**/TutorialWorkflow.e2e.test.ts', + description: 'Tests complete tutorial workflow from user perspective', }, -]; +] async function runTestCategory(category: (typeof TEST_CATEGORIES)[0]) { - console.log(`\n🧪 Running ${category.name} Tests`); - console.log(` ${category.description}`); - console.log(` ${"─".repeat(50)}`); + console.log(`\n🧪 Running ${category.name} Tests`) + console.log(` ${category.description}`) + console.log(` ${'─'.repeat(50)}`) try { - const cmd = category.pattern.endsWith(".e2e.test.ts") + const cmd = category.pattern.endsWith('.e2e.test.ts') ? `npx playwright test ${category.pattern}` - : `npx vitest run ${category.pattern}`; + : `npx vitest run ${category.pattern}` execSync(cmd, { - stdio: "inherit", + stdio: 'inherit', cwd: process.cwd(), - }); + }) - console.log(`✅ ${category.name} tests passed`); - return true; + console.log(`✅ ${category.name} tests passed`) + return true } catch (_error) { - console.error(`❌ ${category.name} tests failed`); - return false; + console.error(`❌ ${category.name} tests failed`) + return false } } async function runAllTests() { - console.log("🚀 Running Tutorial System Regression Tests"); - console.log("=" * 60); + console.log('🚀 Running Tutorial System Regression Tests') + console.log('=' * 60) - const results: boolean[] = []; + const results: boolean[] = [] for (const category of TEST_CATEGORIES) { - const success = await runTestCategory(category); - results.push(success); + const success = await runTestCategory(category) + results.push(success) } - const passedTests = results.filter(Boolean).length; - const totalTests = results.length; + const passedTests = results.filter(Boolean).length + const totalTests = results.length - console.log("\n📊 Test Summary"); - console.log("─".repeat(30)); - console.log(`Passed: ${passedTests}/${totalTests}`); + console.log('\n📊 Test Summary') + console.log('─'.repeat(30)) + console.log(`Passed: ${passedTests}/${totalTests}`) if (passedTests === totalTests) { - console.log("🎉 All tutorial tests passed! No regressions detected."); - process.exit(0); + console.log('🎉 All tutorial tests passed! No regressions detected.') + process.exit(0) } else { - console.error("💥 Some tests failed. Please check the output above."); - process.exit(1); + console.error('💥 Some tests failed. Please check the output above.') + process.exit(1) } } async function runSpecificTest(testName: string) { const category = TEST_CATEGORIES.find((cat) => - cat.name.toLowerCase().includes(testName.toLowerCase()), - ); + cat.name.toLowerCase().includes(testName.toLowerCase()) + ) if (!category) { - console.error(`❌ Test category "${testName}" not found`); - console.log("Available categories:"); - TEST_CATEGORIES.forEach((cat) => console.log(` - ${cat.name}`)); - process.exit(1); + console.error(`❌ Test category "${testName}" not found`) + console.log('Available categories:') + TEST_CATEGORIES.forEach((cat) => console.log(` - ${cat.name}`)) + process.exit(1) } - await runTestCategory(category); + await runTestCategory(category) } // CLI handling -const args = process.argv.slice(2); +const args = process.argv.slice(2) if (args.length === 0) { - runAllTests(); -} else if (args[0] === "--list") { - console.log("Available test categories:"); + runAllTests() +} else if (args[0] === '--list') { + console.log('Available test categories:') TEST_CATEGORIES.forEach((cat, index) => { - console.log(`\n${index + 1}. ${cat.name}`); - console.log(` Pattern: ${cat.pattern}`); - console.log(` Description: ${cat.description}`); - }); + console.log(`\n${index + 1}. ${cat.name}`) + console.log(` Pattern: ${cat.pattern}`) + console.log(` Description: ${cat.description}`) + }) } else { - runSpecificTest(args[0]); + runSpecificTest(args[0]) } diff --git a/apps/web/src/components/tutorial/__tests__/semanticSummary.test.ts b/apps/web/src/components/tutorial/__tests__/semanticSummary.test.ts index ec7f3b62..b575940f 100644 --- a/apps/web/src/components/tutorial/__tests__/semanticSummary.test.ts +++ b/apps/web/src/components/tutorial/__tests__/semanticSummary.test.ts @@ -1,150 +1,148 @@ -import { describe, expect, it } from "vitest"; -import type { PedagogicalSegment } from "../../../utils/unifiedStepGenerator"; -import { generateUnifiedInstructionSequence } from "../../../utils/unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import type { PedagogicalSegment } from '../../../utils/unifiedStepGenerator' +import { generateUnifiedInstructionSequence } from '../../../utils/unifiedStepGenerator' const getSeg = ( seq: ReturnType, - place: number, -): PedagogicalSegment => seq.segments.find((s) => s.place === place)!; + place: number +): PedagogicalSegment => seq.segments.find((s) => s.place === place)! -describe("semantic summaries", () => { - it("Direct, small add (ones): concise summary, ≤ 150 chars", () => { - const seq = generateUnifiedInstructionSequence(1, 3); // +2, uses Direct - const seg = getSeg(seq, 0); - expect(seg.plan[0].rule).toBe("Direct"); - expect(seg.readable.summary).toMatch(/Add 2 .* ones/i); - expect(seg.readable.summary.length).toBeLessThan(150); - expect((seg.readable.chips || []).length).toBeLessThanOrEqual(2); - }); +describe('semantic summaries', () => { + it('Direct, small add (ones): concise summary, ≤ 150 chars', () => { + const seq = generateUnifiedInstructionSequence(1, 3) // +2, uses Direct + const seg = getSeg(seq, 0) + expect(seg.plan[0].rule).toBe('Direct') + expect(seg.readable.summary).toMatch(/Add 2 .* ones/i) + expect(seg.readable.summary.length).toBeLessThan(150) + expect((seg.readable.chips || []).length).toBeLessThanOrEqual(2) + }) - it("Direct, large add (using upper bead): mentions +5", () => { - const seq = generateUnifiedInstructionSequence(1, 9); // +8, should use upper bead - const seg = getSeg(seq, 0); - if (seg.plan[0].rule === "Direct") { - expect(seg.readable.summary).toMatch(/\+5|upper bead/i); - expect(seg.readable.summary).toMatch(/No carry needed/i); + it('Direct, large add (using upper bead): mentions +5', () => { + const seq = generateUnifiedInstructionSequence(1, 9) // +8, should use upper bead + const seg = getSeg(seq, 0) + if (seg.plan[0].rule === 'Direct') { + expect(seg.readable.summary).toMatch(/\+5|upper bead/i) + expect(seg.readable.summary).toMatch(/No carry needed/i) } - }); + }) - it("FiveComplement: mentions +5 − (5−d)", () => { - const seq = generateUnifiedInstructionSequence(3, 7); // +4 uses FiveComplement - const seg = getSeg(seq, 0); - if (seg.plan.some((p) => p.rule === "FiveComplement")) { - expect(seg.readable.summary).toMatch(/\+5 *− *\d+|press 5.*lift/i); - expect(seg.readable.summary).toMatch(/5's friend/i); + it('FiveComplement: mentions +5 − (5−d)', () => { + const seq = generateUnifiedInstructionSequence(3, 7) // +4 uses FiveComplement + const seg = getSeg(seq, 0) + if (seg.plan.some((p) => p.rule === 'FiveComplement')) { + expect(seg.readable.summary).toMatch(/\+5 *− *\d+|press 5.*lift/i) + expect(seg.readable.summary).toMatch(/5's friend/i) } - }); + }) - it("TenComplement no cascade: mentions +10 − (10−d) and carry", () => { - const seq = generateUnifiedInstructionSequence(19, 20); // +1 at ones with carry - const seg = getSeg(seq, 0); - if (seg.plan.some((p) => p.rule === "TenComplement")) { - expect(seg.readable.summary).toMatch(/\+10 *− *\d+|carry.*tens/i); - expect(seg.readable.summary).toMatch(/make 10/i); + it('TenComplement no cascade: mentions +10 − (10−d) and carry', () => { + const seq = generateUnifiedInstructionSequence(19, 20) // +1 at ones with carry + const seg = getSeg(seq, 0) + if (seg.plan.some((p) => p.rule === 'TenComplement')) { + expect(seg.readable.summary).toMatch(/\+10 *− *\d+|carry.*tens/i) + expect(seg.readable.summary).toMatch(/make 10/i) } - }); + }) - it("TenComplement with cascade: mentions ripple/carry path", () => { - const seq = generateUnifiedInstructionSequence(99, 100); // ripple through 9s - const seg = getSeg(seq, 0); - if (seg.plan.some((p) => p.rule === "Cascade")) { - expect(seg.readable.summary).toMatch(/ripples|carry ripples/i); + it('TenComplement with cascade: mentions ripple/carry path', () => { + const seq = generateUnifiedInstructionSequence(99, 100) // ripple through 9s + const seg = getSeg(seq, 0) + if (seg.plan.some((p) => p.rule === 'Cascade')) { + expect(seg.readable.summary).toMatch(/ripples|carry ripples/i) } - }); + }) - it("dev validation ok for valid rules", () => { - const seq = generateUnifiedInstructionSequence(45, 53); // +8 at ones - const seg = getSeg(seq, 0); - expect(seg.readable.validation?.ok).toBe(true); - expect((seg.readable.validation?.issues || []).length).toBe(0); - }); + it('dev validation ok for valid rules', () => { + const seq = generateUnifiedInstructionSequence(45, 53) // +8 at ones + const seg = getSeg(seq, 0) + expect(seg.readable.validation?.ok).toBe(true) + expect((seg.readable.validation?.issues || []).length).toBe(0) + }) - it("summary is concise (under 200 chars for all rules)", () => { + it('summary is concise (under 200 chars for all rules)', () => { const testCases = [ - { start: 1, end: 3, desc: "Direct small" }, - { start: 1, end: 9, desc: "Direct large" }, - { start: 3, end: 7, desc: "FiveComplement" }, - { start: 19, end: 20, desc: "TenComplement" }, - { start: 99, end: 100, desc: "Cascade" }, - ]; + { start: 1, end: 3, desc: 'Direct small' }, + { start: 1, end: 9, desc: 'Direct large' }, + { start: 3, end: 7, desc: 'FiveComplement' }, + { start: 19, end: 20, desc: 'TenComplement' }, + { start: 99, end: 100, desc: 'Cascade' }, + ] testCases.forEach(({ start, end, desc }) => { - const seq = generateUnifiedInstructionSequence(start, end); + const seq = generateUnifiedInstructionSequence(start, end) seq.segments.forEach((seg) => { - expect(seg.readable.summary.length).toBeLessThan(200); - expect(seg.readable.summary.trim().length).toBeGreaterThan(0); - }); - }); - }); + expect(seg.readable.summary.length).toBeLessThan(200) + expect(seg.readable.summary.trim().length).toBeGreaterThan(0) + }) + }) + }) - it("chips are minimal (≤ 2 per segment)", () => { - const seq = generateUnifiedInstructionSequence(3475, 3500); // complex case + it('chips are minimal (≤ 2 per segment)', () => { + const seq = generateUnifiedInstructionSequence(3475, 3500) // complex case seq.segments.forEach((seg) => { - expect((seg.readable.chips || []).length).toBeLessThanOrEqual(2); - }); - }); + expect((seg.readable.chips || []).length).toBeLessThanOrEqual(2) + }) + }) - it("shows provenance information when available", () => { - const seq = generateUnifiedInstructionSequence(3475, 3500); // has provenance - const tensSegment = getSeg(seq, 1); // tens place segment + it('shows provenance information when available', () => { + const seq = generateUnifiedInstructionSequence(3475, 3500) // has provenance + const tensSegment = getSeg(seq, 1) // tens place segment // Should have provenance chip if provenance data exists - const firstStep = seq.steps[tensSegment.stepIndices[0]]; + const firstStep = seq.steps[tensSegment.stepIndices[0]] if (firstStep?.provenance) { - const provenanceChip = tensSegment.readable.chips.find( - (chip) => chip.label === "From addend", - ); - expect(provenanceChip).toBeDefined(); - expect(provenanceChip?.value).toMatch(/\d+ \w+/); // e.g., "2 tens" + const provenanceChip = tensSegment.readable.chips.find((chip) => chip.label === 'From addend') + expect(provenanceChip).toBeDefined() + expect(provenanceChip?.value).toMatch(/\d+ \w+/) // e.g., "2 tens" } - }); + }) - it("math explanations are optional and concise", () => { - const seq = generateUnifiedInstructionSequence(3, 8); // might trigger FiveComplement + it('math explanations are optional and concise', () => { + const seq = generateUnifiedInstructionSequence(3, 8) // might trigger FiveComplement seq.segments.forEach((seg) => { if (seg.readable.showMath) { - expect(seg.readable.showMath.lines.length).toBeLessThanOrEqual(1); + expect(seg.readable.showMath.lines.length).toBeLessThanOrEqual(1) seg.readable.showMath.lines.forEach((line) => { - expect(line.length).toBeLessThan(50); // keep math explanations short - }); + expect(line.length).toBeLessThan(50) // keep math explanations short + }) } - }); - }); + }) + }) - it("guard validation catches mismatches", () => { + it('guard validation catches mismatches', () => { // This test ensures our validation logic works correctly - const seq = generateUnifiedInstructionSequence(3, 7); - const seg = getSeg(seq, 0); + const seq = generateUnifiedInstructionSequence(3, 7) + const seg = getSeg(seq, 0) // For a Direct rule, we should have appropriate guards - if (seg.plan[0].rule === "Direct") { + if (seg.plan[0].rule === 'Direct') { const hasDirectGuard = seg.plan .flatMap((p) => p.conditions) - .some((condition) => /a\+d.*≤ *9/.test(condition)); + .some((condition) => /a\+d.*≤ *9/.test(condition)) if (hasDirectGuard) { - expect(seg.readable.validation?.ok).toBe(true); + expect(seg.readable.validation?.ok).toBe(true) } } - }); + }) - it("step-by-step details are preserved for expansion", () => { - const seq = generateUnifiedInstructionSequence(99, 100); // complex case + it('step-by-step details are preserved for expansion', () => { + const seq = generateUnifiedInstructionSequence(99, 100) // complex case seq.segments.forEach((seg) => { // Steps should still be available for "show details" expansion - expect(Array.isArray(seg.readable.stepsFriendly)).toBe(true); + expect(Array.isArray(seg.readable.stepsFriendly)).toBe(true) seg.readable.stepsFriendly.forEach((step) => { - expect(typeof step).toBe("string"); - expect(step.length).toBeGreaterThan(0); - }); - }); - }); + expect(typeof step).toBe('string') + expect(step.length).toBeGreaterThan(0) + }) + }) + }) - it("titles are short and descriptive", () => { - const seq = generateUnifiedInstructionSequence(3475, 3500); + it('titles are short and descriptive', () => { + const seq = generateUnifiedInstructionSequence(3475, 3500) seq.segments.forEach((seg) => { - expect(seg.readable.title.length).toBeLessThan(30); - expect(seg.readable.title).toMatch(/Add \d+|Make \d+|Carry/); // should be action-oriented - }); - }); -}); + expect(seg.readable.title.length).toBeLessThan(30) + expect(seg.readable.title).toMatch(/Add \d+|Make \d+|Carry/) // should be action-oriented + }) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/termColumnHighlighting.test.ts b/apps/web/src/components/tutorial/__tests__/termColumnHighlighting.test.ts index f762fbc6..c305c25f 100644 --- a/apps/web/src/components/tutorial/__tests__/termColumnHighlighting.test.ts +++ b/apps/web/src/components/tutorial/__tests__/termColumnHighlighting.test.ts @@ -1,135 +1,133 @@ -import { describe, expect, it } from "vitest"; -import { generateUnifiedInstructionSequence } from "../../../utils/unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import { generateUnifiedInstructionSequence } from '../../../utils/unifiedStepGenerator' -describe("Term-to-Column Highlighting Integration", () => { - it("should map term indices to correct column indices", () => { - const result = generateUnifiedInstructionSequence(3475, 3500); // 3475 + 25 +describe('Term-to-Column Highlighting Integration', () => { + it('should map term indices to correct column indices', () => { + const result = generateUnifiedInstructionSequence(3475, 3500) // 3475 + 25 // Find a step with provenance data - const stepWithProvenance = result.steps.find((step) => step.provenance); - expect(stepWithProvenance).toBeDefined(); + const stepWithProvenance = result.steps.find((step) => step.provenance) + expect(stepWithProvenance).toBeDefined() if (stepWithProvenance?.provenance) { // Test the conversion logic: rhsPlace (0=ones, 1=tens) → columnIndex (4=ones, 3=tens) - const expectedColumnIndex = 4 - stepWithProvenance.provenance.rhsPlace; + const expectedColumnIndex = 4 - stepWithProvenance.provenance.rhsPlace // For tens place (rhsPlace=1), should map to columnIndex=3 if (stepWithProvenance.provenance.rhsPlace === 1) { - expect(expectedColumnIndex).toBe(3); + expect(expectedColumnIndex).toBe(3) } // For ones place (rhsPlace=0), should map to columnIndex=4 if (stepWithProvenance.provenance.rhsPlace === 0) { - expect(expectedColumnIndex).toBe(4); + expect(expectedColumnIndex).toBe(4) } } - }); + }) - it("should generate provenance data for complex operations", () => { - const result = generateUnifiedInstructionSequence(3475, 3500); // 3475 + 25 + it('should generate provenance data for complex operations', () => { + const result = generateUnifiedInstructionSequence(3475, 3500) // 3475 + 25 // Should have steps with provenance for both tens and ones digits - const stepsWithProvenance = result.steps.filter((step) => step.provenance); - expect(stepsWithProvenance.length).toBeGreaterThan(0); + const stepsWithProvenance = result.steps.filter((step) => step.provenance) + expect(stepsWithProvenance.length).toBeGreaterThan(0) // Verify we have both tens and ones place operations - const places = stepsWithProvenance.map((step) => step.provenance!.rhsPlace); - expect(places).toContain(0); // ones place - expect(places).toContain(1); // tens place + const places = stepsWithProvenance.map((step) => step.provenance!.rhsPlace) + expect(places).toContain(0) // ones place + expect(places).toContain(1) // tens place // Verify provenance data structure stepsWithProvenance.forEach((step) => { - const prov = step.provenance!; - expect(typeof prov.rhs).toBe("number"); - expect(typeof prov.rhsDigit).toBe("number"); - expect(typeof prov.rhsPlace).toBe("number"); - expect(typeof prov.rhsPlaceName).toBe("string"); - expect(typeof prov.rhsValue).toBe("number"); - }); - }); + const prov = step.provenance! + expect(typeof prov.rhs).toBe('number') + expect(typeof prov.rhsDigit).toBe('number') + expect(typeof prov.rhsPlace).toBe('number') + expect(typeof prov.rhsPlaceName).toBe('string') + expect(typeof prov.rhsValue).toBe('number') + }) + }) - it("should handle bidirectional mapping correctly", () => { - const result = generateUnifiedInstructionSequence(1234, 1289); // 1234 + 55 + it('should handle bidirectional mapping correctly', () => { + const result = generateUnifiedInstructionSequence(1234, 1289) // 1234 + 55 // Create a mock implementation of the mapping functions const getColumnFromTermIndex = (termIndex: number) => { - const step = result.steps[termIndex]; - if (!step?.provenance) return null; - return 4 - step.provenance.rhsPlace; - }; + const step = result.steps[termIndex] + if (!step?.provenance) return null + return 4 - step.provenance.rhsPlace + } const getTermIndicesFromColumn = (columnIndex: number) => { - const termIndices: number[] = []; + const termIndices: number[] = [] result.steps.forEach((step, index) => { if (step.provenance) { - const stepColumnIndex = 4 - step.provenance.rhsPlace; + const stepColumnIndex = 4 - step.provenance.rhsPlace if (stepColumnIndex === columnIndex) { - termIndices.push(index); + termIndices.push(index) } } - }); - return termIndices; - }; + }) + return termIndices + } // Test round-trip mapping const stepsWithProvenance = result.steps .map((step, index) => ({ step, index })) - .filter(({ step }) => step.provenance); + .filter(({ step }) => step.provenance) stepsWithProvenance.forEach(({ index: termIndex }) => { - const columnIndex = getColumnFromTermIndex(termIndex); + const columnIndex = getColumnFromTermIndex(termIndex) if (columnIndex !== null) { - const backToTermIndices = getTermIndicesFromColumn(columnIndex); - expect(backToTermIndices).toContain(termIndex); + const backToTermIndices = getTermIndicesFromColumn(columnIndex) + expect(backToTermIndices).toContain(termIndex) } - }); - }); + }) + }) - it("should handle edge cases gracefully", () => { - const result = generateUnifiedInstructionSequence(5, 7); // Simple case: 5 + 2 + it('should handle edge cases gracefully', () => { + const result = generateUnifiedInstructionSequence(5, 7) // Simple case: 5 + 2 // Even simple cases should work with the mapping const getColumnFromTermIndex = (termIndex: number) => { - const step = result.steps[termIndex]; - if (!step?.provenance) return null; - return 4 - step.provenance.rhsPlace; - }; + const step = result.steps[termIndex] + if (!step?.provenance) return null + return 4 - step.provenance.rhsPlace + } // Should not throw errors for invalid indices - expect(getColumnFromTermIndex(-1)).toBe(null); - expect(getColumnFromTermIndex(999)).toBe(null); + expect(getColumnFromTermIndex(-1)).toBe(null) + expect(getColumnFromTermIndex(999)).toBe(null) // Should handle steps without provenance - const stepWithoutProvenance = result.steps.find((step) => !step.provenance); + const stepWithoutProvenance = result.steps.find((step) => !step.provenance) if (stepWithoutProvenance) { - const index = result.steps.indexOf(stepWithoutProvenance); - expect(getColumnFromTermIndex(index)).toBe(null); + const index = result.steps.indexOf(stepWithoutProvenance) + expect(getColumnFromTermIndex(index)).toBe(null) } - }); + }) - it("should use correct styling for dynamic column highlights", () => { + it('should use correct styling for dynamic column highlights', () => { // Test the expected column-level styling values from the implementation const expectedDynamicColumnStyle = { columnPost: { - stroke: "#3b82f6", + stroke: '#3b82f6', strokeWidth: 4, opacity: 1, }, - }; + } // Verify blue color scheme for column highlighting - expect(expectedDynamicColumnStyle.columnPost.stroke).toBe("#3b82f6"); // Blue - expect(expectedDynamicColumnStyle.columnPost.strokeWidth).toBe(4); // Thicker than default - expect(expectedDynamicColumnStyle.columnPost.opacity).toBe(1); // Fully visible + expect(expectedDynamicColumnStyle.columnPost.stroke).toBe('#3b82f6') // Blue + expect(expectedDynamicColumnStyle.columnPost.strokeWidth).toBe(4) // Thicker than default + expect(expectedDynamicColumnStyle.columnPost.opacity).toBe(1) // Fully visible // Verify this is different from bead-level highlighting const staticBeadStyle = { - fill: "#fbbf24", - stroke: "#f59e0b", + fill: '#fbbf24', + stroke: '#f59e0b', strokeWidth: 3, - }; // Orange - expect(expectedDynamicColumnStyle.columnPost.stroke).not.toBe( - staticBeadStyle.stroke, - ); - }); -}); + } // Orange + expect(expectedDynamicColumnStyle.columnPost.stroke).not.toBe(staticBeadStyle.stroke) + }) +}) diff --git a/apps/web/src/components/tutorial/__tests__/test-setup.ts b/apps/web/src/components/tutorial/__tests__/test-setup.ts index ad3f45c3..0eee762d 100644 --- a/apps/web/src/components/tutorial/__tests__/test-setup.ts +++ b/apps/web/src/components/tutorial/__tests__/test-setup.ts @@ -1,22 +1,22 @@ -import "@testing-library/jest-dom"; -import { vi } from "vitest"; +import '@testing-library/jest-dom' +import { vi } from 'vitest' // Mock IntersectionObserver for components that use it global.IntersectionObserver = vi.fn(() => ({ disconnect: vi.fn(), observe: vi.fn(), unobserve: vi.fn(), -})) as any; +})) as any // Mock ResizeObserver for components that use it global.ResizeObserver = vi.fn(() => ({ disconnect: vi.fn(), observe: vi.fn(), unobserve: vi.fn(), -})) as any; +})) as any // Mock window.matchMedia for responsive components -Object.defineProperty(window, "matchMedia", { +Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, @@ -28,7 +28,7 @@ Object.defineProperty(window, "matchMedia", { removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), -}); +}) // Mock canvas context for any canvas-based components HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ @@ -56,79 +56,73 @@ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ transform: vi.fn(), rect: vi.fn(), clip: vi.fn(), -})) as any; +})) as any // Mock setTimeout and setInterval for more predictable testing -vi.useFakeTimers(); +vi.useFakeTimers() // Add custom matchers for tutorial-specific assertions expect.extend({ toHaveCorrectStartValue(received, expected) { - const pass = received === expected; + const pass = received === expected if (pass) { return { - message: () => - `Expected abacus not to have start value ${expected}, but it did`, + message: () => `Expected abacus not to have start value ${expected}, but it did`, pass: true, - }; + } } else { return { - message: () => - `Expected abacus to have start value ${expected}, but got ${received}`, + message: () => `Expected abacus to have start value ${expected}, but got ${received}`, pass: false, - }; + } } }, toAdvanceMultiStep(received) { - const pass = received > 0; + const pass = received > 0 if (pass) { return { - message: () => - `Expected multi-step not to advance, but it advanced to step ${received}`, + message: () => `Expected multi-step not to advance, but it advanced to step ${received}`, pass: true, - }; + } } else { return { - message: () => - `Expected multi-step to advance, but it remained at step ${received}`, + message: () => `Expected multi-step to advance, but it remained at step ${received}`, pass: false, - }; + } } }, -}); +}) // Mock the unified step generator for consistent test results -vi.mock("../../../utils/unifiedStepGenerator", () => ({ - generateUnifiedInstructionSequence: vi.fn( - (startValue: number, targetValue: number) => ({ - steps: [ - { - stepIndex: 0, - expectedValue: startValue + Math.ceil((targetValue - startValue) / 2), - englishInstruction: `Change value to ${startValue + Math.ceil((targetValue - startValue) / 2)}`, - mathematicalTerm: `${Math.ceil((targetValue - startValue) / 2)}`, - termPosition: { startIndex: 0, endIndex: 2 }, - }, - { - stepIndex: 1, - expectedValue: targetValue, - englishInstruction: `Change value to ${targetValue}`, - mathematicalTerm: `${targetValue}`, - termPosition: { startIndex: 4, endIndex: 6 }, - }, - ], - fullDecomposition: `${startValue} + ${targetValue - startValue} = ${targetValue}`, - }), - ), -})); +vi.mock('../../../utils/unifiedStepGenerator', () => ({ + generateUnifiedInstructionSequence: vi.fn((startValue: number, targetValue: number) => ({ + steps: [ + { + stepIndex: 0, + expectedValue: startValue + Math.ceil((targetValue - startValue) / 2), + englishInstruction: `Change value to ${startValue + Math.ceil((targetValue - startValue) / 2)}`, + mathematicalTerm: `${Math.ceil((targetValue - startValue) / 2)}`, + termPosition: { startIndex: 0, endIndex: 2 }, + }, + { + stepIndex: 1, + expectedValue: targetValue, + englishInstruction: `Change value to ${targetValue}`, + mathematicalTerm: `${targetValue}`, + termPosition: { startIndex: 4, endIndex: 6 }, + }, + ], + fullDecomposition: `${startValue} + ${targetValue - startValue} = ${targetValue}`, + })), +})) // Type declarations for custom matchers declare global { namespace Vi { interface JestAssertion { - toHaveCorrectStartValue(expected: number): T; - toAdvanceMultiStep(): T; + toHaveCorrectStartValue(expected: number): T + toAdvanceMultiStep(): T } } } diff --git a/apps/web/src/components/tutorial/decomposition-reasoning.css b/apps/web/src/components/tutorial/decomposition-reasoning.css index 86c6b368..d5d08906 100644 --- a/apps/web/src/components/tutorial/decomposition-reasoning.css +++ b/apps/web/src/components/tutorial/decomposition-reasoning.css @@ -72,8 +72,7 @@ max-width: 240px; z-index: 50; animation: fadeIn 0.2s ease-out; - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif; } .reason-tooltip__content { diff --git a/apps/web/src/components/tutorial/reason-tooltip.css b/apps/web/src/components/tutorial/reason-tooltip.css index dc85da88..f56024c0 100644 --- a/apps/web/src/components/tutorial/reason-tooltip.css +++ b/apps/web/src/components/tutorial/reason-tooltip.css @@ -50,10 +50,7 @@ padding: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); max-width: 320px; - font-family: - system-ui, - -apple-system, - sans-serif; + font-family: system-ui, -apple-system, sans-serif; } .reason-tooltip__content { diff --git a/apps/web/src/components/tutorial/shared/EditorComponents.tsx b/apps/web/src/components/tutorial/shared/EditorComponents.tsx index 4ee07940..6c07fd4a 100644 --- a/apps/web/src/components/tutorial/shared/EditorComponents.tsx +++ b/apps/web/src/components/tutorial/shared/EditorComponents.tsx @@ -1,43 +1,43 @@ -"use client"; +'use client' -import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; -import type { ReactNode } from "react"; -import { css } from "../../../../styled-system/css"; -import { hstack, vstack } from "../../../../styled-system/patterns"; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import type { ReactNode } from 'react' +import { css } from '../../../../styled-system/css' +import { hstack, vstack } from '../../../../styled-system/patterns' // Shared input styles export const inputStyles = { - w: "full", + w: 'full', px: 2, py: 1, - border: "1px solid", - borderColor: "gray.300", - rounded: "sm", - fontSize: "xs", -} as const; + border: '1px solid', + borderColor: 'gray.300', + rounded: 'sm', + fontSize: 'xs', +} as const export const labelStyles = { - fontSize: "xs", - color: "gray.600", + fontSize: 'xs', + color: 'gray.600', mb: 1, - display: "block", -} as const; + display: 'block', +} as const // Shared Editor Layout Component - wraps entire editor with consistent styling interface EditorLayoutProps { - title: string; - onClose: () => void; - onDelete?: () => void; - deleteLabel?: string; - children: ReactNode; - className?: string; + title: string + onClose: () => void + onDelete?: () => void + deleteLabel?: string + children: ReactNode + className?: string } export function EditorLayout({ title, onClose, onDelete, - deleteLabel = "Delete", + deleteLabel = 'Delete', children, className, }: EditorLayoutProps) { @@ -46,17 +46,17 @@ export function EditorLayout({ className={css( { p: 3, - bg: "purple.50", - border: "1px solid", - borderColor: "purple.200", - rounded: "lg", - height: "100%", - overflowY: "auto", + bg: 'purple.50', + border: '1px solid', + borderColor: 'purple.200', + rounded: 'lg', + height: '100%', + overflowY: 'auto', }, - className, + className )} > -
+
{/* Header */}
- ); + ) } // Shared Editor Header Component interface EditorHeaderProps { - title: string; - onClose: () => void; - onDelete?: () => void; - deleteLabel?: string; + title: string + onClose: () => void + onDelete?: () => void + deleteLabel?: string } export function EditorHeader({ title, onClose, onDelete, - deleteLabel = "Delete", + deleteLabel = 'Delete', }: EditorHeaderProps) { return (

{title} @@ -109,12 +109,12 @@ export function EditorHeader({ className={css({ px: 2, py: 1, - bg: "red.500", - color: "white", - rounded: "sm", - fontSize: "xs", - cursor: "pointer", - _hover: { bg: "red.600" }, + bg: 'red.500', + color: 'white', + rounded: 'sm', + fontSize: 'xs', + cursor: 'pointer', + _hover: { bg: 'red.600' }, })} > {deleteLabel} @@ -124,22 +124,22 @@ export function EditorHeader({ onClick={onClose} className={css({ p: 1, - borderRadius: "sm", - cursor: "pointer", - _hover: { bg: "gray.100" }, + borderRadius: 'sm', + cursor: 'pointer', + _hover: { bg: 'gray.100' }, })} > ✕

- ); + ) } // Shared Field Component interface FieldProps { - label: string; - children: ReactNode; + label: string + children: ReactNode } export function Field({ label, children }: FieldProps) { @@ -148,17 +148,17 @@ export function Field({ label, children }: FieldProps) { {children}
- ); + ) } // Shared Text Input Component interface TextInputProps { - label: string; - value: string; - onChange: (value: string) => void; - placeholder?: string; - multiline?: boolean; - rows?: number; + label: string + value: string + onChange: (value: string) => void + placeholder?: string + multiline?: boolean + rows?: number } export function TextInput({ @@ -178,7 +178,7 @@ export function TextInput({ rows={rows} className={css({ ...inputStyles, - resize: "none", + resize: 'none', })} placeholder={placeholder} /> @@ -192,27 +192,20 @@ export function TextInput({ /> )} - ); + ) } // Shared Number Input Component interface NumberInputProps { - label: string; - value: number | string; - onChange: (value: number) => void; - min?: number; - max?: number; - placeholder?: string; + label: string + value: number | string + onChange: (value: number) => void + min?: number + max?: number + placeholder?: string } -export function NumberInput({ - label, - value, - onChange, - min, - max, - placeholder, -}: NumberInputProps) { +export function NumberInput({ label, value, onChange, min, max, placeholder }: NumberInputProps) { return ( - ); + ) } // Shared Grid Layout Component interface GridLayoutProps { - columns: 1 | 2 | 3 | 4; - gap?: number; - children: ReactNode; + columns: 1 | 2 | 3 | 4 + gap?: number + children: ReactNode } export function GridLayout({ columns, gap = 2, children }: GridLayoutProps) { const gridCols = { - 1: "1fr", - 2: "1fr 1fr", - 3: "1fr 1fr 1fr", - 4: "1fr 1fr 1fr 1fr", - }; + 1: '1fr', + 2: '1fr 1fr', + 3: '1fr 1fr 1fr', + 4: '1fr 1fr 1fr 1fr', + } return (
{children}
- ); + ) } // Shared Section Component interface SectionProps { - title?: string; - children: ReactNode; - collapsible?: boolean; - defaultOpen?: boolean; - background?: "white" | "gray" | "none"; + title?: string + children: ReactNode + collapsible?: boolean + defaultOpen?: boolean + background?: 'white' | 'gray' | 'none' } -export function Section({ - title, - children, - background = "white", -}: SectionProps) { +export function Section({ title, children, background = 'white' }: SectionProps) { const bgStyles = { white: { p: 2, - bg: "white", - border: "1px solid", - borderColor: "gray.200", - rounded: "md", + bg: 'white', + border: '1px solid', + borderColor: 'gray.200', + rounded: 'md', }, gray: { p: 2, - bg: "gray.50", - border: "1px solid", - borderColor: "gray.200", - rounded: "sm", + bg: 'gray.50', + border: '1px solid', + borderColor: 'gray.200', + rounded: 'sm', }, none: {}, - }; + } return (
{title && (

{title} @@ -304,53 +293,49 @@ export function Section({ )} {children}

- ); + ) } // Shared Form Group Component - handles common form layouts interface FormGroupProps { - children: ReactNode; - columns?: 1 | 2 | 3; - gap?: number; + children: ReactNode + columns?: 1 | 2 | 3 + gap?: number } export function FormGroup({ children, columns = 1, gap = 2 }: FormGroupProps) { if (columns === 1) { - return ( -
- {children} -
- ); + return
{children}
} return ( {children} - ); + ) } // Shared Compact Step Item Component interface CompactStepItemProps { - type: "concept" | "practice"; - index: number; - title: string; - subtitle?: string; - description?: string; - isSelected?: boolean; - hasErrors?: boolean; - hasWarnings?: boolean; - errorCount?: number; - warningCount?: number; - onClick?: () => void; - onPreview?: () => void; - onDelete?: () => void; - children?: ReactNode; + type: 'concept' | 'practice' + index: number + title: string + subtitle?: string + description?: string + isSelected?: boolean + hasErrors?: boolean + hasWarnings?: boolean + errorCount?: number + warningCount?: number + onClick?: () => void + onPreview?: () => void + onDelete?: () => void + children?: ReactNode // Hover-add functionality - onAddStepBefore?: () => void; - onAddPracticeStepBefore?: () => void; - onAddStepAfter?: () => void; - onAddPracticeStepAfter?: () => void; + onAddStepBefore?: () => void + onAddPracticeStepBefore?: () => void + onAddStepAfter?: () => void + onAddPracticeStepAfter?: () => void } export function CompactStepItem({ @@ -374,51 +359,48 @@ export function CompactStepItem({ onAddPracticeStepAfter, }: CompactStepItemProps) { const getBorderColor = () => { - if (isSelected) return "blue.500"; - if (hasErrors) return "red.300"; - return "gray.200"; - }; + if (isSelected) return 'blue.500' + if (hasErrors) return 'red.300' + return 'gray.200' + } const getBgColor = () => { - if (isSelected) return "blue.50"; - if (hasWarnings) return "yellow.50"; - if (hasErrors) return "red.50"; - return "white"; - }; + if (isSelected) return 'blue.50' + if (hasWarnings) return 'yellow.50' + if (hasErrors) return 'red.50' + return 'white' + } const getHoverBg = () => { - return isSelected ? "blue.100" : "gray.50"; - }; + return isSelected ? 'blue.100' : 'gray.50' + } - const typeIcon = type === "concept" ? "📝" : "🎯"; - const typeLabel = type === "concept" ? "Step" : "Practice"; + const typeIcon = type === 'concept' ? '📝' : '🎯' + const typeLabel = type === 'concept' ? 'Step' : 'Practice' const _hasAddActions = - onAddStepBefore || - onAddPracticeStepBefore || - onAddStepAfter || - onAddPracticeStepAfter; + onAddStepBefore || onAddPracticeStepBefore || onAddStepAfter || onAddPracticeStepAfter return ( -
+
{/* Main step item */}
@@ -427,19 +409,19 @@ export function CompactStepItem({
@@ -449,11 +431,11 @@ export function CompactStepItem({ {/* Title inline */}
@@ -481,12 +463,12 @@ export function CompactStepItem({ {hasWarnings && ( @@ -499,11 +481,11 @@ export function CompactStepItem({ {subtitle && (
@@ -520,19 +502,19 @@ export function CompactStepItem({ {onPreview && (
- ); + ) } // Between-step hover area with dropdown interface BetweenStepAddProps { - onAddStep: () => void; - onAddPracticeStep: () => void; + onAddStep: () => void + onAddPracticeStep: () => void } -export function BetweenStepAdd({ - onAddStep, - onAddPracticeStep, -}: BetweenStepAddProps) { +export function BetweenStepAdd({ onAddStep, onAddPracticeStep }: BetweenStepAddProps) { return (
@@ -625,13 +604,13 @@ export function BetweenStepAdd({ @@ -639,17 +618,17 @@ export function BetweenStepAdd({
- ); + ) } // Shared Button Component interface ButtonProps { - children: ReactNode; - onClick: () => void; - variant?: "primary" | "secondary" | "outline"; - size?: "xs" | "sm" | "md"; - disabled?: boolean; - title?: string; + children: ReactNode + onClick: () => void + variant?: 'primary' | 'secondary' | 'outline' + size?: 'xs' | 'sm' | 'md' + disabled?: boolean + title?: string } export function Button({ children, onClick, - variant = "secondary", - size = "sm", + variant = 'secondary', + size = 'sm', disabled = false, title, }: ButtonProps) { const variantStyles = { - primary: { bg: "blue.500", color: "white", _hover: { bg: "blue.600" } }, + primary: { bg: 'blue.500', color: 'white', _hover: { bg: 'blue.600' } }, secondary: { - bg: "blue.100", - color: "blue.800", - border: "1px solid", - borderColor: "blue.300", - _hover: { bg: "blue.200" }, + bg: 'blue.100', + color: 'blue.800', + border: '1px solid', + borderColor: 'blue.300', + _hover: { bg: 'blue.200' }, }, outline: { - bg: "gray.100", - color: "gray.700", - border: "1px solid", - borderColor: "gray.300", - _hover: { bg: "gray.200" }, + bg: 'gray.100', + color: 'gray.700', + border: '1px solid', + borderColor: 'gray.300', + _hover: { bg: 'gray.200' }, }, - }; + } const sizeStyles = { - xs: { px: 1, py: 1, fontSize: "xs" }, - sm: { px: 2, py: 1, fontSize: "xs" }, - md: { px: 3, py: 2, fontSize: "sm" }, - }; + xs: { px: 1, py: 1, fontSize: 'xs' }, + sm: { px: 2, py: 1, fontSize: 'xs' }, + md: { px: 3, py: 2, fontSize: 'sm' }, + } return ( - ); + ) } diff --git a/apps/web/src/components/tutorial/test/TutorialPlayerHighlighting.test.tsx b/apps/web/src/components/tutorial/test/TutorialPlayerHighlighting.test.tsx index d37857b7..2a1cf335 100644 --- a/apps/web/src/components/tutorial/test/TutorialPlayerHighlighting.test.tsx +++ b/apps/web/src/components/tutorial/test/TutorialPlayerHighlighting.test.tsx @@ -1,145 +1,145 @@ -import { render } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import type { Tutorial } from "../../../types/tutorial"; -import { TutorialPlayer } from "../TutorialPlayer"; +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { Tutorial } from '../../../types/tutorial' +import { TutorialPlayer } from '../TutorialPlayer' // Mock AbacusReact to capture the customStyles prop -vi.mock("@soroban/abacus-react", () => ({ +vi.mock('@soroban/abacus-react', () => ({ AbacusReact: vi.fn(({ customStyles }) => { // Store the customStyles for testing - (global as any).lastCustomStyles = customStyles; - return
; + ;(global as any).lastCustomStyles = customStyles + return
}), -})); +})) -describe("TutorialPlayer Highlighting", () => { +describe('TutorialPlayer Highlighting', () => { const mockTutorial: Tutorial = { - id: "test-tutorial", - title: "Test Tutorial", - description: "Test tutorial for highlighting", - category: "Test", - difficulty: "beginner", + id: 'test-tutorial', + title: 'Test Tutorial', + description: 'Test tutorial for highlighting', + category: 'Test', + difficulty: 'beginner', estimatedDuration: 5, steps: [ { - id: "step-1", - title: "Test Step 1", - problem: "0 + 1", - description: "Add 1 to the abacus", + id: 'step-1', + title: 'Test Step 1', + problem: '0 + 1', + description: 'Add 1 to the abacus', startValue: 0, targetValue: 1, - highlightBeads: [{ placeValue: 0, beadType: "earth", position: 0 }], - expectedAction: "add", - actionDescription: "Click the first earth bead", + highlightBeads: [{ placeValue: 0, beadType: 'earth', position: 0 }], + expectedAction: 'add', + actionDescription: 'Click the first earth bead', tooltip: { - content: "Adding earth beads", - explanation: "Earth beads are worth 1 each", + content: 'Adding earth beads', + explanation: 'Earth beads are worth 1 each', }, errorMessages: { - wrongBead: "Click the highlighted earth bead", - wrongAction: "Move the bead UP", - hint: "Earth beads move up when adding", + wrongBead: 'Click the highlighted earth bead', + wrongAction: 'Move the bead UP', + hint: 'Earth beads move up when adding', }, }, { - id: "step-2", - title: "Multi-bead Step", - problem: "3 + 4", - description: "Use complement: 4 = 5 - 1", + id: 'step-2', + title: 'Multi-bead Step', + problem: '3 + 4', + description: 'Use complement: 4 = 5 - 1', startValue: 3, targetValue: 7, highlightBeads: [ - { placeValue: 0, beadType: "heaven" }, - { placeValue: 0, beadType: "earth", position: 0 }, + { placeValue: 0, beadType: 'heaven' }, + { placeValue: 0, beadType: 'earth', position: 0 }, ], - expectedAction: "multi-step", - actionDescription: "Add heaven bead, then remove earth bead", + expectedAction: 'multi-step', + actionDescription: 'Add heaven bead, then remove earth bead', tooltip: { - content: "Five Complement", - explanation: "4 = 5 - 1", + content: 'Five Complement', + explanation: '4 = 5 - 1', }, errorMessages: { - wrongBead: "Follow the two-step process", - wrongAction: "Add heaven, then remove earth", - hint: "Complement thinking: 4 = 5 - 1", + wrongBead: 'Follow the two-step process', + wrongAction: 'Add heaven, then remove earth', + hint: 'Complement thinking: 4 = 5 - 1', }, }, ], - tags: ["test"], - author: "Test Author", - version: "1.0.0", + tags: ['test'], + author: 'Test Author', + version: '1.0.0', createdAt: new Date(), updatedAt: new Date(), isPublished: true, - }; + } - it("should highlight single bead in correct column (ones place)", () => { - render(); + it('should highlight single bead in correct column (ones place)', () => { + render() - const customStyles = (global as any).lastCustomStyles; + const customStyles = (global as any).lastCustomStyles // placeValue: 0 (ones place) should map to columnIndex: 4 in customStyles - expect(customStyles).toBeDefined(); - expect(customStyles.beads).toBeDefined(); - expect(customStyles.beads[4]).toBeDefined(); // columnIndex 4 = rightmost column = ones place - expect(customStyles.beads[4].earth).toBeDefined(); + expect(customStyles).toBeDefined() + expect(customStyles.beads).toBeDefined() + expect(customStyles.beads[4]).toBeDefined() // columnIndex 4 = rightmost column = ones place + expect(customStyles.beads[4].earth).toBeDefined() expect(customStyles.beads[4].earth[0]).toEqual({ - fill: "#fbbf24", - stroke: "#f59e0b", + fill: '#fbbf24', + stroke: '#f59e0b', strokeWidth: 3, - }); - }); + }) + }) - it("should highlight multiple beads in same column for complement operations", () => { + it('should highlight multiple beads in same column for complement operations', () => { // Advance to step 2 (multi-bead highlighting) - render(); + render() - const customStyles = (global as any).lastCustomStyles; + const customStyles = (global as any).lastCustomStyles // Both heaven and earth beads should be highlighted in ones place (columnIndex 4) - expect(customStyles).toBeDefined(); - expect(customStyles.beads).toBeDefined(); - expect(customStyles.beads[4]).toBeDefined(); // columnIndex 4 = rightmost column = ones place + expect(customStyles).toBeDefined() + expect(customStyles.beads).toBeDefined() + expect(customStyles.beads[4]).toBeDefined() // columnIndex 4 = rightmost column = ones place // Heaven bead should be highlighted expect(customStyles.beads[4].heaven).toEqual({ - fill: "#fbbf24", - stroke: "#f59e0b", + fill: '#fbbf24', + stroke: '#f59e0b', strokeWidth: 3, - }); + }) // Earth bead position 0 should be highlighted - expect(customStyles.beads[4].earth).toBeDefined(); + expect(customStyles.beads[4].earth).toBeDefined() expect(customStyles.beads[4].earth[0]).toEqual({ - fill: "#fbbf24", - stroke: "#f59e0b", + fill: '#fbbf24', + stroke: '#f59e0b', strokeWidth: 3, - }); - }); + }) + }) - it("should not highlight leftmost column when highlighting ones place", () => { - render(); + it('should not highlight leftmost column when highlighting ones place', () => { + render() - const customStyles = (global as any).lastCustomStyles; + const customStyles = (global as any).lastCustomStyles // columnIndex 0 (leftmost) should NOT be highlighted for ones place operations - expect(customStyles.beads[0]).toBeUndefined(); - expect(customStyles.beads[1]).toBeUndefined(); - expect(customStyles.beads[2]).toBeUndefined(); - expect(customStyles.beads[3]).toBeUndefined(); + expect(customStyles.beads[0]).toBeUndefined() + expect(customStyles.beads[1]).toBeUndefined() + expect(customStyles.beads[2]).toBeUndefined() + expect(customStyles.beads[3]).toBeUndefined() // Only columnIndex 4 (rightmost = ones place) should be highlighted - expect(customStyles.beads[4]).toBeDefined(); - }); + expect(customStyles.beads[4]).toBeDefined() + }) - it("should convert placeValue to columnIndex correctly", () => { + it('should convert placeValue to columnIndex correctly', () => { const testCases = [ { placeValue: 0, expectedColumnIndex: 4 }, // ones place { placeValue: 1, expectedColumnIndex: 3 }, // tens place { placeValue: 2, expectedColumnIndex: 2 }, // hundreds place { placeValue: 3, expectedColumnIndex: 1 }, // thousands place { placeValue: 4, expectedColumnIndex: 0 }, // ten-thousands place - ]; + ] testCases.forEach(({ placeValue, expectedColumnIndex }) => { const testTutorial = { @@ -147,20 +147,18 @@ describe("TutorialPlayer Highlighting", () => { steps: [ { ...mockTutorial.steps[0], - highlightBeads: [ - { placeValue, beadType: "earth" as const, position: 0 }, - ], + highlightBeads: [{ placeValue, beadType: 'earth' as const, position: 0 }], }, ], - }; + } - render(); + render() - const customStyles = (global as any).lastCustomStyles; - expect(customStyles.beads[expectedColumnIndex]).toBeDefined(); + const customStyles = (global as any).lastCustomStyles + expect(customStyles.beads[expectedColumnIndex]).toBeDefined() // Cleanup for next iteration - delete (global as any).lastCustomStyles; - }); - }); -}); + delete (global as any).lastCustomStyles + }) + }) +}) diff --git a/apps/web/src/constants/playerEmojis.ts b/apps/web/src/constants/playerEmojis.ts index b4863cde..6946f891 100644 --- a/apps/web/src/constants/playerEmojis.ts +++ b/apps/web/src/constants/playerEmojis.ts @@ -1,154 +1,154 @@ // Available character emojis for players export const PLAYER_EMOJIS = [ // Abacus - "🧮", + '🧮', // People & Characters - "😀", - "😃", - "😄", - "😁", - "😆", - "😅", - "🤣", - "😂", - "🙂", - "😉", - "😊", - "😇", - "🥰", - "😍", - "🤩", - "😘", - "😗", - "😚", - "😋", - "😛", - "😝", - "😜", - "🤪", - "🤨", - "🧐", - "🤓", - "😎", - "🥸", - "🥳", - "😏", - "😒", - "😞", - "😔", - "😟", - "😕", + '😀', + '😃', + '😄', + '😁', + '😆', + '😅', + '🤣', + '😂', + '🙂', + '😉', + '😊', + '😇', + '🥰', + '😍', + '🤩', + '😘', + '😗', + '😚', + '😋', + '😛', + '😝', + '😜', + '🤪', + '🤨', + '🧐', + '🤓', + '😎', + '🥸', + '🥳', + '😏', + '😒', + '😞', + '😔', + '😟', + '😕', // Fantasy & Fun - "🤠", - "🥷", - "👑", - "🎭", - "🤖", - "👻", - "💀", - "👽", - "🤡", - "🧙‍♂️", - "🧙‍♀️", - "🧚‍♂️", - "🧚‍♀️", - "🧛‍♂️", - "🧛‍♀️", - "🧜‍♂️", - "🧜‍♀️", - "🧝‍♂️", - "🧝‍♀️", - "🦸‍♂️", - "🦸‍♀️", - "🦹‍♂️", + '🤠', + '🥷', + '👑', + '🎭', + '🤖', + '👻', + '💀', + '👽', + '🤡', + '🧙‍♂️', + '🧙‍♀️', + '🧚‍♂️', + '🧚‍♀️', + '🧛‍♂️', + '🧛‍♀️', + '🧜‍♂️', + '🧜‍♀️', + '🧝‍♂️', + '🧝‍♀️', + '🦸‍♂️', + '🦸‍♀️', + '🦹‍♂️', // Animals - "🐶", - "🐱", - "🐭", - "🐹", - "🐰", - "🦊", - "🐻", - "🐼", - "🐻‍❄️", - "🐨", - "🐯", - "🦁", - "🐮", - "🐷", - "🐸", - "🐵", - "🙈", - "🙉", - "🙊", - "🐒", - "🦆", - "🐧", - "🐦", - "🐤", - "🐣", - "🐥", - "🦅", - "🦉", - "🦇", - "🐺", - "🐗", - "🐴", - "🦄", - "🐝", - "🐛", - "🦋", + '🐶', + '🐱', + '🐭', + '🐹', + '🐰', + '🦊', + '🐻', + '🐼', + '🐻‍❄️', + '🐨', + '🐯', + '🦁', + '🐮', + '🐷', + '🐸', + '🐵', + '🙈', + '🙉', + '🙊', + '🐒', + '🦆', + '🐧', + '🐦', + '🐤', + '🐣', + '🐥', + '🦅', + '🦉', + '🦇', + '🐺', + '🐗', + '🐴', + '🦄', + '🐝', + '🐛', + '🦋', // Objects & Symbols - "⭐", - "🌟", - "💫", - "✨", - "⚡", - "🔥", - "🌈", - "🎪", - "🎨", - "🎯", - "🎲", - "🎮", - "🕹️", - "🎸", - "🎺", - "🎷", - "🥁", - "🎻", - "🎤", - "🎧", - "🎬", - "🎥", + '⭐', + '🌟', + '💫', + '✨', + '⚡', + '🔥', + '🌈', + '🎪', + '🎨', + '🎯', + '🎲', + '🎮', + '🕹️', + '🎸', + '🎺', + '🎷', + '🥁', + '🎻', + '🎤', + '🎧', + '🎬', + '🎥', // Food & Drinks - "🍎", - "🍊", - "🍌", - "🍇", - "🍓", - "🥝", - "🍑", - "🥭", - "🍍", - "🥥", - "🥑", - "🍆", - "🥕", - "🌽", - "🌶️", - "🫑", - "🥒", - "🥬", - "🥦", - "🧄", - "🧅", - "🍄", - "🥜", - "🌰", -]; + '🍎', + '🍊', + '🍌', + '🍇', + '🍓', + '🥝', + '🍑', + '🥭', + '🍍', + '🥥', + '🥑', + '🍆', + '🥕', + '🌽', + '🌶️', + '🫑', + '🥒', + '🥬', + '🥦', + '🧄', + '🧅', + '🍄', + '🥜', + '🌰', +] diff --git a/apps/web/src/constants/zIndex.ts b/apps/web/src/constants/zIndex.ts index 2212e991..4d4df944 100644 --- a/apps/web/src/constants/zIndex.ts +++ b/apps/web/src/constants/zIndex.ts @@ -43,20 +43,20 @@ export const Z_INDEX = { OVERLAY: 1000, PLAYER_AVATAR: 1000, // Multiplayer presence indicators }, -} as const; +} as const // Helper function to get z-index value export function getZIndex(path: string): number { - const parts = path.split("."); - let value: any = Z_INDEX; + const parts = path.split('.') + let value: any = Z_INDEX for (const part of parts) { - value = value[part]; + value = value[part] if (value === undefined) { - console.warn(`[zIndex] Unknown path: ${path}`); - return 0; + console.warn(`[zIndex] Unknown path: ${path}`) + return 0 } } - return typeof value === "number" ? value : 0; + return typeof value === 'number' ? value : 0 } diff --git a/apps/web/src/contexts/FullscreenContext.tsx b/apps/web/src/contexts/FullscreenContext.tsx index 20178a18..abb9a93d 100644 --- a/apps/web/src/contexts/FullscreenContext.tsx +++ b/apps/web/src/contexts/FullscreenContext.tsx @@ -1,6 +1,6 @@ -"use client"; +'use client' -import type React from "react"; +import type React from 'react' import { createContext, type ReactNode, @@ -9,112 +9,98 @@ import { useEffect, useRef, useState, -} from "react"; +} from 'react' interface FullscreenContextType { - isFullscreen: boolean; - enterFullscreen: () => Promise; - exitFullscreen: () => Promise; - toggleFullscreen: () => Promise; - setFullscreenElement: (element: HTMLElement | null) => void; - fullscreenElementRef: React.MutableRefObject; + isFullscreen: boolean + enterFullscreen: () => Promise + exitFullscreen: () => Promise + toggleFullscreen: () => Promise + setFullscreenElement: (element: HTMLElement | null) => void + fullscreenElementRef: React.MutableRefObject } -const FullscreenContext = createContext(null); +const FullscreenContext = createContext(null) export function FullscreenProvider({ children }: { children: ReactNode }) { - const [isFullscreen, setIsFullscreen] = useState(false); - const fullscreenElementRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false) + const fullscreenElementRef = useRef(null) useEffect(() => { const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; + setIsFullscreen(!!document.fullscreenElement) + } - document.addEventListener("fullscreenchange", handleFullscreenChange); - document.addEventListener("webkitfullscreenchange", handleFullscreenChange); - document.addEventListener("mozfullscreenchange", handleFullscreenChange); - document.addEventListener("MSFullscreenChange", handleFullscreenChange); + document.addEventListener('fullscreenchange', handleFullscreenChange) + document.addEventListener('webkitfullscreenchange', handleFullscreenChange) + document.addEventListener('mozfullscreenchange', handleFullscreenChange) + document.addEventListener('MSFullscreenChange', handleFullscreenChange) return () => { - document.removeEventListener("fullscreenchange", handleFullscreenChange); - document.removeEventListener( - "webkitfullscreenchange", - handleFullscreenChange, - ); - document.removeEventListener( - "mozfullscreenchange", - handleFullscreenChange, - ); - document.removeEventListener( - "MSFullscreenChange", - handleFullscreenChange, - ); - }; - }, []); + document.removeEventListener('fullscreenchange', handleFullscreenChange) + document.removeEventListener('webkitfullscreenchange', handleFullscreenChange) + document.removeEventListener('mozfullscreenchange', handleFullscreenChange) + document.removeEventListener('MSFullscreenChange', handleFullscreenChange) + } + }, []) const setFullscreenElement = useCallback((element: HTMLElement | null) => { - console.log("🔧 FullscreenContext: Setting fullscreen element:", element); - fullscreenElementRef.current = element; - }, []); + console.log('🔧 FullscreenContext: Setting fullscreen element:', element) + fullscreenElementRef.current = element + }, []) const enterFullscreen = async () => { try { // Use the registered fullscreen element, fallback to document.documentElement - const element = fullscreenElementRef.current || document.documentElement; + const element = fullscreenElementRef.current || document.documentElement + console.log('🚀 FullscreenContext: Entering fullscreen with element:', element) console.log( - "🚀 FullscreenContext: Entering fullscreen with element:", - element, - ); - console.log( - "🚀 FullscreenContext: Current fullscreen element ref:", - fullscreenElementRef.current, - ); + '🚀 FullscreenContext: Current fullscreen element ref:', + fullscreenElementRef.current + ) if (element.requestFullscreen) { - await element.requestFullscreen(); - console.log("✅ FullscreenContext: requestFullscreen() succeeded"); + await element.requestFullscreen() + console.log('✅ FullscreenContext: requestFullscreen() succeeded') } else if ((element as any).webkitRequestFullscreen) { - await (element as any).webkitRequestFullscreen(); - console.log( - "✅ FullscreenContext: webkitRequestFullscreen() succeeded", - ); + await (element as any).webkitRequestFullscreen() + console.log('✅ FullscreenContext: webkitRequestFullscreen() succeeded') } else if ((element as any).mozRequestFullScreen) { - await (element as any).mozRequestFullScreen(); - console.log("✅ FullscreenContext: mozRequestFullScreen() succeeded"); + await (element as any).mozRequestFullScreen() + console.log('✅ FullscreenContext: mozRequestFullScreen() succeeded') } else if ((element as any).msRequestFullscreen) { - await (element as any).msRequestFullscreen(); - console.log("✅ FullscreenContext: msRequestFullscreen() succeeded"); + await (element as any).msRequestFullscreen() + console.log('✅ FullscreenContext: msRequestFullscreen() succeeded') } } catch (error) { - console.error("❌ FullscreenContext: Failed to enter fullscreen:", error); - throw error; + console.error('❌ FullscreenContext: Failed to enter fullscreen:', error) + throw error } - }; + } const exitFullscreen = async () => { try { if (document.exitFullscreen) { - await document.exitFullscreen(); + await document.exitFullscreen() } else if ((document as any).webkitExitFullscreen) { - await (document as any).webkitExitFullscreen(); + await (document as any).webkitExitFullscreen() } else if ((document as any).mozCancelFullScreen) { - await (document as any).mozCancelFullScreen(); + await (document as any).mozCancelFullScreen() } else if ((document as any).msExitFullscreen) { - await (document as any).msExitFullscreen(); + await (document as any).msExitFullscreen() } } catch (error) { - console.error("Failed to exit fullscreen:", error); + console.error('Failed to exit fullscreen:', error) } - }; + } const toggleFullscreen = async () => { if (isFullscreen) { - await exitFullscreen(); + await exitFullscreen() } else { - await enterFullscreen(); + await enterFullscreen() } - }; + } return ( {children} - ); + ) } export function useFullscreen(): FullscreenContextType { - const context = useContext(FullscreenContext); + const context = useContext(FullscreenContext) if (!context) { - throw new Error("useFullscreen must be used within a FullscreenProvider"); + throw new Error('useFullscreen must be used within a FullscreenProvider') } - return context; + return context } diff --git a/apps/web/src/contexts/GameModeContext.tsx b/apps/web/src/contexts/GameModeContext.tsx index 76e5d0b6..71fa497a 100644 --- a/apps/web/src/contexts/GameModeContext.tsx +++ b/apps/web/src/contexts/GameModeContext.tsx @@ -1,64 +1,57 @@ -"use client"; +'use client' -import { - createContext, - type ReactNode, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import type { Player as DBPlayer } from "@/db/schema/players"; -import { useRoomData } from "@/hooks/useRoomData"; +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react' +import type { Player as DBPlayer } from '@/db/schema/players' +import { useRoomData } from '@/hooks/useRoomData' import { useCreatePlayer, useDeletePlayer, useUpdatePlayer, useUserPlayers, -} from "@/hooks/useUserPlayers"; -import { useViewerId } from "@/hooks/useViewerId"; -import { getNextPlayerColor } from "../types/player"; -import { generateUniquePlayerName } from "../utils/playerNames"; +} from '@/hooks/useUserPlayers' +import { useViewerId } from '@/hooks/useViewerId' +import { getNextPlayerColor } from '../types/player' +import { generateUniquePlayerName } from '../utils/playerNames' // Client-side Player type (compatible with old type) export interface Player { - id: string; - name: string; - emoji: string; - color: string; - createdAt: Date | number; - isActive?: boolean; - isLocal?: boolean; + id: string + name: string + emoji: string + color: string + createdAt: Date | number + isActive?: boolean + isLocal?: boolean } -export type GameMode = "single" | "battle" | "tournament"; +export type GameMode = 'single' | 'battle' | 'tournament' export interface GameModeContextType { - gameMode: GameMode; // Computed from activePlayerCount - players: Map; - activePlayers: Set; - activePlayerCount: number; - addPlayer: (player?: Partial) => void; - updatePlayer: (id: string, updates: Partial) => void; - removePlayer: (id: string) => void; - setActive: (id: string, active: boolean) => void; - getActivePlayers: () => Player[]; - getPlayer: (id: string) => Player | undefined; - getAllPlayers: () => Player[]; - resetPlayers: () => void; - isLoading: boolean; + gameMode: GameMode // Computed from activePlayerCount + players: Map + activePlayers: Set + activePlayerCount: number + addPlayer: (player?: Partial) => void + updatePlayer: (id: string, updates: Partial) => void + removePlayer: (id: string) => void + setActive: (id: string, active: boolean) => void + getActivePlayers: () => Player[] + getPlayer: (id: string) => Player | undefined + getAllPlayers: () => Player[] + resetPlayers: () => void + isLoading: boolean } -const GameModeContext = createContext(null); +const GameModeContext = createContext(null) // Default players to create if none exist // Names are generated randomly on first initialization const DEFAULT_PLAYER_CONFIGS = [ - { emoji: "😀", color: "#3b82f6" }, - { emoji: "😎", color: "#8b5cf6" }, - { emoji: "🤠", color: "#10b981" }, - { emoji: "🚀", color: "#f59e0b" }, -]; + { emoji: '😀', color: '#3b82f6' }, + { emoji: '😎', color: '#8b5cf6' }, + { emoji: '🤠', color: '#10b981' }, + { emoji: '🚀', color: '#f59e0b' }, +] // Convert DB player to client Player type function toClientPlayer(dbPlayer: DBPlayer): Player { @@ -69,94 +62,92 @@ function toClientPlayer(dbPlayer: DBPlayer): Player { color: dbPlayer.color, createdAt: dbPlayer.createdAt, isActive: dbPlayer.isActive, - }; + } } export function GameModeProvider({ children }: { children: ReactNode }) { - const { data: dbPlayers = [], isLoading } = useUserPlayers(); - const { mutate: createPlayer } = useCreatePlayer(); - const { mutate: updatePlayerMutation } = useUpdatePlayer(); - const { mutate: deletePlayer } = useDeletePlayer(); - const { roomData, notifyRoomOfPlayerUpdate } = useRoomData(); - const { data: viewerId } = useViewerId(); + const { data: dbPlayers = [], isLoading } = useUserPlayers() + const { mutate: createPlayer } = useCreatePlayer() + const { mutate: updatePlayerMutation } = useUpdatePlayer() + const { mutate: deletePlayer } = useDeletePlayer() + const { roomData, notifyRoomOfPlayerUpdate } = useRoomData() + const { data: viewerId } = useViewerId() - const [isInitialized, setIsInitialized] = useState(false); + const [isInitialized, setIsInitialized] = useState(false) // Convert DB players to Map (local players) const localPlayers = useMemo(() => { - const map = new Map(); + const map = new Map() dbPlayers.forEach((dbPlayer) => { map.set(dbPlayer.id, { ...toClientPlayer(dbPlayer), isLocal: true, - }); - }); - return map; - }, [dbPlayers]); + }) + }) + return map + }, [dbPlayers]) // When in a room, merge all players from all room members const players = useMemo(() => { - const map = new Map(localPlayers); + const map = new Map(localPlayers) if (roomData?.memberPlayers) { // Add players from other room members (marked as remote) - Object.entries(roomData.memberPlayers).forEach( - ([userId, memberPlayers]) => { - // Skip the current user's players (already in localPlayers) - if (userId === viewerId) return; + Object.entries(roomData.memberPlayers).forEach(([userId, memberPlayers]) => { + // Skip the current user's players (already in localPlayers) + if (userId === viewerId) return - memberPlayers.forEach((roomPlayer) => { - map.set(roomPlayer.id, { - id: roomPlayer.id, - name: roomPlayer.name, - emoji: roomPlayer.emoji, - color: roomPlayer.color, - createdAt: Date.now(), - isActive: true, // Players in memberPlayers are active - isLocal: false, // Remote player - }); - }); - }, - ); + memberPlayers.forEach((roomPlayer) => { + map.set(roomPlayer.id, { + id: roomPlayer.id, + name: roomPlayer.name, + emoji: roomPlayer.emoji, + color: roomPlayer.color, + createdAt: Date.now(), + isActive: true, // Players in memberPlayers are active + isLocal: false, // Remote player + }) + }) + }) } - return map; - }, [localPlayers, roomData, viewerId]); + return map + }, [localPlayers, roomData, viewerId]) // Track active players (local + room members when in a room) const activePlayers = useMemo(() => { - const set = new Set(); + const set = new Set() if (roomData?.memberPlayers) { // In room mode: all players from all members are active Object.values(roomData.memberPlayers).forEach((memberPlayers) => { memberPlayers.forEach((player) => { - set.add(player.id); - }); - }); + set.add(player.id) + }) + }) } else { // Solo mode: only local active players dbPlayers.forEach((player) => { if (player.isActive) { - set.add(player.id); + set.add(player.id) } - }); + }) } - return set; - }, [dbPlayers, roomData]); + return set + }, [dbPlayers, roomData]) // Initialize with default players if none exist useEffect(() => { if (!isLoading && !isInitialized) { if (dbPlayers.length === 0) { // Generate unique names for default players, themed by their emoji - const existingNames: string[] = []; + const existingNames: string[] = [] const generatedNames = DEFAULT_PLAYER_CONFIGS.map((config) => { - const name = generateUniquePlayerName(existingNames, config.emoji); - existingNames.push(name); - return name; - }); + const name = generateUniquePlayerName(existingNames, config.emoji) + existingNames.push(name) + return name + }) // Create default players with generated names DEFAULT_PLAYER_CONFIGS.forEach((config, index) => { @@ -165,44 +156,41 @@ export function GameModeProvider({ children }: { children: ReactNode }) { emoji: config.emoji, color: config.color, isActive: index === 0, // First player active by default - }); - }); - console.log( - "✅ Created default players via API with auto-generated names:", - generatedNames, - ); + }) + }) + console.log('✅ Created default players via API with auto-generated names:', generatedNames) } else { - console.log("✅ Loaded players from API", { + console.log('✅ Loaded players from API', { playerCount: dbPlayers.length, activeCount: dbPlayers.filter((p) => p.isActive).length, - }); + }) } - setIsInitialized(true); + setIsInitialized(true) } - }, [dbPlayers, isLoading, isInitialized, createPlayer]); + }, [dbPlayers, isLoading, isInitialized, createPlayer]) const addPlayer = (playerData?: Partial) => { - const playerList = Array.from(players.values()); - const existingNames = playerList.map((p) => p.name); - const emoji = playerData?.emoji ?? "🎮"; + const playerList = Array.from(players.values()) + const existingNames = playerList.map((p) => p.name) + const emoji = playerData?.emoji ?? '🎮' const newPlayer = { name: playerData?.name ?? generateUniquePlayerName(existingNames, emoji), emoji, color: playerData?.color ?? getNextPlayerColor(playerList), isActive: playerData?.isActive ?? false, - }; + } createPlayer(newPlayer, { onSuccess: () => { // Notify room members if in a room - notifyRoomOfPlayerUpdate(); + notifyRoomOfPlayerUpdate() }, - }); - }; + }) + } const updatePlayer = (id: string, updates: Partial) => { - const player = players.get(id); + const player = players.get(id) // Only allow updating local players if (player?.isLocal) { updatePlayerMutation( @@ -210,32 +198,32 @@ export function GameModeProvider({ children }: { children: ReactNode }) { { onSuccess: () => { // Notify room members if in a room - notifyRoomOfPlayerUpdate(); + notifyRoomOfPlayerUpdate() }, - }, - ); + } + ) } else { - console.warn("[GameModeContext] Cannot update remote player:", id); + console.warn('[GameModeContext] Cannot update remote player:', id) } - }; + } const removePlayer = (id: string) => { - const player = players.get(id); + const player = players.get(id) // Only allow removing local players if (player?.isLocal) { deletePlayer(id, { onSuccess: () => { // Notify room members if in a room - notifyRoomOfPlayerUpdate(); + notifyRoomOfPlayerUpdate() }, - }); + }) } else { - console.warn("[GameModeContext] Cannot remove remote player:", id); + console.warn('[GameModeContext] Cannot remove remote player:', id) } - }; + } const setActive = (id: string, active: boolean) => { - const player = players.get(id); + const player = players.get(id) // Only allow changing active status of local players if (player?.isLocal) { updatePlayerMutation( @@ -243,45 +231,42 @@ export function GameModeProvider({ children }: { children: ReactNode }) { { onSuccess: () => { // Notify room members if in a room - notifyRoomOfPlayerUpdate(); + notifyRoomOfPlayerUpdate() }, - }, - ); + } + ) } else { - console.warn( - "[GameModeContext] Cannot change active status of remote player:", - id, - ); + console.warn('[GameModeContext] Cannot change active status of remote player:', id) } - }; + } const getActivePlayers = (): Player[] => { return Array.from(activePlayers) .map((id) => players.get(id)) - .filter((p): p is Player => p !== undefined); - }; + .filter((p): p is Player => p !== undefined) + } const getPlayer = (id: string): Player | undefined => { - return players.get(id); - }; + return players.get(id) + } const getAllPlayers = (): Player[] => { - return Array.from(players.values()); - }; + return Array.from(players.values()) + } const resetPlayers = () => { // Delete all existing players dbPlayers.forEach((player) => { - deletePlayer(player.id); - }); + deletePlayer(player.id) + }) // Generate unique names for default players, themed by their emoji - const existingNames: string[] = []; + const existingNames: string[] = [] const generatedNames = DEFAULT_PLAYER_CONFIGS.map((config) => { - const name = generateUniquePlayerName(existingNames, config.emoji); - existingNames.push(name); - return name; - }); + const name = generateUniquePlayerName(existingNames, config.emoji) + existingNames.push(name) + return name + }) // Create default players with generated names DEFAULT_PLAYER_CONFIGS.forEach((config, index) => { @@ -290,26 +275,26 @@ export function GameModeProvider({ children }: { children: ReactNode }) { emoji: config.emoji, color: config.color, isActive: index === 0, - }); - }); + }) + }) // Notify room members after reset (slight delay to ensure mutations complete) setTimeout(() => { - notifyRoomOfPlayerUpdate(); - }, 100); - }; + notifyRoomOfPlayerUpdate() + }, 100) + } - const activePlayerCount = activePlayers.size; + const activePlayerCount = activePlayers.size // Compute game mode from active player count const gameMode: GameMode = activePlayerCount === 1 - ? "single" + ? 'single' : activePlayerCount === 2 - ? "battle" + ? 'battle' : activePlayerCount >= 3 - ? "tournament" - : "single"; + ? 'tournament' + : 'single' const contextValue: GameModeContextType = { gameMode, @@ -325,19 +310,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) { getAllPlayers, resetPlayers, isLoading, - }; + } - return ( - - {children} - - ); + return {children} } export function useGameMode(): GameModeContextType { - const context = useContext(GameModeContext); + const context = useContext(GameModeContext) if (!context) { - throw new Error("useGameMode must be used within a GameModeProvider"); + throw new Error('useGameMode must be used within a GameModeProvider') } - return context; + return context } diff --git a/apps/web/src/contexts/GameThemeContext.tsx b/apps/web/src/contexts/GameThemeContext.tsx index b13c9ee9..b688af8d 100644 --- a/apps/web/src/contexts/GameThemeContext.tsx +++ b/apps/web/src/contexts/GameThemeContext.tsx @@ -1,47 +1,39 @@ -"use client"; +'use client' -import { - createContext, - type ReactNode, - useContext, - useEffect, - useState, -} from "react"; +import { createContext, type ReactNode, useContext, useEffect, useState } from 'react' export interface GameTheme { - gameName: string; - backgroundColor: string; + gameName: string + backgroundColor: string } interface GameThemeContextType { - theme: GameTheme | null; - setTheme: (theme: GameTheme | null) => void; - isHydrated: boolean; + theme: GameTheme | null + setTheme: (theme: GameTheme | null) => void + isHydrated: boolean } -const GameThemeContext = createContext( - undefined, -); +const GameThemeContext = createContext(undefined) export function GameThemeProvider({ children }: { children: ReactNode }) { - const [theme, setTheme] = useState(null); - const [isHydrated, setIsHydrated] = useState(false); + const [theme, setTheme] = useState(null) + const [isHydrated, setIsHydrated] = useState(false) useEffect(() => { - setIsHydrated(true); - }, []); + setIsHydrated(true) + }, []) return ( {children} - ); + ) } export function useGameTheme() { - const context = useContext(GameThemeContext); + const context = useContext(GameThemeContext) if (context === undefined) { - throw new Error("useGameTheme must be used within a GameThemeProvider"); + throw new Error('useGameTheme must be used within a GameThemeProvider') } - return context; + return context } diff --git a/apps/web/src/contexts/HomeHeroContext.tsx b/apps/web/src/contexts/HomeHeroContext.tsx index 6061668b..234701a3 100644 --- a/apps/web/src/contexts/HomeHeroContext.tsx +++ b/apps/web/src/contexts/HomeHeroContext.tsx @@ -1,92 +1,85 @@ -"use client"; +'use client' -import type React from "react"; -import { - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import type { Subtitle } from "../data/abaciOneSubtitles"; -import { subtitles } from "../data/abaciOneSubtitles"; +import type React from 'react' +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' +import type { Subtitle } from '../data/abaciOneSubtitles' +import { subtitles } from '../data/abaciOneSubtitles' interface HomeHeroContextValue { - subtitle: Subtitle; - abacusValue: number; - setAbacusValue: (value: number) => void; - isHeroVisible: boolean; - setIsHeroVisible: (visible: boolean) => void; - isAbacusLoaded: boolean; - isSubtitleLoaded: boolean; + subtitle: Subtitle + abacusValue: number + setAbacusValue: (value: number) => void + isHeroVisible: boolean + setIsHeroVisible: (visible: boolean) => void + isAbacusLoaded: boolean + isSubtitleLoaded: boolean } -const HomeHeroContext = createContext(null); +const HomeHeroContext = createContext(null) -export { HomeHeroContext }; +export { HomeHeroContext } export function HomeHeroProvider({ children }: { children: React.ReactNode }) { // Use first subtitle for SSR, then select random one on client mount - const [subtitle, setSubtitle] = useState(subtitles[0]); - const [isSubtitleLoaded, setIsSubtitleLoaded] = useState(false); + const [subtitle, setSubtitle] = useState(subtitles[0]) + const [isSubtitleLoaded, setIsSubtitleLoaded] = useState(false) // Select random subtitle only on client side, persist per-session useEffect(() => { // Check if we have a stored subtitle index for this session - const storedIndex = sessionStorage.getItem("heroSubtitleIndex"); + const storedIndex = sessionStorage.getItem('heroSubtitleIndex') if (storedIndex !== null) { // Use the stored subtitle index - const index = parseInt(storedIndex, 10); + const index = parseInt(storedIndex, 10) if (!Number.isNaN(index) && index >= 0 && index < subtitles.length) { - setSubtitle(subtitles[index]); - setIsSubtitleLoaded(true); - return; + setSubtitle(subtitles[index]) + setIsSubtitleLoaded(true) + return } } // Generate a new random index and store it - const randomIndex = Math.floor(Math.random() * subtitles.length); - sessionStorage.setItem("heroSubtitleIndex", randomIndex.toString()); - setSubtitle(subtitles[randomIndex]); - setIsSubtitleLoaded(true); - }, []); + const randomIndex = Math.floor(Math.random() * subtitles.length) + sessionStorage.setItem('heroSubtitleIndex', randomIndex.toString()) + setSubtitle(subtitles[randomIndex]) + setIsSubtitleLoaded(true) + }, []) // Shared abacus value - always start at 0 for SSR/hydration consistency - const [abacusValue, setAbacusValue] = useState(0); - const [isAbacusLoaded, setIsAbacusLoaded] = useState(false); - const isLoadingFromStorage = useRef(false); + const [abacusValue, setAbacusValue] = useState(0) + const [isAbacusLoaded, setIsAbacusLoaded] = useState(false) + const isLoadingFromStorage = useRef(false) // Load from sessionStorage after mount (client-only, no hydration mismatch) useEffect(() => { - isLoadingFromStorage.current = true; // Block saves during load + isLoadingFromStorage.current = true // Block saves during load - const saved = sessionStorage.getItem("heroAbacusValue"); + const saved = sessionStorage.getItem('heroAbacusValue') if (saved) { - const parsedValue = parseInt(saved, 10); + const parsedValue = parseInt(saved, 10) if (!Number.isNaN(parsedValue)) { - setAbacusValue(parsedValue); + setAbacusValue(parsedValue) } } // Use setTimeout to ensure the value has been set before we allow saves setTimeout(() => { - isLoadingFromStorage.current = false; - setIsAbacusLoaded(true); - }, 0); - }, []); + isLoadingFromStorage.current = false + setIsAbacusLoaded(true) + }, 0) + }, []) // Persist value to sessionStorage when it changes (but skip during load) useEffect(() => { if (!isLoadingFromStorage.current) { - sessionStorage.setItem("heroAbacusValue", abacusValue.toString()); + sessionStorage.setItem('heroAbacusValue', abacusValue.toString()) } - }, [abacusValue]); + }, [abacusValue]) // Track hero visibility for nav branding - const [isHeroVisible, setIsHeroVisible] = useState(true); + const [isHeroVisible, setIsHeroVisible] = useState(true) const value = useMemo( () => ({ @@ -98,20 +91,16 @@ export function HomeHeroProvider({ children }: { children: React.ReactNode }) { isAbacusLoaded, isSubtitleLoaded, }), - [subtitle, abacusValue, isHeroVisible, isAbacusLoaded, isSubtitleLoaded], - ); + [subtitle, abacusValue, isHeroVisible, isAbacusLoaded, isSubtitleLoaded] + ) - return ( - - {children} - - ); + return {children} } export function useHomeHero() { - const context = useContext(HomeHeroContext); + const context = useContext(HomeHeroContext) if (!context) { - throw new Error("useHomeHero must be used within HomeHeroProvider"); + throw new Error('useHomeHero must be used within HomeHeroProvider') } - return context; + return context } diff --git a/apps/web/src/contexts/LocaleContext.tsx b/apps/web/src/contexts/LocaleContext.tsx index c8e6d414..4beaa1dc 100644 --- a/apps/web/src/contexts/LocaleContext.tsx +++ b/apps/web/src/contexts/LocaleContext.tsx @@ -1,55 +1,50 @@ -"use client"; +'use client' -import { createContext, useContext, useState, type ReactNode } from "react"; -import { getMessages, type Locale } from "@/i18n/messages"; -import { LOCALE_COOKIE_NAME } from "@/i18n/routing"; +import { createContext, useContext, useState, type ReactNode } from 'react' +import { getMessages, type Locale } from '@/i18n/messages' +import { LOCALE_COOKIE_NAME } from '@/i18n/routing' interface LocaleContextValue { - locale: Locale; - messages: Record; - changeLocale: (newLocale: Locale) => Promise; + locale: Locale + messages: Record + changeLocale: (newLocale: Locale) => Promise } -const LocaleContext = createContext(undefined); +const LocaleContext = createContext(undefined) interface LocaleProviderProps { - children: ReactNode; - initialLocale: Locale; - initialMessages: Record; + children: ReactNode + initialLocale: Locale + initialMessages: Record } -export function LocaleProvider({ - children, - initialLocale, - initialMessages, -}: LocaleProviderProps) { - const [locale, setLocale] = useState(initialLocale); - const [messages, setMessages] = - useState>(initialMessages); +export function LocaleProvider({ children, initialLocale, initialMessages }: LocaleProviderProps) { + const [locale, setLocale] = useState(initialLocale) + const [messages, setMessages] = useState>(initialMessages) const changeLocale = async (newLocale: Locale) => { // Update cookie - document.cookie = `${LOCALE_COOKIE_NAME}=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}`; + document.cookie = `${LOCALE_COOKIE_NAME}=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}` // Load new messages - const newMessages = await getMessages(newLocale); + const newMessages = await getMessages(newLocale) // Update state - setLocale(newLocale); - setMessages(newMessages); - }; + setLocale(newLocale) + setMessages(newMessages) + } return ( {children} - ); + ) } export function useLocaleContext() { - const context = useContext(LocaleContext); + const context = useContext(LocaleContext) if (!context) { - throw new Error("useLocaleContext must be used within LocaleProvider"); + throw new Error('useLocaleContext must be used within LocaleProvider') } - return context; + return context } diff --git a/apps/web/src/contexts/MyAbacusContext.tsx b/apps/web/src/contexts/MyAbacusContext.tsx index 3ad3acb4..757e68e9 100644 --- a/apps/web/src/contexts/MyAbacusContext.tsx +++ b/apps/web/src/contexts/MyAbacusContext.tsx @@ -1,37 +1,35 @@ -"use client"; +'use client' -import type React from "react"; -import { createContext, useContext, useState, useCallback } from "react"; +import type React from 'react' +import { createContext, useContext, useState, useCallback } from 'react' interface MyAbacusContextValue { - isOpen: boolean; - open: () => void; - close: () => void; - toggle: () => void; + isOpen: boolean + open: () => void + close: () => void + toggle: () => void } -const MyAbacusContext = createContext( - undefined, -); +const MyAbacusContext = createContext(undefined) export function MyAbacusProvider({ children }: { children: React.ReactNode }) { - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(false) - const open = useCallback(() => setIsOpen(true), []); - const close = useCallback(() => setIsOpen(false), []); - const toggle = useCallback(() => setIsOpen((prev) => !prev), []); + const open = useCallback(() => setIsOpen(true), []) + const close = useCallback(() => setIsOpen(false), []) + const toggle = useCallback(() => setIsOpen((prev) => !prev), []) return ( {children} - ); + ) } export function useMyAbacus() { - const context = useContext(MyAbacusContext); + const context = useContext(MyAbacusContext) if (!context) { - throw new Error("useMyAbacus must be used within MyAbacusProvider"); + throw new Error('useMyAbacus must be used within MyAbacusProvider') } - return context; + return context } diff --git a/apps/web/src/contexts/ThemeContext.tsx b/apps/web/src/contexts/ThemeContext.tsx index e4988407..76e744ab 100644 --- a/apps/web/src/contexts/ThemeContext.tsx +++ b/apps/web/src/contexts/ThemeContext.tsx @@ -1,79 +1,79 @@ -"use client"; +'use client' -import type React from "react"; -import { createContext, useContext, useEffect, useState } from "react"; +import type React from 'react' +import { createContext, useContext, useEffect, useState } from 'react' -type Theme = "light" | "dark" | "system"; -type ResolvedTheme = "light" | "dark"; +type Theme = 'light' | 'dark' | 'system' +type ResolvedTheme = 'light' | 'dark' interface ThemeContextType { - theme: Theme; - resolvedTheme: ResolvedTheme; - setTheme: (theme: Theme) => void; + theme: Theme + resolvedTheme: ResolvedTheme + setTheme: (theme: Theme) => void } -const ThemeContext = createContext(undefined); +const ThemeContext = createContext(undefined) export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setThemeState] = useState("system"); - const [resolvedTheme, setResolvedTheme] = useState("dark"); + const [theme, setThemeState] = useState('system') + const [resolvedTheme, setResolvedTheme] = useState('dark') // Detect system preference useEffect(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const handleChange = () => { - if (theme === "system") { - setResolvedTheme(mediaQuery.matches ? "dark" : "light"); + if (theme === 'system') { + setResolvedTheme(mediaQuery.matches ? 'dark' : 'light') } - }; + } - handleChange(); - mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); - }, [theme]); + handleChange() + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, [theme]) // Load saved theme preference useEffect(() => { - const saved = localStorage.getItem("theme") as Theme | null; + const saved = localStorage.getItem('theme') as Theme | null if (saved) { - setThemeState(saved); + setThemeState(saved) } - }, []); + }, []) // Update resolved theme when theme changes useEffect(() => { - if (theme === "system") { - const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - setResolvedTheme(isDark ? "dark" : "light"); + if (theme === 'system') { + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches + setResolvedTheme(isDark ? 'dark' : 'light') } else { - setResolvedTheme(theme); + setResolvedTheme(theme) } - }, [theme]); + }, [theme]) // Apply theme to document useEffect(() => { - const root = document.documentElement; - root.setAttribute("data-theme", resolvedTheme); - root.classList.remove("light", "dark"); - root.classList.add(resolvedTheme); - }, [resolvedTheme]); + const root = document.documentElement + root.setAttribute('data-theme', resolvedTheme) + root.classList.remove('light', 'dark') + root.classList.add(resolvedTheme) + }, [resolvedTheme]) const setTheme = (newTheme: Theme) => { - setThemeState(newTheme); - localStorage.setItem("theme", newTheme); - }; + setThemeState(newTheme) + localStorage.setItem('theme', newTheme) + } return ( {children} - ); + ) } export function useTheme() { - const context = useContext(ThemeContext); + const context = useContext(ThemeContext) if (context === undefined) { - throw new Error("useTheme must be used within a ThemeProvider"); + throw new Error('useTheme must be used within a ThemeProvider') } - return context; + return context } diff --git a/apps/web/src/contexts/UserProfileContext.tsx b/apps/web/src/contexts/UserProfileContext.tsx index da35c5cf..e54f95d0 100644 --- a/apps/web/src/contexts/UserProfileContext.tsx +++ b/apps/web/src/contexts/UserProfileContext.tsx @@ -1,23 +1,23 @@ -"use client"; +'use client' -import { createContext, type ReactNode, useContext } from "react"; -import type { UserStats } from "@/db/schema/user-stats"; -import { useUpdateUserStats, useUserStats } from "@/hooks/useUserStats"; +import { createContext, type ReactNode, useContext } from 'react' +import type { UserStats } from '@/db/schema/user-stats' +import { useUpdateUserStats, useUserStats } from '@/hooks/useUserStats' // Client-side stats type (compatible with old UserStatsProfile) export interface UserStatsProfile { - gamesPlayed: number; - totalWins: number; - favoriteGameType: "abacus-numeral" | "complement-pairs" | null; - bestTime: number | null; - highestAccuracy: number; + gamesPlayed: number + totalWins: number + favoriteGameType: 'abacus-numeral' | 'complement-pairs' | null + bestTime: number | null + highestAccuracy: number } export interface UserProfileContextType { - profile: UserStatsProfile; - updateGameStats: (stats: Partial) => void; - resetProfile: () => void; - isLoading: boolean; + profile: UserStatsProfile + updateGameStats: (stats: Partial) => void + resetProfile: () => void + isLoading: boolean } const defaultProfile: UserStatsProfile = { @@ -26,13 +26,13 @@ const defaultProfile: UserStatsProfile = { favoriteGameType: null, bestTime: null, highestAccuracy: 0, -}; +} -const UserProfileContext = createContext(null); +const UserProfileContext = createContext(null) // Convert DB stats to client profile type function toClientProfile(dbStats: UserStats | undefined): UserStatsProfile { - if (!dbStats) return defaultProfile; + if (!dbStats) return defaultProfile return { gamesPlayed: dbStats.gamesPlayed, @@ -40,41 +40,37 @@ function toClientProfile(dbStats: UserStats | undefined): UserStatsProfile { favoriteGameType: dbStats.favoriteGameType, bestTime: dbStats.bestTime, highestAccuracy: dbStats.highestAccuracy, - }; + } } export function UserProfileProvider({ children }: { children: ReactNode }) { - const { data: dbStats, isLoading } = useUserStats(); - const { mutate: updateStats } = useUpdateUserStats(); + const { data: dbStats, isLoading } = useUserStats() + const { mutate: updateStats } = useUpdateUserStats() - const profile = toClientProfile(dbStats); + const profile = toClientProfile(dbStats) const updateGameStats = (stats: Partial) => { - updateStats(stats); - }; + updateStats(stats) + } const resetProfile = () => { - updateStats(defaultProfile); - }; + updateStats(defaultProfile) + } const contextValue: UserProfileContextType = { profile, updateGameStats, resetProfile, isLoading, - }; + } - return ( - - {children} - - ); + return {children} } export function useUserProfile(): UserProfileContextType { - const context = useContext(UserProfileContext); + const context = useContext(UserProfileContext) if (!context) { - throw new Error("useUserProfile must be used within a UserProfileProvider"); + throw new Error('useUserProfile must be used within a UserProfileProvider') } - return context; + return context } diff --git a/apps/web/src/contexts/ViewportContext.tsx b/apps/web/src/contexts/ViewportContext.tsx index f82df38a..b137d459 100644 --- a/apps/web/src/contexts/ViewportContext.tsx +++ b/apps/web/src/contexts/ViewportContext.tsx @@ -1,64 +1,58 @@ -"use client"; +'use client' -import { - createContext, - useContext, - useState, - useEffect, - type ReactNode, -} from "react"; +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' /** * Viewport dimensions */ export interface ViewportDimensions { - width: number; - height: number; + width: number + height: number } /** * Viewport context value */ interface ViewportContextValue { - width: number; - height: number; + width: number + height: number } -const ViewportContext = createContext(null); +const ViewportContext = createContext(null) /** * Hook to get viewport dimensions * Returns mock dimensions in preview mode, actual window dimensions otherwise */ export function useViewport(): ViewportDimensions { - const context = useContext(ViewportContext); + const context = useContext(ViewportContext) // If context is provided (preview mode or custom viewport), use it if (context) { - return context; + return context } // Otherwise, use actual window dimensions (hook will update on resize) const [dimensions, setDimensions] = useState({ - width: typeof window !== "undefined" ? window.innerWidth : 1440, - height: typeof window !== "undefined" ? window.innerHeight : 900, - }); + width: typeof window !== 'undefined' ? window.innerWidth : 1440, + height: typeof window !== 'undefined' ? window.innerHeight : 900, + }) useEffect(() => { const handleResize = () => { setDimensions({ width: window.innerWidth, height: window.innerHeight, - }); - }; + }) + } - window.addEventListener("resize", handleResize); - handleResize(); // Set initial value + window.addEventListener('resize', handleResize) + handleResize() // Set initial value - return () => window.removeEventListener("resize", handleResize); - }, []); + return () => window.removeEventListener('resize', handleResize) + }, []) - return dimensions; + return dimensions } /** @@ -70,13 +64,9 @@ export function ViewportProvider({ width, height, }: { - children: ReactNode; - width: number; - height: number; + children: ReactNode + width: number + height: number }) { - return ( - - {children} - - ); + return {children} } diff --git a/apps/web/src/data/abaciOneSubtitles.ts b/apps/web/src/data/abaciOneSubtitles.ts index c5326c0e..0a9768a1 100644 --- a/apps/web/src/data/abaciOneSubtitles.ts +++ b/apps/web/src/data/abaciOneSubtitles.ts @@ -4,112 +4,112 @@ */ export interface Subtitle { - text: string; - description: string; + text: string + description: string } export const subtitles: Subtitle[] = [ - { text: "Speed Bead Lead", description: "blaze through bead races" }, - { text: "Rod Mod Nod", description: "tweak rod technique, approval earned" }, - { text: "Grid Kid Lid", description: "lock in neat grid habits" }, - { text: "Count Mount Amount", description: "stack up that number sense" }, - { text: "Stack Track Tack", description: "line up beads, lock in sums" }, - { text: "Quick Flick Trick", description: "rapid-fire bead tactics" }, - { text: "Flash Dash Math", description: "fly through numeric challenges" }, - { text: "Slide Glide Pride", description: "smooth soroban strokes" }, - { text: "Shift Sift Gift", description: "sort beads, reveal talent" }, - { text: "Beat Seat Meet", description: "compete head-to-head" }, - { text: "Brain Train Gain", description: "mental math muscle building" }, - { text: "Flow Show Pro", description: "demonstrate soroban mastery" }, - { text: "Fast Blast Past", description: "surpass speed limits" }, - { text: "Snap Tap Map", description: "chart your calculation path" }, - { text: "Row Grow Know", description: "advance through structured drills" }, - { text: "Drill Skill Thrill", description: "practice that excites" }, - { text: "Think Link Sync", description: "connect mind and beads" }, - { text: "Boost Joust Roost", description: "power up, compete, settle in" }, - { text: "Add Grad Rad", description: "level up addition awesomely" }, - { text: "Sum Fun Run", description: "enjoy the arithmetic sprint" }, + { text: 'Speed Bead Lead', description: 'blaze through bead races' }, + { text: 'Rod Mod Nod', description: 'tweak rod technique, approval earned' }, + { text: 'Grid Kid Lid', description: 'lock in neat grid habits' }, + { text: 'Count Mount Amount', description: 'stack up that number sense' }, + { text: 'Stack Track Tack', description: 'line up beads, lock in sums' }, + { text: 'Quick Flick Trick', description: 'rapid-fire bead tactics' }, + { text: 'Flash Dash Math', description: 'fly through numeric challenges' }, + { text: 'Slide Glide Pride', description: 'smooth soroban strokes' }, + { text: 'Shift Sift Gift', description: 'sort beads, reveal talent' }, + { text: 'Beat Seat Meet', description: 'compete head-to-head' }, + { text: 'Brain Train Gain', description: 'mental math muscle building' }, + { text: 'Flow Show Pro', description: 'demonstrate soroban mastery' }, + { text: 'Fast Blast Past', description: 'surpass speed limits' }, + { text: 'Snap Tap Map', description: 'chart your calculation path' }, + { text: 'Row Grow Know', description: 'advance through structured drills' }, + { text: 'Drill Skill Thrill', description: 'practice that excites' }, + { text: 'Think Link Sync', description: 'connect mind and beads' }, + { text: 'Boost Joust Roost', description: 'power up, compete, settle in' }, + { text: 'Add Grad Rad', description: 'level up addition awesomely' }, + { text: 'Sum Fun Run', description: 'enjoy the arithmetic sprint' }, { - text: "Track Stack Pack", - description: "organize solutions systematically", + text: 'Track Stack Pack', + description: 'organize solutions systematically', }, - { text: "Beat Neat Feat", description: "clean victories, impressive wins" }, - { text: "Math Path Wrath", description: "dominate numeric challenges" }, - { text: "Spark Mark Arc", description: "ignite progress, track growth" }, - { text: "Race Pace Ace", description: "speed up, master it" }, - { text: "Flex Hex Reflex", description: "adapt calculations instantly" }, - { text: "Glide Pride Stride", description: "smooth confident progress" }, - { text: "Flash Dash Smash", description: "speed through, crush totals" }, - { text: "Stack Attack Jack", description: "aggressive bead strategies" }, - { text: "Quick Pick Click", description: "rapid bead selection" }, - { text: "Snap Map Tap", description: "visualize and execute" }, - { text: "Mind Find Grind", description: "discover mental endurance" }, - { text: "Flip Skip Rip", description: "fast transitions, tear through" }, - { text: "Blend Trend Send", description: "mix methods, share progress" }, - { text: "Power Tower Hour", description: "build skills intensively" }, - { text: "Launch Staunch Haunch", description: "start strong, stay firm" }, - { text: "Rush Crush Hush", description: "speed quietly dominates" }, - { text: "Swipe Stripe Hype", description: "sleek moves, excitement" }, - { text: "Train Gain Sustain", description: "build lasting ability" }, - { text: "Frame Claim Flame", description: "structure your fire" }, - { text: "Streak Peak Tweak", description: "hot runs, optimize performance" }, - { text: "Edge Pledge Wedge", description: "commit to precision" }, - { text: "Pace Grace Space", description: "rhythm, elegance, room to grow" }, - { text: "Link Think Brink", description: "connect at breakthrough edge" }, - { text: "Quest Test Best", description: "challenge yourself to excel" }, + { text: 'Beat Neat Feat', description: 'clean victories, impressive wins' }, + { text: 'Math Path Wrath', description: 'dominate numeric challenges' }, + { text: 'Spark Mark Arc', description: 'ignite progress, track growth' }, + { text: 'Race Pace Ace', description: 'speed up, master it' }, + { text: 'Flex Hex Reflex', description: 'adapt calculations instantly' }, + { text: 'Glide Pride Stride', description: 'smooth confident progress' }, + { text: 'Flash Dash Smash', description: 'speed through, crush totals' }, + { text: 'Stack Attack Jack', description: 'aggressive bead strategies' }, + { text: 'Quick Pick Click', description: 'rapid bead selection' }, + { text: 'Snap Map Tap', description: 'visualize and execute' }, + { text: 'Mind Find Grind', description: 'discover mental endurance' }, + { text: 'Flip Skip Rip', description: 'fast transitions, tear through' }, + { text: 'Blend Trend Send', description: 'mix methods, share progress' }, + { text: 'Power Tower Hour', description: 'build skills intensively' }, + { text: 'Launch Staunch Haunch', description: 'start strong, stay firm' }, + { text: 'Rush Crush Hush', description: 'speed quietly dominates' }, + { text: 'Swipe Stripe Hype', description: 'sleek moves, excitement' }, + { text: 'Train Gain Sustain', description: 'build lasting ability' }, + { text: 'Frame Claim Flame', description: 'structure your fire' }, + { text: 'Streak Peak Tweak', description: 'hot runs, optimize performance' }, + { text: 'Edge Pledge Wedge', description: 'commit to precision' }, + { text: 'Pace Grace Space', description: 'rhythm, elegance, room to grow' }, + { text: 'Link Think Brink', description: 'connect at breakthrough edge' }, + { text: 'Quest Test Best', description: 'challenge yourself to excel' }, { - text: "Drive Thrive Arrive", - description: "push hard, succeed, reach goals", + text: 'Drive Thrive Arrive', + description: 'push hard, succeed, reach goals', }, - { text: "Smart Start Chart", description: "begin wisely, track progress" }, - { text: "Boost Coast Toast", description: "accelerate, cruise, celebrate" }, - { text: "Spark Dark Embark", description: "ignite before dawn journeys" }, + { text: 'Smart Start Chart', description: 'begin wisely, track progress' }, + { text: 'Boost Coast Toast', description: 'accelerate, cruise, celebrate' }, + { text: 'Spark Dark Embark', description: 'ignite before dawn journeys' }, { - text: "Blaze Graze Amaze", - description: "burn through, touch lightly, wow", + text: 'Blaze Graze Amaze', + description: 'burn through, touch lightly, wow', }, - { text: "Shift Drift Gift", description: "adapt smoothly, reveal talent" }, - { text: "Zone Hone Own", description: "focus, refine, claim mastery" }, - { text: "Vault Halt Exalt", description: "leap high, pause, celebrate" }, - { text: "Peak Seek Streak", description: "find heights, maintain momentum" }, - { text: "Glow Show Grow", description: "shine, display, expand" }, - { text: "Scope Hope Rope", description: "survey possibilities, climb up" }, - { text: "Core Score More", description: "fundamentals yield better results" }, + { text: 'Shift Drift Gift', description: 'adapt smoothly, reveal talent' }, + { text: 'Zone Hone Own', description: 'focus, refine, claim mastery' }, + { text: 'Vault Halt Exalt', description: 'leap high, pause, celebrate' }, + { text: 'Peak Seek Streak', description: 'find heights, maintain momentum' }, + { text: 'Glow Show Grow', description: 'shine, display, expand' }, + { text: 'Scope Hope Rope', description: 'survey possibilities, climb up' }, + { text: 'Core Score More', description: 'fundamentals yield better results' }, { - text: "Rank Bank Thank", - description: "earn status, save wins, appreciate", + text: 'Rank Bank Thank', + description: 'earn status, save wins, appreciate', }, { - text: "Merge Surge Verge", - description: "combine forces, power up, edge closer", + text: 'Merge Surge Verge', + description: 'combine forces, power up, edge closer', }, { - text: "Bold Gold Hold", - description: "brave attempts, prize rewards, maintain", + text: 'Bold Gold Hold', + description: 'brave attempts, prize rewards, maintain', }, - { text: "Rise Prize Wise", description: "ascend, win, learn" }, - { text: "Move Groove Prove", description: "act, find rhythm, demonstrate" }, - { text: "Trust Thrust Adjust", description: "believe, push, refine" }, - { text: "Beam Dream Team", description: "radiate, aspire, collaborate" }, - { text: "Spin Win Grin", description: "rotate beads, succeed, smile" }, - { text: "String Ring Bring", description: "connect, cycle, deliver" }, - { text: "Clear Gear Steer", description: "focus, equip, direct" }, - { text: "Path Math Aftermath", description: "route, calculate, results" }, - { text: "Play Slay Day", description: "engage, dominate, own it" }, - { text: "Code Mode Road", description: "pattern, style, journey" }, - { text: "Craft Draft Shaft", description: "build, sketch, core structure" }, - { text: "Light Might Fight", description: "illuminate, empower, compete" }, - { text: "Stream Dream Extreme", description: "flow, envision, push limits" }, - { text: "Claim Frame Aim", description: "assert, structure, target" }, - { text: "Chart Smart Start", description: "map, intelligent, begin" }, - { text: "Bright Flight Height", description: "brilliant, soar, elevation" }, -]; + { text: 'Rise Prize Wise', description: 'ascend, win, learn' }, + { text: 'Move Groove Prove', description: 'act, find rhythm, demonstrate' }, + { text: 'Trust Thrust Adjust', description: 'believe, push, refine' }, + { text: 'Beam Dream Team', description: 'radiate, aspire, collaborate' }, + { text: 'Spin Win Grin', description: 'rotate beads, succeed, smile' }, + { text: 'String Ring Bring', description: 'connect, cycle, deliver' }, + { text: 'Clear Gear Steer', description: 'focus, equip, direct' }, + { text: 'Path Math Aftermath', description: 'route, calculate, results' }, + { text: 'Play Slay Day', description: 'engage, dominate, own it' }, + { text: 'Code Mode Road', description: 'pattern, style, journey' }, + { text: 'Craft Draft Shaft', description: 'build, sketch, core structure' }, + { text: 'Light Might Fight', description: 'illuminate, empower, compete' }, + { text: 'Stream Dream Extreme', description: 'flow, envision, push limits' }, + { text: 'Claim Frame Aim', description: 'assert, structure, target' }, + { text: 'Chart Smart Start', description: 'map, intelligent, begin' }, + { text: 'Bright Flight Height', description: 'brilliant, soar, elevation' }, +] /** * Get a random subtitle from the list * Uses current timestamp as seed for variety across sessions */ export function getRandomSubtitle(): Subtitle { - const index = Math.floor(Math.random() * subtitles.length); - return subtitles[index]; + const index = Math.floor(Math.random() * subtitles.length) + return subtitles[index] } diff --git a/apps/web/src/data/kyuLevelDetails.ts b/apps/web/src/data/kyuLevelDetails.ts index 975d7036..cdbc23be 100644 --- a/apps/web/src/data/kyuLevelDetails.ts +++ b/apps/web/src/data/kyuLevelDetails.ts @@ -6,21 +6,21 @@ */ export const kyuLevelDetails = { - "10-kyu": `Add/Sub: 2-digit, 5口, 10字 + '10-kyu': `Add/Sub: 2-digit, 5口, 10字 ×: 実+法 = 3 digits (20 problems) Time: 20 min; Pass ≥ 60/200. shuzan.jp`, - "9-kyu": `Add/Sub: 2-digit, 5口, 10字 + '9-kyu': `Add/Sub: 2-digit, 5口, 10字 ×: 実+法 = 3 digits (20) Time: 20 min; Pass ≥ 120/200. (If only one part clears, it's treated as 10-kyu per federation notes.) shuzan.jp`, - "8-kyu": `Add/Sub: 2-digit, 8口, 16字 + '8-kyu': `Add/Sub: 2-digit, 8口, 16字 ×: 実+法 = 4 digits (10) @@ -29,7 +29,7 @@ shuzan.jp`, Time: 20 min; Pass ≥ 120/200. shuzan.jp`, - "7-kyu": `Add/Sub: 2-digit, 10口, 20字 + '7-kyu': `Add/Sub: 2-digit, 10口, 20字 ×: 実+法 = 4 digits (10) @@ -38,7 +38,7 @@ shuzan.jp`, Time: 20 min; Pass ≥ 120/200. shuzan.jp`, - "6-kyu": `Add/Sub: 10口, 30字 + '6-kyu': `Add/Sub: 10口, 30字 ×: 実+法 = 5 digits (20) @@ -47,7 +47,7 @@ shuzan.jp`, Time: 30 min; Pass ≥ 210/300. shuzan.jp`, - "5-kyu": `Add/Sub: 10口, 40字 + '5-kyu': `Add/Sub: 10口, 40字 ×: 実+法 = 6 digits (20) @@ -56,7 +56,7 @@ shuzan.jp`, Time: 30 min; Pass ≥ 210/300. shuzan.jp`, - "4-kyu": `Add/Sub: 10口, 50字 + '4-kyu': `Add/Sub: 10口, 50字 ×: 実+法 = 7 digits (20) @@ -65,7 +65,7 @@ shuzan.jp`, Time: 30 min; Pass ≥ 210/300. shuzan.jp`, - "Pre-3-kyu": `Add/Sub: 10口, 50字 ×5題 and 10口, 60字 ×5題 (total 10) + 'Pre-3-kyu': `Add/Sub: 10口, 50字 ×5題 and 10口, 60字 ×5題 (total 10) ×: 実+法 = 7 digits (20) @@ -74,7 +74,7 @@ shuzan.jp`, Time: 30 min; Pass ≥ 240/300. shuzan.jp`, - "3-kyu": `Add/Sub: 10口, 60字 + '3-kyu': `Add/Sub: 10口, 60字 ×: 実+法 = 7 digits (20) @@ -83,7 +83,7 @@ shuzan.jp`, Time: 30 min; Pass ≥ 240/300. shuzan.jp`, - "Pre-2-kyu": `Add/Sub: 10口, 70字 + 'Pre-2-kyu': `Add/Sub: 10口, 70字 ×: 実+法 = 8 digits (20) @@ -92,7 +92,7 @@ shuzan.jp`, Time: 30 min; Pass ≥ 240/300. shuzan.jp`, - "2-kyu": `Add/Sub: 10口, 80字 + '2-kyu': `Add/Sub: 10口, 80字 ×: 実+法 = 9 digits (20) @@ -101,7 +101,7 @@ shuzan.jp`, Time: 30 min; Pass ≥ 240/300. shuzan.jp`, - "Pre-1-kyu": `Add/Sub: 10口, 90字 + 'Pre-1-kyu': `Add/Sub: 10口, 90字 ×: 実+法 = 10 digits (20) @@ -110,7 +110,7 @@ shuzan.jp`, Time: 30 min; Pass ≥ 240/300. shuzan.jp`, - "1-kyu": `Add/Sub: 10口, 100字 + '1-kyu': `Add/Sub: 10口, 100字 ×: 実+法 = 11 digits (20) @@ -118,6 +118,6 @@ shuzan.jp`, Time: 30 min; Pass ≥ 240/300. shuzan.jp`, -} as const; +} as const -export type KyuLevel = keyof typeof kyuLevelDetails; +export type KyuLevel = keyof typeof kyuLevelDetails diff --git a/apps/web/src/db/__tests__/database-connection.test.ts b/apps/web/src/db/__tests__/database-connection.test.ts index 5f8ba606..7beff395 100644 --- a/apps/web/src/db/__tests__/database-connection.test.ts +++ b/apps/web/src/db/__tests__/database-connection.test.ts @@ -1,27 +1,27 @@ -import Database from "better-sqlite3"; -import { drizzle } from "drizzle-orm/better-sqlite3"; -import { describe, expect, it } from "vitest"; +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { describe, expect, it } from 'vitest' -describe("Database connection", () => { - it("connects to in-memory database", () => { - const sqlite = new Database(":memory:"); - const db = drizzle(sqlite); +describe('Database connection', () => { + it('connects to in-memory database', () => { + const sqlite = new Database(':memory:') + const db = drizzle(sqlite) - expect(db).toBeDefined(); - }); + expect(db).toBeDefined() + }) - it("enables foreign keys", () => { - const sqlite = new Database(":memory:"); - sqlite.pragma("foreign_keys = ON"); + it('enables foreign keys', () => { + const sqlite = new Database(':memory:') + sqlite.pragma('foreign_keys = ON') - const result = sqlite.pragma("foreign_keys", { simple: true }); + const result = sqlite.pragma('foreign_keys', { simple: true }) - expect(result).toBe(1); - }); + expect(result).toBe(1) + }) - it("can run simple queries", () => { - const sqlite = new Database(":memory:"); - const _db = drizzle(sqlite); + it('can run simple queries', () => { + const sqlite = new Database(':memory:') + const _db = drizzle(sqlite) // Create simple table sqlite.exec(` @@ -29,35 +29,33 @@ describe("Database connection", () => { id TEXT PRIMARY KEY, value TEXT ) - `); + `) // Insert - sqlite.prepare("INSERT INTO test (id, value) VALUES ('1', 'hello')").run(); + sqlite.prepare("INSERT INTO test (id, value) VALUES ('1', 'hello')").run() // Query - const result = sqlite - .prepare("SELECT * FROM test WHERE id = '1'") - .get() as { - id: string; - value: string; - }; + const result = sqlite.prepare("SELECT * FROM test WHERE id = '1'").get() as { + id: string + value: string + } - expect(result).toEqual({ id: "1", value: "hello" }); - }); + expect(result).toEqual({ id: '1', value: 'hello' }) + }) - it("supports WAL mode (file-based DB)", () => { + it('supports WAL mode (file-based DB)', () => { // WAL mode doesn't work with in-memory databases // In-memory databases always use 'memory' journal mode // This test verifies the pragma can be set (will use WAL for file DBs) - const sqlite = new Database(":memory:"); + const sqlite = new Database(':memory:') // Try to set WAL mode (will return 'memory' for in-memory DB) - sqlite.pragma("journal_mode = WAL"); - const result = sqlite.pragma("journal_mode", { simple: true }); + sqlite.pragma('journal_mode = WAL') + const result = sqlite.pragma('journal_mode', { simple: true }) // In-memory databases can't use WAL, so expect 'memory' - expect(result).toBe("memory"); + expect(result).toBe('memory') // The actual app uses a file-based DB which will support WAL - }); -}); + }) +}) diff --git a/apps/web/src/db/__tests__/migrations.e2e.test.ts b/apps/web/src/db/__tests__/migrations.e2e.test.ts index f7214fb0..03046942 100644 --- a/apps/web/src/db/__tests__/migrations.e2e.test.ts +++ b/apps/web/src/db/__tests__/migrations.e2e.test.ts @@ -1,163 +1,143 @@ -import Database from "better-sqlite3"; -import { drizzle } from "drizzle-orm/better-sqlite3"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { beforeEach, describe, expect, it } from "vitest"; +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { beforeEach, describe, expect, it } from 'vitest' -describe("Migrations E2E", () => { - let sqlite: Database.Database; +describe('Migrations E2E', () => { + let sqlite: Database.Database beforeEach(() => { // Fresh in-memory DB for each test - sqlite = new Database(":memory:"); - sqlite.pragma("foreign_keys = ON"); - }); + sqlite = new Database(':memory:') + sqlite.pragma('foreign_keys = ON') + }) - it("applies all migrations successfully", () => { - const db = drizzle(sqlite); + it('applies all migrations successfully', () => { + const db = drizzle(sqlite) // Should not throw expect(() => { - migrate(db, { migrationsFolder: "./drizzle" }); - }).not.toThrow(); - }); + migrate(db, { migrationsFolder: './drizzle' }) + }).not.toThrow() + }) - it("creates all expected tables", () => { - const db = drizzle(sqlite); - migrate(db, { migrationsFolder: "./drizzle" }); + it('creates all expected tables', () => { + const db = drizzle(sqlite) + migrate(db, { migrationsFolder: './drizzle' }) const tables = sqlite .prepare("SELECT name FROM sqlite_master WHERE type='table'") - .all() as Array<{ name: string }>; + .all() as Array<{ name: string }> - const tableNames = tables.map((t) => t.name); + const tableNames = tables.map((t) => t.name) - expect(tableNames).toContain("users"); - expect(tableNames).toContain("players"); - expect(tableNames).toContain("user_stats"); - }); + expect(tableNames).toContain('users') + expect(tableNames).toContain('players') + expect(tableNames).toContain('user_stats') + }) - it("creates unique indexes on users", () => { - const db = drizzle(sqlite); - migrate(db, { migrationsFolder: "./drizzle" }); + it('creates unique indexes on users', () => { + const db = drizzle(sqlite) + migrate(db, { migrationsFolder: './drizzle' }) const indexes = sqlite - .prepare( - "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='users'", - ) - .all() as Array<{ name: string }>; + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='users'") + .all() as Array<{ name: string }> - const indexNames = indexes.map((i) => i.name); + const indexNames = indexes.map((i) => i.name) - expect(indexNames).toContain("users_guest_id_unique"); - expect(indexNames).toContain("users_email_unique"); - }); + expect(indexNames).toContain('users_guest_id_unique') + expect(indexNames).toContain('users_email_unique') + }) - it("creates index on players userId", () => { - const db = drizzle(sqlite); - migrate(db, { migrationsFolder: "./drizzle" }); + it('creates index on players userId', () => { + const db = drizzle(sqlite) + migrate(db, { migrationsFolder: './drizzle' }) const indexes = sqlite - .prepare( - "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='players'", - ) - .all() as Array<{ name: string }>; + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='players'") + .all() as Array<{ name: string }> - const indexNames = indexes.map((i) => i.name); + const indexNames = indexes.map((i) => i.name) - expect(indexNames).toContain("players_user_id_idx"); - }); + expect(indexNames).toContain('players_user_id_idx') + }) - it("enforces foreign key constraints", () => { - const db = drizzle(sqlite); - migrate(db, { migrationsFolder: "./drizzle" }); + it('enforces foreign key constraints', () => { + const db = drizzle(sqlite) + migrate(db, { migrationsFolder: './drizzle' }) // Try to insert player with non-existent userId expect(() => { sqlite .prepare( - "INSERT INTO players (id, user_id, name, emoji, color, is_active, created_at) VALUES ('p1', 'nonexistent', 'Test', '😀', '#000', 0, 0)", + "INSERT INTO players (id, user_id, name, emoji, color, is_active, created_at) VALUES ('p1', 'nonexistent', 'Test', '😀', '#000', 0, 0)" ) - .run(); - }).toThrow(); - }); + .run() + }).toThrow() + }) - it("cascades deletes from users to players", () => { - const db = drizzle(sqlite); - migrate(db, { migrationsFolder: "./drizzle" }); + it('cascades deletes from users to players', () => { + const db = drizzle(sqlite) + migrate(db, { migrationsFolder: './drizzle' }) // Insert user - sqlite - .prepare( - "INSERT INTO users (id, guest_id, created_at) VALUES ('u1', 'g1', 0)", - ) - .run(); + sqlite.prepare("INSERT INTO users (id, guest_id, created_at) VALUES ('u1', 'g1', 0)").run() // Insert player for that user sqlite .prepare( - "INSERT INTO players (id, user_id, name, emoji, color, is_active, created_at) VALUES ('p1', 'u1', 'Test', '😀', '#000', 0, 0)", + "INSERT INTO players (id, user_id, name, emoji, color, is_active, created_at) VALUES ('p1', 'u1', 'Test', '😀', '#000', 0, 0)" ) - .run(); + .run() // Verify player exists - const playerBefore = sqlite - .prepare("SELECT * FROM players WHERE id = 'p1'") - .get(); - expect(playerBefore).toBeDefined(); + const playerBefore = sqlite.prepare("SELECT * FROM players WHERE id = 'p1'").get() + expect(playerBefore).toBeDefined() // Delete user - sqlite.prepare("DELETE FROM users WHERE id = 'u1'").run(); + sqlite.prepare("DELETE FROM users WHERE id = 'u1'").run() // Verify player was cascade deleted - const playerAfter = sqlite - .prepare("SELECT * FROM players WHERE id = 'p1'") - .get(); - expect(playerAfter).toBeUndefined(); - }); + const playerAfter = sqlite.prepare("SELECT * FROM players WHERE id = 'p1'").get() + expect(playerAfter).toBeUndefined() + }) - it("cascades deletes from users to user_stats", () => { - const db = drizzle(sqlite); - migrate(db, { migrationsFolder: "./drizzle" }); + it('cascades deletes from users to user_stats', () => { + const db = drizzle(sqlite) + migrate(db, { migrationsFolder: './drizzle' }) // Insert user - sqlite - .prepare( - "INSERT INTO users (id, guest_id, created_at) VALUES ('u1', 'g1', 0)", - ) - .run(); + sqlite.prepare("INSERT INTO users (id, guest_id, created_at) VALUES ('u1', 'g1', 0)").run() // Insert stats for that user sqlite .prepare( - "INSERT INTO user_stats (user_id, games_played, total_wins, highest_accuracy) VALUES ('u1', 5, 2, 0.8)", + "INSERT INTO user_stats (user_id, games_played, total_wins, highest_accuracy) VALUES ('u1', 5, 2, 0.8)" ) - .run(); + .run() // Verify stats exist - const statsBefore = sqlite - .prepare("SELECT * FROM user_stats WHERE user_id = 'u1'") - .get(); - expect(statsBefore).toBeDefined(); + const statsBefore = sqlite.prepare("SELECT * FROM user_stats WHERE user_id = 'u1'").get() + expect(statsBefore).toBeDefined() // Delete user - sqlite.prepare("DELETE FROM users WHERE id = 'u1'").run(); + sqlite.prepare("DELETE FROM users WHERE id = 'u1'").run() // Verify stats were cascade deleted - const statsAfter = sqlite - .prepare("SELECT * FROM user_stats WHERE user_id = 'u1'") - .get(); - expect(statsAfter).toBeUndefined(); - }); + const statsAfter = sqlite.prepare("SELECT * FROM user_stats WHERE user_id = 'u1'").get() + expect(statsAfter).toBeUndefined() + }) - it("is idempotent (can run migrations twice)", () => { - const db = drizzle(sqlite); + it('is idempotent (can run migrations twice)', () => { + const db = drizzle(sqlite) // Run migrations first time - migrate(db, { migrationsFolder: "./drizzle" }); + migrate(db, { migrationsFolder: './drizzle' }) // Run migrations second time (should not throw) expect(() => { - migrate(db, { migrationsFolder: "./drizzle" }); - }).not.toThrow(); - }); -}); + migrate(db, { migrationsFolder: './drizzle' }) + }).not.toThrow() + }) +}) diff --git a/apps/web/src/db/__tests__/schema.test.ts b/apps/web/src/db/__tests__/schema.test.ts index 6aae008b..f166a6c0 100644 --- a/apps/web/src/db/__tests__/schema.test.ts +++ b/apps/web/src/db/__tests__/schema.test.ts @@ -1,114 +1,114 @@ -import { describe, expect, it } from "vitest"; -import { arcadeSessions, players, userStats, users } from "../schema"; +import { describe, expect, it } from 'vitest' +import { arcadeSessions, players, userStats, users } from '../schema' -describe("Schema validation", () => { - describe("users table", () => { - it("has correct structure", () => { - expect(users.id).toBeDefined(); - expect(users.guestId).toBeDefined(); - expect(users.createdAt).toBeDefined(); - expect(users.upgradedAt).toBeDefined(); - expect(users.email).toBeDefined(); - expect(users.name).toBeDefined(); - }); +describe('Schema validation', () => { + describe('users table', () => { + it('has correct structure', () => { + expect(users.id).toBeDefined() + expect(users.guestId).toBeDefined() + expect(users.createdAt).toBeDefined() + expect(users.upgradedAt).toBeDefined() + expect(users.email).toBeDefined() + expect(users.name).toBeDefined() + }) - it("has unique constraints on guestId and email", () => { - expect(users.guestId.notNull).toBe(true); - expect(users.email.notNull).toBe(false); // nullable until upgrade - }); - }); + it('has unique constraints on guestId and email', () => { + expect(users.guestId.notNull).toBe(true) + expect(users.email.notNull).toBe(false) // nullable until upgrade + }) + }) - describe("players table", () => { - it("has correct structure", () => { - expect(players.id).toBeDefined(); - expect(players.userId).toBeDefined(); - expect(players.name).toBeDefined(); - expect(players.emoji).toBeDefined(); - expect(players.color).toBeDefined(); - expect(players.isActive).toBeDefined(); - expect(players.createdAt).toBeDefined(); - }); + describe('players table', () => { + it('has correct structure', () => { + expect(players.id).toBeDefined() + expect(players.userId).toBeDefined() + expect(players.name).toBeDefined() + expect(players.emoji).toBeDefined() + expect(players.color).toBeDefined() + expect(players.isActive).toBeDefined() + expect(players.createdAt).toBeDefined() + }) - it("has foreign key to users", () => { - const userIdColumn = players.userId; - expect(userIdColumn).toBeDefined(); - expect(userIdColumn.notNull).toBe(true); - }); + it('has foreign key to users', () => { + const userIdColumn = players.userId + expect(userIdColumn).toBeDefined() + expect(userIdColumn.notNull).toBe(true) + }) - it("has required fields as not null", () => { - expect(players.name.notNull).toBe(true); - expect(players.emoji.notNull).toBe(true); - expect(players.color.notNull).toBe(true); - expect(players.isActive.notNull).toBe(true); - }); - }); + it('has required fields as not null', () => { + expect(players.name.notNull).toBe(true) + expect(players.emoji.notNull).toBe(true) + expect(players.color.notNull).toBe(true) + expect(players.isActive.notNull).toBe(true) + }) + }) - describe("user_stats table", () => { - it("has correct structure", () => { - expect(userStats.userId).toBeDefined(); - expect(userStats.gamesPlayed).toBeDefined(); - expect(userStats.totalWins).toBeDefined(); - expect(userStats.favoriteGameType).toBeDefined(); - expect(userStats.bestTime).toBeDefined(); - expect(userStats.highestAccuracy).toBeDefined(); - }); + describe('user_stats table', () => { + it('has correct structure', () => { + expect(userStats.userId).toBeDefined() + expect(userStats.gamesPlayed).toBeDefined() + expect(userStats.totalWins).toBeDefined() + expect(userStats.favoriteGameType).toBeDefined() + expect(userStats.bestTime).toBeDefined() + expect(userStats.highestAccuracy).toBeDefined() + }) - it("has foreign key to users", () => { - const userIdColumn = userStats.userId; - expect(userIdColumn).toBeDefined(); - }); + it('has foreign key to users', () => { + const userIdColumn = userStats.userId + expect(userIdColumn).toBeDefined() + }) - it("has correct defaults", () => { - expect(userStats.gamesPlayed.notNull).toBe(true); - expect(userStats.totalWins.notNull).toBe(true); - expect(userStats.highestAccuracy.notNull).toBe(true); - }); - }); + it('has correct defaults', () => { + expect(userStats.gamesPlayed.notNull).toBe(true) + expect(userStats.totalWins.notNull).toBe(true) + expect(userStats.highestAccuracy.notNull).toBe(true) + }) + }) - describe("arcade_sessions table", () => { - it("exists in schema", () => { - expect(arcadeSessions).toBeDefined(); - }); + describe('arcade_sessions table', () => { + it('exists in schema', () => { + expect(arcadeSessions).toBeDefined() + }) - it("has correct structure", () => { - expect(arcadeSessions.userId).toBeDefined(); - expect(arcadeSessions.currentGame).toBeDefined(); - expect(arcadeSessions.gameUrl).toBeDefined(); - expect(arcadeSessions.gameState).toBeDefined(); - expect(arcadeSessions.activePlayers).toBeDefined(); - expect(arcadeSessions.startedAt).toBeDefined(); - expect(arcadeSessions.lastActivityAt).toBeDefined(); - expect(arcadeSessions.expiresAt).toBeDefined(); - expect(arcadeSessions.isActive).toBeDefined(); - expect(arcadeSessions.version).toBeDefined(); - }); + it('has correct structure', () => { + expect(arcadeSessions.userId).toBeDefined() + expect(arcadeSessions.currentGame).toBeDefined() + expect(arcadeSessions.gameUrl).toBeDefined() + expect(arcadeSessions.gameState).toBeDefined() + expect(arcadeSessions.activePlayers).toBeDefined() + expect(arcadeSessions.startedAt).toBeDefined() + expect(arcadeSessions.lastActivityAt).toBeDefined() + expect(arcadeSessions.expiresAt).toBeDefined() + expect(arcadeSessions.isActive).toBeDefined() + expect(arcadeSessions.version).toBeDefined() + }) - it("has userId as primary key", () => { - expect(arcadeSessions.userId).toBeDefined(); - expect(arcadeSessions.userId.primary).toBe(true); - }); + it('has userId as primary key', () => { + expect(arcadeSessions.userId).toBeDefined() + expect(arcadeSessions.userId.primary).toBe(true) + }) - it("has foreign key to users", () => { - const userIdColumn = arcadeSessions.userId; - expect(userIdColumn).toBeDefined(); - expect(userIdColumn.notNull).toBe(true); - }); + it('has foreign key to users', () => { + const userIdColumn = arcadeSessions.userId + expect(userIdColumn).toBeDefined() + expect(userIdColumn.notNull).toBe(true) + }) - it("has required fields as not null", () => { - expect(arcadeSessions.currentGame.notNull).toBe(true); - expect(arcadeSessions.gameUrl.notNull).toBe(true); - expect(arcadeSessions.gameState.notNull).toBe(true); - expect(arcadeSessions.activePlayers.notNull).toBe(true); - expect(arcadeSessions.startedAt.notNull).toBe(true); - expect(arcadeSessions.lastActivityAt.notNull).toBe(true); - expect(arcadeSessions.expiresAt.notNull).toBe(true); - expect(arcadeSessions.isActive.notNull).toBe(true); - expect(arcadeSessions.version.notNull).toBe(true); - }); + it('has required fields as not null', () => { + expect(arcadeSessions.currentGame.notNull).toBe(true) + expect(arcadeSessions.gameUrl.notNull).toBe(true) + expect(arcadeSessions.gameState.notNull).toBe(true) + expect(arcadeSessions.activePlayers.notNull).toBe(true) + expect(arcadeSessions.startedAt.notNull).toBe(true) + expect(arcadeSessions.lastActivityAt.notNull).toBe(true) + expect(arcadeSessions.expiresAt.notNull).toBe(true) + expect(arcadeSessions.isActive.notNull).toBe(true) + expect(arcadeSessions.version.notNull).toBe(true) + }) - it("has correct defaults", () => { - expect(arcadeSessions.isActive.default).toBeDefined(); - expect(arcadeSessions.version.default).toBeDefined(); - }); - }); -}); + it('has correct defaults', () => { + expect(arcadeSessions.isActive.default).toBeDefined() + expect(arcadeSessions.version.default).toBeDefined() + }) + }) +}) diff --git a/apps/web/src/db/index.ts b/apps/web/src/db/index.ts index 9e096be4..a1143058 100644 --- a/apps/web/src/db/index.ts +++ b/apps/web/src/db/index.ts @@ -1,6 +1,6 @@ -import Database from "better-sqlite3"; -import { drizzle } from "drizzle-orm/better-sqlite3"; -import * as schema from "./schema"; +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import * as schema from './schema' /** * Database connection and client @@ -13,10 +13,10 @@ import * as schema from "./schema"; * when the database doesn't exist (e.g., in CI/CD environments). */ -const databaseUrl = process.env.DATABASE_URL || "./data/sqlite.db"; +const databaseUrl = process.env.DATABASE_URL || './data/sqlite.db' -let _sqlite: Database.Database | null = null; -let _db: ReturnType> | null = null; +let _sqlite: Database.Database | null = null +let _db: ReturnType> | null = null /** * Get the database connection (lazy-loaded singleton) @@ -24,17 +24,17 @@ let _db: ReturnType> | null = null; */ function getDb() { if (!_db) { - _sqlite = new Database(databaseUrl); + _sqlite = new Database(databaseUrl) // Enable foreign keys (SQLite requires explicit enable) - _sqlite.pragma("foreign_keys = ON"); + _sqlite.pragma('foreign_keys = ON') // Enable WAL mode for better concurrency - _sqlite.pragma("journal_mode = WAL"); + _sqlite.pragma('journal_mode = WAL') - _db = drizzle(_sqlite, { schema }); + _db = drizzle(_sqlite, { schema }) } - return _db; + return _db } /** @@ -43,8 +43,8 @@ function getDb() { */ export const db = new Proxy({} as ReturnType>, { get(_target, prop) { - return getDb()[prop as keyof ReturnType>]; + return getDb()[prop as keyof ReturnType>] }, -}); +}) -export { schema }; +export { schema } diff --git a/apps/web/src/db/migrate.ts b/apps/web/src/db/migrate.ts index d372d267..4d2b04a7 100644 --- a/apps/web/src/db/migrate.ts +++ b/apps/web/src/db/migrate.ts @@ -1,5 +1,5 @@ -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import { db } from "./index"; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { db } from './index' /** * Migration runner @@ -11,13 +11,13 @@ import { db } from "./index"; */ try { - console.log("🔄 Running migrations..."); + console.log('🔄 Running migrations...') - migrate(db, { migrationsFolder: "./drizzle" }); + migrate(db, { migrationsFolder: './drizzle' }) - console.log("✅ Migrations complete"); - process.exit(0); + console.log('✅ Migrations complete') + process.exit(0) } catch (error) { - console.error("❌ Migration failed:", error); - process.exit(1); + console.error('❌ Migration failed:', error) + process.exit(1) } diff --git a/apps/web/src/db/schema/abacus-settings.ts b/apps/web/src/db/schema/abacus-settings.ts index 42f7e2f7..aad34ca5 100644 --- a/apps/web/src/db/schema/abacus-settings.ts +++ b/apps/web/src/db/schema/abacus-settings.ts @@ -1,5 +1,5 @@ -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { users } from "./users"; +import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { users } from './users' /** * Abacus display settings table - UI preferences per user @@ -7,75 +7,65 @@ import { users } from "./users"; * One-to-one with users table. Stores abacus display configuration. * Deleted when user is deleted (cascade). */ -export const abacusSettings = sqliteTable("abacus_settings", { +export const abacusSettings = sqliteTable('abacus_settings', { /** Primary key and foreign key to users table */ - userId: text("user_id") + userId: text('user_id') .primaryKey() - .references(() => users.id, { onDelete: "cascade" }), + .references(() => users.id, { onDelete: 'cascade' }), /** Color scheme for beads */ - colorScheme: text("color_scheme", { - enum: ["monochrome", "place-value", "heaven-earth", "alternating"], + colorScheme: text('color_scheme', { + enum: ['monochrome', 'place-value', 'heaven-earth', 'alternating'], }) .notNull() - .default("place-value"), + .default('place-value'), /** Bead shape */ - beadShape: text("bead_shape", { - enum: ["diamond", "circle", "square"], + beadShape: text('bead_shape', { + enum: ['diamond', 'circle', 'square'], }) .notNull() - .default("diamond"), + .default('diamond'), /** Color palette */ - colorPalette: text("color_palette", { - enum: ["default", "colorblind", "mnemonic", "grayscale", "nature"], + colorPalette: text('color_palette', { + enum: ['default', 'colorblind', 'mnemonic', 'grayscale', 'nature'], }) .notNull() - .default("default"), + .default('default'), /** Hide inactive beads */ - hideInactiveBeads: integer("hide_inactive_beads", { mode: "boolean" }) - .notNull() - .default(false), + hideInactiveBeads: integer('hide_inactive_beads', { mode: 'boolean' }).notNull().default(false), /** Color numerals based on place value */ - coloredNumerals: integer("colored_numerals", { mode: "boolean" }) - .notNull() - .default(false), + coloredNumerals: integer('colored_numerals', { mode: 'boolean' }).notNull().default(false), /** Scale factor for abacus size */ - scaleFactor: real("scale_factor").notNull().default(1.0), + scaleFactor: real('scale_factor').notNull().default(1.0), /** Show numbers below abacus */ - showNumbers: integer("show_numbers", { mode: "boolean" }) - .notNull() - .default(true), + showNumbers: integer('show_numbers', { mode: 'boolean' }).notNull().default(true), /** Enable animations */ - animated: integer("animated", { mode: "boolean" }).notNull().default(true), + animated: integer('animated', { mode: 'boolean' }).notNull().default(true), /** Enable interaction */ - interactive: integer("interactive", { mode: "boolean" }) - .notNull() - .default(false), + interactive: integer('interactive', { mode: 'boolean' }).notNull().default(false), /** Enable gesture controls */ - gestures: integer("gestures", { mode: "boolean" }).notNull().default(false), + gestures: integer('gestures', { mode: 'boolean' }).notNull().default(false), /** Enable sound effects */ - soundEnabled: integer("sound_enabled", { mode: "boolean" }) - .notNull() - .default(true), + soundEnabled: integer('sound_enabled', { mode: 'boolean' }).notNull().default(true), /** Sound volume (0.0 - 1.0) */ - soundVolume: real("sound_volume").notNull().default(0.8), + soundVolume: real('sound_volume').notNull().default(0.8), /** Display numbers as abaci throughout the app where practical */ - nativeAbacusNumbers: integer("native_abacus_numbers", { mode: "boolean" }) + nativeAbacusNumbers: integer('native_abacus_numbers', { mode: 'boolean' }) .notNull() .default(false), -}); +}) -export type AbacusSettings = typeof abacusSettings.$inferSelect; -export type NewAbacusSettings = typeof abacusSettings.$inferInsert; +export type AbacusSettings = typeof abacusSettings.$inferSelect +export type NewAbacusSettings = typeof abacusSettings.$inferInsert diff --git a/apps/web/src/db/schema/arcade-rooms.ts b/apps/web/src/db/schema/arcade-rooms.ts index 4e033dfd..79b7a2f8 100644 --- a/apps/web/src/db/schema/arcade-rooms.ts +++ b/apps/web/src/db/schema/arcade-rooms.ts @@ -1,60 +1,53 @@ -import { createId } from "@paralleldrive/cuid2"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' -export const arcadeRooms = sqliteTable("arcade_rooms", { - id: text("id") +export const arcadeRooms = sqliteTable('arcade_rooms', { + id: text('id') .primaryKey() .$defaultFn(() => createId()), // Room identity - code: text("code", { length: 6 }).notNull().unique(), // e.g., "ABC123" - name: text("name", { length: 50 }), // Optional: auto-generates from code and game if null + code: text('code', { length: 6 }).notNull().unique(), // e.g., "ABC123" + name: text('name', { length: 50 }), // Optional: auto-generates from code and game if null // Creator info - createdBy: text("created_by").notNull(), // User/guest ID - creatorName: text("creator_name", { length: 50 }).notNull(), - createdAt: integer("created_at", { mode: "timestamp" }) + createdBy: text('created_by').notNull(), // User/guest ID + creatorName: text('creator_name', { length: 50 }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), // Lifecycle - lastActivity: integer("last_activity", { mode: "timestamp" }) + lastActivity: integer('last_activity', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), - ttlMinutes: integer("ttl_minutes").notNull().default(60), // Time to live + ttlMinutes: integer('ttl_minutes').notNull().default(60), // Time to live // Access control - accessMode: text("access_mode", { - enum: [ - "open", - "locked", - "retired", - "password", - "restricted", - "approval-only", - ], + accessMode: text('access_mode', { + enum: ['open', 'locked', 'retired', 'password', 'restricted', 'approval-only'], }) .notNull() - .default("open"), - password: text("password", { length: 255 }), // Hashed password for password-protected rooms - displayPassword: text("display_password", { length: 100 }), // Plain text password for display to room owner + .default('open'), + password: text('password', { length: 255 }), // Hashed password for password-protected rooms + displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner // Game configuration (nullable to support game selection in room) // Accepts any string - validation happens at runtime against validator registry - gameName: text("game_name"), - gameConfig: text("game_config", { mode: "json" }), // Game-specific settings (nullable when no game selected) + gameName: text('game_name'), + gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected) // Current state - status: text("status", { - enum: ["lobby", "playing", "finished"], + status: text('status', { + enum: ['lobby', 'playing', 'finished'], }) .notNull() - .default("lobby"), - currentSessionId: text("current_session_id"), // FK to arcade_sessions (nullable) + .default('lobby'), + currentSessionId: text('current_session_id'), // FK to arcade_sessions (nullable) // Metadata - totalGamesPlayed: integer("total_games_played").notNull().default(0), -}); + totalGamesPlayed: integer('total_games_played').notNull().default(0), +}) -export type ArcadeRoom = typeof arcadeRooms.$inferSelect; -export type NewArcadeRoom = typeof arcadeRooms.$inferInsert; +export type ArcadeRoom = typeof arcadeRooms.$inferSelect +export type NewArcadeRoom = typeof arcadeRooms.$inferInsert diff --git a/apps/web/src/db/schema/arcade-sessions.ts b/apps/web/src/db/schema/arcade-sessions.ts index 774dd45f..5e2bc9e2 100644 --- a/apps/web/src/db/schema/arcade-sessions.ts +++ b/apps/web/src/db/schema/arcade-sessions.ts @@ -1,43 +1,43 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { arcadeRooms } from "./arcade-rooms"; -import { users } from "./users"; +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { arcadeRooms } from './arcade-rooms' +import { users } from './users' -export const arcadeSessions = sqliteTable("arcade_sessions", { +export const arcadeSessions = sqliteTable('arcade_sessions', { // Room ID is now the primary key - one session per room // For room-based multiplayer games, this ensures all members share the same session - roomId: text("room_id") + roomId: text('room_id') .primaryKey() - .references(() => arcadeRooms.id, { onDelete: "cascade" }), + .references(() => arcadeRooms.id, { onDelete: 'cascade' }), // User who "owns" this session (typically the room creator) // For room-based sessions, this is just for reference/ownership tracking - userId: text("user_id") + userId: text('user_id') .notNull() - .references(() => users.id, { onDelete: "cascade" }), + .references(() => users.id, { onDelete: 'cascade' }), // Session metadata // Accepts any string - validation happens at runtime against validator registry - currentGame: text("current_game").notNull(), + currentGame: text('current_game').notNull(), - gameUrl: text("game_url").notNull(), // e.g., '/arcade/matching' + gameUrl: text('game_url').notNull(), // e.g., '/arcade/matching' // Game state (JSON blob) - gameState: text("game_state", { mode: "json" }).notNull(), + gameState: text('game_state', { mode: 'json' }).notNull(), // Active players snapshot (for quick access) - activePlayers: text("active_players", { mode: "json" }).notNull(), + activePlayers: text('active_players', { mode: 'json' }).notNull(), // Timing & TTL - startedAt: integer("started_at", { mode: "timestamp" }).notNull(), - lastActivityAt: integer("last_activity_at", { mode: "timestamp" }).notNull(), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), // TTL-based + startedAt: integer('started_at', { mode: 'timestamp' }).notNull(), + lastActivityAt: integer('last_activity_at', { mode: 'timestamp' }).notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), // TTL-based // Status - isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), // Version for optimistic locking - version: integer("version").notNull().default(1), -}); + version: integer('version').notNull().default(1), +}) -export type ArcadeSession = typeof arcadeSessions.$inferSelect; -export type NewArcadeSession = typeof arcadeSessions.$inferInsert; +export type ArcadeSession = typeof arcadeSessions.$inferSelect +export type NewArcadeSession = typeof arcadeSessions.$inferInsert diff --git a/apps/web/src/db/schema/index.ts b/apps/web/src/db/schema/index.ts index 2c1a701c..aa060b8d 100644 --- a/apps/web/src/db/schema/index.ts +++ b/apps/web/src/db/schema/index.ts @@ -5,20 +5,20 @@ * All tables, relations, and types are exported from here. */ -export * from "./abacus-settings"; -export * from "./arcade-rooms"; -export * from "./arcade-sessions"; -export * from "./players"; -export * from "./player-stats"; -export * from "./room-members"; -export * from "./room-member-history"; -export * from "./room-invitations"; -export * from "./room-reports"; -export * from "./room-bans"; -export * from "./room-join-requests"; -export * from "./room-game-configs"; -export * from "./user-stats"; -export * from "./users"; -export * from "./worksheet-settings"; -export * from "./worksheet-attempts"; -export * from "./worksheet-mastery"; +export * from './abacus-settings' +export * from './arcade-rooms' +export * from './arcade-sessions' +export * from './players' +export * from './player-stats' +export * from './room-members' +export * from './room-member-history' +export * from './room-invitations' +export * from './room-reports' +export * from './room-bans' +export * from './room-join-requests' +export * from './room-game-configs' +export * from './user-stats' +export * from './users' +export * from './worksheet-settings' +export * from './worksheet-attempts' +export * from './worksheet-mastery' diff --git a/apps/web/src/db/schema/player-stats.ts b/apps/web/src/db/schema/player-stats.ts index 6661e817..1b07fbf3 100644 --- a/apps/web/src/db/schema/player-stats.ts +++ b/apps/web/src/db/schema/player-stats.ts @@ -1,5 +1,5 @@ -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { players } from "./players"; +import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { players } from './players' /** * Player stats table - game statistics per player @@ -7,29 +7,29 @@ import { players } from "./players"; * Tracks aggregate performance and per-game breakdowns for each player. * One-to-one with players table. Deleted when player is deleted (cascade). */ -export const playerStats = sqliteTable("player_stats", { +export const playerStats = sqliteTable('player_stats', { /** Primary key and foreign key to players table */ - playerId: text("player_id") + playerId: text('player_id') .primaryKey() - .references(() => players.id, { onDelete: "cascade" }), + .references(() => players.id, { onDelete: 'cascade' }), /** Total number of games played across all game types */ - gamesPlayed: integer("games_played").notNull().default(0), + gamesPlayed: integer('games_played').notNull().default(0), /** Total number of games won */ - totalWins: integer("total_wins").notNull().default(0), + totalWins: integer('total_wins').notNull().default(0), /** Total number of games lost */ - totalLosses: integer("total_losses").notNull().default(0), + totalLosses: integer('total_losses').notNull().default(0), /** Best completion time in milliseconds (across all games) */ - bestTime: integer("best_time"), + bestTime: integer('best_time'), /** Highest accuracy percentage (0.0 - 1.0, across all games) */ - highestAccuracy: real("highest_accuracy").notNull().default(0), + highestAccuracy: real('highest_accuracy').notNull().default(0), /** Player's most-played game type */ - favoriteGameType: text("favorite_game_type"), + favoriteGameType: text('favorite_game_type'), /** * Per-game statistics breakdown (JSON) @@ -49,37 +49,37 @@ export const playerStats = sqliteTable("player_stats", { * ... * } */ - gameStats: text("game_stats", { mode: "json" }) + gameStats: text('game_stats', { mode: 'json' }) .notNull() - .default("{}") + .default('{}') .$type>(), /** When this player last played any game */ - lastPlayedAt: integer("last_played_at", { mode: "timestamp" }), + lastPlayedAt: integer('last_played_at', { mode: 'timestamp' }), /** When this record was created */ - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), /** When this record was last updated */ - updatedAt: integer("updated_at", { mode: "timestamp" }) + updatedAt: integer('updated_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), -}); +}) /** * Per-game stats breakdown stored in JSON */ export interface GameStatsBreakdown { - gamesPlayed: number; - wins: number; - losses: number; - bestTime: number | null; - highestAccuracy: number; - averageScore: number; - lastPlayed: number; // timestamp + gamesPlayed: number + wins: number + losses: number + bestTime: number | null + highestAccuracy: number + averageScore: number + lastPlayed: number // timestamp } -export type PlayerStats = typeof playerStats.$inferSelect; -export type NewPlayerStats = typeof playerStats.$inferInsert; +export type PlayerStats = typeof playerStats.$inferSelect +export type NewPlayerStats = typeof playerStats.$inferInsert diff --git a/apps/web/src/db/schema/players.ts b/apps/web/src/db/schema/players.ts index fe72b0fc..904bddfa 100644 --- a/apps/web/src/db/schema/players.ts +++ b/apps/web/src/db/schema/players.ts @@ -1,6 +1,6 @@ -import { createId } from "@paralleldrive/cuid2"; -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { users } from "./users"; +import { createId } from '@paralleldrive/cuid2' +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { users } from './users' /** * Players table - user-created player profiles for games @@ -9,41 +9,39 @@ import { users } from "./users"; * Players are scoped to a user and deleted when user is deleted. */ export const players = sqliteTable( - "players", + 'players', { - id: text("id") + id: text('id') .primaryKey() .$defaultFn(() => createId()), /** Foreign key to users table - cascades on delete */ - userId: text("user_id") + userId: text('user_id') .notNull() - .references(() => users.id, { onDelete: "cascade" }), + .references(() => users.id, { onDelete: 'cascade' }), /** Player display name */ - name: text("name").notNull(), + name: text('name').notNull(), /** Player emoji avatar */ - emoji: text("emoji").notNull(), + emoji: text('emoji').notNull(), /** Player color (hex) for UI theming */ - color: text("color").notNull(), + color: text('color').notNull(), /** Whether this player is currently active in games */ - isActive: integer("is_active", { mode: "boolean" }) - .notNull() - .default(false), + isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false), /** When this player was created */ - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), }, (table) => ({ /** Index for fast lookups by userId */ - userIdIdx: index("players_user_id_idx").on(table.userId), - }), -); + userIdIdx: index('players_user_id_idx').on(table.userId), + }) +) -export type Player = typeof players.$inferSelect; -export type NewPlayer = typeof players.$inferInsert; +export type Player = typeof players.$inferSelect +export type NewPlayer = typeof players.$inferInsert diff --git a/apps/web/src/db/schema/room-bans.ts b/apps/web/src/db/schema/room-bans.ts index 7ed18212..e6d2511a 100644 --- a/apps/web/src/db/schema/room-bans.ts +++ b/apps/web/src/db/schema/room-bans.ts @@ -1,60 +1,45 @@ -import { createId } from "@paralleldrive/cuid2"; -import { - integer, - sqliteTable, - text, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; -import { arcadeRooms } from "./arcade-rooms"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' +import { arcadeRooms } from './arcade-rooms' /** * Users banned from specific rooms by hosts */ export const roomBans = sqliteTable( - "room_bans", + 'room_bans', { - id: text("id") + id: text('id') .primaryKey() .$defaultFn(() => createId()), - roomId: text("room_id") + roomId: text('room_id') .notNull() - .references(() => arcadeRooms.id, { onDelete: "cascade" }), + .references(() => arcadeRooms.id, { onDelete: 'cascade' }), // Banned user - userId: text("user_id").notNull(), - userName: text("user_name", { length: 50 }).notNull(), // Name at time of ban + userId: text('user_id').notNull(), + userName: text('user_name', { length: 50 }).notNull(), // Name at time of ban // Who banned them - bannedBy: text("banned_by").notNull(), // Host user ID - bannedByName: text("banned_by_name", { length: 50 }).notNull(), + bannedBy: text('banned_by').notNull(), // Host user ID + bannedByName: text('banned_by_name', { length: 50 }).notNull(), // Ban details - reason: text("reason", { - enum: [ - "harassment", - "cheating", - "inappropriate-name", - "spam", - "afk", - "other", - ], + reason: text('reason', { + enum: ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other'], }).notNull(), - notes: text("notes", { length: 500 }), // Optional notes from host + notes: text('notes', { length: 500 }), // Optional notes from host // Timestamps - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), }, (table) => ({ // One ban record per user per room - userRoomIdx: uniqueIndex("idx_room_bans_user_room").on( - table.userId, - table.roomId, - ), - }), -); + userRoomIdx: uniqueIndex('idx_room_bans_user_room').on(table.userId, table.roomId), + }) +) -export type RoomBan = typeof roomBans.$inferSelect; -export type NewRoomBan = typeof roomBans.$inferInsert; +export type RoomBan = typeof roomBans.$inferSelect +export type NewRoomBan = typeof roomBans.$inferInsert diff --git a/apps/web/src/db/schema/room-game-configs.ts b/apps/web/src/db/schema/room-game-configs.ts index ad17e59d..54ed3290 100644 --- a/apps/web/src/db/schema/room-game-configs.ts +++ b/apps/web/src/db/schema/room-game-configs.ts @@ -1,31 +1,26 @@ -import { createId } from "@paralleldrive/cuid2"; -import { - integer, - sqliteTable, - text, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; -import { arcadeRooms } from "./arcade-rooms"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' +import { arcadeRooms } from './arcade-rooms' /** * Game-specific configuration settings for arcade rooms * Each row represents one game's settings for one room */ export const roomGameConfigs = sqliteTable( - "room_game_configs", + 'room_game_configs', { - id: text("id") + id: text('id') .primaryKey() .$defaultFn(() => createId()), // Room reference - roomId: text("room_id") + roomId: text('room_id') .notNull() - .references(() => arcadeRooms.id, { onDelete: "cascade" }), + .references(() => arcadeRooms.id, { onDelete: 'cascade' }), // Game identifier // Accepts any string - validation happens at runtime against validator registry - gameName: text("game_name").notNull(), + gameName: text('game_name').notNull(), // Game-specific configuration JSON // Structure depends on gameName: @@ -34,24 +29,21 @@ export const roomGameConfigs = sqliteTable( // - complement-race: TBD // - number-guesser: { minNumber, maxNumber, roundsToWin } // - math-sprint: { difficulty, questionsPerRound, timePerQuestion } - config: text("config", { mode: "json" }).notNull(), + config: text('config', { mode: 'json' }).notNull(), // Timestamps - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) + updatedAt: integer('updated_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), }, (table) => ({ // Ensure only one config per game per room - uniqueRoomGame: uniqueIndex("room_game_idx").on( - table.roomId, - table.gameName, - ), - }), -); + uniqueRoomGame: uniqueIndex('room_game_idx').on(table.roomId, table.gameName), + }) +) -export type RoomGameConfig = typeof roomGameConfigs.$inferSelect; -export type NewRoomGameConfig = typeof roomGameConfigs.$inferInsert; +export type RoomGameConfig = typeof roomGameConfigs.$inferSelect +export type NewRoomGameConfig = typeof roomGameConfigs.$inferInsert diff --git a/apps/web/src/db/schema/room-invitations.ts b/apps/web/src/db/schema/room-invitations.ts index 50e166ff..257f1399 100644 --- a/apps/web/src/db/schema/room-invitations.ts +++ b/apps/web/src/db/schema/room-invitations.ts @@ -1,69 +1,61 @@ -import { createId } from "@paralleldrive/cuid2"; -import { - integer, - sqliteTable, - text, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; -import { arcadeRooms } from "./arcade-rooms"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' +import { arcadeRooms } from './arcade-rooms' /** * Room invitations sent by hosts to users * Used to invite users back after unbanning or to invite new users */ export const roomInvitations = sqliteTable( - "room_invitations", + 'room_invitations', { - id: text("id") + id: text('id') .primaryKey() .$defaultFn(() => createId()), - roomId: text("room_id") + roomId: text('room_id') .notNull() - .references(() => arcadeRooms.id, { onDelete: "cascade" }), + .references(() => arcadeRooms.id, { onDelete: 'cascade' }), // Invited user - userId: text("user_id").notNull(), - userName: text("user_name", { length: 50 }).notNull(), // Name at time of invitation + userId: text('user_id').notNull(), + userName: text('user_name', { length: 50 }).notNull(), // Name at time of invitation // Who invited them - invitedBy: text("invited_by").notNull(), // Host user ID - invitedByName: text("invited_by_name", { length: 50 }).notNull(), + invitedBy: text('invited_by').notNull(), // Host user ID + invitedByName: text('invited_by_name', { length: 50 }).notNull(), // Invitation status - status: text("status", { - enum: ["pending", "accepted", "declined", "expired"], + status: text('status', { + enum: ['pending', 'accepted', 'declined', 'expired'], }) .notNull() - .default("pending"), + .default('pending'), // Type of invitation - invitationType: text("invitation_type", { - enum: ["manual", "auto-unban", "auto-create"], + invitationType: text('invitation_type', { + enum: ['manual', 'auto-unban', 'auto-create'], }) .notNull() - .default("manual"), + .default('manual'), // Optional message - message: text("message", { length: 500 }), + message: text('message', { length: 500 }), // Timestamps - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), - respondedAt: integer("responded_at", { mode: "timestamp" }), + respondedAt: integer('responded_at', { mode: 'timestamp' }), - expiresAt: integer("expires_at", { mode: "timestamp" }), // Optional expiration + expiresAt: integer('expires_at', { mode: 'timestamp' }), // Optional expiration }, (table) => ({ // One pending invitation per user per room - userRoomIdx: uniqueIndex("idx_room_invitations_user_room").on( - table.userId, - table.roomId, - ), - }), -); + userRoomIdx: uniqueIndex('idx_room_invitations_user_room').on(table.userId, table.roomId), + }) +) -export type RoomInvitation = typeof roomInvitations.$inferSelect; -export type NewRoomInvitation = typeof roomInvitations.$inferInsert; +export type RoomInvitation = typeof roomInvitations.$inferSelect +export type NewRoomInvitation = typeof roomInvitations.$inferInsert diff --git a/apps/web/src/db/schema/room-join-requests.ts b/apps/web/src/db/schema/room-join-requests.ts index 34473aff..d98b7638 100644 --- a/apps/web/src/db/schema/room-join-requests.ts +++ b/apps/web/src/db/schema/room-join-requests.ts @@ -1,53 +1,45 @@ -import { createId } from "@paralleldrive/cuid2"; -import { - integer, - sqliteTable, - text, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; -import { arcadeRooms } from "./arcade-rooms"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' +import { arcadeRooms } from './arcade-rooms' /** * Join requests for approval-only rooms */ export const roomJoinRequests = sqliteTable( - "room_join_requests", + 'room_join_requests', { - id: text("id") + id: text('id') .primaryKey() .$defaultFn(() => createId()), - roomId: text("room_id") + roomId: text('room_id') .notNull() - .references(() => arcadeRooms.id, { onDelete: "cascade" }), + .references(() => arcadeRooms.id, { onDelete: 'cascade' }), // Requesting user - userId: text("user_id").notNull(), - userName: text("user_name", { length: 50 }).notNull(), + userId: text('user_id').notNull(), + userName: text('user_name', { length: 50 }).notNull(), // Request status - status: text("status", { - enum: ["pending", "approved", "denied"], + status: text('status', { + enum: ['pending', 'approved', 'denied'], }) .notNull() - .default("pending"), + .default('pending'), // Timestamps - requestedAt: integer("requested_at", { mode: "timestamp" }) + requestedAt: integer('requested_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), - reviewedAt: integer("reviewed_at", { mode: "timestamp" }), - reviewedBy: text("reviewed_by"), // Host user ID who reviewed - reviewedByName: text("reviewed_by_name", { length: 50 }), + reviewedAt: integer('reviewed_at', { mode: 'timestamp' }), + reviewedBy: text('reviewed_by'), // Host user ID who reviewed + reviewedByName: text('reviewed_by_name', { length: 50 }), }, (table) => ({ // One pending request per user per room - userRoomIdx: uniqueIndex("idx_room_join_requests_user_room").on( - table.userId, - table.roomId, - ), - }), -); + userRoomIdx: uniqueIndex('idx_room_join_requests_user_room').on(table.userId, table.roomId), + }) +) -export type RoomJoinRequest = typeof roomJoinRequests.$inferSelect; -export type NewRoomJoinRequest = typeof roomJoinRequests.$inferInsert; +export type RoomJoinRequest = typeof roomJoinRequests.$inferSelect +export type NewRoomJoinRequest = typeof roomJoinRequests.$inferInsert diff --git a/apps/web/src/db/schema/room-member-history.ts b/apps/web/src/db/schema/room-member-history.ts index d406d226..25024095 100644 --- a/apps/web/src/db/schema/room-member-history.ts +++ b/apps/web/src/db/schema/room-member-history.ts @@ -1,30 +1,30 @@ -import { createId } from "@paralleldrive/cuid2"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { arcadeRooms } from "./arcade-rooms"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { arcadeRooms } from './arcade-rooms' /** * Historical record of all users who have ever been in a room * This table is append-only and tracks the complete history of room membership */ -export const roomMemberHistory = sqliteTable("room_member_history", { - id: text("id") +export const roomMemberHistory = sqliteTable('room_member_history', { + id: text('id') .primaryKey() .$defaultFn(() => createId()), - roomId: text("room_id") + roomId: text('room_id') .notNull() - .references(() => arcadeRooms.id, { onDelete: "cascade" }), + .references(() => arcadeRooms.id, { onDelete: 'cascade' }), - userId: text("user_id").notNull(), - displayName: text("display_name", { length: 50 }).notNull(), + userId: text('user_id').notNull(), + displayName: text('display_name', { length: 50 }).notNull(), // First time this user joined the room - firstJoinedAt: integer("first_joined_at", { mode: "timestamp" }) + firstJoinedAt: integer('first_joined_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), // Last time we saw this user in the room - lastSeenAt: integer("last_seen_at", { mode: "timestamp" }) + lastSeenAt: integer('last_seen_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), @@ -33,17 +33,17 @@ export const roomMemberHistory = sqliteTable("room_member_history", { // 'left' - voluntarily left // 'kicked' - kicked by host // 'banned' - banned by host - lastAction: text("last_action", { - enum: ["active", "left", "kicked", "banned"], + lastAction: text('last_action', { + enum: ['active', 'left', 'kicked', 'banned'], }) .notNull() - .default("active"), + .default('active'), // When the last action occurred - lastActionAt: integer("last_action_at", { mode: "timestamp" }) + lastActionAt: integer('last_action_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), -}); +}) -export type RoomMemberHistory = typeof roomMemberHistory.$inferSelect; -export type NewRoomMemberHistory = typeof roomMemberHistory.$inferInsert; +export type RoomMemberHistory = typeof roomMemberHistory.$inferSelect +export type NewRoomMemberHistory = typeof roomMemberHistory.$inferInsert diff --git a/apps/web/src/db/schema/room-members.ts b/apps/web/src/db/schema/room-members.ts index 1d5a1281..308aebf3 100644 --- a/apps/web/src/db/schema/room-members.ts +++ b/apps/web/src/db/schema/room-members.ts @@ -1,43 +1,36 @@ -import { createId } from "@paralleldrive/cuid2"; -import { - integer, - sqliteTable, - text, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; -import { arcadeRooms } from "./arcade-rooms"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' +import { arcadeRooms } from './arcade-rooms' export const roomMembers = sqliteTable( - "room_members", + 'room_members', { - id: text("id") + id: text('id') .primaryKey() .$defaultFn(() => createId()), - roomId: text("room_id") + roomId: text('room_id') .notNull() - .references(() => arcadeRooms.id, { onDelete: "cascade" }), + .references(() => arcadeRooms.id, { onDelete: 'cascade' }), - userId: text("user_id").notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below) - displayName: text("display_name", { length: 50 }).notNull(), + userId: text('user_id').notNull(), // User/guest ID - UNIQUE: one room per user (enforced by index below) + displayName: text('display_name', { length: 50 }).notNull(), - isCreator: integer("is_creator", { mode: "boolean" }) - .notNull() - .default(false), + isCreator: integer('is_creator', { mode: 'boolean' }).notNull().default(false), - joinedAt: integer("joined_at", { mode: "timestamp" }) + joinedAt: integer('joined_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), - lastSeen: integer("last_seen", { mode: "timestamp" }) + lastSeen: integer('last_seen', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), - isOnline: integer("is_online", { mode: "boolean" }).notNull().default(true), + isOnline: integer('is_online', { mode: 'boolean' }).notNull().default(true), }, (table) => ({ // Explicit unique index for clarity and database-level enforcement - userIdIdx: uniqueIndex("idx_room_members_user_id_unique").on(table.userId), - }), -); + userIdIdx: uniqueIndex('idx_room_members_user_id_unique').on(table.userId), + }) +) -export type RoomMember = typeof roomMembers.$inferSelect; -export type NewRoomMember = typeof roomMembers.$inferInsert; +export type RoomMember = typeof roomMembers.$inferSelect +export type NewRoomMember = typeof roomMembers.$inferInsert diff --git a/apps/web/src/db/schema/room-reports.ts b/apps/web/src/db/schema/room-reports.ts index 8dc32cbe..b6baa405 100644 --- a/apps/web/src/db/schema/room-reports.ts +++ b/apps/web/src/db/schema/room-reports.ts @@ -1,54 +1,47 @@ -import { createId } from "@paralleldrive/cuid2"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { arcadeRooms } from "./arcade-rooms"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { arcadeRooms } from './arcade-rooms' /** * Reports submitted by room members about other members */ -export const roomReports = sqliteTable("room_reports", { - id: text("id") +export const roomReports = sqliteTable('room_reports', { + id: text('id') .primaryKey() .$defaultFn(() => createId()), - roomId: text("room_id") + roomId: text('room_id') .notNull() - .references(() => arcadeRooms.id, { onDelete: "cascade" }), + .references(() => arcadeRooms.id, { onDelete: 'cascade' }), // Who reported - reporterId: text("reporter_id").notNull(), - reporterName: text("reporter_name", { length: 50 }).notNull(), + reporterId: text('reporter_id').notNull(), + reporterName: text('reporter_name', { length: 50 }).notNull(), // Who was reported - reportedUserId: text("reported_user_id").notNull(), - reportedUserName: text("reported_user_name", { length: 50 }).notNull(), + reportedUserId: text('reported_user_id').notNull(), + reportedUserName: text('reported_user_name', { length: 50 }).notNull(), // Report details - reason: text("reason", { - enum: [ - "harassment", - "cheating", - "inappropriate-name", - "spam", - "afk", - "other", - ], + reason: text('reason', { + enum: ['harassment', 'cheating', 'inappropriate-name', 'spam', 'afk', 'other'], }).notNull(), - details: text("details", { length: 500 }), // Optional additional context + details: text('details', { length: 500 }), // Optional additional context // Status tracking - status: text("status", { - enum: ["pending", "reviewed", "dismissed"], + status: text('status', { + enum: ['pending', 'reviewed', 'dismissed'], }) .notNull() - .default("pending"), + .default('pending'), // Timestamps - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), - reviewedAt: integer("reviewed_at", { mode: "timestamp" }), - reviewedBy: text("reviewed_by"), // Host user ID who reviewed -}); + reviewedAt: integer('reviewed_at', { mode: 'timestamp' }), + reviewedBy: text('reviewed_by'), // Host user ID who reviewed +}) -export type RoomReport = typeof roomReports.$inferSelect; -export type NewRoomReport = typeof roomReports.$inferInsert; +export type RoomReport = typeof roomReports.$inferSelect +export type NewRoomReport = typeof roomReports.$inferInsert diff --git a/apps/web/src/db/schema/user-stats.ts b/apps/web/src/db/schema/user-stats.ts index fbe0bc32..2e63fd83 100644 --- a/apps/web/src/db/schema/user-stats.ts +++ b/apps/web/src/db/schema/user-stats.ts @@ -1,5 +1,5 @@ -import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { users } from "./users"; +import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { users } from './users' /** * User stats table - game statistics per user @@ -7,29 +7,29 @@ import { users } from "./users"; * One-to-one with users table. Tracks aggregate game performance. * Deleted when user is deleted (cascade). */ -export const userStats = sqliteTable("user_stats", { +export const userStats = sqliteTable('user_stats', { /** Primary key and foreign key to users table */ - userId: text("user_id") + userId: text('user_id') .primaryKey() - .references(() => users.id, { onDelete: "cascade" }), + .references(() => users.id, { onDelete: 'cascade' }), /** Total number of games played */ - gamesPlayed: integer("games_played").notNull().default(0), + gamesPlayed: integer('games_played').notNull().default(0), /** Total number of games won */ - totalWins: integer("total_wins").notNull().default(0), + totalWins: integer('total_wins').notNull().default(0), /** User's most-played game type */ - favoriteGameType: text("favorite_game_type", { - enum: ["abacus-numeral", "complement-pairs"], + favoriteGameType: text('favorite_game_type', { + enum: ['abacus-numeral', 'complement-pairs'], }), /** Best completion time in milliseconds */ - bestTime: integer("best_time"), + bestTime: integer('best_time'), /** Highest accuracy percentage (0.0 - 1.0) */ - highestAccuracy: real("highest_accuracy").notNull().default(0), -}); + highestAccuracy: real('highest_accuracy').notNull().default(0), +}) -export type UserStats = typeof userStats.$inferSelect; -export type NewUserStats = typeof userStats.$inferInsert; +export type UserStats = typeof userStats.$inferSelect +export type NewUserStats = typeof userStats.$inferInsert diff --git a/apps/web/src/db/schema/users.ts b/apps/web/src/db/schema/users.ts index fce0043d..fcd4cd53 100644 --- a/apps/web/src/db/schema/users.ts +++ b/apps/web/src/db/schema/users.ts @@ -1,5 +1,5 @@ -import { createId } from "@paralleldrive/cuid2"; -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createId } from '@paralleldrive/cuid2' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' /** * Users table - stores both guest and authenticated users @@ -7,28 +7,28 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; * Guest users are created automatically on first visit via middleware. * They can upgrade to full accounts later while preserving their data. */ -export const users = sqliteTable("users", { - id: text("id") +export const users = sqliteTable('users', { + id: text('id') .primaryKey() .$defaultFn(() => createId()), /** Stable guest ID from HttpOnly cookie - unique per browser session */ - guestId: text("guest_id").notNull().unique(), + guestId: text('guest_id').notNull().unique(), /** When this user record was created */ - createdAt: integer("created_at", { mode: "timestamp" }) + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() .$defaultFn(() => new Date()), /** When guest upgraded to full account (null for guests) */ - upgradedAt: integer("upgraded_at", { mode: "timestamp" }), + upgradedAt: integer('upgraded_at', { mode: 'timestamp' }), /** Email (only set after upgrade) */ - email: text("email").unique(), + email: text('email').unique(), /** Display name (only set after upgrade) */ - name: text("name"), -}); + name: text('name'), +}) -export type User = typeof users.$inferSelect; -export type NewUser = typeof users.$inferInsert; +export type User = typeof users.$inferSelect +export type NewUser = typeof users.$inferInsert diff --git a/apps/web/src/db/schema/worksheet-settings.ts b/apps/web/src/db/schema/worksheet-settings.ts index c1547832..141daf6f 100644 --- a/apps/web/src/db/schema/worksheet-settings.ts +++ b/apps/web/src/db/schema/worksheet-settings.ts @@ -1,4 +1,4 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' /** * Worksheet generator settings table - persists user preferences per worksheet type @@ -12,25 +12,25 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; * Note: No foreign key constraint - allows guest users to save settings * (matches pattern used by room_members table) */ -export const worksheetSettings = sqliteTable("worksheet_settings", { +export const worksheetSettings = sqliteTable('worksheet_settings', { /** Unique identifier (UUID) */ - id: text("id").primaryKey(), + id: text('id').primaryKey(), /** User ID (may be authenticated user or guest ID) */ - userId: text("user_id").notNull(), + userId: text('user_id').notNull(), /** Type of worksheet: 'addition', 'subtraction', 'multiplication', etc. */ - worksheetType: text("worksheet_type").notNull(), + worksheetType: text('worksheet_type').notNull(), /** JSON blob containing versioned settings (see config-schemas.ts for types) */ - config: text("config").notNull(), + config: text('config').notNull(), /** Timestamp of creation */ - createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), /** Timestamp of last update */ - updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), -}); + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}) -export type WorksheetSettings = typeof worksheetSettings.$inferSelect; -export type NewWorksheetSettings = typeof worksheetSettings.$inferInsert; +export type WorksheetSettings = typeof worksheetSettings.$inferSelect +export type NewWorksheetSettings = typeof worksheetSettings.$inferInsert diff --git a/apps/web/src/hooks/__tests__/useOptimisticGameState.test.ts b/apps/web/src/hooks/__tests__/useOptimisticGameState.test.ts index ef258bd6..65b3453d 100644 --- a/apps/web/src/hooks/__tests__/useOptimisticGameState.test.ts +++ b/apps/web/src/hooks/__tests__/useOptimisticGameState.test.ts @@ -1,271 +1,271 @@ -import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import type { GameMove } from "@/lib/arcade/validation"; -import { useOptimisticGameState } from "../useOptimisticGameState"; +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { GameMove } from '@/lib/arcade/validation' +import { useOptimisticGameState } from '../useOptimisticGameState' interface TestGameState { - value: number; - moves: number; + value: number + moves: number } -describe("useOptimisticGameState", () => { - const initialState: TestGameState = { value: 0, moves: 0 }; +describe('useOptimisticGameState', () => { + const initialState: TestGameState = { value: 0, moves: 0 } const applyMove = (state: TestGameState, move: GameMove): TestGameState => { - if (move.type === "INCREMENT") { - return { value: state.value + 1, moves: state.moves + 1 }; + if (move.type === 'INCREMENT') { + return { value: state.value + 1, moves: state.moves + 1 } } - return state; - }; + return state + } - it("should initialize with initial state", () => { + it('should initialize with initial state', () => { const { result } = renderHook(() => useOptimisticGameState({ initialState, applyMove, - }), - ); + }) + ) - expect(result.current.state).toEqual(initialState); - expect(result.current.version).toBe(1); - expect(result.current.hasPendingMoves).toBe(false); - }); + expect(result.current.state).toEqual(initialState) + expect(result.current.version).toBe(1) + expect(result.current.hasPendingMoves).toBe(false) + }) - it("should apply optimistic move immediately", () => { + it('should apply optimistic move immediately', () => { const { result } = renderHook(() => useOptimisticGameState({ initialState, applyMove, - }), - ); + }) + ) const move: GameMove = { - type: "INCREMENT", - playerId: "test", - userId: "test-user", + type: 'INCREMENT', + playerId: 'test', + userId: 'test-user', timestamp: Date.now(), data: {}, - }; + } act(() => { - result.current.applyOptimisticMove(move); - }); + result.current.applyOptimisticMove(move) + }) - expect(result.current.state).toEqual({ value: 1, moves: 1 }); - expect(result.current.hasPendingMoves).toBe(true); - }); + expect(result.current.state).toEqual({ value: 1, moves: 1 }) + expect(result.current.hasPendingMoves).toBe(true) + }) - it("should handle move acceptance from server", () => { + it('should handle move acceptance from server', () => { const { result } = renderHook(() => useOptimisticGameState({ initialState, applyMove, - }), - ); + }) + ) const move: GameMove = { - type: "INCREMENT", - playerId: "test", - userId: "test-user", + type: 'INCREMENT', + playerId: 'test', + userId: 'test-user', timestamp: 123, data: {}, - }; + } // Apply optimistically act(() => { - result.current.applyOptimisticMove(move); - }); + result.current.applyOptimisticMove(move) + }) - expect(result.current.hasPendingMoves).toBe(true); + expect(result.current.hasPendingMoves).toBe(true) // Server accepts - const serverState: TestGameState = { value: 1, moves: 1 }; + const serverState: TestGameState = { value: 1, moves: 1 } act(() => { - result.current.handleMoveAccepted(serverState, 2, move); - }); + result.current.handleMoveAccepted(serverState, 2, move) + }) - expect(result.current.state).toEqual(serverState); - expect(result.current.version).toBe(2); - expect(result.current.hasPendingMoves).toBe(false); - }); + expect(result.current.state).toEqual(serverState) + expect(result.current.version).toBe(2) + expect(result.current.hasPendingMoves).toBe(false) + }) - it("should handle move rejection from server", () => { - const onMoveRejected = vi.fn(); + it('should handle move rejection from server', () => { + const onMoveRejected = vi.fn() const { result } = renderHook(() => useOptimisticGameState({ initialState, applyMove, onMoveRejected, - }), - ); + }) + ) const move: GameMove = { - type: "INCREMENT", - playerId: "test", - userId: "test-user", + type: 'INCREMENT', + playerId: 'test', + userId: 'test-user', timestamp: 123, data: {}, - }; + } // Apply optimistically act(() => { - result.current.applyOptimisticMove(move); - }); + result.current.applyOptimisticMove(move) + }) - expect(result.current.state).toEqual({ value: 1, moves: 1 }); + expect(result.current.state).toEqual({ value: 1, moves: 1 }) // Server rejects act(() => { - result.current.handleMoveRejected("Invalid move", move); - }); + result.current.handleMoveRejected('Invalid move', move) + }) // Should rollback to initial state - expect(result.current.state).toEqual(initialState); - expect(result.current.hasPendingMoves).toBe(false); - expect(onMoveRejected).toHaveBeenCalledWith("Invalid move", move); - }); + expect(result.current.state).toEqual(initialState) + expect(result.current.hasPendingMoves).toBe(false) + expect(onMoveRejected).toHaveBeenCalledWith('Invalid move', move) + }) - it("should handle multiple pending moves", () => { + it('should handle multiple pending moves', () => { const { result } = renderHook(() => useOptimisticGameState({ initialState, applyMove, - }), - ); + }) + ) const move1: GameMove = { - type: "INCREMENT", - playerId: "test", - userId: "test-user", + type: 'INCREMENT', + playerId: 'test', + userId: 'test-user', timestamp: 123, data: {}, - }; + } const move2: GameMove = { - type: "INCREMENT", - playerId: "test", - userId: "test-user", + type: 'INCREMENT', + playerId: 'test', + userId: 'test-user', timestamp: 124, data: {}, - }; + } // Apply two moves optimistically act(() => { - result.current.applyOptimisticMove(move1); - result.current.applyOptimisticMove(move2); - }); + result.current.applyOptimisticMove(move1) + result.current.applyOptimisticMove(move2) + }) - expect(result.current.state).toEqual({ value: 2, moves: 2 }); - expect(result.current.hasPendingMoves).toBe(true); + expect(result.current.state).toEqual({ value: 2, moves: 2 }) + expect(result.current.hasPendingMoves).toBe(true) // Server accepts first move act(() => { - result.current.handleMoveAccepted({ value: 1, moves: 1 }, 2, move1); - }); + result.current.handleMoveAccepted({ value: 1, moves: 1 }, 2, move1) + }) // Should still have second move pending - expect(result.current.state).toEqual({ value: 2, moves: 2 }); - expect(result.current.hasPendingMoves).toBe(true); + expect(result.current.state).toEqual({ value: 2, moves: 2 }) + expect(result.current.hasPendingMoves).toBe(true) // Server accepts second move act(() => { - result.current.handleMoveAccepted({ value: 2, moves: 2 }, 3, move2); - }); + result.current.handleMoveAccepted({ value: 2, moves: 2 }, 3, move2) + }) - expect(result.current.state).toEqual({ value: 2, moves: 2 }); - expect(result.current.hasPendingMoves).toBe(false); - }); + expect(result.current.state).toEqual({ value: 2, moves: 2 }) + expect(result.current.hasPendingMoves).toBe(false) + }) - it("should sync with server state", () => { + it('should sync with server state', () => { const { result } = renderHook(() => useOptimisticGameState({ initialState, applyMove, - }), - ); + }) + ) // Apply some optimistic moves act(() => { result.current.applyOptimisticMove({ - type: "INCREMENT", - playerId: "test", - userId: "test-user", + type: 'INCREMENT', + playerId: 'test', + userId: 'test-user', timestamp: 123, data: {}, - }); - }); + }) + }) - expect(result.current.hasPendingMoves).toBe(true); + expect(result.current.hasPendingMoves).toBe(true) // Sync with server (e.g., on reconnect) - const serverState: TestGameState = { value: 5, moves: 5 }; + const serverState: TestGameState = { value: 5, moves: 5 } act(() => { - result.current.syncWithServer(serverState, 10); - }); + result.current.syncWithServer(serverState, 10) + }) - expect(result.current.state).toEqual(serverState); - expect(result.current.version).toBe(10); - expect(result.current.hasPendingMoves).toBe(false); - }); + expect(result.current.state).toEqual(serverState) + expect(result.current.version).toBe(10) + expect(result.current.hasPendingMoves).toBe(false) + }) - it("should reset to initial state", () => { + it('should reset to initial state', () => { const { result } = renderHook(() => useOptimisticGameState({ initialState, applyMove, - }), - ); + }) + ) // Make some changes act(() => { result.current.applyOptimisticMove({ - type: "INCREMENT", - playerId: "test", - userId: "test-user", + type: 'INCREMENT', + playerId: 'test', + userId: 'test-user', timestamp: 123, data: {}, - }); - }); + }) + }) - expect(result.current.state).not.toEqual(initialState); + expect(result.current.state).not.toEqual(initialState) // Reset act(() => { - result.current.reset(); - }); + result.current.reset() + }) - expect(result.current.state).toEqual(initialState); - expect(result.current.version).toBe(1); - expect(result.current.hasPendingMoves).toBe(false); - }); + expect(result.current.state).toEqual(initialState) + expect(result.current.version).toBe(1) + expect(result.current.hasPendingMoves).toBe(false) + }) - it("should call onMoveAccepted callback", () => { - const onMoveAccepted = vi.fn(); + it('should call onMoveAccepted callback', () => { + const onMoveAccepted = vi.fn() const { result } = renderHook(() => useOptimisticGameState({ initialState, applyMove, onMoveAccepted, - }), - ); + }) + ) const move: GameMove = { - type: "INCREMENT", - playerId: "test", - userId: "test-user", + type: 'INCREMENT', + playerId: 'test', + userId: 'test-user', timestamp: 123, data: {}, - }; + } act(() => { - result.current.applyOptimisticMove(move); - }); + result.current.applyOptimisticMove(move) + }) - const serverState: TestGameState = { value: 1, moves: 1 }; + const serverState: TestGameState = { value: 1, moves: 1 } act(() => { - result.current.handleMoveAccepted(serverState, 2, move); - }); + result.current.handleMoveAccepted(serverState, 2, move) + }) - expect(onMoveAccepted).toHaveBeenCalledWith(serverState, move); - }); -}); + expect(onMoveAccepted).toHaveBeenCalledWith(serverState, move) + }) +}) diff --git a/apps/web/src/hooks/__tests__/useRoomData.test.tsx b/apps/web/src/hooks/__tests__/useRoomData.test.tsx index 8c72ef07..ecbeb7cb 100644 --- a/apps/web/src/hooks/__tests__/useRoomData.test.tsx +++ b/apps/web/src/hooks/__tests__/useRoomData.test.tsx @@ -1,21 +1,21 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' import { useCreateRoom, useGetRoomByCode, useJoinRoom, useLeaveRoom, useRoomData, -} from "../useRoomData"; +} from '../useRoomData' // Mock the useViewerId hook -vi.mock("../useViewerId", () => ({ - useViewerId: () => ({ data: "test-user-id" }), -})); +vi.mock('../useViewerId', () => ({ + useViewerId: () => ({ data: 'test-user-id' }), +})) // Mock socket.io-client -vi.mock("socket.io-client", () => ({ +vi.mock('socket.io-client', () => ({ io: vi.fn(() => ({ on: vi.fn(), off: vi.fn(), @@ -23,10 +23,10 @@ vi.mock("socket.io-client", () => ({ disconnect: vi.fn(), connected: false, })), -})); +})) -describe("useRoomData hooks", () => { - let queryClient: QueryClient; +describe('useRoomData hooks', () => { + let queryClient: QueryClient beforeEach(() => { queryClient = new QueryClient({ @@ -34,405 +34,403 @@ describe("useRoomData hooks", () => { queries: { retry: false }, mutations: { retry: false }, }, - }); - vi.clearAllMocks(); - global.fetch = vi.fn(); - }); + }) + vi.clearAllMocks() + global.fetch = vi.fn() + }) const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} - ); + ) - describe("useRoomData", () => { - test("returns null roomData when not in a room", async () => { + describe('useRoomData', () => { + test('returns null roomData when not in a room', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, - }); + }) - const { result } = renderHook(() => useRoomData(), { wrapper }); + const { result } = renderHook(() => useRoomData(), { wrapper }) await waitFor(() => { - expect(result.current.roomData).toBeNull(); - expect(result.current.isInRoom).toBe(false); - }); - }); + expect(result.current.roomData).toBeNull() + expect(result.current.isInRoom).toBe(false) + }) + }) - test("returns room data when user is in a room", async () => { + test('returns room data when user is in a room', async () => { const mockRoomData = { room: { - id: "room-123", - name: "Test Room", - code: "ABC123", - gameName: "matching", + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', }, members: [], memberPlayers: {}, - }; + } global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockRoomData, - }); + }) - const { result } = renderHook(() => useRoomData(), { wrapper }); + const { result } = renderHook(() => useRoomData(), { wrapper }) await waitFor(() => { expect(result.current.roomData).toEqual({ - id: "room-123", - name: "Test Room", - code: "ABC123", - gameName: "matching", + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', members: [], memberPlayers: {}, - }); - expect(result.current.isInRoom).toBe(true); - }); - }); + }) + expect(result.current.isInRoom).toBe(true) + }) + }) - test("provides getRoomShareUrl function", () => { - const { result } = renderHook(() => useRoomData(), { wrapper }); + test('provides getRoomShareUrl function', () => { + const { result } = renderHook(() => useRoomData(), { wrapper }) - const url = result.current.getRoomShareUrl("ABC123"); - expect(url).toContain("/join/ABC123"); - }); - }); + const url = result.current.getRoomShareUrl('ABC123') + expect(url).toContain('/join/ABC123') + }) + }) - describe("useCreateRoom", () => { - test("creates a room successfully", async () => { + describe('useCreateRoom', () => { + test('creates a room successfully', async () => { const mockCreatedRoom = { room: { - id: "new-room-123", - name: "New Room", - code: "XYZ789", - gameName: "matching", + id: 'new-room-123', + name: 'New Room', + code: 'XYZ789', + gameName: 'matching', }, members: [], memberPlayers: {}, - }; + } global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockCreatedRoom, - }); + }) - const { result } = renderHook(() => useCreateRoom(), { wrapper }); + const { result } = renderHook(() => useCreateRoom(), { wrapper }) - let createdRoom: any; + let createdRoom: any result.current.mutate( { - name: "New Room", - gameName: "matching", - creatorName: "Player 1", + name: 'New Room', + gameName: 'matching', + creatorName: 'Player 1', }, { onSuccess: (data) => { - createdRoom = data; + createdRoom = data }, - }, - ); + } + ) await waitFor(() => { expect(createdRoom).toEqual({ - id: "new-room-123", - name: "New Room", - code: "XYZ789", - gameName: "matching", + id: 'new-room-123', + name: 'New Room', + code: 'XYZ789', + gameName: 'matching', members: [], memberPlayers: {}, - }); - }); + }) + }) // Verify fetch was called correctly expect(global.fetch).toHaveBeenCalledWith( - "/api/arcade/rooms", + '/api/arcade/rooms', expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - name: "New Room", - gameName: "matching", - creatorName: "Player 1", + name: 'New Room', + gameName: 'matching', + creatorName: 'Player 1', gameConfig: { difficulty: 6 }, }), - }), - ); - }); + }) + ) + }) - test("handles create room error", async () => { + test('handles create room error', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, - json: async () => ({ error: "Invalid game name" }), - }); + json: async () => ({ error: 'Invalid game name' }), + }) - const { result } = renderHook(() => useCreateRoom(), { wrapper }); + const { result } = renderHook(() => useCreateRoom(), { wrapper }) - let error: any; + let error: any result.current.mutate( { - name: "Bad Room", - gameName: "invalid-game" as any, - creatorName: "Player 1", + name: 'Bad Room', + gameName: 'invalid-game' as any, + creatorName: 'Player 1', }, { onError: (err) => { - error = err; + error = err }, - }, - ); + } + ) await waitFor(() => { - expect(error).toBeDefined(); - expect(error.message).toContain("Invalid game name"); - }); - }); + expect(error).toBeDefined() + expect(error.message).toContain('Invalid game name') + }) + }) - test("updates cache after creating room", async () => { + test('updates cache after creating room', async () => { const mockCreatedRoom = { room: { - id: "new-room-123", - name: "New Room", - code: "XYZ789", - gameName: "matching", + id: 'new-room-123', + name: 'New Room', + code: 'XYZ789', + gameName: 'matching', }, members: [], memberPlayers: {}, - }; + } global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockCreatedRoom, - }); + }) - const { result } = renderHook(() => useCreateRoom(), { wrapper }); + const { result } = renderHook(() => useCreateRoom(), { wrapper }) result.current.mutate({ - name: "New Room", - gameName: "matching", - creatorName: "Player 1", - }); + name: 'New Room', + gameName: 'matching', + creatorName: 'Player 1', + }) await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); + expect(result.current.isSuccess).toBe(true) + }) // Verify cache was updated - const cachedData = queryClient.getQueryData(["rooms", "current"]); + const cachedData = queryClient.getQueryData(['rooms', 'current']) expect(cachedData).toEqual({ - id: "new-room-123", - name: "New Room", - code: "XYZ789", - gameName: "matching", + id: 'new-room-123', + name: 'New Room', + code: 'XYZ789', + gameName: 'matching', members: [], memberPlayers: {}, - }); - }); - }); + }) + }) + }) - describe("useJoinRoom", () => { - test("joins a room successfully", async () => { + describe('useJoinRoom', () => { + test('joins a room successfully', async () => { const mockJoinResult = { member: { - id: "member-1", - userId: "test-user-id", - displayName: "Player 1", + id: 'member-1', + userId: 'test-user-id', + displayName: 'Player 1', isOnline: true, isCreator: false, }, room: { - id: "room-123", - name: "Test Room", - code: "ABC123", - gameName: "matching", + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', members: [], memberPlayers: {}, }, members: [], memberPlayers: {}, activePlayers: [], - }; + } global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockJoinResult, - }); + }) - const { result } = renderHook(() => useJoinRoom(), { wrapper }); + const { result } = renderHook(() => useJoinRoom(), { wrapper }) - let joinedRoom: any; + let joinedRoom: any result.current.mutate( { - roomId: "room-123", - displayName: "Player 1", + roomId: 'room-123', + displayName: 'Player 1', }, { onSuccess: (data) => { - joinedRoom = data; + joinedRoom = data }, - }, - ); + } + ) await waitFor(() => { - expect(joinedRoom).toEqual(mockJoinResult); - }); + expect(joinedRoom).toEqual(mockJoinResult) + }) // Verify fetch was called correctly expect(global.fetch).toHaveBeenCalledWith( - "/api/arcade/rooms/room-123/join", + '/api/arcade/rooms/room-123/join', expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ displayName: "Player 1" }), - }), - ); - }); + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName: 'Player 1' }), + }) + ) + }) - test("handles join room error", async () => { + test('handles join room error', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, - json: async () => ({ error: "Room is locked" }), - }); + json: async () => ({ error: 'Room is locked' }), + }) - const { result } = renderHook(() => useJoinRoom(), { wrapper }); + const { result } = renderHook(() => useJoinRoom(), { wrapper }) - let error: any; + let error: any result.current.mutate( { - roomId: "locked-room", - displayName: "Player 1", + roomId: 'locked-room', + displayName: 'Player 1', }, { onError: (err) => { - error = err; + error = err }, - }, - ); + } + ) await waitFor(() => { - expect(error).toBeDefined(); - expect(error.message).toContain("Room is locked"); - }); - }); - }); + expect(error).toBeDefined() + expect(error.message).toContain('Room is locked') + }) + }) + }) - describe("useLeaveRoom", () => { - test("leaves a room successfully", async () => { + describe('useLeaveRoom', () => { + test('leaves a room successfully', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, - }); + }) - const { result } = renderHook(() => useLeaveRoom(), { wrapper }); + const { result } = renderHook(() => useLeaveRoom(), { wrapper }) - let success = false; - result.current.mutate("room-123", { + let success = false + result.current.mutate('room-123', { onSuccess: () => { - success = true; + success = true }, - }); + }) await waitFor(() => { - expect(success).toBe(true); - }); + expect(success).toBe(true) + }) // Verify fetch was called correctly expect(global.fetch).toHaveBeenCalledWith( - "/api/arcade/rooms/room-123/leave", + '/api/arcade/rooms/room-123/leave', expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - }), - ); + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + ) // Verify cache was cleared - const cachedData = queryClient.getQueryData(["rooms", "current"]); - expect(cachedData).toBeNull(); - }); + const cachedData = queryClient.getQueryData(['rooms', 'current']) + expect(cachedData).toBeNull() + }) - test("handles leave room error", async () => { + test('handles leave room error', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, - json: async () => ({ error: "Not in room" }), - }); + json: async () => ({ error: 'Not in room' }), + }) - const { result } = renderHook(() => useLeaveRoom(), { wrapper }); + const { result } = renderHook(() => useLeaveRoom(), { wrapper }) - let error: any; - result.current.mutate("room-123", { + let error: any + result.current.mutate('room-123', { onError: (err) => { - error = err; + error = err }, - }); + }) await waitFor(() => { - expect(error).toBeDefined(); - expect(error.message).toContain("Not in room"); - }); - }); - }); + expect(error).toBeDefined() + expect(error.message).toContain('Not in room') + }) + }) + }) - describe("useGetRoomByCode", () => { - test("fetches room by code successfully", async () => { + describe('useGetRoomByCode', () => { + test('fetches room by code successfully', async () => { const mockRoom = { room: { - id: "room-123", - name: "Test Room", - code: "ABC123", - gameName: "matching", + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', }, members: [], memberPlayers: {}, - }; + } global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockRoom, - }); + }) - const { result } = renderHook(() => useGetRoomByCode(), { wrapper }); + const { result } = renderHook(() => useGetRoomByCode(), { wrapper }) - let fetchedRoom: any; - result.current.mutate("ABC123", { + let fetchedRoom: any + result.current.mutate('ABC123', { onSuccess: (data) => { - fetchedRoom = data; + fetchedRoom = data }, - }); + }) await waitFor(() => { expect(fetchedRoom).toEqual({ - id: "room-123", - name: "Test Room", - code: "ABC123", - gameName: "matching", + id: 'room-123', + name: 'Test Room', + code: 'ABC123', + gameName: 'matching', members: [], memberPlayers: {}, - }); - }); + }) + }) // Verify fetch was called correctly - expect(global.fetch).toHaveBeenCalledWith( - "/api/arcade/rooms/code/ABC123", - ); - }); + expect(global.fetch).toHaveBeenCalledWith('/api/arcade/rooms/code/ABC123') + }) - test("handles room not found error", async () => { + test('handles room not found error', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, - }); + }) - const { result } = renderHook(() => useGetRoomByCode(), { wrapper }); + const { result } = renderHook(() => useGetRoomByCode(), { wrapper }) - let error: any; - result.current.mutate("INVALID", { + let error: any + result.current.mutate('INVALID', { onError: (err) => { - error = err; + error = err }, - }); + }) await waitFor(() => { - expect(error).toBeDefined(); - expect(error.message).toBe("Room not found"); - }); - }); - }); -}); + expect(error).toBeDefined() + expect(error.message).toBe('Room not found') + }) + }) + }) +}) diff --git a/apps/web/src/hooks/useAbacusSettings.ts b/apps/web/src/hooks/useAbacusSettings.ts index 2fbdf7de..fcaba0c2 100644 --- a/apps/web/src/hooks/useAbacusSettings.ts +++ b/apps/web/src/hooks/useAbacusSettings.ts @@ -1,41 +1,41 @@ -"use client"; +'use client' -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { AbacusSettings } from "@/db/schema/abacus-settings"; -import { api } from "@/lib/queryClient"; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { AbacusSettings } from '@/db/schema/abacus-settings' +import { api } from '@/lib/queryClient' /** * Query key factory for abacus settings */ export const abacusSettingsKeys = { - all: ["abacus-settings"] as const, - detail: () => [...abacusSettingsKeys.all, "detail"] as const, -}; + all: ['abacus-settings'] as const, + detail: () => [...abacusSettingsKeys.all, 'detail'] as const, +} /** * Fetch abacus display settings */ async function fetchAbacusSettings(): Promise { - const res = await api("abacus-settings"); - if (!res.ok) throw new Error("Failed to fetch abacus settings"); - const data = await res.json(); - return data.settings; + const res = await api('abacus-settings') + if (!res.ok) throw new Error('Failed to fetch abacus settings') + const data = await res.json() + return data.settings } /** * Update abacus display settings */ async function updateAbacusSettings( - updates: Partial>, + updates: Partial> ): Promise { - const res = await api("abacus-settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + const res = await api('abacus-settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), - }); - if (!res.ok) throw new Error("Failed to update abacus settings"); - const data = await res.json(); - return data.settings; + }) + if (!res.ok) throw new Error('Failed to update abacus settings') + const data = await res.json() + return data.settings } /** @@ -45,14 +45,14 @@ export function useAbacusSettings() { return useQuery({ queryKey: abacusSettingsKeys.detail(), queryFn: fetchAbacusSettings, - }); + }) } /** * Hook: Update abacus display settings */ export function useUpdateAbacusSettings() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: updateAbacusSettings, @@ -60,38 +60,30 @@ export function useUpdateAbacusSettings() { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: abacusSettingsKeys.detail(), - }); + }) // Snapshot previous value - const previousSettings = queryClient.getQueryData( - abacusSettingsKeys.detail(), - ); + const previousSettings = queryClient.getQueryData(abacusSettingsKeys.detail()) // Optimistically update if (previousSettings) { - const optimisticSettings = { ...previousSettings, ...updates }; - queryClient.setQueryData( - abacusSettingsKeys.detail(), - optimisticSettings, - ); + const optimisticSettings = { ...previousSettings, ...updates } + queryClient.setQueryData(abacusSettingsKeys.detail(), optimisticSettings) } - return { previousSettings }; + return { previousSettings } }, onError: (_err, _updates, context) => { // Rollback on error if (context?.previousSettings) { - queryClient.setQueryData( - abacusSettingsKeys.detail(), - context.previousSettings, - ); + queryClient.setQueryData(abacusSettingsKeys.detail(), context.previousSettings) } }, onSettled: (updatedSettings) => { // Update with server data on success if (updatedSettings) { - queryClient.setQueryData(abacusSettingsKeys.detail(), updatedSettings); + queryClient.setQueryData(abacusSettingsKeys.detail(), updatedSettings) } }, - }); + }) } diff --git a/apps/web/src/hooks/useAccessControl.tsx b/apps/web/src/hooks/useAccessControl.tsx index 8e02de34..c771229e 100644 --- a/apps/web/src/hooks/useAccessControl.tsx +++ b/apps/web/src/hooks/useAccessControl.tsx @@ -1,7 +1,7 @@ -"use client"; +'use client' -import { createContext, type ReactNode, useContext } from "react"; -import type { AccessContext, Permission, UserRole } from "../types/tutorial"; +import { createContext, type ReactNode, useContext } from 'react' +import type { AccessContext, Permission, UserRole } from '../types/tutorial' // Default context value (no permissions) const defaultAccessContext: AccessContext = { @@ -12,17 +12,17 @@ const defaultAccessContext: AccessContext = { canEdit: false, canPublish: false, canDelete: false, -}; +} // Create context -const AccessControlContext = createContext(defaultAccessContext); +const AccessControlContext = createContext(defaultAccessContext) // Provider component interface AccessControlProviderProps { - children: ReactNode; - userId?: string; - roles?: UserRole[]; - isAuthenticated?: boolean; + children: ReactNode + userId?: string + roles?: UserRole[] + isAuthenticated?: boolean } export function AccessControlProvider({ @@ -32,31 +32,20 @@ export function AccessControlProvider({ isAuthenticated = false, }: AccessControlProviderProps) { // Calculate permissions based on roles - const permissions = roles.flatMap((role) => role.permissions); + const permissions = roles.flatMap((role) => role.permissions) // Check if user has admin role - const isAdmin = roles.some( - (role) => role.name === "admin" || role.name === "superuser", - ); + const isAdmin = roles.some((role) => role.name === 'admin' || role.name === 'superuser') // Check specific permissions const canEdit = - isAdmin || - permissions.some( - (p) => p.resource === "tutorial" && p.actions.includes("update"), - ); + isAdmin || permissions.some((p) => p.resource === 'tutorial' && p.actions.includes('update')) const canPublish = - isAdmin || - permissions.some( - (p) => p.resource === "tutorial" && p.actions.includes("publish"), - ); + isAdmin || permissions.some((p) => p.resource === 'tutorial' && p.actions.includes('publish')) const canDelete = - isAdmin || - permissions.some( - (p) => p.resource === "tutorial" && p.actions.includes("delete"), - ); + isAdmin || permissions.some((p) => p.resource === 'tutorial' && p.actions.includes('delete')) const accessContext: AccessContext = { userId, @@ -66,53 +55,47 @@ export function AccessControlProvider({ canEdit, canPublish, canDelete, - }; + } return ( - - {children} - - ); + {children} + ) } // Hook to use access control export function useAccessControl(): AccessContext { - const context = useContext(AccessControlContext); + const context = useContext(AccessControlContext) if (!context) { - throw new Error( - "useAccessControl must be used within an AccessControlProvider", - ); + throw new Error('useAccessControl must be used within an AccessControlProvider') } - return context; + return context } // Hook for conditional rendering based on permissions export function usePermission( - resource: Permission["resource"], - action: Permission["actions"][number], + resource: Permission['resource'], + action: Permission['actions'][number] ): boolean { - const { isAdmin, roles } = useAccessControl(); + const { isAdmin, roles } = useAccessControl() - if (isAdmin) return true; + if (isAdmin) return true return roles.some((role) => role.permissions.some( - (permission) => - permission.resource === resource && permission.actions.includes(action), - ), - ); + (permission) => permission.resource === resource && permission.actions.includes(action) + ) + ) } // Hook for editor access specifically export function useEditorAccess(): { - canAccessEditor: boolean; - canEditTutorials: boolean; - canPublishTutorials: boolean; - canDeleteTutorials: boolean; - reason?: string; + canAccessEditor: boolean + canEditTutorials: boolean + canPublishTutorials: boolean + canDeleteTutorials: boolean + reason?: string } { - const { isAuthenticated, isAdmin, canEdit, canPublish, canDelete } = - useAccessControl(); + const { isAuthenticated, isAdmin, canEdit, canPublish, canDelete } = useAccessControl() if (!isAuthenticated) { return { @@ -120,29 +103,29 @@ export function useEditorAccess(): { canEditTutorials: false, canPublishTutorials: false, canDeleteTutorials: false, - reason: "Authentication required", - }; + reason: 'Authentication required', + } } - const canAccessEditor = isAdmin || canEdit; + const canAccessEditor = isAdmin || canEdit return { canAccessEditor, canEditTutorials: canEdit, canPublishTutorials: canPublish, canDeleteTutorials: canDelete, - reason: canAccessEditor ? undefined : "Insufficient permissions", - }; + reason: canAccessEditor ? undefined : 'Insufficient permissions', + } } // Higher-order component for protecting routes interface ProtectedComponentProps { - children: ReactNode; - fallback?: ReactNode; + children: ReactNode + fallback?: ReactNode requirePermissions?: { - resource: Permission["resource"]; - actions: Permission["actions"]; - }[]; + resource: Permission['resource'] + actions: Permission['actions'] + }[] } export function ProtectedComponent({ @@ -150,14 +133,14 @@ export function ProtectedComponent({ fallback =
Access denied
, requirePermissions = [], }: ProtectedComponentProps) { - const { isAuthenticated, roles, isAdmin } = useAccessControl(); + const { isAuthenticated, roles, isAdmin } = useAccessControl() if (!isAuthenticated) { - return <>{fallback}; + return <>{fallback} } if (isAdmin) { - return <>{children}; + return <>{children} } // Check if user has all required permissions @@ -166,26 +149,26 @@ export function ProtectedComponent({ role.permissions.some( (permission) => permission.resource === resource && - actions.every((action) => permission.actions.includes(action)), - ), - ), - ); + actions.every((action) => permission.actions.includes(action)) + ) + ) + ) if (!hasAllPermissions) { - return <>{fallback}; + return <>{fallback} } - return <>{children}; + return <>{children} } // Component for editor-specific protection interface EditorProtectedProps { - children: ReactNode; - fallback?: ReactNode; + children: ReactNode + fallback?: ReactNode } export function EditorProtected({ children, fallback }: EditorProtectedProps) { - const { canAccessEditor, reason } = useEditorAccess(); + const { canAccessEditor, reason } = useEditorAccess() if (!canAccessEditor) { return ( @@ -197,46 +180,42 @@ export function EditorProtected({ children, fallback }: EditorProtectedProps) {
)} - ); + ) } - return <>{children}; + return <>{children} } // Dev mode provider (for development without auth) export function DevAccessProvider({ children }: { children: ReactNode }) { const devRoles: UserRole[] = [ { - id: "dev-admin", - name: "admin", + id: 'dev-admin', + name: 'admin', permissions: [ { - resource: "tutorial", - actions: ["create", "read", "update", "delete", "publish"], + resource: 'tutorial', + actions: ['create', 'read', 'update', 'delete', 'publish'], }, { - resource: "step", - actions: ["create", "read", "update", "delete"], + resource: 'step', + actions: ['create', 'read', 'update', 'delete'], }, { - resource: "user", - actions: ["read", "update"], + resource: 'user', + actions: ['read', 'update'], }, { - resource: "system", - actions: ["read"], + resource: 'system', + actions: ['read'], }, ], }, - ]; + ] return ( - + {children} - ); + ) } diff --git a/apps/web/src/hooks/useArcadeSession.ts b/apps/web/src/hooks/useArcadeSession.ts index 5717f14a..9251b056 100644 --- a/apps/web/src/hooks/useArcadeSession.ts +++ b/apps/web/src/hooks/useArcadeSession.ts @@ -1,84 +1,83 @@ -import { useCallback, useContext, useEffect, useRef, useState } from "react"; -import type { GameMove } from "@/lib/arcade/validation"; -import { useArcadeSocket } from "./useArcadeSocket"; +import { useCallback, useContext, useEffect, useRef, useState } from 'react' +import type { GameMove } from '@/lib/arcade/validation' +import { useArcadeSocket } from './useArcadeSocket' import { type UseOptimisticGameStateOptions, useOptimisticGameState, -} from "./useOptimisticGameState"; -import type { RetryState } from "@/lib/arcade/error-handling"; -import { PreviewModeContext } from "@/components/GamePreview"; +} from './useOptimisticGameState' +import type { RetryState } from '@/lib/arcade/error-handling' +import { PreviewModeContext } from '@/components/GamePreview' -export interface UseArcadeSessionOptions - extends UseOptimisticGameStateOptions { +export interface UseArcadeSessionOptions extends UseOptimisticGameStateOptions { /** * User ID for the session */ - userId: string; + userId: string /** * Room ID for multi-user sync (optional) * If provided, game state will sync across all users in the room */ - roomId?: string; + roomId?: string /** * Auto-join session on mount * @default true */ - autoJoin?: boolean; + autoJoin?: boolean } export interface UseArcadeSessionReturn { /** * Current game state (with optimistic updates) */ - state: TState; + state: TState /** * Server-confirmed version */ - version: number; + version: number /** * Whether socket is connected */ - connected: boolean; + connected: boolean /** * Whether there are pending moves */ - hasPendingMoves: boolean; + hasPendingMoves: boolean /** * Last error from server (move rejection) */ - lastError: string | null; + lastError: string | null /** * Current retry state (for showing UI indicators) */ - retryState: RetryState; + retryState: RetryState /** * Send a game move (applies optimistically and sends to server) * Note: playerId must be provided by caller (not omitted) */ - sendMove: (move: Omit) => void; + sendMove: (move: Omit) => void /** * Exit the arcade session */ - exitSession: () => void; + exitSession: () => void /** * Clear the last error */ - clearError: () => void; + clearError: () => void /** * Manually sync with server (useful after reconnect) */ - refresh: () => void; + refresh: () => void } /** @@ -99,12 +98,12 @@ export interface UseArcadeSessionReturn { * ``` */ export function useArcadeSession( - options: UseArcadeSessionOptions, + options: UseArcadeSessionOptions ): UseArcadeSessionReturn { - const { userId, roomId, autoJoin = true, ...optimisticOptions } = options; + const { userId, roomId, autoJoin = true, ...optimisticOptions } = options // Check if we're in preview mode - const previewMode = useContext(PreviewModeContext); + const previewMode = useContext(PreviewModeContext) // If in preview mode, return mock session immediately if (previewMode?.isPreview && previewMode?.mockState) { @@ -113,7 +112,7 @@ export function useArcadeSession( retryCount: 0, move: null, timestamp: null, - }; + } return { state: previewMode.mockState as TState, @@ -134,11 +133,11 @@ export function useArcadeSession( refresh: () => { // Mock: do nothing in preview }, - }; + } } // Optimistic state management - const optimistic = useOptimisticGameState(optimisticOptions); + const optimistic = useOptimisticGameState(optimisticOptions) // Track retry state (exposed to UI for indicators) const [retryState, setRetryState] = useState({ @@ -146,7 +145,7 @@ export function useArcadeSession( retryCount: 0, move: null, timestamp: null, - }); + }) // WebSocket connection const { @@ -157,64 +156,57 @@ export function useArcadeSession( exitSession: socketExitSession, } = useArcadeSocket({ onSessionState: (data) => { - optimistic.syncWithServer(data.gameState as TState, data.version); + optimistic.syncWithServer(data.gameState as TState, data.version) }, onMoveAccepted: (data) => { - const isRetry = retryState.move?.timestamp === data.move.timestamp; + const isRetry = retryState.move?.timestamp === data.move.timestamp console.log( - `[AutoRetry] ACCEPTED move=${data.move.type} ts=${data.move.timestamp} isRetry=${isRetry} retryCount=${retryState.retryCount || 0}`, - ); + `[AutoRetry] ACCEPTED move=${data.move.type} ts=${data.move.timestamp} isRetry=${isRetry} retryCount=${retryState.retryCount || 0}` + ) // Check if this was a retried move if (isRetry && retryState.isRetrying) { console.log( - `[AutoRetry] SUCCESS after ${retryState.retryCount} retries move=${data.move.type}`, - ); + `[AutoRetry] SUCCESS after ${retryState.retryCount} retries move=${data.move.type}` + ) // Clear retry state setRetryState({ isRetrying: false, retryCount: 0, move: null, timestamp: null, - }); + }) } - optimistic.handleMoveAccepted( - data.gameState as TState, - data.version, - data.move, - ); + optimistic.handleMoveAccepted(data.gameState as TState, data.version, data.move) }, onMoveRejected: (data) => { - const isRetry = retryState.move?.timestamp === data.move.timestamp; + const isRetry = retryState.move?.timestamp === data.move.timestamp console.warn( - `[AutoRetry] REJECTED move=${data.move.type} ts=${data.move.timestamp} isRetry=${isRetry} versionConflict=${!!data.versionConflict} error="${data.error}"`, - ); + `[AutoRetry] REJECTED move=${data.move.type} ts=${data.move.timestamp} isRetry=${isRetry} versionConflict=${!!data.versionConflict} error="${data.error}"` + ) // For version conflicts, automatically retry the move if (data.versionConflict) { - const retryCount = - isRetry && retryState.isRetrying ? retryState.retryCount + 1 : 1; + const retryCount = isRetry && retryState.isRetrying ? retryState.retryCount + 1 : 1 if (retryCount > 5) { - console.error( - `[AutoRetry] FAILED after 5 retries move=${data.move.type}`, - ); + console.error(`[AutoRetry] FAILED after 5 retries move=${data.move.type}`) // Clear retry state and show error setRetryState({ isRetrying: false, retryCount: 0, move: null, timestamp: null, - }); - optimistic.handleMoveRejected(data.error, data.move); - return; + }) + optimistic.handleMoveRejected(data.error, data.move) + return } console.warn( - `[AutoRetry] SCHEDULE_RETRY_${retryCount} room=${roomId || "none"} move=${data.move.type} ts=${data.move.timestamp} delay=${10 * retryCount}ms`, - ); + `[AutoRetry] SCHEDULE_RETRY_${retryCount} room=${roomId || 'none'} move=${data.move.type} ts=${data.move.timestamp} delay=${10 * retryCount}ms` + ) // Update retry state setRetryState({ @@ -222,26 +214,26 @@ export function useArcadeSession( retryCount, move: data.move, timestamp: data.move.timestamp, - }); + }) // Wait a tiny bit for server state to propagate, then retry setTimeout(() => { console.warn( - `[AutoRetry] SENDING_RETRY_${retryCount} move=${data.move.type} ts=${data.move.timestamp}`, - ); - socketSendMove(userId, data.move, roomId); - }, 10 * retryCount); + `[AutoRetry] SENDING_RETRY_${retryCount} move=${data.move.type} ts=${data.move.timestamp}` + ) + socketSendMove(userId, data.move, roomId) + }, 10 * retryCount) // Don't show error to user - we're handling it automatically - return; + return } // Non-version-conflict errors: show to user - optimistic.handleMoveRejected(data.error, data.move); + optimistic.handleMoveRejected(data.error, data.move) }, onSessionEnded: () => { - optimistic.reset(); + optimistic.reset() }, onNoActiveSession: () => { @@ -249,52 +241,50 @@ export function useArcadeSession( }, onError: (data) => { - console.error(`[ArcadeSession] Error: ${data.error}`); + console.error(`[ArcadeSession] Error: ${data.error}`) }, - }); + }) // Auto-join session when connected useEffect(() => { if (connected && autoJoin && userId) { - joinSession(userId, roomId); + joinSession(userId, roomId) } - }, [connected, autoJoin, userId, roomId, joinSession]); + }, [connected, autoJoin, userId, roomId, joinSession]) // Send move with optimistic update const sendMove = useCallback( - (move: Omit) => { + (move: Omit) => { // IMPORTANT: playerId must always be explicitly provided by caller // playerId is the database player ID (avatar), never the userId/viewerId - if (!("playerId" in move) || !move.playerId) { - throw new Error( - "playerId is required in all moves and must be a valid player ID", - ); + if (!('playerId' in move) || !move.playerId) { + throw new Error('playerId is required in all moves and must be a valid player ID') } const fullMove: GameMove = { ...move, timestamp: Date.now(), - } as GameMove; + } as GameMove // Apply optimistically - optimistic.applyOptimisticMove(fullMove); + optimistic.applyOptimisticMove(fullMove) // Send to server with roomId for room-based games - socketSendMove(userId, fullMove, roomId); + socketSendMove(userId, fullMove, roomId) }, - [userId, roomId, optimistic, socketSendMove], - ); + [userId, roomId, optimistic, socketSendMove] + ) const exitSession = useCallback(() => { - socketExitSession(userId); - optimistic.reset(); - }, [userId, socketExitSession, optimistic]); + socketExitSession(userId) + optimistic.reset() + }, [userId, socketExitSession, optimistic]) const refresh = useCallback(() => { if (connected && userId) { - joinSession(userId, roomId); + joinSession(userId, roomId) } - }, [connected, userId, roomId, joinSession]); + }, [connected, userId, roomId, joinSession]) return { state: optimistic.state, @@ -307,5 +297,5 @@ export function useArcadeSession( exitSession, clearError: optimistic.clearError, refresh, - }; + } } diff --git a/apps/web/src/hooks/useArcadeSocket.ts b/apps/web/src/hooks/useArcadeSocket.ts index 35cdb35a..fd06e716 100644 --- a/apps/web/src/hooks/useArcadeSocket.ts +++ b/apps/web/src/hooks/useArcadeSocket.ts @@ -1,37 +1,29 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { io, type Socket } from "socket.io-client"; -import type { GameMove } from "@/lib/arcade/validation"; +import { useCallback, useEffect, useRef, useState } from 'react' +import { io, type Socket } from 'socket.io-client' +import type { GameMove } from '@/lib/arcade/validation' export interface ArcadeSocketEvents { onSessionState?: (data: { - gameState: unknown; - currentGame: string; - gameUrl: string; - activePlayers: number[]; - version: number; - }) => void; - onMoveAccepted?: (data: { - gameState: unknown; - version: number; - move: GameMove; - }) => void; - onMoveRejected?: (data: { - error: string; - move: GameMove; - versionConflict?: boolean; - }) => void; - onSessionEnded?: () => void; - onNoActiveSession?: () => void; - onError?: (error: { error: string }) => void; + gameState: unknown + currentGame: string + gameUrl: string + activePlayers: number[] + version: number + }) => void + onMoveAccepted?: (data: { gameState: unknown; version: number; move: GameMove }) => void + onMoveRejected?: (data: { error: string; move: GameMove; versionConflict?: boolean }) => void + onSessionEnded?: () => void + onNoActiveSession?: () => void + onError?: (error: { error: string }) => void } export interface UseArcadeSocketReturn { - socket: Socket | null; - connected: boolean; - joinSession: (userId: string, roomId?: string) => void; - sendMove: (userId: string, move: GameMove, roomId?: string) => void; - exitSession: (userId: string) => void; - pingSession: (userId: string) => void; + socket: Socket | null + connected: boolean + joinSession: (userId: string, roomId?: string) => void + sendMove: (userId: string, move: GameMove, roomId?: string) => void + exitSession: (userId: string) => void + pingSession: (userId: string) => void } /** @@ -40,125 +32,119 @@ export interface UseArcadeSocketReturn { * @param events - Event handlers for socket events * @returns Socket instance and helper methods */ -export function useArcadeSocket( - events: ArcadeSocketEvents = {}, -): UseArcadeSocketReturn { - const [socket, setSocket] = useState(null); - const [connected, setConnected] = useState(false); - const eventsRef = useRef(events); +export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocketReturn { + const [socket, setSocket] = useState(null) + const [connected, setConnected] = useState(false) + const eventsRef = useRef(events) // Update events ref when they change useEffect(() => { - eventsRef.current = events; - }, [events]); + eventsRef.current = events + }, [events]) // Initialize socket connection useEffect(() => { const socketInstance = io({ - path: "/api/socket", + path: '/api/socket', reconnection: true, reconnectionDelay: 1000, reconnectionAttempts: 5, - }); + }) - socketInstance.on("connect", () => { - console.log("[ArcadeSocket] Connected"); - setConnected(true); - }); + socketInstance.on('connect', () => { + console.log('[ArcadeSocket] Connected') + setConnected(true) + }) - socketInstance.on("disconnect", () => { - console.log("[ArcadeSocket] Disconnected"); - setConnected(false); - }); + socketInstance.on('disconnect', () => { + console.log('[ArcadeSocket] Disconnected') + setConnected(false) + }) - socketInstance.on("session-state", (data) => { - eventsRef.current.onSessionState?.(data); - }); + socketInstance.on('session-state', (data) => { + eventsRef.current.onSessionState?.(data) + }) - socketInstance.on("no-active-session", () => { - eventsRef.current.onNoActiveSession?.(); - }); + socketInstance.on('no-active-session', () => { + eventsRef.current.onNoActiveSession?.() + }) - socketInstance.on("move-accepted", (data) => { - eventsRef.current.onMoveAccepted?.(data); - }); + socketInstance.on('move-accepted', (data) => { + eventsRef.current.onMoveAccepted?.(data) + }) - socketInstance.on("move-rejected", (data) => { - console.log(`[ArcadeSocket] Move rejected: ${data.error}`); - eventsRef.current.onMoveRejected?.(data); - }); + socketInstance.on('move-rejected', (data) => { + console.log(`[ArcadeSocket] Move rejected: ${data.error}`) + eventsRef.current.onMoveRejected?.(data) + }) - socketInstance.on("session-ended", () => { - console.log("[ArcadeSocket] Session ended"); - eventsRef.current.onSessionEnded?.(); - }); + socketInstance.on('session-ended', () => { + console.log('[ArcadeSocket] Session ended') + eventsRef.current.onSessionEnded?.() + }) - socketInstance.on("session-error", (data) => { - console.error("[ArcadeSocket] Session error", data); - eventsRef.current.onError?.(data); - }); + socketInstance.on('session-error', (data) => { + console.error('[ArcadeSocket] Session error', data) + eventsRef.current.onError?.(data) + }) - socketInstance.on("pong-session", () => { - console.log("[ArcadeSocket] Pong received"); - }); + socketInstance.on('pong-session', () => { + console.log('[ArcadeSocket] Pong received') + }) - setSocket(socketInstance); + setSocket(socketInstance) return () => { - socketInstance.disconnect(); - }; - }, []); + socketInstance.disconnect() + } + }, []) const joinSession = useCallback( (userId: string, roomId?: string) => { if (!socket) { - console.warn( - "[ArcadeSocket] Cannot join session - socket not connected", - ); - return; + console.warn('[ArcadeSocket] Cannot join session - socket not connected') + return } console.log( - "[ArcadeSocket] Joining session for user:", + '[ArcadeSocket] Joining session for user:', userId, - roomId ? `in room ${roomId}` : "(solo)", - ); - socket.emit("join-arcade-session", { userId, roomId }); + roomId ? `in room ${roomId}` : '(solo)' + ) + socket.emit('join-arcade-session', { userId, roomId }) }, - [socket], - ); + [socket] + ) const sendMove = useCallback( (userId: string, move: GameMove, roomId?: string) => { if (!socket) { - console.warn("[ArcadeSocket] Cannot send move - socket not connected"); - return; + console.warn('[ArcadeSocket] Cannot send move - socket not connected') + return } - socket.emit("game-move", { userId, move, roomId }); + socket.emit('game-move', { userId, move, roomId }) }, - [socket], - ); + [socket] + ) const exitSession = useCallback( (userId: string) => { if (!socket) { - console.warn( - "[ArcadeSocket] Cannot exit session - socket not connected", - ); - return; + console.warn('[ArcadeSocket] Cannot exit session - socket not connected') + return } - console.log("[ArcadeSocket] Exiting session for user:", userId); - socket.emit("exit-arcade-session", { userId }); + console.log('[ArcadeSocket] Exiting session for user:', userId) + socket.emit('exit-arcade-session', { userId }) }, - [socket], - ); + [socket] + ) const pingSession = useCallback( (userId: string) => { - if (!socket) return; - socket.emit("ping-session", { userId }); + if (!socket) return + socket.emit('ping-session', { userId }) }, - [socket], - ); + [socket] + ) return { socket, @@ -167,5 +153,5 @@ export function useArcadeSocket( sendMove, exitSession, pingSession, - }; + } } diff --git a/apps/web/src/hooks/useClipboard.ts b/apps/web/src/hooks/useClipboard.ts index 677a824c..38cf8197 100644 --- a/apps/web/src/hooks/useClipboard.ts +++ b/apps/web/src/hooks/useClipboard.ts @@ -1,28 +1,28 @@ -import { useCallback, useState } from "react"; +import { useCallback, useState } from 'react' export interface UseClipboardOptions { /** * Timeout in milliseconds to reset the copied state * @default 1500 */ - timeout?: number; + timeout?: number } export interface UseClipboardReturn { /** * Whether the text was recently copied */ - copied: boolean; + copied: boolean /** * Copy text to clipboard */ - copy: (text: string) => Promise; + copy: (text: string) => Promise /** * Reset the copied state manually */ - reset: () => void; + reset: () => void } /** @@ -37,30 +37,28 @@ export interface UseClipboardReturn { * * ``` */ -export function useClipboard( - options: UseClipboardOptions = {}, -): UseClipboardReturn { - const { timeout = 1500 } = options; - const [copied, setCopied] = useState(false); +export function useClipboard(options: UseClipboardOptions = {}): UseClipboardReturn { + const { timeout = 1500 } = options + const [copied, setCopied] = useState(false) const copy = useCallback( async (text: string) => { try { - await navigator.clipboard.writeText(text); - setCopied(true); + await navigator.clipboard.writeText(text) + setCopied(true) setTimeout(() => { - setCopied(false); - }, timeout); + setCopied(false) + }, timeout) } catch (error) { - console.error("[useClipboard] Failed to copy to clipboard:", error); + console.error('[useClipboard] Failed to copy to clipboard:', error) } }, - [timeout], - ); + [timeout] + ) const reset = useCallback(() => { - setCopied(false); - }, []); + setCopied(false) + }, []) - return { copied, copy, reset }; + return { copied, copy, reset } } diff --git a/apps/web/src/hooks/useOptimisticGameState.ts b/apps/web/src/hooks/useOptimisticGameState.ts index b77adf41..96a1b457 100644 --- a/apps/web/src/hooks/useOptimisticGameState.ts +++ b/apps/web/src/hooks/useOptimisticGameState.ts @@ -1,89 +1,85 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { GameMove } from "@/lib/arcade/validation"; +import { useCallback, useEffect, useRef, useState } from 'react' +import type { GameMove } from '@/lib/arcade/validation' export interface PendingMove { - move: GameMove; - optimisticState: TState; - timestamp: number; + move: GameMove + optimisticState: TState + timestamp: number } export interface UseOptimisticGameStateOptions { /** * Initial game state */ - initialState: TState; + initialState: TState /** * Apply a move to the state optimistically (client-side) * This should be the same logic that runs on the server */ - applyMove: (state: TState, move: GameMove) => TState; + applyMove: (state: TState, move: GameMove) => TState /** * Called when server accepts a move */ - onMoveAccepted?: (state: TState, move: GameMove) => void; + onMoveAccepted?: (state: TState, move: GameMove) => void /** * Called when server rejects a move */ - onMoveRejected?: (error: string, move: GameMove) => void; + onMoveRejected?: (error: string, move: GameMove) => void } export interface UseOptimisticGameStateReturn { /** * Current game state (includes optimistic updates) */ - state: TState; + state: TState /** * Server-confirmed version number */ - version: number; + version: number /** * Whether there are pending moves awaiting server confirmation */ - hasPendingMoves: boolean; + hasPendingMoves: boolean /** * Last error from server (move rejection) */ - lastError: string | null; + lastError: string | null /** * Apply a move optimistically and send to server */ - applyOptimisticMove: (move: GameMove) => void; + applyOptimisticMove: (move: GameMove) => void /** * Handle server accepting a move */ - handleMoveAccepted: ( - serverState: TState, - serverVersion: number, - acceptedMove: GameMove, - ) => void; + handleMoveAccepted: (serverState: TState, serverVersion: number, acceptedMove: GameMove) => void /** * Handle server rejecting a move */ - handleMoveRejected: (error: string, rejectedMove: GameMove) => void; + handleMoveRejected: (error: string, rejectedMove: GameMove) => void /** * Sync state with server (on reconnect or initial load) */ - syncWithServer: (serverState: TState, serverVersion: number) => void; + syncWithServer: (serverState: TState, serverVersion: number) => void /** * Clear the last error */ - clearError: () => void; + clearError: () => void /** * Reset to initial state */ - reset: () => void; + reset: () => void } /** @@ -97,38 +93,37 @@ export interface UseOptimisticGameStateReturn { * @returns Game state and update methods */ export function useOptimisticGameState( - options: UseOptimisticGameStateOptions, + options: UseOptimisticGameStateOptions ): UseOptimisticGameStateReturn { - const { initialState, applyMove, onMoveAccepted, onMoveRejected } = options; + const { initialState, applyMove, onMoveAccepted, onMoveRejected } = options // Server-confirmed state and version - const [serverState, setServerState] = useState(initialState); - const [serverVersion, setServerVersion] = useState(1); + const [serverState, setServerState] = useState(initialState) + const [serverVersion, setServerVersion] = useState(1) // Pending moves that haven't been confirmed by server yet - const [pendingMoves, setPendingMoves] = useState[]>([]); + const [pendingMoves, setPendingMoves] = useState[]>([]) // Last error from move rejection - const [lastError, setLastError] = useState(null); + const [lastError, setLastError] = useState(null) // Ref for callbacks to avoid stale closures - const callbacksRef = useRef({ onMoveAccepted, onMoveRejected }); + const callbacksRef = useRef({ onMoveAccepted, onMoveRejected }) useEffect(() => { - callbacksRef.current = { onMoveAccepted, onMoveRejected }; - }, [onMoveAccepted, onMoveRejected]); + callbacksRef.current = { onMoveAccepted, onMoveRejected } + }, [onMoveAccepted, onMoveRejected]) // Current state = server state + all pending moves applied const currentState = pendingMoves.reduce( (_state, pending) => pending.optimisticState, - serverState, - ); + serverState + ) const applyOptimisticMove = useCallback( (move: GameMove) => { setPendingMoves((prev) => { - const baseState = - prev.length > 0 ? prev[prev.length - 1].optimisticState : serverState; - const optimisticState = applyMove(baseState, move); + const baseState = prev.length > 0 ? prev[prev.length - 1].optimisticState : serverState + const optimisticState = applyMove(baseState, move) return [ ...prev, @@ -137,95 +132,79 @@ export function useOptimisticGameState( optimisticState, timestamp: Date.now(), }, - ]; - }); + ] + }) }, - [serverState, applyMove], - ); + [serverState, applyMove] + ) const handleMoveAccepted = useCallback( - ( - newServerState: TState, - newServerVersion: number, - acceptedMove: GameMove, - ) => { + (newServerState: TState, newServerVersion: number, acceptedMove: GameMove) => { // Update server state - setServerState(newServerState); - setServerVersion(newServerVersion); + setServerState(newServerState) + setServerVersion(newServerVersion) // Remove the accepted move from pending queue setPendingMoves((prev) => { const index = prev.findIndex( - (p) => - p.move.type === acceptedMove.type && - p.move.timestamp === acceptedMove.timestamp, - ); + (p) => p.move.type === acceptedMove.type && p.move.timestamp === acceptedMove.timestamp + ) if (index !== -1) { - return prev.slice(index + 1); + return prev.slice(index + 1) } // Move not found in pending queue - might be from another tab // Clear all pending moves since server state is now authoritative - return []; - }); + return [] + }) - callbacksRef.current.onMoveAccepted?.(newServerState, acceptedMove); + callbacksRef.current.onMoveAccepted?.(newServerState, acceptedMove) }, - [], - ); + [] + ) - const handleMoveRejected = useCallback( - (error: string, rejectedMove: GameMove) => { - // Set the error for UI display - console.warn( - `[ErrorState] SET_ERROR error="${error}" move=${rejectedMove.type}`, - ); - setLastError(error); + const handleMoveRejected = useCallback((error: string, rejectedMove: GameMove) => { + // Set the error for UI display + console.warn(`[ErrorState] SET_ERROR error="${error}" move=${rejectedMove.type}`) + setLastError(error) - // Remove the rejected move and all subsequent moves from pending queue - setPendingMoves((prev) => { - const index = prev.findIndex( - (p) => - p.move.type === rejectedMove.type && - p.move.timestamp === rejectedMove.timestamp, - ); + // Remove the rejected move and all subsequent moves from pending queue + setPendingMoves((prev) => { + const index = prev.findIndex( + (p) => p.move.type === rejectedMove.type && p.move.timestamp === rejectedMove.timestamp + ) - if (index !== -1) { - // Rollback: remove rejected move and everything after it - return prev.slice(0, index); - } + if (index !== -1) { + // Rollback: remove rejected move and everything after it + return prev.slice(0, index) + } - return prev; - }); + return prev + }) - callbacksRef.current.onMoveRejected?.(error, rejectedMove); - }, - [], - ); + callbacksRef.current.onMoveRejected?.(error, rejectedMove) + }, []) - const syncWithServer = useCallback( - (newServerState: TState, newServerVersion: number) => { - console.log(`[ErrorState] SYNC_WITH_SERVER version=${newServerVersion}`); - setServerState(newServerState); - setServerVersion(newServerVersion); - // Clear pending moves on sync (new authoritative state from server) - setPendingMoves([]); - }, - [], - ); + const syncWithServer = useCallback((newServerState: TState, newServerVersion: number) => { + console.log(`[ErrorState] SYNC_WITH_SERVER version=${newServerVersion}`) + setServerState(newServerState) + setServerVersion(newServerVersion) + // Clear pending moves on sync (new authoritative state from server) + setPendingMoves([]) + }, []) const clearError = useCallback(() => { - console.log("[ErrorState] CLEAR_ERROR"); - setLastError(null); - }, []); + console.log('[ErrorState] CLEAR_ERROR') + setLastError(null) + }, []) const reset = useCallback(() => { - setServerState(initialState); - setServerVersion(1); - setPendingMoves([]); - setLastError(null); - }, [initialState]); + setServerState(initialState) + setServerVersion(1) + setPendingMoves([]) + setLastError(null) + }, [initialState]) return { state: currentState, @@ -238,5 +217,5 @@ export function useOptimisticGameState( syncWithServer, clearError, reset, - }; + } } diff --git a/apps/web/src/hooks/usePlayerStats.ts b/apps/web/src/hooks/usePlayerStats.ts index 2e6daf74..cfb133d9 100644 --- a/apps/web/src/hooks/usePlayerStats.ts +++ b/apps/web/src/hooks/usePlayerStats.ts @@ -1,12 +1,12 @@ -"use client"; +'use client' -import { useQuery } from "@tanstack/react-query"; +import { useQuery } from '@tanstack/react-query' import type { GetAllPlayerStatsResponse, GetPlayerStatsResponse, PlayerStatsData, -} from "@/lib/arcade/stats/types"; -import { api } from "@/lib/queryClient"; +} from '@/lib/arcade/stats/types' +import { api } from '@/lib/queryClient' /** * Hook to fetch stats for a specific player or all user's players @@ -24,22 +24,21 @@ import { api } from "@/lib/queryClient"; */ export function usePlayerStats(playerId?: string) { return useQuery({ - queryKey: playerId ? ["player-stats", playerId] : ["player-stats"], + queryKey: playerId ? ['player-stats', playerId] : ['player-stats'], queryFn: async () => { - const url = playerId ? `player-stats/${playerId}` : "player-stats"; + const url = playerId ? `player-stats/${playerId}` : 'player-stats' - const res = await api(url); + const res = await api(url) if (!res.ok) { - throw new Error("Failed to fetch player stats"); + throw new Error('Failed to fetch player stats') } - const data: GetPlayerStatsResponse | GetAllPlayerStatsResponse = - await res.json(); + const data: GetPlayerStatsResponse | GetAllPlayerStatsResponse = await res.json() // Return single player stats or array of all stats - return "stats" in data ? data.stats : data.playerStats; + return 'stats' in data ? data.stats : data.playerStats }, - }); + }) } /** @@ -49,19 +48,19 @@ export function usePlayerStats(playerId?: string) { */ export function useAllPlayerStats() { const query = useQuery({ - queryKey: ["player-stats"], + queryKey: ['player-stats'], queryFn: async () => { - const res = await api("player-stats"); + const res = await api('player-stats') if (!res.ok) { - throw new Error("Failed to fetch player stats"); + throw new Error('Failed to fetch player stats') } - const data: GetAllPlayerStatsResponse = await res.json(); - return data.playerStats; + const data: GetAllPlayerStatsResponse = await res.json() + return data.playerStats }, - }); + }) - return query; + return query } /** @@ -71,18 +70,18 @@ export function useAllPlayerStats() { */ export function useSinglePlayerStats(playerId: string) { const query = useQuery({ - queryKey: ["player-stats", playerId], + queryKey: ['player-stats', playerId], queryFn: async () => { - const res = await api(`player-stats/${playerId}`); + const res = await api(`player-stats/${playerId}`) if (!res.ok) { - throw new Error("Failed to fetch player stats"); + throw new Error('Failed to fetch player stats') } - const data: GetPlayerStatsResponse = await res.json(); - return data.stats; + const data: GetPlayerStatsResponse = await res.json() + return data.stats }, enabled: !!playerId, // Only run if playerId is provided - }); + }) - return query; + return query } diff --git a/apps/web/src/hooks/useRecordGameResult.ts b/apps/web/src/hooks/useRecordGameResult.ts index 2ba7e699..4854fdef 100644 --- a/apps/web/src/hooks/useRecordGameResult.ts +++ b/apps/web/src/hooks/useRecordGameResult.ts @@ -1,8 +1,8 @@ -"use client"; +'use client' -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { GameResult, RecordGameResponse } from "@/lib/arcade/stats/types"; -import { api } from "@/lib/queryClient"; +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { GameResult, RecordGameResponse } from '@/lib/arcade/stats/types' +import { api } from '@/lib/queryClient' /** * Hook to record a game result and update player stats @@ -19,35 +19,33 @@ import { api } from "@/lib/queryClient"; * ``` */ export function useRecordGameResult() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: async (gameResult: GameResult): Promise => { - const res = await api("player-stats/record-game", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const res = await api('player-stats/record-game', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gameResult }), - }); + }) if (!res.ok) { - const error = await res - .json() - .catch(() => ({ error: "Failed to record game result" })); - throw new Error(error.error || "Failed to record game result"); + const error = await res.json().catch(() => ({ error: 'Failed to record game result' })) + throw new Error(error.error || 'Failed to record game result') } - return res.json(); + return res.json() }, onSuccess: (response) => { // Invalidate player stats queries to trigger refetch - queryClient.invalidateQueries({ queryKey: ["player-stats"] }); + queryClient.invalidateQueries({ queryKey: ['player-stats'] }) - console.log("✅ Game result recorded successfully:", response.updates); + console.log('✅ Game result recorded successfully:', response.updates) }, onError: (error) => { - console.error("❌ Failed to record game result:", error); + console.error('❌ Failed to record game result:', error) }, - }); + }) } diff --git a/apps/web/src/hooks/useRoomData.ts b/apps/web/src/hooks/useRoomData.ts index 4fc74edb..c2267afe 100644 --- a/apps/web/src/hooks/useRoomData.ts +++ b/apps/web/src/hooks/useRoomData.ts @@ -1,154 +1,142 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useState } from "react"; -import { io, type Socket } from "socket.io-client"; -import { useViewerId } from "./useViewerId"; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useState } from 'react' +import { io, type Socket } from 'socket.io-client' +import { useViewerId } from './useViewerId' export interface RoomMember { - id: string; - userId: string; - displayName: string; - isOnline: boolean; - isCreator: boolean; + id: string + userId: string + displayName: string + isOnline: boolean + isCreator: boolean } export interface RoomPlayer { - id: string; - name: string; - emoji: string; - color: string; + id: string + name: string + emoji: string + color: string } export interface RoomData { - id: string; - name: string; - code: string; - gameName: string | null; // Nullable to support game selection in room - gameConfig?: Record | null; // Game-specific settings - accessMode: - | "open" - | "password" - | "approval-only" - | "restricted" - | "locked" - | "retired"; - members: RoomMember[]; - memberPlayers: Record; // userId -> players + id: string + name: string + code: string + gameName: string | null // Nullable to support game selection in room + gameConfig?: Record | null // Game-specific settings + accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired' + members: RoomMember[] + memberPlayers: Record // userId -> players } export interface CreateRoomParams { - name: string | null; - gameName?: string | null; // Optional - rooms can be created without a game - creatorName?: string; - gameConfig?: Record; - accessMode?: - | "open" - | "password" - | "approval-only" - | "restricted" - | "locked" - | "retired"; - password?: string; + name: string | null + gameName?: string | null // Optional - rooms can be created without a game + creatorName?: string + gameConfig?: Record + accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired' + password?: string } export interface JoinRoomResult { - member: RoomMember; - room: RoomData; - activePlayers: RoomPlayer[]; + member: RoomMember + room: RoomData + activePlayers: RoomPlayer[] autoLeave?: { - roomIds: string[]; - message: string; - }; + roomIds: string[] + message: string + } } /** * Query key factory for rooms */ export const roomKeys = { - all: ["rooms"] as const, - current: () => [...roomKeys.all, "current"] as const, -}; + all: ['rooms'] as const, + current: () => [...roomKeys.all, 'current'] as const, +} /** * Fetch the user's current room */ async function fetchCurrentRoom(): Promise { - const response = await fetch("/api/arcade/rooms/current"); + const response = await fetch('/api/arcade/rooms/current') if (!response.ok) { - if (response.status === 404) return null; - throw new Error("Failed to fetch current room"); + if (response.status === 404) return null + throw new Error('Failed to fetch current room') } - const data = await response.json(); - if (!data.room) return null; + const data = await response.json() + if (!data.room) return null return { id: data.room.id, name: data.room.name, code: data.room.code, gameName: data.room.gameName, gameConfig: data.room.gameConfig || null, - accessMode: data.room.accessMode || "open", + accessMode: data.room.accessMode || 'open', members: data.members || [], memberPlayers: data.memberPlayers || {}, - }; + } } /** * Create a new room */ async function createRoomApi(params: CreateRoomParams): Promise { - const response = await fetch("/api/arcade/rooms", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/arcade/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: params.name, gameName: params.gameName || null, - creatorName: params.creatorName || "Player", + creatorName: params.creatorName || 'Player', gameConfig: params.gameConfig || null, accessMode: params.accessMode, password: params.password, }), - }); + }) if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to create room"); + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to create room') } - const data = await response.json(); + const data = await response.json() return { id: data.room.id, name: data.room.name, code: data.room.code, gameName: data.room.gameName, gameConfig: data.room.gameConfig || null, - accessMode: data.room.accessMode || "open", + accessMode: data.room.accessMode || 'open', members: data.members || [], memberPlayers: data.memberPlayers || {}, - }; + } } /** * Join a room */ async function joinRoomApi(params: { - roomId: string; - displayName?: string; - password?: string; + roomId: string + displayName?: string + password?: string }): Promise { const response = await fetch(`/api/arcade/rooms/${params.roomId}/join`, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - displayName: params.displayName || "Player", + displayName: params.displayName || 'Player', password: params.password, }), - }); + }) if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to join room"); + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to join room') } - const data = await response.json(); + const data = await response.json() return { ...data, room: { @@ -157,11 +145,11 @@ async function joinRoomApi(params: { code: data.room.code, gameName: data.room.gameName, gameConfig: data.room.gameConfig || null, - accessMode: data.room.accessMode || "open", + accessMode: data.room.accessMode || 'open', members: data.members || [], memberPlayers: data.memberPlayers || {}, }, - }; + } } /** @@ -169,13 +157,13 @@ async function joinRoomApi(params: { */ async function leaveRoomApi(roomId: string): Promise { const response = await fetch(`/api/arcade/rooms/${roomId}/leave`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to leave room"); + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to leave room') } } @@ -183,51 +171,51 @@ async function leaveRoomApi(roomId: string): Promise { * Get room by join code */ async function getRoomByCodeApi(code: string): Promise { - const response = await fetch(`/api/arcade/rooms/code/${code}`); + const response = await fetch(`/api/arcade/rooms/code/${code}`) if (!response.ok) { if (response.status === 404) { - throw new Error("Room not found"); + throw new Error('Room not found') } - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to find room"); + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to find room') } - const data = await response.json(); + const data = await response.json() return { id: data.room.id, name: data.room.name, code: data.room.code, gameName: data.room.gameName, gameConfig: data.room.gameConfig || null, - accessMode: data.room.accessMode || "open", + accessMode: data.room.accessMode || 'open', members: data.members || [], memberPlayers: data.memberPlayers || {}, - }; + } } export interface ModerationEvent { - type: "kicked" | "banned" | "report" | "invitation" | "join-request"; + type: 'kicked' | 'banned' | 'report' | 'invitation' | 'join-request' data: { - roomId?: string; - kickedBy?: string; - bannedBy?: string; - reason?: string; - reportId?: string; - reporterName?: string; - reportedUserName?: string; - reportedUserId?: string; + roomId?: string + kickedBy?: string + bannedBy?: string + reason?: string + reportId?: string + reporterName?: string + reportedUserName?: string + reportedUserId?: string // Invitation fields - invitationId?: string; - invitedBy?: string; - invitedByName?: string; - invitationType?: "manual" | "auto-unban" | "auto-create"; - message?: string; + invitationId?: string + invitedBy?: string + invitedByName?: string + invitationType?: 'manual' | 'auto-unban' | 'auto-create' + message?: string // Join request fields - requestId?: string; - requesterId?: string; - requesterName?: string; - }; + requestId?: string + requesterId?: string + requesterName?: string + } } /** @@ -235,11 +223,10 @@ export interface ModerationEvent { * Returns null if user is not in any room */ export function useRoomData() { - const { data: userId } = useViewerId(); - const queryClient = useQueryClient(); - const [socket, setSocket] = useState(null); - const [moderationEvent, setModerationEvent] = - useState(null); + const { data: userId } = useViewerId() + const queryClient = useQueryClient() + const [socket, setSocket] = useState(null) + const [moderationEvent, setModerationEvent] = useState(null) // Fetch current room with TanStack Query const { @@ -251,200 +238,177 @@ export function useRoomData() { queryFn: fetchCurrentRoom, enabled: !!userId, staleTime: 30000, // Consider data fresh for 30 seconds - }); + }) // Initialize socket connection when user is authenticated (regardless of room membership) useEffect(() => { if (!userId) { if (socket) { - socket.disconnect(); - setSocket(null); + socket.disconnect() + setSocket(null) } - return; + return } - const sock = io({ path: "/api/socket" }); + const sock = io({ path: '/api/socket' }) - sock.on("connect", () => { + sock.on('connect', () => { // Always join user-specific channel for personal notifications (invitations, bans, kicks) - sock.emit("join-user-channel", { userId }); + sock.emit('join-user-channel', { userId }) // Join room channel only if user is in a room if (roomData?.id) { - sock.emit("join-room", { roomId: roomData.id, userId }); + sock.emit('join-room', { roomId: roomData.id, userId }) } - }); + }) - sock.on("disconnect", () => { + sock.on('disconnect', () => { // Socket disconnected - }); + }) - setSocket(sock); + setSocket(sock) return () => { if (sock.connected) { // Leave the room before disconnecting (if in a room) if (roomData?.id) { - sock.emit("leave-room", { roomId: roomData.id, userId }); + sock.emit('leave-room', { roomId: roomData.id, userId }) } - sock.disconnect(); + sock.disconnect() } - }; - }, [userId, roomData?.id]); + } + }, [userId, roomData?.id]) // Subscribe to real-time updates via socket useEffect(() => { - if (!socket) return; + if (!socket) return const handleRoomJoined = (data: { - roomId: string; - members: RoomMember[]; - memberPlayers: Record; + roomId: string + members: RoomMember[] + memberPlayers: Record }) => { if (data.roomId === roomData?.id) { - queryClient.setQueryData( - roomKeys.current(), - (prev) => { - if (!prev) return null; - return { - ...prev, - members: data.members, - memberPlayers: data.memberPlayers, - }; - }, - ); + queryClient.setQueryData(roomKeys.current(), (prev) => { + if (!prev) return null + return { + ...prev, + members: data.members, + memberPlayers: data.memberPlayers, + } + }) } - }; + } const handleMemberJoined = (data: { - roomId: string; - userId: string; - members: RoomMember[]; - memberPlayers: Record; + roomId: string + userId: string + members: RoomMember[] + memberPlayers: Record }) => { if (data.roomId === roomData?.id) { - queryClient.setQueryData( - roomKeys.current(), - (prev) => { - if (!prev) return null; - return { - ...prev, - members: data.members, - memberPlayers: data.memberPlayers, - }; - }, - ); + queryClient.setQueryData(roomKeys.current(), (prev) => { + if (!prev) return null + return { + ...prev, + members: data.members, + memberPlayers: data.memberPlayers, + } + }) } - }; + } const handleMemberLeft = (data: { - roomId: string; - userId: string; - members: RoomMember[]; - memberPlayers: Record; + roomId: string + userId: string + members: RoomMember[] + memberPlayers: Record }) => { if (data.roomId === roomData?.id) { - queryClient.setQueryData( - roomKeys.current(), - (prev) => { - if (!prev) return null; - return { - ...prev, - members: data.members, - memberPlayers: data.memberPlayers, - }; - }, - ); + queryClient.setQueryData(roomKeys.current(), (prev) => { + if (!prev) return null + return { + ...prev, + members: data.members, + memberPlayers: data.memberPlayers, + } + }) } - }; + } const handleRoomPlayersUpdated = (data: { - roomId: string; - memberPlayers: Record; + roomId: string + memberPlayers: Record }) => { if (data.roomId === roomData?.id) { - queryClient.setQueryData( - roomKeys.current(), - (prev) => { - if (!prev) return null; - return { - ...prev, - memberPlayers: data.memberPlayers, - }; - }, - ); + queryClient.setQueryData(roomKeys.current(), (prev) => { + if (!prev) return null + return { + ...prev, + memberPlayers: data.memberPlayers, + } + }) } - }; + } const handlePlayerDeactivated = (data: { - roomId: string; - playerId: string; - playerName: string; - deactivatedBy: string; - memberPlayers: Record; + roomId: string + playerId: string + playerName: string + deactivatedBy: string + memberPlayers: Record }) => { if (data.roomId === roomData?.id) { - queryClient.setQueryData( - roomKeys.current(), - (prev) => { - if (!prev) return null; - return { - ...prev, - memberPlayers: data.memberPlayers, - }; - }, - ); + queryClient.setQueryData(roomKeys.current(), (prev) => { + if (!prev) return null + return { + ...prev, + memberPlayers: data.memberPlayers, + } + }) } - }; + } // Moderation event handlers - const handleKickedFromRoom = (data: { - roomId: string; - kickedBy: string; - reason?: string; - }) => { + const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => { setModerationEvent({ - type: "kicked", + type: 'kicked', data: { roomId: data.roomId, kickedBy: data.kickedBy, reason: data.reason, }, - }); + }) // Clear room data since user was kicked - queryClient.setQueryData(roomKeys.current(), null); - }; + queryClient.setQueryData(roomKeys.current(), null) + } - const handleBannedFromRoom = (data: { - roomId: string; - bannedBy: string; - reason: string; - }) => { + const handleBannedFromRoom = (data: { roomId: string; bannedBy: string; reason: string }) => { setModerationEvent({ - type: "banned", + type: 'banned', data: { roomId: data.roomId, bannedBy: data.bannedBy, reason: data.reason, }, - }); + }) // Clear room data since user was banned - queryClient.setQueryData(roomKeys.current(), null); - }; + queryClient.setQueryData(roomKeys.current(), null) + } const handleReportSubmitted = (data: { - roomId: string; + roomId: string report: { - id: string; - reporterName: string; - reportedUserName: string; - reportedUserId: string; - reason: string; - createdAt: Date; - }; + id: string + reporterName: string + reportedUserName: string + reportedUserId: string + reason: string + createdAt: Date + } }) => { setModerationEvent({ - type: "report", + type: 'report', data: { roomId: data.roomId, reportId: data.report.id, @@ -453,22 +417,22 @@ export function useRoomData() { reportedUserId: data.report.reportedUserId, reason: data.report.reason, }, - }); - }; + }) + } const handleInvitationReceived = (data: { invitation: { - id: string; - roomId: string; - invitedBy: string; - invitedByName: string; - invitationType?: "manual" | "auto-unban" | "auto-create"; - message?: string; - createdAt: Date; - }; + id: string + roomId: string + invitedBy: string + invitedByName: string + invitationType?: 'manual' | 'auto-unban' | 'auto-create' + message?: string + createdAt: Date + } }) => { setModerationEvent({ - type: "invitation", + type: 'invitation', data: { roomId: data.invitation.roomId, invitationId: data.invitation.id, @@ -477,122 +441,114 @@ export function useRoomData() { invitationType: data.invitation.invitationType, message: data.invitation.message, }, - }); - }; + }) + } const handleJoinRequestSubmitted = (data: { - roomId: string; + roomId: string request: { - id: string; - userId: string; - userName: string; - createdAt: Date; - }; + id: string + userId: string + userName: string + createdAt: Date + } }) => { setModerationEvent({ - type: "join-request", + type: 'join-request', data: { roomId: data.roomId, requestId: data.request.id, requesterId: data.request.userId, requesterName: data.request.userName, }, - }); - }; + }) + } const handleRoomGameChanged = (data: { - roomId: string; - gameName: string | null; - gameConfig?: Record; + roomId: string + gameName: string | null + gameConfig?: Record }) => { if (data.roomId === roomData?.id) { - queryClient.setQueryData( - roomKeys.current(), - (prev) => { - if (!prev) return null; - return { - ...prev, - gameName: data.gameName, - // Only update gameConfig if it was provided in the broadcast - ...(data.gameConfig !== undefined - ? { gameConfig: data.gameConfig } - : {}), - }; - }, - ); + queryClient.setQueryData(roomKeys.current(), (prev) => { + if (!prev) return null + return { + ...prev, + gameName: data.gameName, + // Only update gameConfig if it was provided in the broadcast + ...(data.gameConfig !== undefined ? { gameConfig: data.gameConfig } : {}), + } + }) } - }; + } const handleOwnershipTransferred = (data: { - roomId: string; - oldOwnerId: string; - newOwnerId: string; - newOwnerName: string; - members: RoomMember[]; + roomId: string + oldOwnerId: string + newOwnerId: string + newOwnerName: string + members: RoomMember[] }) => { if (data.roomId === roomData?.id) { - queryClient.setQueryData( - roomKeys.current(), - (prev) => { - if (!prev) return null; - return { - ...prev, - members: data.members, - }; - }, - ); + queryClient.setQueryData(roomKeys.current(), (prev) => { + if (!prev) return null + return { + ...prev, + members: data.members, + } + }) } - }; + } - socket.on("room-joined", handleRoomJoined); - socket.on("member-joined", handleMemberJoined); - socket.on("member-left", handleMemberLeft); - socket.on("room-players-updated", handleRoomPlayersUpdated); - socket.on("player-deactivated", handlePlayerDeactivated); - socket.on("kicked-from-room", handleKickedFromRoom); - socket.on("banned-from-room", handleBannedFromRoom); - socket.on("report-submitted", handleReportSubmitted); - socket.on("room-invitation-received", handleInvitationReceived); - socket.on("join-request-submitted", handleJoinRequestSubmitted); - socket.on("room-game-changed", handleRoomGameChanged); - socket.on("ownership-transferred", handleOwnershipTransferred); + socket.on('room-joined', handleRoomJoined) + socket.on('member-joined', handleMemberJoined) + socket.on('member-left', handleMemberLeft) + socket.on('room-players-updated', handleRoomPlayersUpdated) + socket.on('player-deactivated', handlePlayerDeactivated) + socket.on('kicked-from-room', handleKickedFromRoom) + socket.on('banned-from-room', handleBannedFromRoom) + socket.on('report-submitted', handleReportSubmitted) + socket.on('room-invitation-received', handleInvitationReceived) + socket.on('join-request-submitted', handleJoinRequestSubmitted) + socket.on('room-game-changed', handleRoomGameChanged) + socket.on('ownership-transferred', handleOwnershipTransferred) return () => { - socket.off("room-joined", handleRoomJoined); - socket.off("member-joined", handleMemberJoined); - socket.off("member-left", handleMemberLeft); - socket.off("room-players-updated", handleRoomPlayersUpdated); - socket.off("player-deactivated", handlePlayerDeactivated); - socket.off("kicked-from-room", handleKickedFromRoom); - socket.off("banned-from-room", handleBannedFromRoom); - socket.off("report-submitted", handleReportSubmitted); - socket.off("room-invitation-received", handleInvitationReceived); - socket.off("join-request-submitted", handleJoinRequestSubmitted); - socket.off("room-game-changed", handleRoomGameChanged); - socket.off("ownership-transferred", handleOwnershipTransferred); - }; - }, [socket, roomData?.id, queryClient]); + socket.off('room-joined', handleRoomJoined) + socket.off('member-joined', handleMemberJoined) + socket.off('member-left', handleMemberLeft) + socket.off('room-players-updated', handleRoomPlayersUpdated) + socket.off('player-deactivated', handlePlayerDeactivated) + socket.off('kicked-from-room', handleKickedFromRoom) + socket.off('banned-from-room', handleBannedFromRoom) + socket.off('report-submitted', handleReportSubmitted) + socket.off('room-invitation-received', handleInvitationReceived) + socket.off('join-request-submitted', handleJoinRequestSubmitted) + socket.off('room-game-changed', handleRoomGameChanged) + socket.off('ownership-transferred', handleOwnershipTransferred) + } + }, [socket, roomData?.id, queryClient]) // Function to notify room members of player updates const notifyRoomOfPlayerUpdate = useCallback(() => { if (socket && roomData?.id && userId) { - socket.emit("players-updated", { roomId: roomData.id, userId }); + socket.emit('players-updated', { roomId: roomData.id, userId }) } - }, [socket, roomData?.id, userId]); + }, [socket, roomData?.id, userId]) /** * Generate a shareable URL for the room using the join code */ const getRoomShareUrl = useCallback((code: string): string => { - return `${window.location.origin}/join/${code.toUpperCase()}`; - }, []); + return `${window.location.origin}/join/${code.toUpperCase()}` + }, []) /** * Clear the moderation event after it's been handled */ const clearModerationEvent = useCallback(() => { - setModerationEvent(null); - }, []); + setModerationEvent(null) + }, []) return { // Data @@ -605,52 +561,52 @@ export function useRoomData() { getRoomShareUrl, notifyRoomOfPlayerUpdate, clearModerationEvent, - }; + } } /** * Hook: Create a room */ export function useCreateRoom() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: createRoomApi, onSuccess: (newRoom) => { // Optimistically set the cache with the new room data - queryClient.setQueryData(roomKeys.current(), newRoom); + queryClient.setQueryData(roomKeys.current(), newRoom) }, - }); + }) } /** * Hook: Join a room */ export function useJoinRoom() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: joinRoomApi, onSuccess: (result) => { // Optimistically set the cache with the joined room data - queryClient.setQueryData(roomKeys.current(), result.room); + queryClient.setQueryData(roomKeys.current(), result.room) }, - }); + }) } /** * Hook: Leave a room */ export function useLeaveRoom() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: leaveRoomApi, onSuccess: () => { // Optimistically clear the room data - queryClient.setQueryData(roomKeys.current(), null); + queryClient.setQueryData(roomKeys.current(), null) }, - }); + }) } /** @@ -659,36 +615,36 @@ export function useLeaveRoom() { export function useGetRoomByCode() { return useMutation({ mutationFn: getRoomByCodeApi, - }); + }) } /** * Set game for a room */ async function setRoomGameApi(params: { - roomId: string; - gameName: string; - gameConfig?: Record; + roomId: string + gameName: string + gameConfig?: Record }): Promise { // Only include gameConfig in the request if it was explicitly provided // Otherwise, we preserve the existing gameConfig in the database const body: { gameName: string; gameConfig?: Record } = { gameName: params.gameName, - }; + } if (params.gameConfig !== undefined) { - body.gameConfig = params.gameConfig; + body.gameConfig = params.gameConfig } const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }); + }) if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to set room game"); + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to set room game') } } @@ -696,23 +652,23 @@ async function setRoomGameApi(params: { * Hook: Set game for a room */ export function useSetRoomGame() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: setRoomGameApi, onSuccess: (_, variables) => { // Update the cache with the new game queryClient.setQueryData(roomKeys.current(), (prev) => { - if (!prev) return null; + if (!prev) return null return { ...prev, gameName: variables.gameName, - }; - }); + } + }) // Refetch to get the full updated room data - queryClient.invalidateQueries({ queryKey: roomKeys.current() }); + queryClient.invalidateQueries({ queryKey: roomKeys.current() }) }, - }); + }) } /** @@ -722,17 +678,17 @@ export function useSetRoomGame() { */ async function clearRoomGameApi(roomId: string): Promise { const response = await fetch(`/api/arcade/rooms/${roomId}/settings`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gameName: null, // DO NOT send gameConfig: null - we want to preserve settings! }), - }); + }) if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to clear room game"); + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to clear room game') } } @@ -740,43 +696,43 @@ async function clearRoomGameApi(roomId: string): Promise { * Hook: Clear/reset game for a room (returns to game selection screen) */ export function useClearRoomGame() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: clearRoomGameApi, onSuccess: () => { // Update the cache to clear the game queryClient.setQueryData(roomKeys.current(), (prev) => { - if (!prev) return null; + if (!prev) return null return { ...prev, gameName: null, - }; - }); + } + }) // Refetch to get the full updated room data - queryClient.invalidateQueries({ queryKey: roomKeys.current() }); + queryClient.invalidateQueries({ queryKey: roomKeys.current() }) }, - }); + }) } /** * Update game config for current room (game-specific settings) */ async function updateGameConfigApi(params: { - roomId: string; - gameConfig: Record; + roomId: string + gameConfig: Record }): Promise { const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gameConfig: params.gameConfig, }), - }); + }) if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to update game config"); + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to update game config') } } @@ -785,39 +741,36 @@ async function updateGameConfigApi(params: { * This allows games to persist their settings (e.g., difficulty, card count) */ export function useUpdateGameConfig() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: updateGameConfigApi, onSuccess: (_, variables) => { // Update the cache with the new gameConfig queryClient.setQueryData(roomKeys.current(), (prev) => { - if (!prev) return null; + if (!prev) return null return { ...prev, gameConfig: variables.gameConfig, - }; - }); + } + }) }, - }); + }) } /** * Kick a user from the room (host only) */ -async function kickUserFromRoomApi(params: { - roomId: string; - userId: string; -}): Promise { +async function kickUserFromRoomApi(params: { roomId: string; userId: string }): Promise { const response = await fetch(`/api/arcade/rooms/${params.roomId}/kick`, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: params.userId }), - }); + }) if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to kick user"); + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to kick user') } } @@ -825,58 +778,58 @@ async function kickUserFromRoomApi(params: { * Hook: Kick a user from the room (host only) */ export function useKickUser() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: kickUserFromRoomApi, onSuccess: () => { // The socket will handle updating members, but invalidate just in case - queryClient.invalidateQueries({ queryKey: roomKeys.current() }); + queryClient.invalidateQueries({ queryKey: roomKeys.current() }) }, - }); + }) } /** * Deactivate a specific player in the room (host only) */ async function deactivatePlayerInRoomApi(params: { - roomId: string; - playerId: string; + roomId: string + playerId: string }): Promise { - const url = `/api/arcade/rooms/${params.roomId}/deactivate-player`; - console.log("[useRoomData] deactivatePlayerInRoomApi called"); - console.log("[useRoomData] URL:", url); - console.log("[useRoomData] params:", params); + const url = `/api/arcade/rooms/${params.roomId}/deactivate-player` + console.log('[useRoomData] deactivatePlayerInRoomApi called') + console.log('[useRoomData] URL:', url) + console.log('[useRoomData] params:', params) const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playerId: params.playerId }), - }); + }) - console.log("[useRoomData] Response status:", response.status); - console.log("[useRoomData] Response ok:", response.ok); + console.log('[useRoomData] Response status:', response.status) + console.log('[useRoomData] Response ok:', response.ok) if (!response.ok) { - const errorData = await response.json(); - console.error("[useRoomData] Error response:", errorData); - throw new Error(errorData.error || "Failed to deactivate player"); + const errorData = await response.json() + console.error('[useRoomData] Error response:', errorData) + throw new Error(errorData.error || 'Failed to deactivate player') } - console.log("[useRoomData] deactivatePlayerInRoomApi success"); + console.log('[useRoomData] deactivatePlayerInRoomApi success') } /** * Hook: Deactivate a specific player in the room (host only) */ export function useDeactivatePlayer() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: deactivatePlayerInRoomApi, onSuccess: () => { // The socket will handle updating players, but invalidate just in case - queryClient.invalidateQueries({ queryKey: roomKeys.current() }); + queryClient.invalidateQueries({ queryKey: roomKeys.current() }) }, - }); + }) } diff --git a/apps/web/src/hooks/useUserPlayers.ts b/apps/web/src/hooks/useUserPlayers.ts index 97d717a9..5fc56ab9 100644 --- a/apps/web/src/hooks/useUserPlayers.ts +++ b/apps/web/src/hooks/useUserPlayers.ts @@ -1,43 +1,43 @@ -"use client"; +'use client' -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { Player } from "@/db/schema/players"; -import { api } from "@/lib/queryClient"; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { Player } from '@/db/schema/players' +import { api } from '@/lib/queryClient' /** * Query key factory for players */ export const playerKeys = { - all: ["players"] as const, - lists: () => [...playerKeys.all, "list"] as const, + all: ['players'] as const, + lists: () => [...playerKeys.all, 'list'] as const, list: () => [...playerKeys.lists()] as const, - detail: (id: string) => [...playerKeys.all, "detail", id] as const, -}; + detail: (id: string) => [...playerKeys.all, 'detail', id] as const, +} /** * Fetch all players for the current user */ async function fetchPlayers(): Promise { - const res = await api("players"); - if (!res.ok) throw new Error("Failed to fetch players"); - const data = await res.json(); - return data.players; + const res = await api('players') + if (!res.ok) throw new Error('Failed to fetch players') + const data = await res.json() + return data.players } /** * Create a new player */ async function createPlayer( - newPlayer: Pick & { isActive?: boolean }, + newPlayer: Pick & { isActive?: boolean } ): Promise { - const res = await api("players", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const res = await api('players', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newPlayer), - }); - if (!res.ok) throw new Error("Failed to create player"); - const data = await res.json(); - return data.player; + }) + if (!res.ok) throw new Error('Failed to create player') + const data = await res.json() + return data.player } /** @@ -47,25 +47,25 @@ async function updatePlayer({ id, updates, }: { - id: string; - updates: Partial>; + id: string + updates: Partial> }): Promise { const res = await api(`players/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), - }); + }) if (!res.ok) { // Extract error message from response if available try { - const errorData = await res.json(); - throw new Error(errorData.error || "Failed to update player"); + const errorData = await res.json() + throw new Error(errorData.error || 'Failed to update player') } catch (_jsonError) { - throw new Error("Failed to update player"); + throw new Error('Failed to update player') } } - const data = await res.json(); - return data.player; + const data = await res.json() + return data.player } /** @@ -73,9 +73,9 @@ async function updatePlayer({ */ async function deletePlayer(id: string): Promise { const res = await api(`players/${id}`, { - method: "DELETE", - }); - if (!res.ok) throw new Error("Failed to delete player"); + method: 'DELETE', + }) + if (!res.ok) throw new Error('Failed to delete player') } /** @@ -85,25 +85,23 @@ export function useUserPlayers() { return useQuery({ queryKey: playerKeys.list(), queryFn: fetchPlayers, - }); + }) } /** * Hook: Create a new player */ export function useCreatePlayer() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: createPlayer, onMutate: async (newPlayer) => { // Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: playerKeys.lists() }); + await queryClient.cancelQueries({ queryKey: playerKeys.lists() }) // Snapshot previous value - const previousPlayers = queryClient.getQueryData( - playerKeys.list(), - ); + const previousPlayers = queryClient.getQueryData(playerKeys.list()) // Optimistically update to new value if (previousPlayers) { @@ -112,130 +110,118 @@ export function useCreatePlayer() { ...newPlayer, createdAt: new Date(), isActive: newPlayer.isActive ?? false, - userId: "temp-user", // Temporary userId, will be replaced by server response - }; + userId: 'temp-user', // Temporary userId, will be replaced by server response + } queryClient.setQueryData(playerKeys.list(), [ ...previousPlayers, optimisticPlayer, - ]); + ]) } - return { previousPlayers }; + return { previousPlayers } }, onError: (_err, _newPlayer, context) => { // Rollback on error if (context?.previousPlayers) { - queryClient.setQueryData(playerKeys.list(), context.previousPlayers); + queryClient.setQueryData(playerKeys.list(), context.previousPlayers) } }, onSettled: () => { // Always refetch after error or success - queryClient.invalidateQueries({ queryKey: playerKeys.lists() }); + queryClient.invalidateQueries({ queryKey: playerKeys.lists() }) }, - }); + }) } /** * Hook: Update a player */ export function useUpdatePlayer() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: updatePlayer, onMutate: async ({ id, updates }) => { // Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: playerKeys.lists() }); + await queryClient.cancelQueries({ queryKey: playerKeys.lists() }) // Snapshot previous value - const previousPlayers = queryClient.getQueryData( - playerKeys.list(), - ); + const previousPlayers = queryClient.getQueryData(playerKeys.list()) // Optimistically update if (previousPlayers) { const optimisticPlayers = previousPlayers.map((player) => - player.id === id ? { ...player, ...updates } : player, - ); - queryClient.setQueryData( - playerKeys.list(), - optimisticPlayers, - ); + player.id === id ? { ...player, ...updates } : player + ) + queryClient.setQueryData(playerKeys.list(), optimisticPlayers) } - return { previousPlayers }; + return { previousPlayers } }, onError: (err, _variables, context) => { // Log error for debugging - console.error("Failed to update player:", err.message); + console.error('Failed to update player:', err.message) // Rollback on error if (context?.previousPlayers) { - queryClient.setQueryData(playerKeys.list(), context.previousPlayers); + queryClient.setQueryData(playerKeys.list(), context.previousPlayers) } }, onSettled: (_data, _error, { id }) => { // Refetch after error or success - queryClient.invalidateQueries({ queryKey: playerKeys.lists() }); + queryClient.invalidateQueries({ queryKey: playerKeys.lists() }) if (_data) { - queryClient.setQueryData(playerKeys.detail(id), _data); + queryClient.setQueryData(playerKeys.detail(id), _data) } }, - }); + }) } /** * Hook: Delete a player */ export function useDeletePlayer() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: deletePlayer, onMutate: async (id) => { // Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: playerKeys.lists() }); + await queryClient.cancelQueries({ queryKey: playerKeys.lists() }) // Snapshot previous value - const previousPlayers = queryClient.getQueryData( - playerKeys.list(), - ); + const previousPlayers = queryClient.getQueryData(playerKeys.list()) // Optimistically remove from list if (previousPlayers) { - const optimisticPlayers = previousPlayers.filter( - (player) => player.id !== id, - ); - queryClient.setQueryData( - playerKeys.list(), - optimisticPlayers, - ); + const optimisticPlayers = previousPlayers.filter((player) => player.id !== id) + queryClient.setQueryData(playerKeys.list(), optimisticPlayers) } - return { previousPlayers }; + return { previousPlayers } }, onError: (_err, _id, context) => { // Rollback on error if (context?.previousPlayers) { - queryClient.setQueryData(playerKeys.list(), context.previousPlayers); + queryClient.setQueryData(playerKeys.list(), context.previousPlayers) } }, onSettled: () => { // Refetch after error or success - queryClient.invalidateQueries({ queryKey: playerKeys.lists() }); + queryClient.invalidateQueries({ queryKey: playerKeys.lists() }) }, - }); + }) } /** * Hook: Set player active status */ export function useSetPlayerActive() { - const { mutate: updatePlayer } = useUpdatePlayer(); + const { mutate: updatePlayer } = useUpdatePlayer() return { setActive: (id: string, isActive: boolean) => { - updatePlayer({ id, updates: { isActive } }); + updatePlayer({ id, updates: { isActive } }) }, - }; + } } diff --git a/apps/web/src/hooks/useUserStats.ts b/apps/web/src/hooks/useUserStats.ts index 9b51dd65..8f0dd3f5 100644 --- a/apps/web/src/hooks/useUserStats.ts +++ b/apps/web/src/hooks/useUserStats.ts @@ -1,41 +1,39 @@ -"use client"; +'use client' -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { UserStats } from "@/db/schema/user-stats"; -import { api } from "@/lib/queryClient"; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { UserStats } from '@/db/schema/user-stats' +import { api } from '@/lib/queryClient' /** * Query key factory for user stats */ export const statsKeys = { - all: ["user-stats"] as const, - detail: () => [...statsKeys.all, "detail"] as const, -}; + all: ['user-stats'] as const, + detail: () => [...statsKeys.all, 'detail'] as const, +} /** * Fetch user statistics */ async function fetchUserStats(): Promise { - const res = await api("user-stats"); - if (!res.ok) throw new Error("Failed to fetch user stats"); - const data = await res.json(); - return data.stats; + const res = await api('user-stats') + if (!res.ok) throw new Error('Failed to fetch user stats') + const data = await res.json() + return data.stats } /** * Update user statistics */ -async function updateUserStats( - updates: Partial>, -): Promise { - const res = await api("user-stats", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, +async function updateUserStats(updates: Partial>): Promise { + const res = await api('user-stats', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), - }); - if (!res.ok) throw new Error("Failed to update user stats"); - const data = await res.json(); - return data.stats; + }) + if (!res.ok) throw new Error('Failed to update user stats') + const data = await res.json() + return data.stats } /** @@ -45,74 +43,69 @@ export function useUserStats() { return useQuery({ queryKey: statsKeys.detail(), queryFn: fetchUserStats, - }); + }) } /** * Hook: Update user statistics */ export function useUpdateUserStats() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: updateUserStats, onMutate: async (updates) => { // Cancel outgoing refetches - await queryClient.cancelQueries({ queryKey: statsKeys.detail() }); + await queryClient.cancelQueries({ queryKey: statsKeys.detail() }) // Snapshot previous value - const previousStats = queryClient.getQueryData( - statsKeys.detail(), - ); + const previousStats = queryClient.getQueryData(statsKeys.detail()) // Optimistically update if (previousStats) { - const optimisticStats = { ...previousStats, ...updates }; - queryClient.setQueryData( - statsKeys.detail(), - optimisticStats, - ); + const optimisticStats = { ...previousStats, ...updates } + queryClient.setQueryData(statsKeys.detail(), optimisticStats) } - return { previousStats }; + return { previousStats } }, onError: (_err, _updates, context) => { // Rollback on error if (context?.previousStats) { - queryClient.setQueryData(statsKeys.detail(), context.previousStats); + queryClient.setQueryData(statsKeys.detail(), context.previousStats) } }, onSettled: (updatedStats) => { // Update with server data on success if (updatedStats) { - queryClient.setQueryData(statsKeys.detail(), updatedStats); + queryClient.setQueryData(statsKeys.detail(), updatedStats) } }, - }); + }) } /** * Hook: Increment games played */ export function useIncrementGamesPlayed() { - const { data: stats } = useUserStats(); - const { mutate: updateStats } = useUpdateUserStats(); + const { data: stats } = useUserStats() + const { mutate: updateStats } = useUpdateUserStats() return { incrementGamesPlayed: () => { if (stats) { - updateStats({ gamesPlayed: stats.gamesPlayed + 1 }); + updateStats({ gamesPlayed: stats.gamesPlayed + 1 }) } }, - }; + } } /** * Hook: Record a win */ export function useRecordWin() { - const { data: stats } = useUserStats(); - const { mutate: updateStats } = useUpdateUserStats(); + const { data: stats } = useUserStats() + const { mutate: updateStats } = useUpdateUserStats() return { recordWin: () => { @@ -120,40 +113,40 @@ export function useRecordWin() { updateStats({ gamesPlayed: stats.gamesPlayed + 1, totalWins: stats.totalWins + 1, - }); + }) } }, - }; + } } /** * Hook: Update best time if faster */ export function useUpdateBestTime() { - const { data: stats } = useUserStats(); - const { mutate: updateStats } = useUpdateUserStats(); + const { data: stats } = useUserStats() + const { mutate: updateStats } = useUpdateUserStats() return { updateBestTime: (newTime: number) => { if (stats && (stats.bestTime === null || newTime < stats.bestTime)) { - updateStats({ bestTime: newTime }); + updateStats({ bestTime: newTime }) } }, - }; + } } /** * Hook: Update highest accuracy if better */ export function useUpdateHighestAccuracy() { - const { data: stats } = useUserStats(); - const { mutate: updateStats } = useUpdateUserStats(); + const { data: stats } = useUserStats() + const { mutate: updateStats } = useUpdateUserStats() return { updateHighestAccuracy: (newAccuracy: number) => { if (stats && newAccuracy > stats.highestAccuracy) { - updateStats({ highestAccuracy: newAccuracy }); + updateStats({ highestAccuracy: newAccuracy }) } }, - }; + } } diff --git a/apps/web/src/hooks/useViewerId.ts b/apps/web/src/hooks/useViewerId.ts index d81134df..b0df925f 100644 --- a/apps/web/src/hooks/useViewerId.ts +++ b/apps/web/src/hooks/useViewerId.ts @@ -1,27 +1,27 @@ -"use client"; +'use client' -import { useQuery } from "@tanstack/react-query"; -import { api } from "@/lib/queryClient"; +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/queryClient' /** * Query key for viewer ID */ export const viewerKeys = { - all: ["viewer"] as const, - id: () => [...viewerKeys.all, "id"] as const, -}; + all: ['viewer'] as const, + id: () => [...viewerKeys.all, 'id'] as const, +} /** * Fetch the current viewer ID */ async function fetchViewerId(): Promise { try { - const res = await api("viewer"); - if (!res.ok) return null; - const data = await res.json(); - return data.viewerId; + const res = await api('viewer') + if (!res.ok) return null + const data = await res.json() + return data.viewerId } catch { - return null; + return null } } @@ -37,5 +37,5 @@ export function useViewerId() { queryFn: fetchViewerId, staleTime: 5 * 60 * 1000, // 5 minutes retry: false, - }); + }) } diff --git a/apps/web/src/i18n/locales/calendar/messages.ts b/apps/web/src/i18n/locales/calendar/messages.ts index f16d5ebe..a470d5be 100644 --- a/apps/web/src/i18n/locales/calendar/messages.ts +++ b/apps/web/src/i18n/locales/calendar/messages.ts @@ -1,10 +1,10 @@ -import de from "./de.json"; -import en from "./en.json"; -import es from "./es.json"; -import goh from "./goh.json"; -import hi from "./hi.json"; -import ja from "./ja.json"; -import la from "./la.json"; +import de from './de.json' +import en from './en.json' +import es from './es.json' +import goh from './goh.json' +import hi from './hi.json' +import ja from './ja.json' +import la from './la.json' export const calendarMessages = { en: en.calendar, @@ -14,4 +14,4 @@ export const calendarMessages = { es: es.calendar, la: la.calendar, goh: goh.calendar, -} as const; +} as const diff --git a/apps/web/src/i18n/locales/create/messages.ts b/apps/web/src/i18n/locales/create/messages.ts index 457aba7f..178de4f0 100644 --- a/apps/web/src/i18n/locales/create/messages.ts +++ b/apps/web/src/i18n/locales/create/messages.ts @@ -1,10 +1,10 @@ -import de from "./de.json"; -import en from "./en.json"; -import es from "./es.json"; -import goh from "./goh.json"; -import hi from "./hi.json"; -import ja from "./ja.json"; -import la from "./la.json"; +import de from './de.json' +import en from './en.json' +import es from './es.json' +import goh from './goh.json' +import hi from './hi.json' +import ja from './ja.json' +import la from './la.json' export const createMessages = { en: en.create, @@ -14,4 +14,4 @@ export const createMessages = { es: es.create, la: la.create, goh: goh.create, -} as const; +} as const diff --git a/apps/web/src/i18n/locales/games/messages.ts b/apps/web/src/i18n/locales/games/messages.ts index 2b6895e8..66cc1ab4 100644 --- a/apps/web/src/i18n/locales/games/messages.ts +++ b/apps/web/src/i18n/locales/games/messages.ts @@ -1,10 +1,10 @@ -import de from "./de.json"; -import en from "./en.json"; -import es from "./es.json"; -import goh from "./goh.json"; -import hi from "./hi.json"; -import ja from "./ja.json"; -import la from "./la.json"; +import de from './de.json' +import en from './en.json' +import es from './es.json' +import goh from './goh.json' +import hi from './hi.json' +import ja from './ja.json' +import la from './la.json' export const gamesMessages = { en: en.games, @@ -14,4 +14,4 @@ export const gamesMessages = { es: es.games, la: la.games, goh: goh.games, -} as const; +} as const diff --git a/apps/web/src/i18n/locales/guide/hi.json b/apps/web/src/i18n/locales/guide/hi.json index 7e653e4a..ccb4ca09 100644 --- a/apps/web/src/i18n/locales/guide/hi.json +++ b/apps/web/src/i18n/locales/guide/hi.json @@ -66,11 +66,7 @@ }, "placeValues": { "title": "स्थानीय मान:", - "points": [ - "सबसे दाएं = इकाई (1)", - "अगला बाएं = दहाई (10)", - "सैकड़ा, हजार आदि के लिए जारी रखें" - ] + "points": ["सबसे दाएं = इकाई (1)", "अगला बाएं = दहाई (10)", "सैकड़ा, हजार आदि के लिए जारी रखें"] } }, "examples": { diff --git a/apps/web/src/i18n/locales/guide/ja.json b/apps/web/src/i18n/locales/guide/ja.json index ab065827..627fe160 100644 --- a/apps/web/src/i18n/locales/guide/ja.json +++ b/apps/web/src/i18n/locales/guide/ja.json @@ -66,11 +66,7 @@ }, "placeValues": { "title": "位の値:", - "points": [ - "一番右=一の位(1)", - "次の左=十の位(10)", - "百、千などに続く" - ] + "points": ["一番右=一の位(1)", "次の左=十の位(10)", "百、千などに続く"] } }, "examples": { @@ -115,19 +111,11 @@ "title": "🎮 インタラクティブそろばんの使い方", "heaven": { "title": "天珠(上):", - "points": [ - "各5ポイント", - "クリックしてオン/オフ切り替え", - "有効時は青、無効時は灰色" - ] + "points": ["各5ポイント", "クリックしてオン/オフ切り替え", "有効時は青、無効時は灰色"] }, "earth": { "title": "地珠(下):", - "points": [ - "各1ポイント", - "クリックしてグループを有効化", - "有効時は緑、無効時は灰色" - ] + "points": ["各1ポイント", "クリックしてグループを有効化", "有効時は緑、無効時は灰色"] } }, "readyToPractice": { diff --git a/apps/web/src/i18n/locales/guide/messages.ts b/apps/web/src/i18n/locales/guide/messages.ts index 947f5638..96d0beaf 100644 --- a/apps/web/src/i18n/locales/guide/messages.ts +++ b/apps/web/src/i18n/locales/guide/messages.ts @@ -1,10 +1,10 @@ -import de from "./de.json"; -import en from "./en.json"; -import es from "./es.json"; -import goh from "./goh.json"; -import hi from "./hi.json"; -import ja from "./ja.json"; -import la from "./la.json"; +import de from './de.json' +import en from './en.json' +import es from './es.json' +import goh from './goh.json' +import hi from './hi.json' +import ja from './ja.json' +import la from './la.json' export const guideMessages = { en: en.guide, @@ -14,4 +14,4 @@ export const guideMessages = { es: es.guide, la: la.guide, goh: goh.guide, -} as const; +} as const diff --git a/apps/web/src/i18n/locales/home/messages.ts b/apps/web/src/i18n/locales/home/messages.ts index 3fac4431..e6d66645 100644 --- a/apps/web/src/i18n/locales/home/messages.ts +++ b/apps/web/src/i18n/locales/home/messages.ts @@ -1,10 +1,10 @@ -import de from "./de.json"; -import en from "./en.json"; -import es from "./es.json"; -import goh from "./goh.json"; -import hi from "./hi.json"; -import ja from "./ja.json"; -import la from "./la.json"; +import de from './de.json' +import en from './en.json' +import es from './es.json' +import goh from './goh.json' +import hi from './hi.json' +import ja from './ja.json' +import la from './la.json' export const homeMessages = { en: en.home, @@ -14,4 +14,4 @@ export const homeMessages = { es: es.home, la: la.home, goh: goh.home, -} as const; +} as const diff --git a/apps/web/src/i18n/locales/tutorial/messages.ts b/apps/web/src/i18n/locales/tutorial/messages.ts index 08c4c3ae..23322cc5 100644 --- a/apps/web/src/i18n/locales/tutorial/messages.ts +++ b/apps/web/src/i18n/locales/tutorial/messages.ts @@ -1,10 +1,10 @@ -import de from "./de.json"; -import en from "./en.json"; -import es from "./es.json"; -import goh from "./goh.json"; -import hi from "./hi.json"; -import ja from "./ja.json"; -import la from "./la.json"; +import de from './de.json' +import en from './en.json' +import es from './es.json' +import goh from './goh.json' +import hi from './hi.json' +import ja from './ja.json' +import la from './la.json' export const tutorialMessages = { en: en.tutorial, @@ -14,4 +14,4 @@ export const tutorialMessages = { es: es.tutorial, la: la.tutorial, goh: goh.tutorial, -} as const; +} as const diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index 1dd82f8a..87b56c5e 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -1,12 +1,12 @@ -import { rithmomachiaMessages } from "@/arcade-games/rithmomachia/messages"; -import { calendarMessages } from "@/i18n/locales/calendar/messages"; -import { createMessages } from "@/i18n/locales/create/messages"; -import { gamesMessages } from "@/i18n/locales/games/messages"; -import { guideMessages } from "@/i18n/locales/guide/messages"; -import { homeMessages } from "@/i18n/locales/home/messages"; -import { tutorialMessages } from "@/i18n/locales/tutorial/messages"; +import { rithmomachiaMessages } from '@/arcade-games/rithmomachia/messages' +import { calendarMessages } from '@/i18n/locales/calendar/messages' +import { createMessages } from '@/i18n/locales/create/messages' +import { gamesMessages } from '@/i18n/locales/games/messages' +import { guideMessages } from '@/i18n/locales/guide/messages' +import { homeMessages } from '@/i18n/locales/home/messages' +import { tutorialMessages } from '@/i18n/locales/tutorial/messages' -export type Locale = "en" | "de" | "ja" | "hi" | "es" | "la" | "goh"; +export type Locale = 'en' | 'de' | 'ja' | 'hi' | 'es' | 'la' | 'goh' /** * Deep merge messages from multiple sources @@ -14,18 +14,14 @@ export type Locale = "en" | "de" | "ja" | "hi" | "es" | "la" | "goh"; function mergeMessages(...sources: Record[]): Record { return sources.reduce((acc, source) => { for (const [key, value] of Object.entries(source)) { - if ( - typeof value === "object" && - value !== null && - !Array.isArray(value) - ) { - acc[key] = mergeMessages(acc[key] || {}, value); + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + acc[key] = mergeMessages(acc[key] || {}, value) } else { - acc[key] = value; + acc[key] = value } } - return acc; - }, {}); + return acc + }, {}) } /** @@ -37,7 +33,7 @@ export async function getMessages(locale: Locale) { common: { // Add app-wide translations here as needed }, - }; + } // Merge all co-located feature messages return mergeMessages( @@ -48,6 +44,6 @@ export async function getMessages(locale: Locale) { { tutorial: tutorialMessages[locale] }, { calendar: calendarMessages[locale] }, { create: createMessages[locale] }, - rithmomachiaMessages[locale], - ); + rithmomachiaMessages[locale] + ) } diff --git a/apps/web/src/i18n/request.ts b/apps/web/src/i18n/request.ts index 7fb15ee9..0e5eadfd 100644 --- a/apps/web/src/i18n/request.ts +++ b/apps/web/src/i18n/request.ts @@ -1,40 +1,33 @@ -import { getRequestConfig } from "next-intl/server"; -import { headers, cookies } from "next/headers"; -import { - defaultLocale, - LOCALE_COOKIE_NAME, - locales, - type Locale, -} from "./routing"; -import { getMessages } from "./messages"; +import { getRequestConfig } from 'next-intl/server' +import { headers, cookies } from 'next/headers' +import { defaultLocale, LOCALE_COOKIE_NAME, locales, type Locale } from './routing' +import { getMessages } from './messages' export async function getRequestLocale(): Promise { // Get locale from header (set by middleware) or cookie - const headersList = await headers(); - const cookieStore = await cookies(); + const headersList = await headers() + const cookieStore = await cookies() - let locale = headersList.get("x-locale") as Locale | null; + let locale = headersList.get('x-locale') as Locale | null if (!locale) { - locale = - (cookieStore.get(LOCALE_COOKIE_NAME)?.value as Locale | undefined) ?? - null; + locale = (cookieStore.get(LOCALE_COOKIE_NAME)?.value as Locale | undefined) ?? null } // Validate and fallback to default if (!locale || !locales.includes(locale)) { - locale = defaultLocale; + locale = defaultLocale } - return locale; + return locale } export default getRequestConfig(async () => { - const locale = await getRequestLocale(); + const locale = await getRequestLocale() return { locale, messages: await getMessages(locale), - timeZone: "UTC", - }; -}); + timeZone: 'UTC', + } +}) diff --git a/apps/web/src/i18n/routing.ts b/apps/web/src/i18n/routing.ts index 0aece745..206c2779 100644 --- a/apps/web/src/i18n/routing.ts +++ b/apps/web/src/i18n/routing.ts @@ -1,9 +1,9 @@ // Supported locales -export const locales = ["en", "de", "ja", "hi", "es", "la", "goh"] as const; -export type Locale = (typeof locales)[number]; +export const locales = ['en', 'de', 'ja', 'hi', 'es', 'la', 'goh'] as const +export type Locale = (typeof locales)[number] // Default locale -export const defaultLocale: Locale = "en"; +export const defaultLocale: Locale = 'en' // Locale cookie name -export const LOCALE_COOKIE_NAME = "NEXT_LOCALE"; +export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE' diff --git a/apps/web/src/lib/3d-printing/jobManager.ts b/apps/web/src/lib/3d-printing/jobManager.ts index 903f3f2c..30798cd3 100644 --- a/apps/web/src/lib/3d-printing/jobManager.ts +++ b/apps/web/src/lib/3d-printing/jobManager.ts @@ -1,138 +1,125 @@ -import { exec } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { promisify } from "node:util"; +import { exec } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { promisify } from 'node:util' -const execAsync = promisify(exec); +const execAsync = promisify(exec) -export type JobStatus = "pending" | "processing" | "completed" | "failed"; +export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed' export interface Job { - id: string; - status: JobStatus; - params: AbacusParams; - error?: string; - outputPath?: string; - createdAt: Date; - completedAt?: Date; - progress?: string; + id: string + status: JobStatus + params: AbacusParams + error?: string + outputPath?: string + createdAt: Date + completedAt?: Date + progress?: string } export interface AbacusParams { - columns: number; // Number of columns (1-13) - scaleFactor: number; // Overall size multiplier - widthMm?: number; // Optional: desired width in mm (overrides scaleFactor) - format: "stl" | "3mf" | "scad"; + columns: number // Number of columns (1-13) + scaleFactor: number // Overall size multiplier + widthMm?: number // Optional: desired width in mm (overrides scaleFactor) + format: 'stl' | '3mf' | 'scad' // 3MF color options - frameColor?: string; - heavenBeadColor?: string; - earthBeadColor?: string; - decorationColor?: string; + frameColor?: string + heavenBeadColor?: string + earthBeadColor?: string + decorationColor?: string } // In-memory job storage (can be upgraded to Redis later) -const jobs = new Map(); +const jobs = new Map() // Temporary directory for generated files -const TEMP_DIR = join(process.cwd(), "tmp", "3d-jobs"); +const TEMP_DIR = join(process.cwd(), 'tmp', '3d-jobs') export class JobManager { static generateJobId(): string { - return randomBytes(16).toString("hex"); + return randomBytes(16).toString('hex') } static async createJob(params: AbacusParams): Promise { - const jobId = JobManager.generateJobId(); + const jobId = JobManager.generateJobId() const job: Job = { id: jobId, - status: "pending", + status: 'pending', params, createdAt: new Date(), - }; + } - jobs.set(jobId, job); + jobs.set(jobId, job) // Start processing in background JobManager.processJob(jobId).catch((error) => { - console.error(`Job ${jobId} failed:`, error); - const job = jobs.get(jobId); + console.error(`Job ${jobId} failed:`, error) + const job = jobs.get(jobId) if (job) { - job.status = "failed"; - job.error = error.message; - job.completedAt = new Date(); + job.status = 'failed' + job.error = error.message + job.completedAt = new Date() } - }); + }) - return jobId; + return jobId } static getJob(jobId: string): Job | undefined { - return jobs.get(jobId); + return jobs.get(jobId) } static async processJob(jobId: string): Promise { - const job = jobs.get(jobId); - if (!job) throw new Error("Job not found"); + const job = jobs.get(jobId) + if (!job) throw new Error('Job not found') - job.status = "processing"; - job.progress = "Preparing workspace..."; + job.status = 'processing' + job.progress = 'Preparing workspace...' // Create temp directory - await mkdir(TEMP_DIR, { recursive: true }); + await mkdir(TEMP_DIR, { recursive: true }) - const outputFileName = `abacus-${jobId}.${job.params.format}`; - const outputPath = join(TEMP_DIR, outputFileName); + const outputFileName = `abacus-${jobId}.${job.params.format}` + const outputPath = join(TEMP_DIR, outputFileName) try { // Build OpenSCAD command - const scadPath = join( - process.cwd(), - "public", - "3d-models", - "abacus.scad", - ); - const stlPath = join( - process.cwd(), - "public", - "3d-models", - "simplified.abacus.stl", - ); + const scadPath = join(process.cwd(), 'public', '3d-models', 'abacus.scad') + const stlPath = join(process.cwd(), 'public', '3d-models', 'simplified.abacus.stl') // If format is 'scad', just copy the file with custom parameters - if (job.params.format === "scad") { - job.progress = "Generating OpenSCAD file..."; - const scadContent = await readFile(scadPath, "utf-8"); + if (job.params.format === 'scad') { + job.progress = 'Generating OpenSCAD file...' + const scadContent = await readFile(scadPath, 'utf-8') const customizedScad = scadContent .replace(/columns = \d+\.?\d*/, `columns = ${job.params.columns}`) - .replace( - /scale_factor = \d+\.?\d*/, - `scale_factor = ${job.params.scaleFactor}`, - ); + .replace(/scale_factor = \d+\.?\d*/, `scale_factor = ${job.params.scaleFactor}`) - await writeFile(outputPath, customizedScad); - job.outputPath = outputPath; - job.status = "completed"; - job.completedAt = new Date(); - job.progress = "Complete!"; - return; + await writeFile(outputPath, customizedScad) + job.outputPath = outputPath + job.status = 'completed' + job.completedAt = new Date() + job.progress = 'Complete!' + return } - job.progress = "Rendering 3D model..."; + job.progress = 'Rendering 3D model...' // Build command with parameters const cmd = [ - "openscad", - "-o", + 'openscad', + '-o', outputPath, - "-D", + '-D', `'columns=${job.params.columns}'`, - "-D", + '-D', `'scale_factor=${job.params.scaleFactor}'`, scadPath, - ].join(" "); + ].join(' ') - console.log(`Executing: ${cmd}`); + console.log(`Executing: ${cmd}`) // Execute OpenSCAD (with 60s timeout) // Note: OpenSCAD may exit with non-zero status due to CGAL warnings @@ -140,98 +127,88 @@ export class JobManager { try { await execAsync(cmd, { timeout: 60000, - cwd: join(process.cwd(), "public", "3d-models"), - }); + cwd: join(process.cwd(), 'public', '3d-models'), + }) } catch (execError) { // Log the error but don't throw yet - check if output was created - console.warn( - `OpenSCAD reported errors, but checking if output was created:`, - execError, - ); + console.warn(`OpenSCAD reported errors, but checking if output was created:`, execError) // Check if output file exists despite the error try { - await readFile(outputPath); - console.log( - `Output file created despite OpenSCAD warnings - proceeding`, - ); + await readFile(outputPath) + console.log(`Output file created despite OpenSCAD warnings - proceeding`) } catch (readError) { // File doesn't exist, this is a real failure - console.error( - `OpenSCAD execution failed and no output file created:`, - execError, - ); + console.error(`OpenSCAD execution failed and no output file created:`, execError) if (execError instanceof Error) { - throw new Error(`OpenSCAD error: ${execError.message}`); + throw new Error(`OpenSCAD error: ${execError.message}`) } - throw execError; + throw execError } } - job.progress = "Finalizing..."; + job.progress = 'Finalizing...' // Verify output exists and check file size - const fileBuffer = await readFile(outputPath); - const fileSizeMB = fileBuffer.length / (1024 * 1024); + const fileBuffer = await readFile(outputPath) + const fileSizeMB = fileBuffer.length / (1024 * 1024) // Maximum file size: 100MB (to prevent memory issues) - const MAX_FILE_SIZE_MB = 100; + const MAX_FILE_SIZE_MB = 100 if (fileSizeMB > MAX_FILE_SIZE_MB) { throw new Error( - `Generated file is too large (${fileSizeMB.toFixed(1)}MB). Maximum allowed is ${MAX_FILE_SIZE_MB}MB. Try reducing scale parameters.`, - ); + `Generated file is too large (${fileSizeMB.toFixed(1)}MB). Maximum allowed is ${MAX_FILE_SIZE_MB}MB. Try reducing scale parameters.` + ) } - console.log(`Generated STL file size: ${fileSizeMB.toFixed(2)}MB`); + console.log(`Generated STL file size: ${fileSizeMB.toFixed(2)}MB`) - job.outputPath = outputPath; - job.status = "completed"; - job.completedAt = new Date(); - job.progress = "Complete!"; + job.outputPath = outputPath + job.status = 'completed' + job.completedAt = new Date() + job.progress = 'Complete!' - console.log(`Job ${jobId} completed successfully`); + console.log(`Job ${jobId} completed successfully`) } catch (error) { - console.error(`Job ${jobId} failed:`, error); - job.status = "failed"; - job.error = - error instanceof Error ? error.message : "Unknown error occurred"; - job.completedAt = new Date(); - throw error; + console.error(`Job ${jobId} failed:`, error) + job.status = 'failed' + job.error = error instanceof Error ? error.message : 'Unknown error occurred' + job.completedAt = new Date() + throw error } } static async getJobOutput(jobId: string): Promise { - const job = jobs.get(jobId); - if (!job) throw new Error("Job not found"); - if (job.status !== "completed") - throw new Error(`Job is ${job.status}, not completed`); - if (!job.outputPath) throw new Error("Output path not set"); + const job = jobs.get(jobId) + if (!job) throw new Error('Job not found') + if (job.status !== 'completed') throw new Error(`Job is ${job.status}, not completed`) + if (!job.outputPath) throw new Error('Output path not set') - return await readFile(job.outputPath); + return await readFile(job.outputPath) } static async cleanupJob(jobId: string): Promise { - const job = jobs.get(jobId); - if (!job) return; + const job = jobs.get(jobId) + if (!job) return if (job.outputPath) { try { - await rm(job.outputPath); + await rm(job.outputPath) } catch (error) { - console.error(`Failed to cleanup job ${jobId}:`, error); + console.error(`Failed to cleanup job ${jobId}:`, error) } } - jobs.delete(jobId); + jobs.delete(jobId) } // Cleanup old jobs (should be called periodically) static async cleanupOldJobs(maxAgeMs = 3600000): Promise { - const now = Date.now(); + const now = Date.now() for (const [jobId, job] of jobs.entries()) { - const age = now - job.createdAt.getTime(); + const age = now - job.createdAt.getTime() if (age > maxAgeMs) { - await JobManager.cleanupJob(jobId); + await JobManager.cleanupJob(jobId) } } } diff --git a/apps/web/src/lib/__tests__/guest-token.test.ts b/apps/web/src/lib/__tests__/guest-token.test.ts index 46046591..ac9f2184 100644 --- a/apps/web/src/lib/__tests__/guest-token.test.ts +++ b/apps/web/src/lib/__tests__/guest-token.test.ts @@ -2,171 +2,163 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it } from "vitest"; -import { - createGuestToken, - GUEST_COOKIE_NAME, - verifyGuestToken, -} from "../guest-token"; +import { beforeEach, describe, expect, it } from 'vitest' +import { createGuestToken, GUEST_COOKIE_NAME, verifyGuestToken } from '../guest-token' -describe("Guest Token Utilities", () => { +describe('Guest Token Utilities', () => { beforeEach(() => { // Set AUTH_SECRET for tests - process.env.AUTH_SECRET = "test-secret-key-for-jwt-signing"; - }); + process.env.AUTH_SECRET = 'test-secret-key-for-jwt-signing' + }) - describe("GUEST_COOKIE_NAME", () => { - it("uses __Host- prefix in production, plain name in dev", () => { + describe('GUEST_COOKIE_NAME', () => { + it('uses __Host- prefix in production, plain name in dev', () => { // In test environment, NODE_ENV is not 'production' - expect(GUEST_COOKIE_NAME).toBe("guest"); - }); - }); + expect(GUEST_COOKIE_NAME).toBe('guest') + }) + }) - describe("createGuestToken", () => { - it("creates a valid JWT token", async () => { - const sid = "test-session-id"; - const token = await createGuestToken(sid); + describe('createGuestToken', () => { + it('creates a valid JWT token', async () => { + const sid = 'test-session-id' + const token = await createGuestToken(sid) - expect(token).toBeDefined(); - expect(typeof token).toBe("string"); - expect(token.split(".")).toHaveLength(3); // JWT has 3 parts - }); + expect(token).toBeDefined() + expect(typeof token).toBe('string') + expect(token.split('.')).toHaveLength(3) // JWT has 3 parts + }) - it("includes session ID in payload", async () => { - const sid = "test-session-id-123"; - const token = await createGuestToken(sid); - const verified = await verifyGuestToken(token); + it('includes session ID in payload', async () => { + const sid = 'test-session-id-123' + const token = await createGuestToken(sid) + const verified = await verifyGuestToken(token) - expect(verified.sid).toBe(sid); - }); + expect(verified.sid).toBe(sid) + }) - it("sets expiration time correctly", async () => { - const sid = "test-session-id"; - const maxAgeSec = 3600; // 1 hour - const token = await createGuestToken(sid, maxAgeSec); - const verified = await verifyGuestToken(token); + it('sets expiration time correctly', async () => { + const sid = 'test-session-id' + const maxAgeSec = 3600 // 1 hour + const token = await createGuestToken(sid, maxAgeSec) + const verified = await verifyGuestToken(token) - const now = Math.floor(Date.now() / 1000); - expect(verified.exp).toBeGreaterThan(now); - expect(verified.exp).toBeLessThanOrEqual(now + maxAgeSec + 5); // +5 for clock skew - }); + const now = Math.floor(Date.now() / 1000) + expect(verified.exp).toBeGreaterThan(now) + expect(verified.exp).toBeLessThanOrEqual(now + maxAgeSec + 5) // +5 for clock skew + }) - it("sets issued at time", async () => { - const sid = "test-session-id"; - const token = await createGuestToken(sid); - const verified = await verifyGuestToken(token); + it('sets issued at time', async () => { + const sid = 'test-session-id' + const token = await createGuestToken(sid) + const verified = await verifyGuestToken(token) - const now = Math.floor(Date.now() / 1000); - expect(verified.iat).toBeLessThanOrEqual(now); - expect(verified.iat).toBeGreaterThan(now - 10); // Within last 10 seconds - }); + const now = Math.floor(Date.now() / 1000) + expect(verified.iat).toBeLessThanOrEqual(now) + expect(verified.iat).toBeGreaterThan(now - 10) // Within last 10 seconds + }) - it("throws if AUTH_SECRET is missing", async () => { - delete process.env.AUTH_SECRET; + it('throws if AUTH_SECRET is missing', async () => { + delete process.env.AUTH_SECRET - await expect(createGuestToken("test")).rejects.toThrow( - "AUTH_SECRET environment variable is required", - ); - }); - }); + await expect(createGuestToken('test')).rejects.toThrow( + 'AUTH_SECRET environment variable is required' + ) + }) + }) - describe("verifyGuestToken", () => { - it("verifies valid tokens", async () => { - const sid = "test-session-id"; - const token = await createGuestToken(sid); + describe('verifyGuestToken', () => { + it('verifies valid tokens', async () => { + const sid = 'test-session-id' + const token = await createGuestToken(sid) - const result = await verifyGuestToken(token); + const result = await verifyGuestToken(token) - expect(result).toBeDefined(); - expect(result.sid).toBe(sid); - expect(result.iat).toBeDefined(); - expect(result.exp).toBeDefined(); - }); + expect(result).toBeDefined() + expect(result.sid).toBe(sid) + expect(result.iat).toBeDefined() + expect(result.exp).toBeDefined() + }) - it("rejects invalid tokens", async () => { - const invalidToken = "invalid.jwt.token"; + it('rejects invalid tokens', async () => { + const invalidToken = 'invalid.jwt.token' - await expect(verifyGuestToken(invalidToken)).rejects.toThrow(); - }); + await expect(verifyGuestToken(invalidToken)).rejects.toThrow() + }) - it("rejects tokens with wrong payload type", async () => { + it('rejects tokens with wrong payload type', async () => { // Manually create a token with wrong type - const { SignJWT } = await import("jose"); - const key = new TextEncoder().encode(process.env.AUTH_SECRET!); - const wrongToken = await new SignJWT({ typ: "wrong", sid: "test" }) - .setProtectedHeader({ alg: "HS256" }) - .sign(key); + const { SignJWT } = await import('jose') + const key = new TextEncoder().encode(process.env.AUTH_SECRET!) + const wrongToken = await new SignJWT({ typ: 'wrong', sid: 'test' }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(key) - await expect(verifyGuestToken(wrongToken)).rejects.toThrow( - "Invalid guest token payload", - ); - }); + await expect(verifyGuestToken(wrongToken)).rejects.toThrow('Invalid guest token payload') + }) - it("rejects tokens without sid", async () => { + it('rejects tokens without sid', async () => { // Manually create a token without sid - const { SignJWT } = await import("jose"); - const key = new TextEncoder().encode(process.env.AUTH_SECRET!); - const wrongToken = await new SignJWT({ typ: "guest" }) - .setProtectedHeader({ alg: "HS256" }) - .sign(key); + const { SignJWT } = await import('jose') + const key = new TextEncoder().encode(process.env.AUTH_SECRET!) + const wrongToken = await new SignJWT({ typ: 'guest' }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(key) - await expect(verifyGuestToken(wrongToken)).rejects.toThrow( - "Invalid guest token payload", - ); - }); + await expect(verifyGuestToken(wrongToken)).rejects.toThrow('Invalid guest token payload') + }) - it("rejects expired tokens", async () => { - const sid = "test-session-id"; - const token = await createGuestToken(sid, -1); // Expired 1 second ago + it('rejects expired tokens', async () => { + const sid = 'test-session-id' + const token = await createGuestToken(sid, -1) // Expired 1 second ago - await expect(verifyGuestToken(token)).rejects.toThrow(); - }); + await expect(verifyGuestToken(token)).rejects.toThrow() + }) - it("rejects tokens signed with wrong secret", async () => { - const sid = "test-session-id"; - const token = await createGuestToken(sid); + it('rejects tokens signed with wrong secret', async () => { + const sid = 'test-session-id' + const token = await createGuestToken(sid) // Change the secret - process.env.AUTH_SECRET = "different-secret"; + process.env.AUTH_SECRET = 'different-secret' - await expect(verifyGuestToken(token)).rejects.toThrow(); - }); - }); + await expect(verifyGuestToken(token)).rejects.toThrow() + }) + }) - describe("Token lifecycle", () => { - it("supports create → verify → decode flow", async () => { - const sid = crypto.randomUUID(); - const maxAgeSec = 7200; // 2 hours + describe('Token lifecycle', () => { + it('supports create → verify → decode flow', async () => { + const sid = crypto.randomUUID() + const maxAgeSec = 7200 // 2 hours // Create token - const token = await createGuestToken(sid, maxAgeSec); + const token = await createGuestToken(sid, maxAgeSec) // Verify token - const verified = await verifyGuestToken(token); + const verified = await verifyGuestToken(token) // Check all fields - expect(verified.sid).toBe(sid); - expect(verified.iat).toBeDefined(); - expect(verified.exp).toBe(verified.iat + maxAgeSec); - }); + expect(verified.sid).toBe(sid) + expect(verified.iat).toBeDefined() + expect(verified.exp).toBe(verified.iat + maxAgeSec) + }) - it("tokens have same sid for same input", async () => { - const sid = "same-session-id"; + it('tokens have same sid for same input', async () => { + const sid = 'same-session-id' // Creating tokens at different times - const token1 = await createGuestToken(sid); + const token1 = await createGuestToken(sid) // Wait at least 1 second to ensure different iat - await new Promise((resolve) => setTimeout(resolve, 1100)); + await new Promise((resolve) => setTimeout(resolve, 1100)) - const token2 = await createGuestToken(sid); + const token2 = await createGuestToken(sid) // Tokens may be different (different iat) or same (if created in same second) // But both should verify with same sid - const verified1 = await verifyGuestToken(token1); - const verified2 = await verifyGuestToken(token2); - expect(verified1.sid).toBe(verified2.sid); - expect(verified1.sid).toBe(sid); - }); - }); -}); + const verified1 = await verifyGuestToken(token1) + const verified2 = await verifyGuestToken(token2) + expect(verified1.sid).toBe(verified2.sid) + expect(verified1.sid).toBe(sid) + }) + }) +}) diff --git a/apps/web/src/lib/api/example.ts b/apps/web/src/lib/api/example.ts index 17f56ed6..74a1bfc1 100644 --- a/apps/web/src/lib/api/example.ts +++ b/apps/web/src/lib/api/example.ts @@ -5,14 +5,14 @@ * QueryClient and apiUrl helper for making API requests. */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api } from "@/lib/queryClient"; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/queryClient' // Example type for an API resource interface User { - id: string; - name: string; - email: string; + id: string + name: string + email: string } /** @@ -20,15 +20,15 @@ interface User { */ export function useUsers() { return useQuery({ - queryKey: ["users"], + queryKey: ['users'], queryFn: async () => { - const response = await api("users"); + const response = await api('users') if (!response.ok) { - throw new Error("Failed to fetch users"); + throw new Error('Failed to fetch users') } - return response.json() as Promise; + return response.json() as Promise }, - }); + }) } /** @@ -36,91 +36,91 @@ export function useUsers() { */ export function useUser(userId: string) { return useQuery({ - queryKey: ["users", userId], + queryKey: ['users', userId], queryFn: async () => { - const response = await api(`users/${userId}`); + const response = await api(`users/${userId}`) if (!response.ok) { - throw new Error("Failed to fetch user"); + throw new Error('Failed to fetch user') } - return response.json() as Promise; + return response.json() as Promise }, enabled: !!userId, // Only run query if userId is provided - }); + }) } /** * Example mutation hook - creates a new user */ export function useCreateUser() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ - mutationFn: async (newUser: Omit) => { - const response = await api("users", { - method: "POST", + mutationFn: async (newUser: Omit) => { + const response = await api('users', { + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify(newUser), - }); + }) if (!response.ok) { - throw new Error("Failed to create user"); + throw new Error('Failed to create user') } - return response.json() as Promise; + return response.json() as Promise }, onSuccess: () => { // Invalidate and refetch users query after successful creation - queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ['users'] }) }, - }); + }) } /** * Example mutation hook - updates a user */ export function useUpdateUser() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: async (user: User) => { const response = await api(`users/${user.id}`, { - method: "PUT", + method: 'PUT', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify(user), - }); + }) if (!response.ok) { - throw new Error("Failed to update user"); + throw new Error('Failed to update user') } - return response.json() as Promise; + return response.json() as Promise }, onSuccess: (updatedUser) => { // Invalidate both the list and the individual user query - queryClient.invalidateQueries({ queryKey: ["users"] }); - queryClient.invalidateQueries({ queryKey: ["users", updatedUser.id] }); + queryClient.invalidateQueries({ queryKey: ['users'] }) + queryClient.invalidateQueries({ queryKey: ['users', updatedUser.id] }) }, - }); + }) } /** * Example mutation hook - deletes a user */ export function useDeleteUser() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() return useMutation({ mutationFn: async (userId: string) => { const response = await api(`users/${userId}`, { - method: "DELETE", - }); + method: 'DELETE', + }) if (!response.ok) { - throw new Error("Failed to delete user"); + throw new Error('Failed to delete user') } }, onSuccess: () => { // Invalidate users query after successful deletion - queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ['users'] }) }, - }); + }) } diff --git a/apps/web/src/lib/arcade/__tests__/arcade-session-integration.test.ts b/apps/web/src/lib/arcade/__tests__/arcade-session-integration.test.ts index bb31d964..5e47906f 100644 --- a/apps/web/src/lib/arcade/__tests__/arcade-session-integration.test.ts +++ b/apps/web/src/lib/arcade/__tests__/arcade-session-integration.test.ts @@ -1,23 +1,23 @@ -import { eq } from "drizzle-orm"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { MemoryPairsState } from "@/arcade-games/matching/types"; -import { db, schema } from "@/db"; +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { MemoryPairsState } from '@/arcade-games/matching/types' +import { db, schema } from '@/db' import { applyGameMove, createArcadeSession, deleteArcadeSession, getArcadeSession, -} from "../session-manager"; -import { createRoom, deleteRoom } from "../room-manager"; +} from '../session-manager' +import { createRoom, deleteRoom } from '../room-manager' /** * Integration test for the full arcade session flow * Tests the complete lifecycle: create -> join -> move -> validate -> sync */ -describe("Arcade Session Integration", () => { - const testUserId = "integration-test-user"; - const testGuestId = "integration-test-guest"; - let testRoomId: string; +describe('Arcade Session Integration', () => { + const testUserId = 'integration-test-user' + const testGuestId = 'integration-test-guest' + let testRoomId: string beforeEach(async () => { // Create test user @@ -28,45 +28,45 @@ describe("Arcade Session Integration", () => { guestId: testGuestId, createdAt: new Date(), }) - .onConflictDoNothing(); + .onConflictDoNothing() // Create test room const room = await createRoom({ - name: "Test Room", + name: 'Test Room', createdBy: testGuestId, - creatorName: "Test User", - gameName: "matching", - gameConfig: { difficulty: 6, gameType: "abacus-numeral", turnTimer: 30 }, + creatorName: 'Test User', + gameName: 'matching', + gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 }, ttlMinutes: 60, - }); - testRoomId = room.id; - }); + }) + testRoomId = room.id + }) afterEach(async () => { // Clean up - await deleteArcadeSession(testGuestId); + await deleteArcadeSession(testGuestId) if (testRoomId) { - await deleteRoom(testRoomId); + await deleteRoom(testRoomId) } - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) + }) - it("should complete full session lifecycle", async () => { + it('should complete full session lifecycle', async () => { // 1. Create session const initialState: MemoryPairsState = { cards: [], gameCards: [], flippedCards: [], - gameType: "abacus-numeral", + gameType: 'abacus-numeral', difficulty: 6, turnTimer: 30, - gamePhase: "setup", - currentPlayer: "1", + gamePhase: 'setup', + currentPlayer: '1', matchedPairs: 0, totalPairs: 6, moves: 0, scores: {}, - activePlayers: ["1"], + activePlayers: ['1'], playerMetadata: {}, consecutiveMatches: {}, gameStartTime: null, @@ -78,104 +78,100 @@ describe("Arcade Session Integration", () => { showMismatchFeedback: false, lastMatchedPair: null, playerHovers: {}, - }; + } const session = await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", + gameName: 'matching', + gameUrl: '/arcade/matching', initialState, - activePlayers: ["1"], + activePlayers: ['1'], roomId: testRoomId, - }); + }) - expect(session).toBeDefined(); - expect(session.userId).toBe(testUserId); - expect(session.version).toBe(1); - expect((session.gameState as MemoryPairsState).gamePhase).toBe("setup"); + expect(session).toBeDefined() + expect(session.userId).toBe(testUserId) + expect(session.version).toBe(1) + expect((session.gameState as MemoryPairsState).gamePhase).toBe('setup') // 2. Retrieve session (simulating "join") - const retrieved = await getArcadeSession(testUserId); - expect(retrieved).toBeDefined(); - expect(retrieved?.userId).toBe(testUserId); + const retrieved = await getArcadeSession(testUserId) + expect(retrieved).toBeDefined() + expect(retrieved?.userId).toBe(testUserId) // 3. Apply a valid move (START_GAME) const startGameMove = { - type: "START_GAME", + type: 'START_GAME', playerId: testUserId, timestamp: Date.now(), data: { - activePlayers: ["1"], + activePlayers: ['1'], }, - }; + } - const result = await applyGameMove(testUserId, startGameMove as any); + const result = await applyGameMove(testUserId, startGameMove as any) - expect(result.success).toBe(true); - expect(result.session).toBeDefined(); - expect(result.session?.version).toBe(2); // Version incremented - expect((result.session?.gameState as MemoryPairsState).gamePhase).toBe( - "playing", - ); - expect( - (result.session?.gameState as MemoryPairsState).gameCards.length, - ).toBe(12); // 6 pairs + expect(result.success).toBe(true) + expect(result.session).toBeDefined() + expect(result.session?.version).toBe(2) // Version incremented + expect((result.session?.gameState as MemoryPairsState).gamePhase).toBe('playing') + expect((result.session?.gameState as MemoryPairsState).gameCards.length).toBe(12) // 6 pairs // 4. Try an invalid move (should be rejected) const invalidMove = { - type: "FLIP_CARD", + type: 'FLIP_CARD', playerId: testUserId, timestamp: Date.now(), data: { - cardId: "non-existent-card", + cardId: 'non-existent-card', }, - }; + } - const invalidResult = await applyGameMove(testUserId, invalidMove as any); + const invalidResult = await applyGameMove(testUserId, invalidMove as any) - expect(invalidResult.success).toBe(false); - expect(invalidResult.error).toBe("Card not found"); + expect(invalidResult.success).toBe(false) + expect(invalidResult.error).toBe('Card not found') // Version should NOT have incremented - const sessionAfterInvalid = await getArcadeSession(testUserId); - expect(sessionAfterInvalid?.version).toBe(2); // Still 2, not 3 + const sessionAfterInvalid = await getArcadeSession(testUserId) + expect(sessionAfterInvalid?.version).toBe(2) // Still 2, not 3 // 5. Clean up (exit session) - await deleteArcadeSession(testUserId); + await deleteArcadeSession(testUserId) - const deletedSession = await getArcadeSession(testUserId); - expect(deletedSession).toBeUndefined(); - }); + const deletedSession = await getArcadeSession(testUserId) + expect(deletedSession).toBeUndefined() + }) - it("should handle concurrent move attempts", async () => { + it('should handle concurrent move attempts', async () => { // Create session in playing state const playingState: MemoryPairsState = { cards: [], gameCards: [ { - id: "card-1", - type: "number", + id: 'card-1', + type: 'number', number: 5, matched: false, }, { - id: "card-2", - type: "abacus", + id: 'card-2', + type: 'abacus', number: 5, matched: false, }, ], flippedCards: [], - gameType: "abacus-numeral", + gameType: 'abacus-numeral', difficulty: 6, turnTimer: 30, - gamePhase: "playing", - currentPlayer: "1", + gamePhase: 'playing', + currentPlayer: '1', matchedPairs: 0, totalPairs: 6, moves: 0, scores: { 1: 0 }, - activePlayers: ["1"], + activePlayers: ['1'], playerMetadata: {}, consecutiveMatches: { 1: 0 }, gameStartTime: Date.now(), @@ -187,45 +183,45 @@ describe("Arcade Session Integration", () => { showMismatchFeedback: false, lastMatchedPair: null, playerHovers: {}, - }; + } await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", + gameName: 'matching', + gameUrl: '/arcade/matching', initialState: playingState, - activePlayers: ["1"], + activePlayers: ['1'], roomId: testRoomId, - }); + }) // First move: flip card 1 const move1 = { - type: "FLIP_CARD", + type: 'FLIP_CARD', playerId: testUserId, timestamp: Date.now(), - data: { cardId: "card-1" }, - }; + data: { cardId: 'card-1' }, + } - const result1 = await applyGameMove(testUserId, move1 as any); - expect(result1.success).toBe(true); - expect(result1.session?.version).toBe(2); + const result1 = await applyGameMove(testUserId, move1 as any) + expect(result1.success).toBe(true) + expect(result1.session?.version).toBe(2) // Second move: flip card 2 (should match) const move2 = { - type: "FLIP_CARD", + type: 'FLIP_CARD', playerId: testUserId, timestamp: Date.now() + 1, - data: { cardId: "card-2" }, - }; + data: { cardId: 'card-2' }, + } - const result2 = await applyGameMove(testUserId, move2 as any); - expect(result2.success).toBe(true); - expect(result2.session?.version).toBe(3); + const result2 = await applyGameMove(testUserId, move2 as any) + expect(result2.success).toBe(true) + expect(result2.session?.version).toBe(3) // Verify the match was recorded - const state = result2.session?.gameState as MemoryPairsState; - expect(state.matchedPairs).toBe(1); - expect(state.gameCards[0].matched).toBe(true); - expect(state.gameCards[1].matched).toBe(true); - }); -}); + const state = result2.session?.gameState as MemoryPairsState + expect(state.matchedPairs).toBe(1) + expect(state.gameCards[0].matched).toBe(true) + expect(state.gameCards[1].matched).toBe(true) + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/modal-rooms.test.ts b/apps/web/src/lib/arcade/__tests__/modal-rooms.test.ts index a0607fa2..d819a2fa 100644 --- a/apps/web/src/lib/arcade/__tests__/modal-rooms.test.ts +++ b/apps/web/src/lib/arcade/__tests__/modal-rooms.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { eq } from "drizzle-orm"; -import { db, schema } from "@/db"; -import { addRoomMember, getRoomMember, getUserRooms } from "../room-membership"; -import { createRoom, deleteRoom } from "../room-manager"; +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { eq } from 'drizzle-orm' +import { db, schema } from '@/db' +import { addRoomMember, getRoomMember, getUserRooms } from '../room-membership' +import { createRoom, deleteRoom } from '../room-manager' /** * Integration tests for modal room enforcement @@ -10,14 +10,14 @@ import { createRoom, deleteRoom } from "../room-manager"; * Tests the database-level unique constraint combined with application-level * auto-leave logic to ensure users can only be in one room at a time. */ -describe("Modal Room Enforcement", () => { - const testGuestId1 = "modal-test-guest-1"; - const testGuestId2 = "modal-test-guest-2"; - const testUserId1 = "modal-test-user-1"; - const testUserId2 = "modal-test-user-2"; - let room1Id: string; - let room2Id: string; - let room3Id: string; +describe('Modal Room Enforcement', () => { + const testGuestId1 = 'modal-test-guest-1' + const testGuestId2 = 'modal-test-guest-2' + const testUserId1 = 'modal-test-user-1' + const testUserId2 = 'modal-test-user-2' + let room1Id: string + let room2Id: string + let room3Id: string beforeEach(async () => { // Create test users @@ -35,227 +35,223 @@ describe("Modal Room Enforcement", () => { createdAt: new Date(), }, ]) - .onConflictDoNothing(); + .onConflictDoNothing() // Create test rooms const room1 = await createRoom({ - name: "Modal Test Room 1", + name: 'Modal Test Room 1', createdBy: testGuestId1, - creatorName: "User 1", - gameName: "matching", + creatorName: 'User 1', + gameName: 'matching', gameConfig: { difficulty: 6 }, ttlMinutes: 60, - }); - room1Id = room1.id; + }) + room1Id = room1.id const room2 = await createRoom({ - name: "Modal Test Room 2", + name: 'Modal Test Room 2', createdBy: testGuestId1, - creatorName: "User 1", - gameName: "matching", + creatorName: 'User 1', + gameName: 'matching', gameConfig: { difficulty: 8 }, ttlMinutes: 60, - }); - room2Id = room2.id; + }) + room2Id = room2.id const room3 = await createRoom({ - name: "Modal Test Room 3", + name: 'Modal Test Room 3', createdBy: testGuestId1, - creatorName: "User 1", - gameName: "memory-quiz", + creatorName: 'User 1', + gameName: 'memory-quiz', gameConfig: {}, ttlMinutes: 60, - }); - room3Id = room3.id; - }); + }) + room3Id = room3.id + }) afterEach(async () => { // Clean up - await db - .delete(schema.roomMembers) - .where(eq(schema.roomMembers.userId, testGuestId1)); - await db - .delete(schema.roomMembers) - .where(eq(schema.roomMembers.userId, testGuestId2)); + await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId1)) + await db.delete(schema.roomMembers).where(eq(schema.roomMembers.userId, testGuestId2)) try { - await deleteRoom(room1Id); - await deleteRoom(room2Id); - await deleteRoom(room3Id); + await deleteRoom(room1Id) + await deleteRoom(room2Id) + await deleteRoom(room3Id) } catch { // Rooms may have been deleted in test } - await db.delete(schema.users).where(eq(schema.users.id, testUserId1)); - await db.delete(schema.users).where(eq(schema.users.id, testUserId2)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId1)) + await db.delete(schema.users).where(eq(schema.users.id, testUserId2)) + }) - it("should allow user to join their first room", async () => { + it('should allow user to join their first room', async () => { const result = await addRoomMember({ roomId: room1Id, userId: testGuestId1, - displayName: "Test User", + displayName: 'Test User', isCreator: false, - }); + }) - expect(result.member).toBeDefined(); - expect(result.member.roomId).toBe(room1Id); - expect(result.member.userId).toBe(testGuestId1); - expect(result.autoLeaveResult).toBeUndefined(); + expect(result.member).toBeDefined() + expect(result.member.roomId).toBe(room1Id) + expect(result.member.userId).toBe(testGuestId1) + expect(result.autoLeaveResult).toBeUndefined() - const userRooms = await getUserRooms(testGuestId1); - expect(userRooms).toHaveLength(1); - expect(userRooms[0]).toBe(room1Id); - }); + const userRooms = await getUserRooms(testGuestId1) + expect(userRooms).toHaveLength(1) + expect(userRooms[0]).toBe(room1Id) + }) - it("should automatically leave previous room when joining new one", async () => { + it('should automatically leave previous room when joining new one', async () => { // Join room 1 await addRoomMember({ roomId: room1Id, userId: testGuestId1, - displayName: "Test User", + displayName: 'Test User', isCreator: false, - }); + }) - let userRooms = await getUserRooms(testGuestId1); - expect(userRooms).toHaveLength(1); - expect(userRooms[0]).toBe(room1Id); + let userRooms = await getUserRooms(testGuestId1) + expect(userRooms).toHaveLength(1) + expect(userRooms[0]).toBe(room1Id) // Join room 2 (should auto-leave room 1) const result = await addRoomMember({ roomId: room2Id, userId: testGuestId1, - displayName: "Test User", + displayName: 'Test User', isCreator: false, - }); + }) - expect(result.autoLeaveResult).toBeDefined(); - expect(result.autoLeaveResult?.leftRooms).toHaveLength(1); - expect(result.autoLeaveResult?.leftRooms[0]).toBe(room1Id); - expect(result.autoLeaveResult?.previousRoomMembers).toHaveLength(1); + expect(result.autoLeaveResult).toBeDefined() + expect(result.autoLeaveResult?.leftRooms).toHaveLength(1) + expect(result.autoLeaveResult?.leftRooms[0]).toBe(room1Id) + expect(result.autoLeaveResult?.previousRoomMembers).toHaveLength(1) - userRooms = await getUserRooms(testGuestId1); - expect(userRooms).toHaveLength(1); - expect(userRooms[0]).toBe(room2Id); + userRooms = await getUserRooms(testGuestId1) + expect(userRooms).toHaveLength(1) + expect(userRooms[0]).toBe(room2Id) // Verify user is no longer in room 1 - const room1Member = await getRoomMember(room1Id, testGuestId1); - expect(room1Member).toBeUndefined(); + const room1Member = await getRoomMember(room1Id, testGuestId1) + expect(room1Member).toBeUndefined() // Verify user is in room 2 - const room2Member = await getRoomMember(room2Id, testGuestId1); - expect(room2Member).toBeDefined(); - }); + const room2Member = await getRoomMember(room2Id, testGuestId1) + expect(room2Member).toBeDefined() + }) - it("should handle rejoining the same room without auto-leave", async () => { + it('should handle rejoining the same room without auto-leave', async () => { // Join room 1 const firstJoin = await addRoomMember({ roomId: room1Id, userId: testGuestId1, - displayName: "Test User", + displayName: 'Test User', isCreator: false, - }); + }) - expect(firstJoin.autoLeaveResult).toBeUndefined(); + expect(firstJoin.autoLeaveResult).toBeUndefined() // "Rejoin" room 1 (should just update status) const secondJoin = await addRoomMember({ roomId: room1Id, userId: testGuestId1, - displayName: "Test User Updated", + displayName: 'Test User Updated', isCreator: false, - }); + }) - expect(secondJoin.autoLeaveResult).toBeUndefined(); - expect(secondJoin.member.roomId).toBe(room1Id); + expect(secondJoin.autoLeaveResult).toBeUndefined() + expect(secondJoin.member.roomId).toBe(room1Id) - const userRooms = await getUserRooms(testGuestId1); - expect(userRooms).toHaveLength(1); - expect(userRooms[0]).toBe(room1Id); - }); + const userRooms = await getUserRooms(testGuestId1) + expect(userRooms).toHaveLength(1) + expect(userRooms[0]).toBe(room1Id) + }) - it("should allow different users in different rooms simultaneously", async () => { + it('should allow different users in different rooms simultaneously', async () => { // User 1 joins room 1 await addRoomMember({ roomId: room1Id, userId: testGuestId1, - displayName: "User 1", + displayName: 'User 1', isCreator: false, - }); + }) // User 2 joins room 2 await addRoomMember({ roomId: room2Id, userId: testGuestId2, - displayName: "User 2", + displayName: 'User 2', isCreator: false, - }); + }) - const user1Rooms = await getUserRooms(testGuestId1); - const user2Rooms = await getUserRooms(testGuestId2); + const user1Rooms = await getUserRooms(testGuestId1) + const user2Rooms = await getUserRooms(testGuestId2) - expect(user1Rooms).toHaveLength(1); - expect(user1Rooms[0]).toBe(room1Id); + expect(user1Rooms).toHaveLength(1) + expect(user1Rooms[0]).toBe(room1Id) - expect(user2Rooms).toHaveLength(1); - expect(user2Rooms[0]).toBe(room2Id); - }); + expect(user2Rooms).toHaveLength(1) + expect(user2Rooms[0]).toBe(room2Id) + }) - it("should auto-leave when switching between multiple rooms", async () => { + it('should auto-leave when switching between multiple rooms', async () => { // Join room 1 await addRoomMember({ roomId: room1Id, userId: testGuestId1, - displayName: "Test User", - }); + displayName: 'Test User', + }) // Join room 2 (auto-leave room 1) const result2 = await addRoomMember({ roomId: room2Id, userId: testGuestId1, - displayName: "Test User", - }); - expect(result2.autoLeaveResult?.leftRooms).toContain(room1Id); + displayName: 'Test User', + }) + expect(result2.autoLeaveResult?.leftRooms).toContain(room1Id) // Join room 3 (auto-leave room 2) const result3 = await addRoomMember({ roomId: room3Id, userId: testGuestId1, - displayName: "Test User", - }); - expect(result3.autoLeaveResult?.leftRooms).toContain(room2Id); + displayName: 'Test User', + }) + expect(result3.autoLeaveResult?.leftRooms).toContain(room2Id) // Verify only in room 3 - const userRooms = await getUserRooms(testGuestId1); - expect(userRooms).toHaveLength(1); - expect(userRooms[0]).toBe(room3Id); - }); + const userRooms = await getUserRooms(testGuestId1) + expect(userRooms).toHaveLength(1) + expect(userRooms[0]).toBe(room3Id) + }) - it("should provide correct auto-leave metadata", async () => { + it('should provide correct auto-leave metadata', async () => { // Join room 1 await addRoomMember({ roomId: room1Id, userId: testGuestId1, - displayName: "Original Name", - }); + displayName: 'Original Name', + }) // Join room 2 and check metadata const result = await addRoomMember({ roomId: room2Id, userId: testGuestId1, - displayName: "New Name", - }); + displayName: 'New Name', + }) - expect(result.autoLeaveResult).toBeDefined(); - expect(result.autoLeaveResult?.previousRoomMembers).toHaveLength(1); + expect(result.autoLeaveResult).toBeDefined() + expect(result.autoLeaveResult?.previousRoomMembers).toHaveLength(1) - const previousMember = result.autoLeaveResult?.previousRoomMembers[0]; - expect(previousMember?.roomId).toBe(room1Id); - expect(previousMember?.member.userId).toBe(testGuestId1); - expect(previousMember?.member.displayName).toBe("Original Name"); - }); + const previousMember = result.autoLeaveResult?.previousRoomMembers[0] + expect(previousMember?.roomId).toBe(room1Id) + expect(previousMember?.member.userId).toBe(testGuestId1) + expect(previousMember?.member.displayName).toBe('Original Name') + }) - it("should enforce unique constraint at database level", async () => { + it('should enforce unique constraint at database level', async () => { // This test verifies the database constraint catches issues even if // application logic fails @@ -263,23 +259,23 @@ describe("Modal Room Enforcement", () => { await addRoomMember({ roomId: room1Id, userId: testGuestId1, - displayName: "Test User", - }); + displayName: 'Test User', + }) // Try to directly insert a second membership (bypassing auto-leave logic) const directInsert = async () => { await db.insert(schema.roomMembers).values({ roomId: room2Id, userId: testGuestId1, - displayName: "Test User", + displayName: 'Test User', isCreator: false, joinedAt: new Date(), lastSeen: new Date(), isOnline: true, - }); - }; + }) + } // Should fail due to unique constraint - await expect(directInsert()).rejects.toThrow(); - }); -}); + await expect(directInsert()).rejects.toThrow() + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/orphaned-session-cleanup.test.ts b/apps/web/src/lib/arcade/__tests__/orphaned-session-cleanup.test.ts index d9ccd742..a1a2366d 100644 --- a/apps/web/src/lib/arcade/__tests__/orphaned-session-cleanup.test.ts +++ b/apps/web/src/lib/arcade/__tests__/orphaned-session-cleanup.test.ts @@ -1,12 +1,8 @@ -import { eq } from "drizzle-orm"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { db, schema } from "@/db"; -import { - createArcadeSession, - deleteArcadeSession, - getArcadeSession, -} from "../session-manager"; -import { createRoom, deleteRoom } from "../room-manager"; +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { db, schema } from '@/db' +import { createArcadeSession, deleteArcadeSession, getArcadeSession } from '../session-manager' +import { createRoom, deleteRoom } from '../room-manager' /** * Integration tests for orphaned session cleanup @@ -15,10 +11,10 @@ import { createRoom, deleteRoom } from "../room-manager"; * cleaned up to prevent the bug where users get redirected to * non-existent games when rooms have been TTL deleted. */ -describe("Orphaned Session Cleanup", () => { - const testUserId = "orphan-test-user-id"; - const testGuestId = "orphan-test-guest-id"; - let testRoomId: string; +describe('Orphaned Session Cleanup', () => { + const testUserId = 'orphan-test-user-id' + const testGuestId = 'orphan-test-guest-id' + let testRoomId: string beforeEach(async () => { // Create test user @@ -29,117 +25,117 @@ describe("Orphaned Session Cleanup", () => { guestId: testGuestId, createdAt: new Date(), }) - .onConflictDoNothing(); + .onConflictDoNothing() // Create test room const room = await createRoom({ - name: "Orphan Test Room", + name: 'Orphan Test Room', createdBy: testGuestId, - creatorName: "Test User", - gameName: "matching", - gameConfig: { difficulty: 6, gameType: "abacus-numeral", turnTimer: 30 }, + creatorName: 'Test User', + gameName: 'matching', + gameConfig: { difficulty: 6, gameType: 'abacus-numeral', turnTimer: 30 }, ttlMinutes: 60, - }); - testRoomId = room.id; - }); + }) + testRoomId = room.id + }) afterEach(async () => { // Clean up - await deleteArcadeSession(testGuestId); + await deleteArcadeSession(testGuestId) if (testRoomId) { try { - await deleteRoom(testRoomId); + await deleteRoom(testRoomId) } catch { // Room may have been deleted in test } } - await db.delete(schema.users).where(eq(schema.users.id, testUserId)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId)) + }) // NOTE: This test is no longer valid with roomId as primary key // roomId cannot be null since it's the primary key with a foreign key constraint // Orphaned sessions are now automatically cleaned up via CASCADE delete when room is deleted - it.skip("should return undefined when session has no roomId", async () => { + it.skip('should return undefined when session has no roomId', async () => { // This test scenario is impossible with the new schema where roomId is the primary key // and has a foreign key constraint with CASCADE delete - }); + }) - it("should return undefined when session room has been deleted", async () => { + it('should return undefined when session room has been deleted', async () => { // Create a session with a valid room const session = await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { gamePhase: "setup" }, - activePlayers: ["player-1"], + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { gamePhase: 'setup' }, + activePlayers: ['player-1'], roomId: testRoomId, - }); + }) - expect(session).toBeDefined(); - expect(session.roomId).toBe(testRoomId); + expect(session).toBeDefined() + expect(session.roomId).toBe(testRoomId) // Delete the room (simulating TTL expiration) - await deleteRoom(testRoomId); + await deleteRoom(testRoomId) // Getting the session should detect missing room and auto-delete - const result = await getArcadeSession(testGuestId); - expect(result).toBeUndefined(); + const result = await getArcadeSession(testGuestId) + expect(result).toBeUndefined() // Verify session was actually deleted const [directCheck] = await db .select() .from(schema.arcadeSessions) .where(eq(schema.arcadeSessions.userId, testUserId)) - .limit(1); + .limit(1) - expect(directCheck).toBeUndefined(); - }); + expect(directCheck).toBeUndefined() + }) - it("should return valid session when room exists", async () => { + it('should return valid session when room exists', async () => { // Create a session with a valid room const session = await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { gamePhase: "setup" }, - activePlayers: ["player-1"], + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { gamePhase: 'setup' }, + activePlayers: ['player-1'], roomId: testRoomId, - }); + }) - expect(session).toBeDefined(); + expect(session).toBeDefined() // Getting the session should work fine when room exists - const result = await getArcadeSession(testGuestId); - expect(result).toBeDefined(); - expect(result?.roomId).toBe(testRoomId); - expect(result?.currentGame).toBe("matching"); - }); + const result = await getArcadeSession(testGuestId) + expect(result).toBeDefined() + expect(result?.roomId).toBe(testRoomId) + expect(result?.currentGame).toBe('matching') + }) - it("should handle multiple getArcadeSession calls idempotently", async () => { + it('should handle multiple getArcadeSession calls idempotently', async () => { // Create a session with a valid room await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { gamePhase: "setup" }, - activePlayers: ["player-1"], + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { gamePhase: 'setup' }, + activePlayers: ['player-1'], roomId: testRoomId, - }); + }) // Delete the room - await deleteRoom(testRoomId); + await deleteRoom(testRoomId) // Multiple calls should all return undefined and not error - const result1 = await getArcadeSession(testGuestId); - const result2 = await getArcadeSession(testGuestId); - const result3 = await getArcadeSession(testGuestId); + const result1 = await getArcadeSession(testGuestId) + const result2 = await getArcadeSession(testGuestId) + const result3 = await getArcadeSession(testGuestId) - expect(result1).toBeUndefined(); - expect(result2).toBeUndefined(); - expect(result3).toBeUndefined(); - }); + expect(result1).toBeUndefined() + expect(result2).toBeUndefined() + expect(result3).toBeUndefined() + }) - it("should prevent orphaned sessions from causing redirect loops", async () => { + it('should prevent orphaned sessions from causing redirect loops', async () => { /** * Regression test for the specific bug: * - Room gets TTL deleted @@ -155,23 +151,23 @@ describe("Orphaned Session Cleanup", () => { // 1. Create session with room await createArcadeSession({ userId: testGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", - initialState: { gamePhase: "setup" }, - activePlayers: ["player-1"], + gameName: 'matching', + gameUrl: '/arcade/matching', + initialState: { gamePhase: 'setup' }, + activePlayers: ['player-1'], roomId: testRoomId, - }); + }) // 2. Room gets TTL deleted - await deleteRoom(testRoomId); + await deleteRoom(testRoomId) // 3. User's client checks for active session - const activeSession = await getArcadeSession(testGuestId); + const activeSession = await getArcadeSession(testGuestId) // 4. Should return undefined, preventing redirect - expect(activeSession).toBeUndefined(); + expect(activeSession).toBeUndefined() // 5. User can now proceed to arcade lobby normally // (no redirect to non-existent game) - }); -}); + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/player-ownership-integration.test.ts b/apps/web/src/lib/arcade/__tests__/player-ownership-integration.test.ts index f11a7f2a..95056ec4 100644 --- a/apps/web/src/lib/arcade/__tests__/player-ownership-integration.test.ts +++ b/apps/web/src/lib/arcade/__tests__/player-ownership-integration.test.ts @@ -5,8 +5,8 @@ * the full stack: database → utilities → client state */ -import { beforeEach, describe, expect, it } from "vitest"; -import type { RoomData } from "@/hooks/useRoomData"; +import { beforeEach, describe, expect, it } from 'vitest' +import type { RoomData } from '@/hooks/useRoomData' import { buildPlayerMetadata, buildPlayerOwnershipFromRoomData, @@ -16,367 +16,300 @@ import { groupPlayersByOwner, isPlayerOwnedByUser, type PlayerOwnershipMap, -} from "../player-ownership"; +} from '../player-ownership' -describe("Player Ownership Integration Tests", () => { +describe('Player Ownership Integration Tests', () => { // Simulate a real multiplayer room scenario - let mockRoomData: RoomData; - let playerIds: string[]; - let playersMap: Map; + let mockRoomData: RoomData + let playerIds: string[] + let playersMap: Map beforeEach(() => { // Setup: 3 users in a room, each with different number of players mockRoomData = { - id: "room-integration-test", - name: "Test Multiplayer Room", - code: "TEST", - gameName: "memory-pairs", + id: 'room-integration-test', + name: 'Test Multiplayer Room', + code: 'TEST', + gameName: 'memory-pairs', members: [ { - id: "member-1", - userId: "user-alice", - displayName: "Alice", + id: 'member-1', + userId: 'user-alice', + displayName: 'Alice', isOnline: true, isCreator: true, }, { - id: "member-2", - userId: "user-bob", - displayName: "Bob", + id: 'member-2', + userId: 'user-bob', + displayName: 'Bob', isOnline: true, isCreator: false, }, { - id: "member-3", - userId: "user-charlie", - displayName: "Charlie", + id: 'member-3', + userId: 'user-charlie', + displayName: 'Charlie', isOnline: true, isCreator: false, }, ], memberPlayers: { - "user-alice": [ - { id: "player-a1", name: "Alice P1", emoji: "🐶", color: "#ff0000" }, - { id: "player-a2", name: "Alice P2", emoji: "🐱", color: "#ff4444" }, + 'user-alice': [ + { id: 'player-a1', name: 'Alice P1', emoji: '🐶', color: '#ff0000' }, + { id: 'player-a2', name: 'Alice P2', emoji: '🐱', color: '#ff4444' }, ], - "user-bob": [ - { id: "player-b1", name: "Bob P1", emoji: "🐭", color: "#00ff00" }, - ], - "user-charlie": [ + 'user-bob': [{ id: 'player-b1', name: 'Bob P1', emoji: '🐭', color: '#00ff00' }], + 'user-charlie': [ { - id: "player-c1", - name: "Charlie P1", - emoji: "🦊", - color: "#0000ff", + id: 'player-c1', + name: 'Charlie P1', + emoji: '🦊', + color: '#0000ff', }, { - id: "player-c2", - name: "Charlie P2", - emoji: "🐻", - color: "#4444ff", + id: 'player-c2', + name: 'Charlie P2', + emoji: '🐻', + color: '#4444ff', }, { - id: "player-c3", - name: "Charlie P3", - emoji: "🐼", - color: "#8888ff", + id: 'player-c3', + name: 'Charlie P3', + emoji: '🐼', + color: '#8888ff', }, ], }, - }; + } - playerIds = [ - "player-a1", - "player-a2", - "player-b1", - "player-c1", - "player-c2", - "player-c3", - ]; + playerIds = ['player-a1', 'player-a2', 'player-b1', 'player-c1', 'player-c2', 'player-c3'] playersMap = new Map([ - ["player-a1", { name: "Alice P1", emoji: "🐶", color: "#ff0000" }], - ["player-a2", { name: "Alice P2", emoji: "🐱", color: "#ff4444" }], - ["player-b1", { name: "Bob P1", emoji: "🐭", color: "#00ff00" }], - ["player-c1", { name: "Charlie P1", emoji: "🦊", color: "#0000ff" }], - ["player-c2", { name: "Charlie P2", emoji: "🐻", color: "#4444ff" }], - ["player-c3", { name: "Charlie P3", emoji: "🐼", color: "#8888ff" }], - ]); - }); + ['player-a1', { name: 'Alice P1', emoji: '🐶', color: '#ff0000' }], + ['player-a2', { name: 'Alice P2', emoji: '🐱', color: '#ff4444' }], + ['player-b1', { name: 'Bob P1', emoji: '🐭', color: '#00ff00' }], + ['player-c1', { name: 'Charlie P1', emoji: '🦊', color: '#0000ff' }], + ['player-c2', { name: 'Charlie P2', emoji: '🐻', color: '#4444ff' }], + ['player-c3', { name: 'Charlie P3', emoji: '🐼', color: '#8888ff' }], + ]) + }) - describe("Full multiplayer ownership flow", () => { - it("should correctly identify ownership across all players", () => { - const ownership = buildPlayerOwnershipFromRoomData(mockRoomData); + describe('Full multiplayer ownership flow', () => { + it('should correctly identify ownership across all players', () => { + const ownership = buildPlayerOwnershipFromRoomData(mockRoomData) // Alice owns 2 players - expect(isPlayerOwnedByUser("player-a1", "user-alice", ownership)).toBe( - true, - ); - expect(isPlayerOwnedByUser("player-a2", "user-alice", ownership)).toBe( - true, - ); + expect(isPlayerOwnedByUser('player-a1', 'user-alice', ownership)).toBe(true) + expect(isPlayerOwnedByUser('player-a2', 'user-alice', ownership)).toBe(true) // Bob owns 1 player - expect(isPlayerOwnedByUser("player-b1", "user-bob", ownership)).toBe( - true, - ); + expect(isPlayerOwnedByUser('player-b1', 'user-bob', ownership)).toBe(true) // Charlie owns 3 players - expect(isPlayerOwnedByUser("player-c1", "user-charlie", ownership)).toBe( - true, - ); - expect(isPlayerOwnedByUser("player-c2", "user-charlie", ownership)).toBe( - true, - ); - expect(isPlayerOwnedByUser("player-c3", "user-charlie", ownership)).toBe( - true, - ); + expect(isPlayerOwnedByUser('player-c1', 'user-charlie', ownership)).toBe(true) + expect(isPlayerOwnedByUser('player-c2', 'user-charlie', ownership)).toBe(true) + expect(isPlayerOwnedByUser('player-c3', 'user-charlie', ownership)).toBe(true) // Cross-ownership checks (should be false) - expect(isPlayerOwnedByUser("player-a1", "user-bob", ownership)).toBe( - false, - ); - expect(isPlayerOwnedByUser("player-b1", "user-charlie", ownership)).toBe( - false, - ); - expect(isPlayerOwnedByUser("player-c1", "user-alice", ownership)).toBe( - false, - ); - }); + expect(isPlayerOwnedByUser('player-a1', 'user-bob', ownership)).toBe(false) + expect(isPlayerOwnedByUser('player-b1', 'user-charlie', ownership)).toBe(false) + expect(isPlayerOwnedByUser('player-c1', 'user-alice', ownership)).toBe(false) + }) - it("should build complete metadata for game state", () => { - const ownership = buildPlayerOwnershipFromRoomData(mockRoomData); - const metadata = buildPlayerMetadata(playerIds, ownership, playersMap); + it('should build complete metadata for game state', () => { + const ownership = buildPlayerOwnershipFromRoomData(mockRoomData) + const metadata = buildPlayerMetadata(playerIds, ownership, playersMap) // Check all players have metadata - expect(Object.keys(metadata)).toHaveLength(6); + expect(Object.keys(metadata)).toHaveLength(6) // Verify Alice's players - expect(metadata["player-a1"]).toEqual({ - id: "player-a1", - name: "Alice P1", - emoji: "🐶", - color: "#ff0000", - userId: "user-alice", - }); - expect(metadata["player-a2"].userId).toBe("user-alice"); + expect(metadata['player-a1']).toEqual({ + id: 'player-a1', + name: 'Alice P1', + emoji: '🐶', + color: '#ff0000', + userId: 'user-alice', + }) + expect(metadata['player-a2'].userId).toBe('user-alice') // Verify Bob's player - expect(metadata["player-b1"].userId).toBe("user-bob"); + expect(metadata['player-b1'].userId).toBe('user-bob') // Verify Charlie's players - expect(metadata["player-c1"].userId).toBe("user-charlie"); - expect(metadata["player-c2"].userId).toBe("user-charlie"); - expect(metadata["player-c3"].userId).toBe("user-charlie"); - }); + expect(metadata['player-c1'].userId).toBe('user-charlie') + expect(metadata['player-c2'].userId).toBe('user-charlie') + expect(metadata['player-c3'].userId).toBe('user-charlie') + }) - it("should correctly group players by owner for team display", () => { - const ownership = buildPlayerOwnershipFromRoomData(mockRoomData); - const groups = groupPlayersByOwner(playerIds, ownership); + it('should correctly group players by owner for team display', () => { + const ownership = buildPlayerOwnershipFromRoomData(mockRoomData) + const groups = groupPlayersByOwner(playerIds, ownership) - expect(groups.size).toBe(3); - expect(groups.get("user-alice")).toEqual(["player-a1", "player-a2"]); - expect(groups.get("user-bob")).toEqual(["player-b1"]); - expect(groups.get("user-charlie")).toEqual([ - "player-c1", - "player-c2", - "player-c3", - ]); - }); + expect(groups.size).toBe(3) + expect(groups.get('user-alice')).toEqual(['player-a1', 'player-a2']) + expect(groups.get('user-bob')).toEqual(['player-b1']) + expect(groups.get('user-charlie')).toEqual(['player-c1', 'player-c2', 'player-c3']) + }) - it("should identify unique participants", () => { - const ownership = buildPlayerOwnershipFromRoomData(mockRoomData); - const owners = getUniqueOwners(ownership); + it('should identify unique participants', () => { + const ownership = buildPlayerOwnershipFromRoomData(mockRoomData) + const owners = getUniqueOwners(ownership) - expect(owners).toHaveLength(3); - expect(owners).toContain("user-alice"); - expect(owners).toContain("user-bob"); - expect(owners).toContain("user-charlie"); - }); - }); + expect(owners).toHaveLength(3) + expect(owners).toContain('user-alice') + expect(owners).toContain('user-bob') + expect(owners).toContain('user-charlie') + }) + }) - describe("Turn-based game authorization scenarios", () => { - let ownership: PlayerOwnershipMap; + describe('Turn-based game authorization scenarios', () => { + let ownership: PlayerOwnershipMap beforeEach(() => { - ownership = buildPlayerOwnershipFromRoomData(mockRoomData); - }); + ownership = buildPlayerOwnershipFromRoomData(mockRoomData) + }) - it("should allow Alice to act when her player has the turn", () => { - const currentPlayerId = "player-a1"; - const currentViewerId = "user-alice"; + it('should allow Alice to act when her player has the turn', () => { + const currentPlayerId = 'player-a1' + const currentViewerId = 'user-alice' - const canAct = isPlayerOwnedByUser( - currentPlayerId, - currentViewerId, - ownership, - ); - expect(canAct).toBe(true); - }); + const canAct = isPlayerOwnedByUser(currentPlayerId, currentViewerId, ownership) + expect(canAct).toBe(true) + }) - it("should block Bob from acting when Alice has the turn", () => { - const currentPlayerId = "player-a1"; // Alice's turn - const currentViewerId = "user-bob"; // Bob trying to act + it('should block Bob from acting when Alice has the turn', () => { + const currentPlayerId = 'player-a1' // Alice's turn + const currentViewerId = 'user-bob' // Bob trying to act - const canAct = isPlayerOwnedByUser( - currentPlayerId, - currentViewerId, - ownership, - ); - expect(canAct).toBe(false); - }); + const canAct = isPlayerOwnedByUser(currentPlayerId, currentViewerId, ownership) + expect(canAct).toBe(false) + }) - it("should handle turn rotation correctly", () => { - const turnOrder = ["player-a1", "player-b1", "player-c1"]; + it('should handle turn rotation correctly', () => { + const turnOrder = ['player-a1', 'player-b1', 'player-c1'] for (const currentPlayer of turnOrder) { - const owner = getPlayerOwner(currentPlayer, ownership); - expect(owner).toBeDefined(); + const owner = getPlayerOwner(currentPlayer, ownership) + expect(owner).toBeDefined() // Verify only the owner can act - for (const userId of ["user-alice", "user-bob", "user-charlie"]) { - const canAct = isPlayerOwnedByUser(currentPlayer, userId, ownership); - expect(canAct).toBe(userId === owner); + for (const userId of ['user-alice', 'user-bob', 'user-charlie']) { + const canAct = isPlayerOwnedByUser(currentPlayer, userId, ownership) + expect(canAct).toBe(userId === owner) } } - }); - }); + }) + }) - describe("Player filtering for UI display", () => { - let ownership: PlayerOwnershipMap; + describe('Player filtering for UI display', () => { + let ownership: PlayerOwnershipMap beforeEach(() => { - ownership = buildPlayerOwnershipFromRoomData(mockRoomData); - }); + ownership = buildPlayerOwnershipFromRoomData(mockRoomData) + }) - it("should filter to show only local players for current user", () => { - const alicePlayers = filterPlayersByOwner( - playerIds, - "user-alice", - ownership, - ); - expect(alicePlayers).toEqual(["player-a1", "player-a2"]); + it('should filter to show only local players for current user', () => { + const alicePlayers = filterPlayersByOwner(playerIds, 'user-alice', ownership) + expect(alicePlayers).toEqual(['player-a1', 'player-a2']) - const bobPlayers = filterPlayersByOwner(playerIds, "user-bob", ownership); - expect(bobPlayers).toEqual(["player-b1"]); + const bobPlayers = filterPlayersByOwner(playerIds, 'user-bob', ownership) + expect(bobPlayers).toEqual(['player-b1']) - const charliePlayers = filterPlayersByOwner( - playerIds, - "user-charlie", - ownership, - ); - expect(charliePlayers).toEqual(["player-c1", "player-c2", "player-c3"]); - }); + const charliePlayers = filterPlayersByOwner(playerIds, 'user-charlie', ownership) + expect(charliePlayers).toEqual(['player-c1', 'player-c2', 'player-c3']) + }) - it("should handle empty results for users not in game", () => { - const outsiderPlayers = filterPlayersByOwner( - playerIds, - "user-not-in-room", - ownership, - ); - expect(outsiderPlayers).toEqual([]); - }); - }); + it('should handle empty results for users not in game', () => { + const outsiderPlayers = filterPlayersByOwner(playerIds, 'user-not-in-room', ownership) + expect(outsiderPlayers).toEqual([]) + }) + }) - describe("Edge cases and error handling", () => { - it("should handle room with no players gracefully", () => { + describe('Edge cases and error handling', () => { + it('should handle room with no players gracefully', () => { const emptyRoomData: RoomData = { ...mockRoomData, memberPlayers: {}, - }; + } - const ownership = buildPlayerOwnershipFromRoomData(emptyRoomData); - expect(ownership).toEqual({}); + const ownership = buildPlayerOwnershipFromRoomData(emptyRoomData) + expect(ownership).toEqual({}) - const owners = getUniqueOwners(ownership); - expect(owners).toEqual([]); - }); + const owners = getUniqueOwners(ownership) + expect(owners).toEqual([]) + }) - it("should handle single-player room", () => { + it('should handle single-player room', () => { const soloRoomData: RoomData = { ...mockRoomData, memberPlayers: { - "user-solo": [ - { id: "player-solo", name: "Solo", emoji: "🚀", color: "#ff00ff" }, - ], + 'user-solo': [{ id: 'player-solo', name: 'Solo', emoji: '🚀', color: '#ff00ff' }], }, - }; + } - const ownership = buildPlayerOwnershipFromRoomData(soloRoomData); - expect(Object.keys(ownership)).toHaveLength(1); - expect(ownership["player-solo"]).toBe("user-solo"); - }); + const ownership = buildPlayerOwnershipFromRoomData(soloRoomData) + expect(Object.keys(ownership)).toHaveLength(1) + expect(ownership['player-solo']).toBe('user-solo') + }) - it("should handle metadata building with missing player data", () => { - const ownership = buildPlayerOwnershipFromRoomData(mockRoomData); - const partialPlayerIds = ["player-a1", "player-nonexistent", "player-b1"]; + it('should handle metadata building with missing player data', () => { + const ownership = buildPlayerOwnershipFromRoomData(mockRoomData) + const partialPlayerIds = ['player-a1', 'player-nonexistent', 'player-b1'] - const metadata = buildPlayerMetadata( - partialPlayerIds, - ownership, - playersMap, - ); + const metadata = buildPlayerMetadata(partialPlayerIds, ownership, playersMap) - expect(metadata["player-a1"]).toBeDefined(); - expect(metadata["player-b1"]).toBeDefined(); - expect(metadata["player-nonexistent"]).toBeUndefined(); - }); + expect(metadata['player-a1']).toBeDefined() + expect(metadata['player-b1']).toBeDefined() + expect(metadata['player-nonexistent']).toBeUndefined() + }) - it("should use fallback userId when player not in ownership map", () => { + it('should use fallback userId when player not in ownership map', () => { const partialOwnership: PlayerOwnershipMap = { - "player-a1": "user-alice", - }; + 'player-a1': 'user-alice', + } const metadata = buildPlayerMetadata( - ["player-a1", "player-a2"], + ['player-a1', 'player-a2'], partialOwnership, playersMap, - "fallback-user", - ); + 'fallback-user' + ) - expect(metadata["player-a1"].userId).toBe("user-alice"); - expect(metadata["player-a2"].userId).toBe("fallback-user"); - }); - }); + expect(metadata['player-a1'].userId).toBe('user-alice') + expect(metadata['player-a2'].userId).toBe('fallback-user') + }) + }) - describe("Real-world multiplayer scenarios", () => { - it("should handle player leaving mid-game", () => { - const ownership = buildPlayerOwnershipFromRoomData(mockRoomData); + describe('Real-world multiplayer scenarios', () => { + it('should handle player leaving mid-game', () => { + const ownership = buildPlayerOwnershipFromRoomData(mockRoomData) // Bob leaves, but his player data remains in game state - const remainingPlayerIds = playerIds.filter((id) => id !== "player-b1"); + const remainingPlayerIds = playerIds.filter((id) => id !== 'player-b1') // Ownership map still has Bob's data - expect(ownership["player-b1"]).toBe("user-bob"); + expect(ownership['player-b1']).toBe('user-bob') // But filtered lists exclude his players - const alicePlayers = filterPlayersByOwner( - remainingPlayerIds, - "user-alice", - ownership, - ); - const bobPlayers = filterPlayersByOwner( - remainingPlayerIds, - "user-bob", - ownership, - ); + const alicePlayers = filterPlayersByOwner(remainingPlayerIds, 'user-alice', ownership) + const bobPlayers = filterPlayersByOwner(remainingPlayerIds, 'user-bob', ownership) - expect(alicePlayers).toHaveLength(2); - expect(bobPlayers).toHaveLength(0); // Bob's player filtered out - }); + expect(alicePlayers).toHaveLength(2) + expect(bobPlayers).toHaveLength(0) // Bob's player filtered out + }) - it("should handle user with multiple active players in turn order", () => { - const ownership = buildPlayerOwnershipFromRoomData(mockRoomData); + it('should handle user with multiple active players in turn order', () => { + const ownership = buildPlayerOwnershipFromRoomData(mockRoomData) // Charlie has 3 players in rotation - const charliePlayers = ["player-c1", "player-c2", "player-c3"]; + const charliePlayers = ['player-c1', 'player-c2', 'player-c3'] for (const playerId of charliePlayers) { - expect(isPlayerOwnedByUser(playerId, "user-charlie", ownership)).toBe( - true, - ); - expect(getPlayerOwner(playerId, ownership)).toBe("user-charlie"); + expect(isPlayerOwnedByUser(playerId, 'user-charlie', ownership)).toBe(true) + expect(getPlayerOwner(playerId, ownership)).toBe('user-charlie') } - }); - }); -}); + }) + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/player-ownership.test.ts b/apps/web/src/lib/arcade/__tests__/player-ownership.test.ts index 0cd1b587..a59b0d36 100644 --- a/apps/web/src/lib/arcade/__tests__/player-ownership.test.ts +++ b/apps/web/src/lib/arcade/__tests__/player-ownership.test.ts @@ -2,8 +2,8 @@ * Tests for player ownership utilities */ -import { describe, expect, it } from "vitest"; -import type { RoomData } from "@/hooks/useRoomData"; +import { describe, expect, it } from 'vitest' +import type { RoomData } from '@/hooks/useRoomData' import { buildPlayerMetadata, buildPlayerOwnershipFromRoomData, @@ -13,271 +13,236 @@ import { groupPlayersByOwner, isPlayerOwnedByUser, type PlayerOwnershipMap, -} from "../player-ownership"; +} from '../player-ownership' -describe("player-ownership utilities", () => { +describe('player-ownership utilities', () => { // Sample data for tests const mockOwnershipMap: PlayerOwnershipMap = { - "player-1": "user-a", - "player-2": "user-a", - "player-3": "user-b", - "player-4": "user-c", - }; + 'player-1': 'user-a', + 'player-2': 'user-a', + 'player-3': 'user-b', + 'player-4': 'user-c', + } const mockRoomData: RoomData = { - id: "room-123", - name: "Test Room", - code: "ABCD", - gameName: "memory-pairs", + id: 'room-123', + name: 'Test Room', + code: 'ABCD', + gameName: 'memory-pairs', members: [ { - id: "member-1", - userId: "user-a", - displayName: "User A", + id: 'member-1', + userId: 'user-a', + displayName: 'User A', isOnline: true, isCreator: true, }, { - id: "member-2", - userId: "user-b", - displayName: "User B", + id: 'member-2', + userId: 'user-b', + displayName: 'User B', isOnline: true, isCreator: false, }, ], memberPlayers: { - "user-a": [ - { id: "player-1", name: "Player 1", emoji: "🐶", color: "#ff0000" }, - { id: "player-2", name: "Player 2", emoji: "🐱", color: "#00ff00" }, - ], - "user-b": [ - { id: "player-3", name: "Player 3", emoji: "🐭", color: "#0000ff" }, + 'user-a': [ + { id: 'player-1', name: 'Player 1', emoji: '🐶', color: '#ff0000' }, + { id: 'player-2', name: 'Player 2', emoji: '🐱', color: '#00ff00' }, ], + 'user-b': [{ id: 'player-3', name: 'Player 3', emoji: '🐭', color: '#0000ff' }], }, - }; + } - describe("buildPlayerOwnershipFromRoomData", () => { - it("should build ownership map from room data", () => { - const ownership = buildPlayerOwnershipFromRoomData(mockRoomData); + describe('buildPlayerOwnershipFromRoomData', () => { + it('should build ownership map from room data', () => { + const ownership = buildPlayerOwnershipFromRoomData(mockRoomData) expect(ownership).toEqual({ - "player-1": "user-a", - "player-2": "user-a", - "player-3": "user-b", - }); - }); + 'player-1': 'user-a', + 'player-2': 'user-a', + 'player-3': 'user-b', + }) + }) - it("should return empty map for null room data", () => { - const ownership = buildPlayerOwnershipFromRoomData(null); - expect(ownership).toEqual({}); - }); + it('should return empty map for null room data', () => { + const ownership = buildPlayerOwnershipFromRoomData(null) + expect(ownership).toEqual({}) + }) - it("should return empty map for undefined room data", () => { - const ownership = buildPlayerOwnershipFromRoomData(undefined); - expect(ownership).toEqual({}); - }); + it('should return empty map for undefined room data', () => { + const ownership = buildPlayerOwnershipFromRoomData(undefined) + expect(ownership).toEqual({}) + }) - it("should return empty map when memberPlayers is missing", () => { + it('should return empty map when memberPlayers is missing', () => { const roomDataWithoutPlayers = { ...mockRoomData, memberPlayers: undefined as any, - }; - const ownership = buildPlayerOwnershipFromRoomData( - roomDataWithoutPlayers, - ); - expect(ownership).toEqual({}); - }); - }); + } + const ownership = buildPlayerOwnershipFromRoomData(roomDataWithoutPlayers) + expect(ownership).toEqual({}) + }) + }) - describe("isPlayerOwnedByUser", () => { - it("should return true when player belongs to user", () => { - expect(isPlayerOwnedByUser("player-1", "user-a", mockOwnershipMap)).toBe( - true, - ); - expect(isPlayerOwnedByUser("player-2", "user-a", mockOwnershipMap)).toBe( - true, - ); - }); + describe('isPlayerOwnedByUser', () => { + it('should return true when player belongs to user', () => { + expect(isPlayerOwnedByUser('player-1', 'user-a', mockOwnershipMap)).toBe(true) + expect(isPlayerOwnedByUser('player-2', 'user-a', mockOwnershipMap)).toBe(true) + }) - it("should return false when player does not belong to user", () => { - expect(isPlayerOwnedByUser("player-1", "user-b", mockOwnershipMap)).toBe( - false, - ); - expect(isPlayerOwnedByUser("player-3", "user-a", mockOwnershipMap)).toBe( - false, - ); - }); + it('should return false when player does not belong to user', () => { + expect(isPlayerOwnedByUser('player-1', 'user-b', mockOwnershipMap)).toBe(false) + expect(isPlayerOwnedByUser('player-3', 'user-a', mockOwnershipMap)).toBe(false) + }) - it("should return false for non-existent player", () => { - expect( - isPlayerOwnedByUser("player-999", "user-a", mockOwnershipMap), - ).toBe(false); - }); - }); + it('should return false for non-existent player', () => { + expect(isPlayerOwnedByUser('player-999', 'user-a', mockOwnershipMap)).toBe(false) + }) + }) - describe("getPlayerOwner", () => { - it("should return the owner user ID for a player", () => { - expect(getPlayerOwner("player-1", mockOwnershipMap)).toBe("user-a"); - expect(getPlayerOwner("player-3", mockOwnershipMap)).toBe("user-b"); - expect(getPlayerOwner("player-4", mockOwnershipMap)).toBe("user-c"); - }); + describe('getPlayerOwner', () => { + it('should return the owner user ID for a player', () => { + expect(getPlayerOwner('player-1', mockOwnershipMap)).toBe('user-a') + expect(getPlayerOwner('player-3', mockOwnershipMap)).toBe('user-b') + expect(getPlayerOwner('player-4', mockOwnershipMap)).toBe('user-c') + }) - it("should return undefined for non-existent player", () => { - expect(getPlayerOwner("player-999", mockOwnershipMap)).toBeUndefined(); - }); - }); + it('should return undefined for non-existent player', () => { + expect(getPlayerOwner('player-999', mockOwnershipMap)).toBeUndefined() + }) + }) - describe("buildPlayerMetadata", () => { + describe('buildPlayerMetadata', () => { const mockPlayersMap = new Map([ - ["player-1", { name: "Dog", emoji: "🐶", color: "#ff0000" }], - ["player-2", { name: "Cat", emoji: "🐱", color: "#00ff00" }], - ["player-3", { name: "Mouse", emoji: "🐭", color: "#0000ff" }], - ]); + ['player-1', { name: 'Dog', emoji: '🐶', color: '#ff0000' }], + ['player-2', { name: 'Cat', emoji: '🐱', color: '#00ff00' }], + ['player-3', { name: 'Mouse', emoji: '🐭', color: '#0000ff' }], + ]) - it("should build metadata with correct ownership", () => { + it('should build metadata with correct ownership', () => { const metadata = buildPlayerMetadata( - ["player-1", "player-2", "player-3"], + ['player-1', 'player-2', 'player-3'], mockOwnershipMap, - mockPlayersMap, - ); + mockPlayersMap + ) expect(metadata).toEqual({ - "player-1": { - id: "player-1", - name: "Dog", - emoji: "🐶", - color: "#ff0000", - userId: "user-a", + 'player-1': { + id: 'player-1', + name: 'Dog', + emoji: '🐶', + color: '#ff0000', + userId: 'user-a', }, - "player-2": { - id: "player-2", - name: "Cat", - emoji: "🐱", - color: "#00ff00", - userId: "user-a", + 'player-2': { + id: 'player-2', + name: 'Cat', + emoji: '🐱', + color: '#00ff00', + userId: 'user-a', }, - "player-3": { - id: "player-3", - name: "Mouse", - emoji: "🐭", - color: "#0000ff", - userId: "user-b", + 'player-3': { + id: 'player-3', + name: 'Mouse', + emoji: '🐭', + color: '#0000ff', + userId: 'user-b', }, - }); - }); + }) + }) - it("should use fallback userId when player not in ownership map", () => { + it('should use fallback userId when player not in ownership map', () => { + const metadata = buildPlayerMetadata(['player-1'], {}, mockPlayersMap, 'fallback-user') + + expect(metadata['player-1'].userId).toBe('fallback-user') + }) + + it('should skip players not in players map', () => { const metadata = buildPlayerMetadata( - ["player-1"], - {}, - mockPlayersMap, - "fallback-user", - ); - - expect(metadata["player-1"].userId).toBe("fallback-user"); - }); - - it("should skip players not in players map", () => { - const metadata = buildPlayerMetadata( - ["player-1", "player-999"], + ['player-1', 'player-999'], mockOwnershipMap, - mockPlayersMap, - ); + mockPlayersMap + ) - expect(metadata["player-1"]).toBeDefined(); - expect(metadata["player-999"]).toBeUndefined(); - }); - }); + expect(metadata['player-1']).toBeDefined() + expect(metadata['player-999']).toBeUndefined() + }) + }) - describe("filterPlayersByOwner", () => { - it("should return only players owned by specified user", () => { - const playerIds = ["player-1", "player-2", "player-3", "player-4"]; + describe('filterPlayersByOwner', () => { + it('should return only players owned by specified user', () => { + const playerIds = ['player-1', 'player-2', 'player-3', 'player-4'] - const userAPlayers = filterPlayersByOwner( - playerIds, - "user-a", - mockOwnershipMap, - ); - expect(userAPlayers).toEqual(["player-1", "player-2"]); + const userAPlayers = filterPlayersByOwner(playerIds, 'user-a', mockOwnershipMap) + expect(userAPlayers).toEqual(['player-1', 'player-2']) - const userBPlayers = filterPlayersByOwner( - playerIds, - "user-b", - mockOwnershipMap, - ); - expect(userBPlayers).toEqual(["player-3"]); + const userBPlayers = filterPlayersByOwner(playerIds, 'user-b', mockOwnershipMap) + expect(userBPlayers).toEqual(['player-3']) - const userCPlayers = filterPlayersByOwner( - playerIds, - "user-c", - mockOwnershipMap, - ); - expect(userCPlayers).toEqual(["player-4"]); - }); + const userCPlayers = filterPlayersByOwner(playerIds, 'user-c', mockOwnershipMap) + expect(userCPlayers).toEqual(['player-4']) + }) - it("should return empty array when user owns no players", () => { - const playerIds = ["player-1", "player-2", "player-3"]; - const result = filterPlayersByOwner( - playerIds, - "user-nonexistent", - mockOwnershipMap, - ); - expect(result).toEqual([]); - }); + it('should return empty array when user owns no players', () => { + const playerIds = ['player-1', 'player-2', 'player-3'] + const result = filterPlayersByOwner(playerIds, 'user-nonexistent', mockOwnershipMap) + expect(result).toEqual([]) + }) - it("should return empty array for empty input", () => { - const result = filterPlayersByOwner([], "user-a", mockOwnershipMap); - expect(result).toEqual([]); - }); - }); + it('should return empty array for empty input', () => { + const result = filterPlayersByOwner([], 'user-a', mockOwnershipMap) + expect(result).toEqual([]) + }) + }) - describe("getUniqueOwners", () => { - it("should return array of unique user IDs", () => { - const owners = getUniqueOwners(mockOwnershipMap); - expect(owners).toHaveLength(3); - expect(owners).toContain("user-a"); - expect(owners).toContain("user-b"); - expect(owners).toContain("user-c"); - }); + describe('getUniqueOwners', () => { + it('should return array of unique user IDs', () => { + const owners = getUniqueOwners(mockOwnershipMap) + expect(owners).toHaveLength(3) + expect(owners).toContain('user-a') + expect(owners).toContain('user-b') + expect(owners).toContain('user-c') + }) - it("should return empty array for empty ownership map", () => { - const owners = getUniqueOwners({}); - expect(owners).toEqual([]); - }); + it('should return empty array for empty ownership map', () => { + const owners = getUniqueOwners({}) + expect(owners).toEqual([]) + }) - it("should deduplicate user IDs", () => { + it('should deduplicate user IDs', () => { const ownership: PlayerOwnershipMap = { - "player-1": "user-a", - "player-2": "user-a", - "player-3": "user-a", - }; - const owners = getUniqueOwners(ownership); - expect(owners).toEqual(["user-a"]); - }); - }); + 'player-1': 'user-a', + 'player-2': 'user-a', + 'player-3': 'user-a', + } + const owners = getUniqueOwners(ownership) + expect(owners).toEqual(['user-a']) + }) + }) - describe("groupPlayersByOwner", () => { - it("should group players by their owner", () => { - const playerIds = ["player-1", "player-2", "player-3", "player-4"]; - const groups = groupPlayersByOwner(playerIds, mockOwnershipMap); + describe('groupPlayersByOwner', () => { + it('should group players by their owner', () => { + const playerIds = ['player-1', 'player-2', 'player-3', 'player-4'] + const groups = groupPlayersByOwner(playerIds, mockOwnershipMap) - expect(groups.size).toBe(3); - expect(groups.get("user-a")).toEqual(["player-1", "player-2"]); - expect(groups.get("user-b")).toEqual(["player-3"]); - expect(groups.get("user-c")).toEqual(["player-4"]); - }); + expect(groups.size).toBe(3) + expect(groups.get('user-a')).toEqual(['player-1', 'player-2']) + expect(groups.get('user-b')).toEqual(['player-3']) + expect(groups.get('user-c')).toEqual(['player-4']) + }) - it("should return empty map for empty input", () => { - const groups = groupPlayersByOwner([], mockOwnershipMap); - expect(groups.size).toBe(0); - }); + it('should return empty map for empty input', () => { + const groups = groupPlayersByOwner([], mockOwnershipMap) + expect(groups.size).toBe(0) + }) - it("should skip players not in ownership map", () => { - const playerIds = ["player-1", "player-999"]; - const groups = groupPlayersByOwner(playerIds, mockOwnershipMap); + it('should skip players not in ownership map', () => { + const playerIds = ['player-1', 'player-999'] + const groups = groupPlayersByOwner(playerIds, mockOwnershipMap) - expect(groups.size).toBe(1); - expect(groups.get("user-a")).toEqual(["player-1"]); - }); - }); -}); + expect(groups.size).toBe(1) + expect(groups.get('user-a')).toEqual(['player-1']) + }) + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/room-invitations.test.ts b/apps/web/src/lib/arcade/__tests__/room-invitations.test.ts index e9f01e1d..72940284 100644 --- a/apps/web/src/lib/arcade/__tests__/room-invitations.test.ts +++ b/apps/web/src/lib/arcade/__tests__/room-invitations.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ -import { eq } from "drizzle-orm"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { db, schema } from "@/db"; +import { eq } from 'drizzle-orm' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { db, schema } from '@/db' import { createInvitation, getUserPendingInvitations, @@ -12,8 +12,8 @@ import { acceptInvitation, declineInvitation, cancelInvitation, -} from "../room-invitations"; -import { createRoom } from "../room-manager"; +} from '../room-invitations' +import { createRoom } from '../room-manager' /** * Room Invitations Unit Tests @@ -24,324 +24,310 @@ import { createRoom } from "../room-manager"; * - Invitation status transitions */ -describe("Room Invitations", () => { - let testUserId1: string; - let testUserId2: string; - let testGuestId1: string; - let testGuestId2: string; - let testRoomId: string; +describe('Room Invitations', () => { + let testUserId1: string + let testUserId2: string + let testGuestId1: string + let testGuestId2: string + let testRoomId: string beforeEach(async () => { // Create test users - testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; - testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}`; + testGuestId1 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` + testGuestId2 = `test-guest-${Date.now()}-${Math.random().toString(36).slice(2)}` - const [user1] = await db - .insert(schema.users) - .values({ guestId: testGuestId1 }) - .returning(); - const [user2] = await db - .insert(schema.users) - .values({ guestId: testGuestId2 }) - .returning(); + const [user1] = await db.insert(schema.users).values({ guestId: testGuestId1 }).returning() + const [user2] = await db.insert(schema.users).values({ guestId: testGuestId2 }).returning() - testUserId1 = user1.id; - testUserId2 = user2.id; + testUserId1 = user1.id + testUserId2 = user2.id // Create test room const room = await createRoom({ - name: "Test Room", + name: 'Test Room', createdBy: testGuestId1, - creatorName: "Host User", - gameName: "matching", + creatorName: 'Host User', + gameName: 'matching', gameConfig: {}, - accessMode: "restricted", - }); - testRoomId = room.id; - }); + accessMode: 'restricted', + }) + testRoomId = room.id + }) afterEach(async () => { // Clean up invitations (should cascade, but be explicit) - await db - .delete(schema.roomInvitations) - .where(eq(schema.roomInvitations.roomId, testRoomId)); + await db.delete(schema.roomInvitations).where(eq(schema.roomInvitations.roomId, testRoomId)) // Clean up room if (testRoomId) { - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, testRoomId)); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, testRoomId)) } // Clean up users - await db.delete(schema.users).where(eq(schema.users.id, testUserId1)); - await db.delete(schema.users).where(eq(schema.users.id, testUserId2)); - }); + await db.delete(schema.users).where(eq(schema.users.id, testUserId1)) + await db.delete(schema.users).where(eq(schema.users.id, testUserId2)) + }) - describe("Creating Invitations", () => { - it("creates a new invitation", async () => { + describe('Creating Invitations', () => { + it('creates a new invitation', async () => { const invitation = await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User", + userName: 'Guest User', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - }); + invitedByName: 'Host User', + invitationType: 'manual', + }) - expect(invitation).toBeDefined(); - expect(invitation.roomId).toBe(testRoomId); - expect(invitation.userId).toBe(testUserId2); - expect(invitation.status).toBe("pending"); - expect(invitation.invitationType).toBe("manual"); - }); + expect(invitation).toBeDefined() + expect(invitation.roomId).toBe(testRoomId) + expect(invitation.userId).toBe(testUserId2) + expect(invitation.status).toBe('pending') + expect(invitation.invitationType).toBe('manual') + }) - it("updates existing invitation instead of creating duplicate", async () => { + it('updates existing invitation instead of creating duplicate', async () => { // Create first invitation const invitation1 = await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User", + userName: 'Guest User', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - message: "First invite", - }); + invitedByName: 'Host User', + invitationType: 'manual', + message: 'First invite', + }) // Create second invitation for same user/room const invitation2 = await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User Updated", + userName: 'Guest User Updated', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - message: "Second invite", - }); + invitedByName: 'Host User', + invitationType: 'manual', + message: 'Second invite', + }) // Should have same ID (updated, not created) - expect(invitation2.id).toBe(invitation1.id); - expect(invitation2.message).toBe("Second invite"); - expect(invitation2.status).toBe("pending"); // Reset to pending + expect(invitation2.id).toBe(invitation1.id) + expect(invitation2.message).toBe('Second invite') + expect(invitation2.status).toBe('pending') // Reset to pending // Verify only one invitation exists const allInvitations = await db .select() .from(schema.roomInvitations) - .where(eq(schema.roomInvitations.userId, testUserId2)); + .where(eq(schema.roomInvitations.userId, testUserId2)) - expect(allInvitations).toHaveLength(1); - }); - }); + expect(allInvitations).toHaveLength(1) + }) + }) - describe("Accepting Invitations", () => { - it("marks invitation as accepted", async () => { + describe('Accepting Invitations', () => { + it('marks invitation as accepted', async () => { // Create invitation const invitation = await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User", + userName: 'Guest User', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - }); + invitedByName: 'Host User', + invitationType: 'manual', + }) - expect(invitation.status).toBe("pending"); + expect(invitation.status).toBe('pending') // Accept invitation - const accepted = await acceptInvitation(invitation.id); + const accepted = await acceptInvitation(invitation.id) - expect(accepted.status).toBe("accepted"); - expect(accepted.respondedAt).toBeDefined(); - expect(accepted.respondedAt).toBeInstanceOf(Date); - }); + expect(accepted.status).toBe('accepted') + expect(accepted.respondedAt).toBeDefined() + expect(accepted.respondedAt).toBeInstanceOf(Date) + }) - it("removes invitation from pending list after acceptance", async () => { + it('removes invitation from pending list after acceptance', async () => { // Create invitation await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User", + userName: 'Guest User', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - }); + invitedByName: 'Host User', + invitationType: 'manual', + }) // Verify it's in pending list - let pending = await getUserPendingInvitations(testUserId2); - expect(pending).toHaveLength(1); + let pending = await getUserPendingInvitations(testUserId2) + expect(pending).toHaveLength(1) // Accept it - await acceptInvitation(pending[0].id); + await acceptInvitation(pending[0].id) // Verify it's no longer in pending list - pending = await getUserPendingInvitations(testUserId2); - expect(pending).toHaveLength(0); - }); + pending = await getUserPendingInvitations(testUserId2) + expect(pending).toHaveLength(0) + }) - it("BUG FIX: invitation stays pending if not explicitly accepted", async () => { + it('BUG FIX: invitation stays pending if not explicitly accepted', async () => { // This test verifies the bug we fixed const invitation = await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User", + userName: 'Guest User', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - }); + invitedByName: 'Host User', + invitationType: 'manual', + }) // Get invitation (simulating join API checking it) - const retrieved = await getInvitation(testRoomId, testUserId2); - expect(retrieved?.status).toBe("pending"); + const retrieved = await getInvitation(testRoomId, testUserId2) + expect(retrieved?.status).toBe('pending') // WITHOUT calling acceptInvitation, invitation stays pending - const stillPending = await getInvitation(testRoomId, testUserId2); - expect(stillPending?.status).toBe("pending"); + const stillPending = await getInvitation(testRoomId, testUserId2) + expect(stillPending?.status).toBe('pending') // This is the bug: user joined but invitation not marked accepted // Now verify the fix works - await acceptInvitation(invitation.id); - const nowAccepted = await getInvitation(testRoomId, testUserId2); - expect(nowAccepted?.status).toBe("accepted"); - }); - }); + await acceptInvitation(invitation.id) + const nowAccepted = await getInvitation(testRoomId, testUserId2) + expect(nowAccepted?.status).toBe('accepted') + }) + }) - describe("Declining Invitations", () => { - it("marks invitation as declined", async () => { + describe('Declining Invitations', () => { + it('marks invitation as declined', async () => { const invitation = await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User", + userName: 'Guest User', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - }); + invitedByName: 'Host User', + invitationType: 'manual', + }) - const declined = await declineInvitation(invitation.id); + const declined = await declineInvitation(invitation.id) - expect(declined.status).toBe("declined"); - expect(declined.respondedAt).toBeDefined(); - }); + expect(declined.status).toBe('declined') + expect(declined.respondedAt).toBeDefined() + }) - it("removes invitation from pending list after declining", async () => { + it('removes invitation from pending list after declining', async () => { const invitation = await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User", + userName: 'Guest User', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - }); + invitedByName: 'Host User', + invitationType: 'manual', + }) // Decline - await declineInvitation(invitation.id); + await declineInvitation(invitation.id) // Verify no longer pending - const pending = await getUserPendingInvitations(testUserId2); - expect(pending).toHaveLength(0); - }); - }); + const pending = await getUserPendingInvitations(testUserId2) + expect(pending).toHaveLength(0) + }) + }) - describe("Canceling Invitations", () => { - it("deletes invitation completely", async () => { + describe('Canceling Invitations', () => { + it('deletes invitation completely', async () => { await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest User", + userName: 'Guest User', invitedBy: testUserId1, - invitedByName: "Host User", - invitationType: "manual", - }); + invitedByName: 'Host User', + invitationType: 'manual', + }) // Cancel - await cancelInvitation(testRoomId, testUserId2); + await cancelInvitation(testRoomId, testUserId2) // Verify deleted - const invitation = await getInvitation(testRoomId, testUserId2); - expect(invitation).toBeUndefined(); - }); - }); + const invitation = await getInvitation(testRoomId, testUserId2) + expect(invitation).toBeUndefined() + }) + }) - describe("Retrieving Invitations", () => { - it("gets pending invitations for a user", async () => { + describe('Retrieving Invitations', () => { + it('gets pending invitations for a user', async () => { // Create invitations for multiple rooms const room2 = await createRoom({ - name: "Room 2", + name: 'Room 2', createdBy: testGuestId1, - creatorName: "Host", - gameName: "matching", + creatorName: 'Host', + gameName: 'matching', gameConfig: {}, - accessMode: "restricted", - }); + accessMode: 'restricted', + }) await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest", + userName: 'Guest', invitedBy: testUserId1, - invitedByName: "Host", - invitationType: "manual", - }); + invitedByName: 'Host', + invitationType: 'manual', + }) await createInvitation({ roomId: room2.id, userId: testUserId2, - userName: "Guest", + userName: 'Guest', invitedBy: testUserId1, - invitedByName: "Host", - invitationType: "manual", - }); + invitedByName: 'Host', + invitationType: 'manual', + }) - const pending = await getUserPendingInvitations(testUserId2); - expect(pending).toHaveLength(2); + const pending = await getUserPendingInvitations(testUserId2) + expect(pending).toHaveLength(2) // Clean up - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, room2.id)); - }); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id)) + }) - it("only returns pending invitations, not accepted/declined", async () => { + it('only returns pending invitations, not accepted/declined', async () => { const inv1 = await createInvitation({ roomId: testRoomId, userId: testUserId2, - userName: "Guest", + userName: 'Guest', invitedBy: testUserId1, - invitedByName: "Host", - invitationType: "manual", - }); + invitedByName: 'Host', + invitationType: 'manual', + }) // Create second room and invitation const room2 = await createRoom({ - name: "Room 2", + name: 'Room 2', createdBy: testGuestId1, - creatorName: "Host", - gameName: "matching", + creatorName: 'Host', + gameName: 'matching', gameConfig: {}, - accessMode: "restricted", - }); + accessMode: 'restricted', + }) const inv2 = await createInvitation({ roomId: room2.id, userId: testUserId2, - userName: "Guest", + userName: 'Guest', invitedBy: testUserId1, - invitedByName: "Host", - invitationType: "manual", - }); + invitedByName: 'Host', + invitationType: 'manual', + }) // Accept first, decline second - await acceptInvitation(inv1.id); - await declineInvitation(inv2.id); + await acceptInvitation(inv1.id) + await declineInvitation(inv2.id) // Should have no pending - const pending = await getUserPendingInvitations(testUserId2); - expect(pending).toHaveLength(0); + const pending = await getUserPendingInvitations(testUserId2) + expect(pending).toHaveLength(0) // Clean up - await db - .delete(schema.arcadeRooms) - .where(eq(schema.arcadeRooms.id, room2.id)); - }); - }); -}); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, room2.id)) + }) + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/room-manager.test.ts b/apps/web/src/lib/arcade/__tests__/room-manager.test.ts index af479ca4..ed955f0b 100644 --- a/apps/web/src/lib/arcade/__tests__/room-manager.test.ts +++ b/apps/web/src/lib/arcade/__tests__/room-manager.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { db, type schema } from "@/db"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { db, type schema } from '@/db' import { cleanupExpiredRooms, createRoom, @@ -11,11 +11,11 @@ import { touchRoom, updateRoom, type CreateRoomOptions, -} from "../room-manager"; -import * as roomCode from "../room-code"; +} from '../room-manager' +import * as roomCode from '../room-code' // Mock the database -vi.mock("@/db", () => ({ +vi.mock('@/db', () => ({ db: { query: { arcadeRooms: { @@ -29,455 +29,448 @@ vi.mock("@/db", () => ({ }, schema: { arcadeRooms: { - id: "id", - code: "code", - name: "name", - gameName: "gameName", - accessMode: "accessMode", - password: "password", - status: "status", - lastActivity: "lastActivity", + id: 'id', + code: 'code', + name: 'name', + gameName: 'gameName', + accessMode: 'accessMode', + password: 'password', + status: 'status', + lastActivity: 'lastActivity', }, arcadeSessions: { - userId: "userId", - roomId: "roomId", + userId: 'userId', + roomId: 'roomId', }, }, -})); +})) // Mock room-code module -vi.mock("../room-code", () => ({ +vi.mock('../room-code', () => ({ generateRoomCode: vi.fn(), -})); +})) -describe("Room Manager", () => { +describe('Room Manager', () => { const mockRoom: schema.ArcadeRoom = { - id: "room-123", - code: "ABC123", - name: "Test Room", - createdBy: "user-1", - creatorName: "Test User", + id: 'room-123', + code: 'ABC123', + name: 'Test Room', + createdBy: 'user-1', + creatorName: 'Test User', createdAt: new Date(), lastActivity: new Date(), ttlMinutes: 60, - accessMode: "open", + accessMode: 'open', password: null, displayPassword: null, - gameName: "matching", + gameName: 'matching', gameConfig: { difficulty: 6 }, - status: "lobby", + status: 'lobby', currentSessionId: null, totalGamesPlayed: 0, - }; + } beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("createRoom", () => { - it("creates a room with generated code", async () => { + describe('createRoom', () => { + it('creates a room with generated code', async () => { const options: CreateRoomOptions = { - name: "Test Room", - createdBy: "user-1", - creatorName: "Test User", - gameName: "matching", + name: 'Test Room', + createdBy: 'user-1', + creatorName: 'Test User', + gameName: 'matching', gameConfig: { difficulty: 6 }, - }; + } // Mock code generation - vi.mocked(roomCode.generateRoomCode).mockReturnValue("ABC123"); + vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123') // Mock code uniqueness check - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined); + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined) // Mock insert const mockInsert = { values: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([mockRoom]), - }; - vi.mocked(db.insert).mockReturnValue(mockInsert as any); + } + vi.mocked(db.insert).mockReturnValue(mockInsert as any) - const room = await createRoom(options); + const room = await createRoom(options) - expect(room).toEqual(mockRoom); - expect(roomCode.generateRoomCode).toHaveBeenCalled(); - expect(db.insert).toHaveBeenCalled(); - }); + expect(room).toEqual(mockRoom) + expect(roomCode.generateRoomCode).toHaveBeenCalled() + expect(db.insert).toHaveBeenCalled() + }) - it("retries code generation on collision", async () => { + it('retries code generation on collision', async () => { const options: CreateRoomOptions = { - name: "Test Room", - createdBy: "user-1", - creatorName: "Test User", - gameName: "matching", + name: 'Test Room', + createdBy: 'user-1', + creatorName: 'Test User', + gameName: 'matching', gameConfig: { difficulty: 6 }, - }; + } // First code collides, second is unique vi.mocked(roomCode.generateRoomCode) - .mockReturnValueOnce("ABC123") - .mockReturnValueOnce("XYZ789"); + .mockReturnValueOnce('ABC123') + .mockReturnValueOnce('XYZ789') // First check finds collision, second check is unique vi.mocked(db.query.arcadeRooms.findFirst) .mockResolvedValueOnce(mockRoom) // Collision - .mockResolvedValueOnce(undefined); // Unique + .mockResolvedValueOnce(undefined) // Unique const mockInsert = { values: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([{ ...mockRoom, code: "XYZ789" }]), - }; - vi.mocked(db.insert).mockReturnValue(mockInsert as any); + returning: vi.fn().mockResolvedValue([{ ...mockRoom, code: 'XYZ789' }]), + } + vi.mocked(db.insert).mockReturnValue(mockInsert as any) - const room = await createRoom(options); + const room = await createRoom(options) - expect(room.code).toBe("XYZ789"); - expect(roomCode.generateRoomCode).toHaveBeenCalledTimes(2); - }); + expect(room.code).toBe('XYZ789') + expect(roomCode.generateRoomCode).toHaveBeenCalledTimes(2) + }) - it("throws error after max collision attempts", async () => { + it('throws error after max collision attempts', async () => { const options: CreateRoomOptions = { - name: "Test Room", - createdBy: "user-1", - creatorName: "Test User", - gameName: "matching", + name: 'Test Room', + createdBy: 'user-1', + creatorName: 'Test User', + gameName: 'matching', gameConfig: { difficulty: 6 }, - }; + } // All codes collide - vi.mocked(roomCode.generateRoomCode).mockReturnValue("ABC123"); - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom); + vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123') + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom) - await expect(createRoom(options)).rejects.toThrow( - "Failed to generate unique room code", - ); - }); + await expect(createRoom(options)).rejects.toThrow('Failed to generate unique room code') + }) - it("sets default TTL to 60 minutes", async () => { + it('sets default TTL to 60 minutes', async () => { const options: CreateRoomOptions = { - name: "Test Room", - createdBy: "user-1", - creatorName: "Test User", - gameName: "matching", + name: 'Test Room', + createdBy: 'user-1', + creatorName: 'Test User', + gameName: 'matching', gameConfig: { difficulty: 6 }, - }; + } - vi.mocked(roomCode.generateRoomCode).mockReturnValue("ABC123"); - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined); + vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123') + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined) const mockInsert = { values: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([mockRoom]), - }; - vi.mocked(db.insert).mockReturnValue(mockInsert as any); + } + vi.mocked(db.insert).mockReturnValue(mockInsert as any) - const room = await createRoom(options); + const room = await createRoom(options) - expect(room.ttlMinutes).toBe(60); - }); + expect(room.ttlMinutes).toBe(60) + }) - it("respects custom TTL", async () => { + it('respects custom TTL', async () => { const options: CreateRoomOptions = { - name: "Test Room", - createdBy: "user-1", - creatorName: "Test User", - gameName: "matching", + name: 'Test Room', + createdBy: 'user-1', + creatorName: 'Test User', + gameName: 'matching', gameConfig: { difficulty: 6 }, ttlMinutes: 120, - }; + } - vi.mocked(roomCode.generateRoomCode).mockReturnValue("ABC123"); - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined); + vi.mocked(roomCode.generateRoomCode).mockReturnValue('ABC123') + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined) const mockInsert = { values: vi.fn().mockReturnThis(), - returning: vi - .fn() - .mockResolvedValue([{ ...mockRoom, ttlMinutes: 120 }]), - }; - vi.mocked(db.insert).mockReturnValue(mockInsert as any); + returning: vi.fn().mockResolvedValue([{ ...mockRoom, ttlMinutes: 120 }]), + } + vi.mocked(db.insert).mockReturnValue(mockInsert as any) - const room = await createRoom(options); + const room = await createRoom(options) - expect(room.ttlMinutes).toBe(120); - }); - }); + expect(room.ttlMinutes).toBe(120) + }) + }) - describe("getRoomById", () => { - it("returns room when found", async () => { - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom); + describe('getRoomById', () => { + it('returns room when found', async () => { + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom) - const room = await getRoomById("room-123"); + const room = await getRoomById('room-123') - expect(room).toEqual(mockRoom); - expect(db.query.arcadeRooms.findFirst).toHaveBeenCalled(); - }); + expect(room).toEqual(mockRoom) + expect(db.query.arcadeRooms.findFirst).toHaveBeenCalled() + }) - it("returns undefined when not found", async () => { - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined); + it('returns undefined when not found', async () => { + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined) - const room = await getRoomById("nonexistent"); + const room = await getRoomById('nonexistent') - expect(room).toBeUndefined(); - }); - }); + expect(room).toBeUndefined() + }) + }) - describe("getRoomByCode", () => { - it("returns room when found", async () => { - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom); + describe('getRoomByCode', () => { + it('returns room when found', async () => { + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom) - const room = await getRoomByCode("ABC123"); + const room = await getRoomByCode('ABC123') - expect(room).toEqual(mockRoom); - }); + expect(room).toEqual(mockRoom) + }) - it("converts code to uppercase", async () => { - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom); + it('converts code to uppercase', async () => { + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom) - await getRoomByCode("abc123"); + await getRoomByCode('abc123') // Check that the where clause used uppercase - const call = vi.mocked(db.query.arcadeRooms.findFirst).mock.calls[0][0]; - expect(call).toBeDefined(); - }); + const call = vi.mocked(db.query.arcadeRooms.findFirst).mock.calls[0][0] + expect(call).toBeDefined() + }) - it("returns undefined when not found", async () => { - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined); + it('returns undefined when not found', async () => { + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined) - const room = await getRoomByCode("NONEXISTENT"); + const room = await getRoomByCode('NONEXISTENT') - expect(room).toBeUndefined(); - }); - }); + expect(room).toBeUndefined() + }) + }) - describe("updateRoom", () => { - it("updates room and returns updated data", async () => { - const updates = { name: "Updated Room", status: "playing" as const }; + describe('updateRoom', () => { + it('updates room and returns updated data', async () => { + const updates = { name: 'Updated Room', status: 'playing' as const } const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([{ ...mockRoom, ...updates }]), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) - const room = await updateRoom("room-123", updates); + const room = await updateRoom('room-123', updates) - expect(room?.name).toBe("Updated Room"); - expect(room?.status).toBe("playing"); - expect(db.update).toHaveBeenCalled(); - }); + expect(room?.name).toBe('Updated Room') + expect(room?.status).toBe('playing') + expect(db.update).toHaveBeenCalled() + }) - it("updates lastActivity timestamp", async () => { + it('updates lastActivity timestamp', async () => { const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([mockRoom]), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) - await updateRoom("room-123", { name: "Updated" }); + await updateRoom('room-123', { name: 'Updated' }) - const setCall = mockUpdate.set.mock.calls[0][0]; - expect(setCall).toHaveProperty("lastActivity"); - expect(setCall.lastActivity).toBeInstanceOf(Date); - }); - }); + const setCall = mockUpdate.set.mock.calls[0][0] + expect(setCall).toHaveProperty('lastActivity') + expect(setCall.lastActivity).toBeInstanceOf(Date) + }) + }) - describe("touchRoom", () => { - it("updates lastActivity timestamp", async () => { + describe('touchRoom', () => { + it('updates lastActivity timestamp', async () => { const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) - await touchRoom("room-123"); + await touchRoom('room-123') - expect(db.update).toHaveBeenCalled(); - const setCall = mockUpdate.set.mock.calls[0][0]; - expect(setCall).toHaveProperty("lastActivity"); - expect(setCall.lastActivity).toBeInstanceOf(Date); - }); - }); + expect(db.update).toHaveBeenCalled() + const setCall = mockUpdate.set.mock.calls[0][0] + expect(setCall).toHaveProperty('lastActivity') + expect(setCall.lastActivity).toBeInstanceOf(Date) + }) + }) - describe("deleteRoom", () => { - it("deletes room from database", async () => { + describe('deleteRoom', () => { + it('deletes room from database', async () => { const mockDelete = { where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.delete).mockReturnValue(mockDelete as any); + } + vi.mocked(db.delete).mockReturnValue(mockDelete as any) - await deleteRoom("room-123"); + await deleteRoom('room-123') - expect(db.delete).toHaveBeenCalled(); - }); - }); + expect(db.delete).toHaveBeenCalled() + }) + }) - describe("listActiveRooms", () => { - const activeRooms = [ - mockRoom, - { ...mockRoom, id: "room-456", name: "Another Room" }, - ]; + describe('listActiveRooms', () => { + const activeRooms = [mockRoom, { ...mockRoom, id: 'room-456', name: 'Another Room' }] - it("returns active rooms", async () => { - vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms); + it('returns active rooms', async () => { + vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms) - const rooms = await listActiveRooms(); + const rooms = await listActiveRooms() - expect(rooms).toEqual(activeRooms); - expect(rooms).toHaveLength(2); - }); + expect(rooms).toEqual(activeRooms) + expect(rooms).toHaveLength(2) + }) - it("filters by game name when provided", async () => { - vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([mockRoom]); + it('filters by game name when provided', async () => { + vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([mockRoom]) - const rooms = await listActiveRooms("matching"); + const rooms = await listActiveRooms('matching') - expect(rooms).toHaveLength(1); - expect(db.query.arcadeRooms.findMany).toHaveBeenCalled(); - }); + expect(rooms).toHaveLength(1) + expect(db.query.arcadeRooms.findMany).toHaveBeenCalled() + }) - it("only includes open and password-protected rooms", async () => { - vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms); + it('only includes open and password-protected rooms', async () => { + vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms) - await listActiveRooms(); + await listActiveRooms() // Verify the where clause filters by accessMode - const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0]; - expect(call).toBeDefined(); - }); + const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0] + expect(call).toBeDefined() + }) - it("limits results to 50 rooms", async () => { - vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms); + it('limits results to 50 rooms', async () => { + vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms) - await listActiveRooms(); + await listActiveRooms() - const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0]; - expect(call?.limit).toBe(50); - }); - }); + const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0] + expect(call?.limit).toBe(50) + }) + }) - describe("cleanupExpiredRooms", () => { - it("deletes expired rooms", async () => { - const now = new Date(); + describe('cleanupExpiredRooms', () => { + it('deletes expired rooms', async () => { + const now = new Date() const expiredRoom = { ...mockRoom, lastActivity: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago ttlMinutes: 60, // 1 hour TTL = expired - }; + } - vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([expiredRoom]); + vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([expiredRoom]) const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) const mockDelete = { where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.delete).mockReturnValue(mockDelete as any); + } + vi.mocked(db.delete).mockReturnValue(mockDelete as any) - const count = await cleanupExpiredRooms(); + const count = await cleanupExpiredRooms() - expect(count).toBe(1); - expect(db.update).toHaveBeenCalled(); // Should clear roomId from sessions first - expect(db.delete).toHaveBeenCalled(); - }); + expect(count).toBe(1) + expect(db.update).toHaveBeenCalled() // Should clear roomId from sessions first + expect(db.delete).toHaveBeenCalled() + }) - it("does not delete active rooms", async () => { - const now = new Date(); + it('does not delete active rooms', async () => { + const now = new Date() const activeRoom = { ...mockRoom, lastActivity: new Date(now.getTime() - 30 * 60 * 1000), // 30 min ago ttlMinutes: 60, // 1 hour TTL = still active - }; + } - vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([activeRoom]); + vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([activeRoom]) - const count = await cleanupExpiredRooms(); + const count = await cleanupExpiredRooms() - expect(count).toBe(0); - expect(db.delete).not.toHaveBeenCalled(); - }); + expect(count).toBe(0) + expect(db.delete).not.toHaveBeenCalled() + }) - it("handles mixed expired and active rooms", async () => { - const now = new Date(); + it('handles mixed expired and active rooms', async () => { + const now = new Date() const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) const rooms = [ { ...mockRoom, - id: "expired-1", + id: 'expired-1', lastActivity: new Date(now.getTime() - 2 * 60 * 60 * 1000), ttlMinutes: 60, }, { ...mockRoom, - id: "active-1", + id: 'active-1', lastActivity: new Date(now.getTime() - 30 * 60 * 1000), ttlMinutes: 60, }, { ...mockRoom, - id: "expired-2", + id: 'expired-2', lastActivity: new Date(now.getTime() - 3 * 60 * 60 * 1000), ttlMinutes: 120, }, - ]; + ] - vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(rooms); + vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(rooms) const mockDelete = { where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.delete).mockReturnValue(mockDelete as any); + } + vi.mocked(db.delete).mockReturnValue(mockDelete as any) - const count = await cleanupExpiredRooms(); + const count = await cleanupExpiredRooms() - expect(count).toBe(2); // Only 2 expired rooms - expect(db.delete).toHaveBeenCalled(); - }); + expect(count).toBe(2) // Only 2 expired rooms + expect(db.delete).toHaveBeenCalled() + }) - it("returns 0 when no rooms exist", async () => { - vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([]); + it('returns 0 when no rooms exist', async () => { + vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue([]) - const count = await cleanupExpiredRooms(); + const count = await cleanupExpiredRooms() - expect(count).toBe(0); - expect(db.delete).not.toHaveBeenCalled(); - }); - }); + expect(count).toBe(0) + expect(db.delete).not.toHaveBeenCalled() + }) + }) - describe("isRoomCreator", () => { - it("returns true for room creator", async () => { - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom); + describe('isRoomCreator', () => { + it('returns true for room creator', async () => { + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom) - const isCreator = await isRoomCreator("room-123", "user-1"); + const isCreator = await isRoomCreator('room-123', 'user-1') - expect(isCreator).toBe(true); - }); + expect(isCreator).toBe(true) + }) - it("returns false for non-creator", async () => { - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom); + it('returns false for non-creator', async () => { + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(mockRoom) - const isCreator = await isRoomCreator("room-123", "user-2"); + const isCreator = await isRoomCreator('room-123', 'user-2') - expect(isCreator).toBe(false); - }); + expect(isCreator).toBe(false) + }) - it("returns false when room not found", async () => { - vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined); + it('returns false when room not found', async () => { + vi.mocked(db.query.arcadeRooms.findFirst).mockResolvedValue(undefined) - const isCreator = await isRoomCreator("nonexistent", "user-1"); + const isCreator = await isRoomCreator('nonexistent', 'user-1') - expect(isCreator).toBe(false); - }); - }); -}); + expect(isCreator).toBe(false) + }) + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/room-membership.test.ts b/apps/web/src/lib/arcade/__tests__/room-membership.test.ts index d5d6a3e4..341d47e7 100644 --- a/apps/web/src/lib/arcade/__tests__/room-membership.test.ts +++ b/apps/web/src/lib/arcade/__tests__/room-membership.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { db, type schema } from "@/db"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { db, type schema } from '@/db' import { addRoomMember, getOnlineMemberCount, @@ -13,10 +13,10 @@ import { setMemberOnline, touchMember, type AddMemberOptions, -} from "../room-membership"; +} from '../room-membership' // Mock the database -vi.mock("@/db", () => ({ +vi.mock('@/db', () => ({ db: { query: { roomMembers: { @@ -30,359 +30,353 @@ vi.mock("@/db", () => ({ }, schema: { roomMembers: { - id: "id", - roomId: "roomId", - userId: "userId", - isOnline: "isOnline", - joinedAt: "joinedAt", + id: 'id', + roomId: 'roomId', + userId: 'userId', + isOnline: 'isOnline', + joinedAt: 'joinedAt', }, }, -})); +})) -describe("Room Membership", () => { +describe('Room Membership', () => { const mockMember: schema.RoomMember = { - id: "member-123", - roomId: "room-123", - userId: "user-1", - displayName: "Test User", + id: 'member-123', + roomId: 'room-123', + userId: 'user-1', + displayName: 'Test User', isCreator: false, joinedAt: new Date(), lastSeen: new Date(), isOnline: true, - }; + } beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("addRoomMember", () => { - it("adds new member to room", async () => { + describe('addRoomMember', () => { + it('adds new member to room', async () => { const options: AddMemberOptions = { - roomId: "room-123", - userId: "user-1", - displayName: "Test User", - }; + roomId: 'room-123', + userId: 'user-1', + displayName: 'Test User', + } // No existing member - vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined); + vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined) // Mock insert const mockInsert = { values: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([mockMember]), - }; - vi.mocked(db.insert).mockReturnValue(mockInsert as any); + } + vi.mocked(db.insert).mockReturnValue(mockInsert as any) // Mock getUserRooms to return empty array (no existing rooms) - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]); + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]) - const result = await addRoomMember(options); + const result = await addRoomMember(options) - expect(result.member).toEqual(mockMember); - expect(result.autoLeaveResult).toBeUndefined(); - expect(db.insert).toHaveBeenCalled(); - }); + expect(result.member).toEqual(mockMember) + expect(result.autoLeaveResult).toBeUndefined() + expect(db.insert).toHaveBeenCalled() + }) - it("updates existing member instead of creating duplicate", async () => { + it('updates existing member instead of creating duplicate', async () => { const options: AddMemberOptions = { - roomId: "room-123", - userId: "user-1", - displayName: "Test User", - }; + roomId: 'room-123', + userId: 'user-1', + displayName: 'Test User', + } // Existing member found - const existingMember = { ...mockMember, isOnline: false }; - vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue( - existingMember, - ); + const existingMember = { ...mockMember, isOnline: false } + vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(existingMember) // Mock update const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), - returning: vi - .fn() - .mockResolvedValue([{ ...existingMember, isOnline: true }]), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + returning: vi.fn().mockResolvedValue([{ ...existingMember, isOnline: true }]), + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) - const result = await addRoomMember(options); + const result = await addRoomMember(options) - expect(result.member.isOnline).toBe(true); - expect(result.autoLeaveResult).toBeUndefined(); - expect(db.update).toHaveBeenCalled(); - expect(db.insert).not.toHaveBeenCalled(); - }); + expect(result.member.isOnline).toBe(true) + expect(result.autoLeaveResult).toBeUndefined() + expect(db.update).toHaveBeenCalled() + expect(db.insert).not.toHaveBeenCalled() + }) - it("sets isCreator flag when specified", async () => { + it('sets isCreator flag when specified', async () => { const options: AddMemberOptions = { - roomId: "room-123", - userId: "user-1", - displayName: "Test User", + roomId: 'room-123', + userId: 'user-1', + displayName: 'Test User', isCreator: true, - }; + } - vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined); + vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined) const mockInsert = { values: vi.fn().mockReturnThis(), - returning: vi - .fn() - .mockResolvedValue([{ ...mockMember, isCreator: true }]), - }; - vi.mocked(db.insert).mockReturnValue(mockInsert as any); + returning: vi.fn().mockResolvedValue([{ ...mockMember, isCreator: true }]), + } + vi.mocked(db.insert).mockReturnValue(mockInsert as any) // Mock getUserRooms to return empty array - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]); + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]) - const result = await addRoomMember(options); + const result = await addRoomMember(options) - expect(result.member.isCreator).toBe(true); - }); + expect(result.member.isCreator).toBe(true) + }) - it("sets isOnline to true by default", async () => { + it('sets isOnline to true by default', async () => { const options: AddMemberOptions = { - roomId: "room-123", - userId: "user-1", - displayName: "Test User", - }; + roomId: 'room-123', + userId: 'user-1', + displayName: 'Test User', + } - vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined); + vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined) const mockInsert = { values: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([mockMember]), - }; - vi.mocked(db.insert).mockReturnValue(mockInsert as any); + } + vi.mocked(db.insert).mockReturnValue(mockInsert as any) // Mock getUserRooms to return empty array - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]); + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]) - const result = await addRoomMember(options); + const result = await addRoomMember(options) - expect(result.member.isOnline).toBe(true); - }); - }); + expect(result.member.isOnline).toBe(true) + }) + }) - describe("getRoomMember", () => { - it("returns member when found", async () => { - vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(mockMember); + describe('getRoomMember', () => { + it('returns member when found', async () => { + vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(mockMember) - const member = await getRoomMember("room-123", "user-1"); + const member = await getRoomMember('room-123', 'user-1') - expect(member).toEqual(mockMember); - }); + expect(member).toEqual(mockMember) + }) - it("returns undefined when not found", async () => { - vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined); + it('returns undefined when not found', async () => { + vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined) - const member = await getRoomMember("room-123", "user-999"); + const member = await getRoomMember('room-123', 'user-999') - expect(member).toBeUndefined(); - }); - }); + expect(member).toBeUndefined() + }) + }) - describe("getRoomMembers", () => { + describe('getRoomMembers', () => { const members = [ mockMember, { ...mockMember, - id: "member-456", - userId: "user-2", - displayName: "User 2", + id: 'member-456', + userId: 'user-2', + displayName: 'User 2', }, - ]; + ] - it("returns all members in room", async () => { - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(members); + it('returns all members in room', async () => { + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(members) - const result = await getRoomMembers("room-123"); + const result = await getRoomMembers('room-123') - expect(result).toEqual(members); - expect(result).toHaveLength(2); - }); + expect(result).toEqual(members) + expect(result).toHaveLength(2) + }) - it("returns empty array when no members", async () => { - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]); + it('returns empty array when no members', async () => { + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]) - const result = await getRoomMembers("room-123"); + const result = await getRoomMembers('room-123') - expect(result).toEqual([]); - }); - }); + expect(result).toEqual([]) + }) + }) - describe("getOnlineRoomMembers", () => { + describe('getOnlineRoomMembers', () => { const onlineMembers = [ mockMember, - { ...mockMember, id: "member-456", userId: "user-2", isOnline: true }, - ]; + { ...mockMember, id: 'member-456', userId: 'user-2', isOnline: true }, + ] - it("returns only online members", async () => { - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(onlineMembers); + it('returns only online members', async () => { + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(onlineMembers) - const result = await getOnlineRoomMembers("room-123"); + const result = await getOnlineRoomMembers('room-123') - expect(result).toEqual(onlineMembers); - expect(result.every((m) => m.isOnline)).toBe(true); - }); + expect(result).toEqual(onlineMembers) + expect(result.every((m) => m.isOnline)).toBe(true) + }) - it("returns empty array when no online members", async () => { - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]); + it('returns empty array when no online members', async () => { + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]) - const result = await getOnlineRoomMembers("room-123"); + const result = await getOnlineRoomMembers('room-123') - expect(result).toEqual([]); - }); - }); + expect(result).toEqual([]) + }) + }) - describe("setMemberOnline", () => { - it("updates member online status to true", async () => { + describe('setMemberOnline', () => { + it('updates member online status to true', async () => { const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) - await setMemberOnline("room-123", "user-1", true); + await setMemberOnline('room-123', 'user-1', true) - expect(db.update).toHaveBeenCalled(); - const setCall = mockUpdate.set.mock.calls[0][0]; - expect(setCall.isOnline).toBe(true); - expect(setCall.lastSeen).toBeInstanceOf(Date); - }); + expect(db.update).toHaveBeenCalled() + const setCall = mockUpdate.set.mock.calls[0][0] + expect(setCall.isOnline).toBe(true) + expect(setCall.lastSeen).toBeInstanceOf(Date) + }) - it("updates member online status to false", async () => { + it('updates member online status to false', async () => { const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) - await setMemberOnline("room-123", "user-1", false); + await setMemberOnline('room-123', 'user-1', false) - const setCall = mockUpdate.set.mock.calls[0][0]; - expect(setCall.isOnline).toBe(false); - }); + const setCall = mockUpdate.set.mock.calls[0][0] + expect(setCall.isOnline).toBe(false) + }) - it("updates lastSeen timestamp", async () => { + it('updates lastSeen timestamp', async () => { const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) - await setMemberOnline("room-123", "user-1", true); + await setMemberOnline('room-123', 'user-1', true) - const setCall = mockUpdate.set.mock.calls[0][0]; - expect(setCall.lastSeen).toBeInstanceOf(Date); - }); - }); + const setCall = mockUpdate.set.mock.calls[0][0] + expect(setCall.lastSeen).toBeInstanceOf(Date) + }) + }) - describe("touchMember", () => { - it("updates lastSeen timestamp", async () => { + describe('touchMember', () => { + it('updates lastSeen timestamp', async () => { const mockUpdate = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.update).mockReturnValue(mockUpdate as any); + } + vi.mocked(db.update).mockReturnValue(mockUpdate as any) - await touchMember("room-123", "user-1"); + await touchMember('room-123', 'user-1') - expect(db.update).toHaveBeenCalled(); - const setCall = mockUpdate.set.mock.calls[0][0]; - expect(setCall.lastSeen).toBeInstanceOf(Date); - }); - }); + expect(db.update).toHaveBeenCalled() + const setCall = mockUpdate.set.mock.calls[0][0] + expect(setCall.lastSeen).toBeInstanceOf(Date) + }) + }) - describe("removeMember", () => { - it("removes member from room", async () => { + describe('removeMember', () => { + it('removes member from room', async () => { const mockDelete = { where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.delete).mockReturnValue(mockDelete as any); + } + vi.mocked(db.delete).mockReturnValue(mockDelete as any) - await removeMember("room-123", "user-1"); + await removeMember('room-123', 'user-1') - expect(db.delete).toHaveBeenCalled(); - }); - }); + expect(db.delete).toHaveBeenCalled() + }) + }) - describe("removeAllMembers", () => { - it("removes all members from room", async () => { + describe('removeAllMembers', () => { + it('removes all members from room', async () => { const mockDelete = { where: vi.fn().mockReturnThis(), - }; - vi.mocked(db.delete).mockReturnValue(mockDelete as any); + } + vi.mocked(db.delete).mockReturnValue(mockDelete as any) - await removeAllMembers("room-123"); + await removeAllMembers('room-123') - expect(db.delete).toHaveBeenCalled(); - }); - }); + expect(db.delete).toHaveBeenCalled() + }) + }) - describe("getOnlineMemberCount", () => { - it("returns count of online members", async () => { + describe('getOnlineMemberCount', () => { + it('returns count of online members', async () => { const onlineMembers = [ mockMember, - { ...mockMember, id: "member-456", userId: "user-2" }, - { ...mockMember, id: "member-789", userId: "user-3" }, - ]; + { ...mockMember, id: 'member-456', userId: 'user-2' }, + { ...mockMember, id: 'member-789', userId: 'user-3' }, + ] - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(onlineMembers); + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(onlineMembers) - const count = await getOnlineMemberCount("room-123"); + const count = await getOnlineMemberCount('room-123') - expect(count).toBe(3); - }); + expect(count).toBe(3) + }) - it("returns 0 when no online members", async () => { - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]); + it('returns 0 when no online members', async () => { + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]) - const count = await getOnlineMemberCount("room-123"); + const count = await getOnlineMemberCount('room-123') - expect(count).toBe(0); - }); - }); + expect(count).toBe(0) + }) + }) - describe("isMember", () => { - it("returns true when user is member", async () => { - vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(mockMember); + describe('isMember', () => { + it('returns true when user is member', async () => { + vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(mockMember) - const result = await isMember("room-123", "user-1"); + const result = await isMember('room-123', 'user-1') - expect(result).toBe(true); - }); + expect(result).toBe(true) + }) - it("returns false when user is not member", async () => { - vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined); + it('returns false when user is not member', async () => { + vi.mocked(db.query.roomMembers.findFirst).mockResolvedValue(undefined) - const result = await isMember("room-123", "user-999"); + const result = await isMember('room-123', 'user-999') - expect(result).toBe(false); - }); - }); + expect(result).toBe(false) + }) + }) - describe("getUserRooms", () => { - it("returns list of room IDs user is member of", async () => { + describe('getUserRooms', () => { + it('returns list of room IDs user is member of', async () => { const memberships = [ - { ...mockMember, roomId: "room-1" }, - { ...mockMember, roomId: "room-2" }, - { ...mockMember, roomId: "room-3" }, - ]; + { ...mockMember, roomId: 'room-1' }, + { ...mockMember, roomId: 'room-2' }, + { ...mockMember, roomId: 'room-3' }, + ] - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(memberships); + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue(memberships) - const rooms = await getUserRooms("user-1"); + const rooms = await getUserRooms('user-1') - expect(rooms).toEqual(["room-1", "room-2", "room-3"]); - }); + expect(rooms).toEqual(['room-1', 'room-2', 'room-3']) + }) - it("returns empty array when user has no rooms", async () => { - vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]); + it('returns empty array when user has no rooms', async () => { + vi.mocked(db.query.roomMembers.findMany).mockResolvedValue([]) - const rooms = await getUserRooms("user-1"); + const rooms = await getUserRooms('user-1') - expect(rooms).toEqual([]); - }); - }); -}); + expect(rooms).toEqual([]) + }) + }) +}) diff --git a/apps/web/src/lib/arcade/__tests__/session-manager.test.ts b/apps/web/src/lib/arcade/__tests__/session-manager.test.ts index dd099ce8..8f14368e 100644 --- a/apps/web/src/lib/arcade/__tests__/session-manager.test.ts +++ b/apps/web/src/lib/arcade/__tests__/session-manager.test.ts @@ -1,16 +1,16 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { db } from "@/db"; +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { db } from '@/db' import { applyGameMove, createArcadeSession, deleteArcadeSession, getArcadeSession, updateSessionActivity, -} from "../session-manager"; -import type { GameMove } from "../validation"; +} from '../session-manager' +import type { GameMove } from '../validation' // Mock the database -vi.mock("@/db", () => ({ +vi.mock('@/db', () => ({ db: { query: { users: { @@ -45,11 +45,11 @@ vi.mock("@/db", () => ({ users: { guestId: {}, id: {} }, arcadeSessions: { userId: {} }, }, -})); +})) // Mock the validation module -vi.mock("../validation", async () => { - const actual = await vi.importActual("../validation"); +vi.mock('../validation', async () => { + const actual = await vi.importActual('../validation') return { ...actual, getValidator: vi.fn(() => ({ @@ -59,12 +59,12 @@ vi.mock("../validation", async () => { })), getInitialState: vi.fn(), })), - }; -}); + } +}) -describe("session-manager", () => { - const mockGuestId = "149e3e7e-4006-4a17-9f9f-28b0ec188c28"; - const mockUserId = "m2rb9gjhhqp2fky171quf1lj"; +describe('session-manager', () => { + const mockGuestId = '149e3e7e-4006-4a17-9f9f-28b0ec188c28' + const mockUserId = 'm2rb9gjhhqp2fky171quf1lj' const mockUser = { id: mockUserId, guestId: mockGuestId, @@ -72,16 +72,16 @@ describe("session-manager", () => { createdAt: new Date(), upgradedAt: null, email: null, - }; + } beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - describe("createArcadeSession", () => { - it("should look up user by guestId and use the database user.id for FK", async () => { + describe('createArcadeSession', () => { + it('should look up user by guestId and use the database user.id for FK', async () => { // Mock user lookup - vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser); + vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser) // Mock session creation const mockInsert = vi.fn(() => ({ @@ -89,90 +89,90 @@ describe("session-manager", () => { returning: vi.fn().mockResolvedValue([ { userId: mockUserId, // Should use database ID, not guestId - currentGame: "matching", + currentGame: 'matching', gameState: {}, version: 1, }, ]), })), - })); - vi.mocked(db.insert).mockImplementation(mockInsert as any); + })) + vi.mocked(db.insert).mockImplementation(mockInsert as any) await createArcadeSession({ userId: mockGuestId, // Passing guestId - gameName: "matching", - gameUrl: "/arcade/matching", + gameName: 'matching', + gameUrl: '/arcade/matching', initialState: {}, - activePlayers: ["1"], - roomId: "test-room-id", - }); + activePlayers: ['1'], + roomId: 'test-room-id', + }) // Verify user lookup by guestId expect(db.query.users.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.anything(), - }), - ); + }) + ) // Verify session uses database user.id - const insertCall = mockInsert.mock.results[0].value; - const valuesCall = insertCall.values.mock.results[0].value; - const returningCall = valuesCall.returning; + const insertCall = mockInsert.mock.results[0].value + const valuesCall = insertCall.values.mock.results[0].value + const returningCall = valuesCall.returning - expect(returningCall).toHaveBeenCalled(); - }); + expect(returningCall).toHaveBeenCalled() + }) - it("should create new user if guestId not found", async () => { + it('should create new user if guestId not found', async () => { // Mock user not found - vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined); + vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined) - const newUser = { ...mockUser, id: "new-user-id" }; + const newUser = { ...mockUser, id: 'new-user-id' } // Mock user creation const mockInsertUser = vi.fn(() => ({ values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([newUser]), })), - })); + })) // Mock session creation const mockInsertSession = vi.fn(() => ({ values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([ { - userId: "new-user-id", - currentGame: "matching", + userId: 'new-user-id', + currentGame: 'matching', gameState: {}, version: 1, }, ]), })), - })); + })) - let insertCallCount = 0; + let insertCallCount = 0 vi.mocked(db.insert).mockImplementation((() => { - insertCallCount++; - return insertCallCount === 1 ? mockInsertUser() : mockInsertSession(); - }) as any); + insertCallCount++ + return insertCallCount === 1 ? mockInsertUser() : mockInsertSession() + }) as any) await createArcadeSession({ userId: mockGuestId, - gameName: "matching", - gameUrl: "/arcade/matching", + gameName: 'matching', + gameUrl: '/arcade/matching', initialState: {}, - activePlayers: ["1"], - roomId: "test-room-id", - }); + activePlayers: ['1'], + roomId: 'test-room-id', + }) // Verify user was created - expect(db.insert).toHaveBeenCalledTimes(2); // user + session - }); - }); + expect(db.insert).toHaveBeenCalledTimes(2) // user + session + }) + }) - describe("getArcadeSession", () => { - it("should translate guestId to user.id before querying", async () => { + describe('getArcadeSession', () => { + it('should translate guestId to user.id before querying', async () => { // Mock user lookup - vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser); + vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser) // Mock session query vi.mocked(db.select).mockReturnValue({ @@ -181,7 +181,7 @@ describe("session-manager", () => { limit: vi.fn().mockResolvedValue([ { userId: mockUserId, - currentGame: "matching", + currentGame: 'matching', gameState: {}, version: 1, expiresAt: new Date(Date.now() + 1000000), @@ -190,44 +190,44 @@ describe("session-manager", () => { ]), }), }), - } as any); + } as any) - const session = await getArcadeSession(mockGuestId); + const session = await getArcadeSession(mockGuestId) // Verify user lookup - expect(db.query.users.findFirst).toHaveBeenCalled(); + expect(db.query.users.findFirst).toHaveBeenCalled() // Verify session was found - expect(session).toBeDefined(); - expect(session?.userId).toBe(mockUserId); - }); + expect(session).toBeDefined() + expect(session?.userId).toBe(mockUserId) + }) - it("should return undefined if user not found", async () => { - vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined); + it('should return undefined if user not found', async () => { + vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined) - const session = await getArcadeSession(mockGuestId); + const session = await getArcadeSession(mockGuestId) - expect(session).toBeUndefined(); - }); - }); + expect(session).toBeUndefined() + }) + }) - describe("applyGameMove", () => { + describe('applyGameMove', () => { const mockSession = { userId: mockUserId, - currentGame: "matching" as const, + currentGame: 'matching' as const, gameState: { flippedCards: [] }, version: 1, isActive: true, expiresAt: new Date(Date.now() + 1000000), startedAt: new Date(), lastActivityAt: new Date(), - gameUrl: "/arcade/matching", + gameUrl: '/arcade/matching', activePlayers: [1] as any, - }; + } - it("should use session.userId (database ID) for update WHERE clause", async () => { + it('should use session.userId (database ID) for update WHERE clause', async () => { // Mock user lookup - vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser); + vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser) // Mock session query vi.mocked(db.select).mockReturnValue({ @@ -236,7 +236,7 @@ describe("session-manager", () => { limit: vi.fn().mockResolvedValue([mockSession]), }), }), - } as any); + } as any) // Mock update with proper chain const mockReturning = vi.fn().mockResolvedValue([ @@ -244,104 +244,104 @@ describe("session-manager", () => { ...mockSession, version: 2, }, - ]); + ]) const mockWhere = vi.fn().mockReturnValue({ returning: mockReturning, - }); + }) const mockSet = vi.fn().mockReturnValue({ where: mockWhere, - }); + }) vi.mocked(db.update).mockReturnValue({ set: mockSet, - } as any); + } as any) const move: GameMove = { - type: "FLIP_CARD", - data: { cardId: "1" }, - playerId: "1", + type: 'FLIP_CARD', + data: { cardId: '1' }, + playerId: '1', userId: mockUserId, timestamp: Date.now(), - }; + } - await applyGameMove(mockGuestId, move); + await applyGameMove(mockGuestId, move) // Verify the chain was called - expect(mockSet).toHaveBeenCalled(); - expect(mockWhere).toHaveBeenCalled(); - expect(mockReturning).toHaveBeenCalled(); - }); - }); + expect(mockSet).toHaveBeenCalled() + expect(mockWhere).toHaveBeenCalled() + expect(mockReturning).toHaveBeenCalled() + }) + }) - describe("deleteArcadeSession", () => { - it("should translate guestId to user.id before deleting", async () => { + describe('deleteArcadeSession', () => { + it('should translate guestId to user.id before deleting', async () => { // Mock user lookup - vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser); + vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser) - const mockWhere = vi.fn(); + const mockWhere = vi.fn() vi.mocked(db.delete).mockReturnValue({ where: mockWhere, - } as any); + } as any) - await deleteArcadeSession(mockGuestId); + await deleteArcadeSession(mockGuestId) // Verify user lookup happened - expect(db.query.users.findFirst).toHaveBeenCalled(); + expect(db.query.users.findFirst).toHaveBeenCalled() // Verify delete was called - expect(mockWhere).toHaveBeenCalled(); - }); + expect(mockWhere).toHaveBeenCalled() + }) - it("should do nothing if user not found", async () => { - vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined); + it('should do nothing if user not found', async () => { + vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined) - const mockWhere = vi.fn(); + const mockWhere = vi.fn() vi.mocked(db.delete).mockReturnValue({ where: mockWhere, - } as any); + } as any) - await deleteArcadeSession(mockGuestId); + await deleteArcadeSession(mockGuestId) // Verify delete was NOT called - expect(mockWhere).not.toHaveBeenCalled(); - }); - }); + expect(mockWhere).not.toHaveBeenCalled() + }) + }) - describe("updateSessionActivity", () => { - it("should translate guestId to user.id before updating", async () => { + describe('updateSessionActivity', () => { + it('should translate guestId to user.id before updating', async () => { // Mock user lookup - vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser); + vi.mocked(db.query.users.findFirst).mockResolvedValue(mockUser) - const mockWhere = vi.fn(); + const mockWhere = vi.fn() vi.mocked(db.update).mockReturnValue({ set: vi.fn().mockReturnValue({ where: mockWhere, }), - } as any); + } as any) - await updateSessionActivity(mockGuestId); + await updateSessionActivity(mockGuestId) // Verify user lookup happened - expect(db.query.users.findFirst).toHaveBeenCalled(); + expect(db.query.users.findFirst).toHaveBeenCalled() // Verify update was called - expect(mockWhere).toHaveBeenCalled(); - }); + expect(mockWhere).toHaveBeenCalled() + }) - it("should do nothing if user not found", async () => { - vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined); + it('should do nothing if user not found', async () => { + vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined) - const mockWhere = vi.fn(); + const mockWhere = vi.fn() vi.mocked(db.update).mockReturnValue({ set: vi.fn().mockReturnValue({ where: mockWhere, }), - } as any); + } as any) - await updateSessionActivity(mockGuestId); + await updateSessionActivity(mockGuestId) // Verify update was NOT called - expect(mockWhere).not.toHaveBeenCalled(); - }); - }); -}); + expect(mockWhere).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/web/src/lib/arcade/error-handling.ts b/apps/web/src/lib/arcade/error-handling.ts index cb920aa6..5daeff56 100644 --- a/apps/web/src/lib/arcade/error-handling.ts +++ b/apps/web/src/lib/arcade/error-handling.ts @@ -3,137 +3,128 @@ * Provides user-friendly error messages, retry logic, and recovery suggestions */ -import type { GameMove } from "@/lib/arcade/validation"; +import type { GameMove } from '@/lib/arcade/validation' -export type ErrorSeverity = "info" | "warning" | "error" | "fatal"; +export type ErrorSeverity = 'info' | 'warning' | 'error' | 'fatal' export type ErrorCategory = - | "version-conflict" - | "permission" - | "validation" - | "network" - | "game-rule" - | "unknown"; + | 'version-conflict' + | 'permission' + | 'validation' + | 'network' + | 'game-rule' + | 'unknown' export interface EnhancedError { - category: ErrorCategory; - severity: ErrorSeverity; - userMessage: string; // User-friendly message - technicalMessage: string; // For console/debugging - autoRetry: boolean; // Is this being auto-retried? - retryCount?: number; // Current retry attempt - suggestion?: string; // What the user should do - recoverable: boolean; // Can the user recover from this? + category: ErrorCategory + severity: ErrorSeverity + userMessage: string // User-friendly message + technicalMessage: string // For console/debugging + autoRetry: boolean // Is this being auto-retried? + retryCount?: number // Current retry attempt + suggestion?: string // What the user should do + recoverable: boolean // Can the user recover from this? } export interface RetryState { - isRetrying: boolean; - retryCount: number; - move: GameMove | null; - timestamp: number | null; + isRetrying: boolean + retryCount: number + move: GameMove | null + timestamp: number | null } /** * Parse raw error messages into enhanced error objects */ -export function parseError( - error: string, - move?: GameMove, - retryCount?: number, -): EnhancedError { +export function parseError(error: string, move?: GameMove, retryCount?: number): EnhancedError { // Version conflict errors - if ( - error.includes("version conflict") || - error.includes("Version conflict") - ) { + if (error.includes('version conflict') || error.includes('Version conflict')) { return { - category: "version-conflict", - severity: retryCount && retryCount > 3 ? "warning" : "info", + category: 'version-conflict', + severity: retryCount && retryCount > 3 ? 'warning' : 'info', userMessage: retryCount && retryCount > 2 - ? "Multiple players are making moves quickly, still syncing..." - : "Another player made a move, syncing...", - technicalMessage: `Version conflict on ${move?.type || "unknown move"}`, + ? 'Multiple players are making moves quickly, still syncing...' + : 'Another player made a move, syncing...', + technicalMessage: `Version conflict on ${move?.type || 'unknown move'}`, autoRetry: true, retryCount, suggestion: - retryCount && retryCount > 4 - ? "Wait a moment for the game to stabilize" - : undefined, + retryCount && retryCount > 4 ? 'Wait a moment for the game to stabilize' : undefined, recoverable: true, - }; + } } // Permission errors (403) - if (error.includes("Only the host") || error.includes("403")) { + if (error.includes('Only the host') || error.includes('403')) { return { - category: "permission", - severity: "warning", - userMessage: "Only the room host can change this setting", + category: 'permission', + severity: 'warning', + userMessage: 'Only the room host can change this setting', technicalMessage: `403 Forbidden: ${error}`, autoRetry: false, - suggestion: "Ask the host to make this change", + suggestion: 'Ask the host to make this change', recoverable: false, - }; + } } // Network errors if ( - error.includes("network") || - error.includes("Network") || - error.includes("timeout") || - error.includes("fetch") + error.includes('network') || + error.includes('Network') || + error.includes('timeout') || + error.includes('fetch') ) { return { - category: "network", - severity: "error", - userMessage: "Network connection issue", + category: 'network', + severity: 'error', + userMessage: 'Network connection issue', technicalMessage: error, autoRetry: false, - suggestion: "Check your internet connection", + suggestion: 'Check your internet connection', recoverable: true, - }; + } } // Validation/game rule errors (from validator) if ( - error.includes("Invalid") || - error.includes("cannot") || - error.includes("not allowed") || - error.includes("must") + error.includes('Invalid') || + error.includes('cannot') || + error.includes('not allowed') || + error.includes('must') ) { return { - category: "game-rule", - severity: "warning", + category: 'game-rule', + severity: 'warning', userMessage: error, // Validator messages are already user-friendly technicalMessage: error, autoRetry: false, recoverable: true, - }; + } } // Not in room - if (error.includes("not in this room")) { + if (error.includes('not in this room')) { return { - category: "permission", - severity: "error", - userMessage: "You are not in this room", + category: 'permission', + severity: 'error', + userMessage: 'You are not in this room', technicalMessage: error, autoRetry: false, - suggestion: "Rejoin the room to continue playing", + suggestion: 'Rejoin the room to continue playing', recoverable: false, - }; + } } // Unknown errors return { - category: "unknown", - severity: "error", - userMessage: "Something went wrong", + category: 'unknown', + severity: 'error', + userMessage: 'Something went wrong', technicalMessage: error, autoRetry: false, - suggestion: "Try refreshing the page", + suggestion: 'Try refreshing the page', recoverable: false, - }; + } } /** @@ -141,28 +132,28 @@ export function parseError( */ export function getMoveActionName(move: GameMove): string { switch (move.type) { - case "START_GAME": - return "starting game"; - case "MAKE_MOVE": - return "moving piece"; - case "DECLARE_HARMONY": - return "declaring harmony"; - case "RESIGN": - return "resigning"; - case "OFFER_DRAW": - return "offering draw"; - case "ACCEPT_DRAW": - return "accepting draw"; - case "CLAIM_REPETITION": - return "claiming repetition"; - case "CLAIM_FIFTY_MOVE": - return "claiming fifty-move rule"; - case "SET_CONFIG": - return "updating settings"; - case "RESET_GAME": - return "resetting game"; + case 'START_GAME': + return 'starting game' + case 'MAKE_MOVE': + return 'moving piece' + case 'DECLARE_HARMONY': + return 'declaring harmony' + case 'RESIGN': + return 'resigning' + case 'OFFER_DRAW': + return 'offering draw' + case 'ACCEPT_DRAW': + return 'accepting draw' + case 'CLAIM_REPETITION': + return 'claiming repetition' + case 'CLAIM_FIFTY_MOVE': + return 'claiming fifty-move rule' + case 'SET_CONFIG': + return 'updating settings' + case 'RESET_GAME': + return 'resetting game' default: - return "performing action"; + return 'performing action' } } @@ -171,27 +162,27 @@ export function getMoveActionName(move: GameMove): string { */ export function shouldShowToast(error: EnhancedError): boolean { // Version conflicts: only show toast after 2+ retries - if (error.category === "version-conflict") { - return (error.retryCount ?? 0) >= 2; + if (error.category === 'version-conflict') { + return (error.retryCount ?? 0) >= 2 } // Permission errors: always show toast - if (error.category === "permission") { - return true; + if (error.category === 'permission') { + return true } // Network errors: always show toast - if (error.category === "network") { - return true; + if (error.category === 'network') { + return true } // Game rule errors: show in banner, not toast - if (error.category === "game-rule") { - return false; + if (error.category === 'game-rule') { + return false } // Unknown errors: show toast - return true; + return true } /** @@ -199,41 +190,39 @@ export function shouldShowToast(error: EnhancedError): boolean { */ export function shouldShowBanner(error: EnhancedError): boolean { // Version conflicts: only show banner if max retries exceeded - if (error.category === "version-conflict") { - return (error.retryCount ?? 0) >= 5; + if (error.category === 'version-conflict') { + return (error.retryCount ?? 0) >= 5 } // Game rule errors: always show in banner - if (error.category === "game-rule") { - return true; + if (error.category === 'game-rule') { + return true } // Fatal errors: show in banner - if (error.severity === "fatal") { - return true; + if (error.severity === 'fatal') { + return true } // Network errors after retry - if (error.category === "network") { - return true; + if (error.category === 'network') { + return true } - return false; + return false } /** * Get toast type from error severity */ -export function getToastType( - severity: ErrorSeverity, -): "success" | "error" | "info" { +export function getToastType(severity: ErrorSeverity): 'success' | 'error' | 'info' { switch (severity) { - case "info": - return "info"; - case "warning": - return "error"; // Use error styling for warnings - case "error": - case "fatal": - return "error"; + case 'info': + return 'info' + case 'warning': + return 'error' // Use error styling for warnings + case 'error': + case 'fatal': + return 'error' } } diff --git a/apps/web/src/lib/arcade/game-config-helpers.ts b/apps/web/src/lib/arcade/game-config-helpers.ts index 16d11e34..2d58ddd3 100644 --- a/apps/web/src/lib/arcade/game-config-helpers.ts +++ b/apps/web/src/lib/arcade/game-config-helpers.ts @@ -5,11 +5,11 @@ * Uses the room_game_configs table (one row per game per room). */ -import { and, eq } from "drizzle-orm"; -import { createId } from "@paralleldrive/cuid2"; -import { db, schema } from "@/db"; -import type { GameName } from "./validators"; -import type { GameConfigByName } from "./game-configs"; +import { and, eq } from 'drizzle-orm' +import { createId } from '@paralleldrive/cuid2' +import { db, schema } from '@/db' +import type { GameName } from './validators' +import type { GameConfigByName } from './game-configs' import { DEFAULT_MATCHING_CONFIG, DEFAULT_MEMORY_QUIZ_CONFIG, @@ -17,51 +17,49 @@ import { DEFAULT_CARD_SORTING_CONFIG, DEFAULT_RITHMOMACHIA_CONFIG, DEFAULT_YIJS_DEMO_CONFIG, -} from "./game-configs"; +} from './game-configs' // Lazy-load game registry to avoid loading React components on server function getGame(gameName: string) { // Only load game registry in browser environment // On server, we fall back to switch statement validation - if (typeof window !== "undefined") { + if (typeof window !== 'undefined') { try { - const { getGame: registryGetGame } = require("./game-registry"); - return registryGetGame(gameName); + const { getGame: registryGetGame } = require('./game-registry') + return registryGetGame(gameName) } catch (error) { - console.warn("[GameConfig] Failed to load game registry:", error); - return undefined; + console.warn('[GameConfig] Failed to load game registry:', error) + return undefined } } - return undefined; + return undefined } /** * Extended game name type that includes both registered validators and legacy games * TODO: Remove 'complement-race' once migrated to the new modular system */ -type ExtendedGameName = GameName | "complement-race"; +type ExtendedGameName = GameName | 'complement-race' /** * Get default config for a game */ -function getDefaultGameConfig( - gameName: ExtendedGameName, -): GameConfigByName[ExtendedGameName] { +function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[ExtendedGameName] { switch (gameName) { - case "matching": - return DEFAULT_MATCHING_CONFIG; - case "memory-quiz": - return DEFAULT_MEMORY_QUIZ_CONFIG; - case "complement-race": - return DEFAULT_COMPLEMENT_RACE_CONFIG; - case "card-sorting": - return DEFAULT_CARD_SORTING_CONFIG; - case "rithmomachia": - return DEFAULT_RITHMOMACHIA_CONFIG; - case "yjs-demo": - return DEFAULT_YIJS_DEMO_CONFIG; + case 'matching': + return DEFAULT_MATCHING_CONFIG + case 'memory-quiz': + return DEFAULT_MEMORY_QUIZ_CONFIG + case 'complement-race': + return DEFAULT_COMPLEMENT_RACE_CONFIG + case 'card-sorting': + return DEFAULT_CARD_SORTING_CONFIG + case 'rithmomachia': + return DEFAULT_RITHMOMACHIA_CONFIG + case 'yjs-demo': + return DEFAULT_YIJS_DEMO_CONFIG default: - throw new Error(`Unknown game: ${gameName}`); + throw new Error(`Unknown game: ${gameName}`) } } @@ -71,27 +69,27 @@ function getDefaultGameConfig( */ export async function getGameConfig( roomId: string, - gameName: T, + gameName: T ): Promise { // Query the room_game_configs table for this specific room+game const configRow = await db.query.roomGameConfigs.findFirst({ where: and( eq(schema.roomGameConfigs.roomId, roomId), - eq(schema.roomGameConfigs.gameName, gameName), + eq(schema.roomGameConfigs.gameName, gameName) ), - }); + }) // If no config exists, return defaults if (!configRow) { - return getDefaultGameConfig(gameName) as GameConfigByName[T]; + return getDefaultGameConfig(gameName) as GameConfigByName[T] } // Merge saved config with defaults to handle missing fields - const defaults = getDefaultGameConfig(gameName); + const defaults = getDefaultGameConfig(gameName) return { ...defaults, ...(configRow.config as object), - } as GameConfigByName[T]; + } as GameConfigByName[T] } /** @@ -101,32 +99,32 @@ export async function getGameConfig( export async function setGameConfig( roomId: string, gameName: T, - config: Partial, + config: Partial ): Promise { - const now = new Date(); + const now = new Date() // Check if config already exists const existing = await db.query.roomGameConfigs.findFirst({ where: and( eq(schema.roomGameConfigs.roomId, roomId), - eq(schema.roomGameConfigs.gameName, gameName), + eq(schema.roomGameConfigs.gameName, gameName) ), - }); + }) if (existing) { // Update existing config (merge with existing values) - const mergedConfig = { ...(existing.config as object), ...config }; + const mergedConfig = { ...(existing.config as object), ...config } await db .update(schema.roomGameConfigs) .set({ config: mergedConfig as any, updatedAt: now, }) - .where(eq(schema.roomGameConfigs.id, existing.id)); + .where(eq(schema.roomGameConfigs.id, existing.id)) } else { // Insert new config (merge with defaults) - const defaults = getDefaultGameConfig(gameName); - const mergedConfig = { ...defaults, ...config }; + const defaults = getDefaultGameConfig(gameName) + const mergedConfig = { ...defaults, ...config } await db.insert(schema.roomGameConfigs).values({ id: createId(), @@ -135,10 +133,10 @@ export async function setGameConfig( config: mergedConfig as any, createdAt: now, updatedAt: now, - }); + }) } - console.log(`[GameConfig] Updated ${gameName} config for room ${roomId}`); + console.log(`[GameConfig] Updated ${gameName} config for room ${roomId}`) } /** @@ -148,36 +146,25 @@ export async function setGameConfig( export async function updateGameConfigField< T extends ExtendedGameName, K extends keyof GameConfigByName[T], ->( - roomId: string, - gameName: T, - field: K, - value: GameConfigByName[T][K], -): Promise { +>(roomId: string, gameName: T, field: K, value: GameConfigByName[T][K]): Promise { // Create a partial config with just the field being updated - const partialConfig: Partial = {} as any; - (partialConfig as any)[field] = value; - await setGameConfig(roomId, gameName, partialConfig); + const partialConfig: Partial = {} as any + ;(partialConfig as any)[field] = value + await setGameConfig(roomId, gameName, partialConfig) } /** * Delete a game's config from the database * Useful when clearing game selection or cleaning up */ -export async function deleteGameConfig( - roomId: string, - gameName: ExtendedGameName, -): Promise { +export async function deleteGameConfig(roomId: string, gameName: ExtendedGameName): Promise { await db .delete(schema.roomGameConfigs) .where( - and( - eq(schema.roomGameConfigs.roomId, roomId), - eq(schema.roomGameConfigs.gameName, gameName), - ), - ); + and(eq(schema.roomGameConfigs.roomId, roomId), eq(schema.roomGameConfigs.gameName, gameName)) + ) - console.log(`[GameConfig] Deleted ${gameName} config for room ${roomId}`); + console.log(`[GameConfig] Deleted ${gameName} config for room ${roomId}`) } /** @@ -185,18 +172,18 @@ export async function deleteGameConfig( * Returns a map of gameName -> config */ export async function getAllGameConfigs( - roomId: string, + roomId: string ): Promise>> { const configs = await db.query.roomGameConfigs.findMany({ where: eq(schema.roomGameConfigs.roomId, roomId), - }); + }) - const result: Partial> = {}; + const result: Partial> = {} for (const config of configs) { - result[config.gameName as ExtendedGameName] = config.config; + result[config.gameName as ExtendedGameName] = config.config } - return result; + return result } /** @@ -204,10 +191,8 @@ export async function getAllGameConfigs( * Called when deleting a room (cascade should handle this, but useful for explicit cleanup) */ export async function deleteAllGameConfigs(roomId: string): Promise { - await db - .delete(schema.roomGameConfigs) - .where(eq(schema.roomGameConfigs.roomId, roomId)); - console.log(`[GameConfig] Deleted all configs for room ${roomId}`); + await db.delete(schema.roomGameConfigs).where(eq(schema.roomGameConfigs.roomId, roomId)) + console.log(`[GameConfig] Deleted all configs for room ${roomId}`) } /** @@ -217,51 +202,46 @@ export async function deleteAllGameConfigs(roomId: string): Promise { * NEW: Uses game registry validation functions instead of switch statements. * Games now own their own validation logic! */ -export function validateGameConfig( - gameName: ExtendedGameName, - config: any, -): boolean { +export function validateGameConfig(gameName: ExtendedGameName, config: any): boolean { // Try to get game from registry - const game = getGame(gameName); + const game = getGame(gameName) // If game has a validateConfig function, use it if (game && game.validateConfig) { - return game.validateConfig(config); + return game.validateConfig(config) } // Fallback for legacy games without registry (e.g., complement-race, matching, memory-quiz) switch (gameName) { - case "matching": + case 'matching': return ( - typeof config === "object" && + typeof config === 'object' && config !== null && - ["abacus-numeral", "complement-pairs"].includes(config.gameType) && - typeof config.difficulty === "number" && + ['abacus-numeral', 'complement-pairs'].includes(config.gameType) && + typeof config.difficulty === 'number' && [6, 8, 12, 15].includes(config.difficulty) && - typeof config.turnTimer === "number" && + typeof config.turnTimer === 'number' && config.turnTimer >= 5 && config.turnTimer <= 300 - ); + ) - case "memory-quiz": + case 'memory-quiz': return ( - typeof config === "object" && + typeof config === 'object' && config !== null && [2, 5, 8, 12, 15].includes(config.selectedCount) && - typeof config.displayTime === "number" && + typeof config.displayTime === 'number' && config.displayTime > 0 && - ["beginner", "easy", "medium", "hard", "expert"].includes( - config.selectedDifficulty, - ) && - ["cooperative", "competitive"].includes(config.playMode) - ); + ['beginner', 'easy', 'medium', 'hard', 'expert'].includes(config.selectedDifficulty) && + ['cooperative', 'competitive'].includes(config.playMode) + ) - case "complement-race": + case 'complement-race': // TODO: Add validation when complement-race settings are defined - return typeof config === "object" && config !== null; + return typeof config === 'object' && config !== null default: // If no validator found, accept any object - return typeof config === "object" && config !== null; + return typeof config === 'object' && config !== null } } diff --git a/apps/web/src/lib/arcade/game-configs.ts b/apps/web/src/lib/arcade/game-configs.ts index 72275b13..361dab34 100644 --- a/apps/web/src/lib/arcade/game-configs.ts +++ b/apps/web/src/lib/arcade/game-configs.ts @@ -13,19 +13,17 @@ */ // Type-only imports (won't load React components at runtime) -import type { memoryQuizGame } from "@/arcade-games/memory-quiz"; -import type { matchingGame } from "@/arcade-games/matching"; -import type { cardSortingGame } from "@/arcade-games/card-sorting"; -import type { yjsDemoGame } from "@/arcade-games/yjs-demo"; -import type { rithmomachiaGame } from "@/arcade-games/rithmomachia"; +import type { memoryQuizGame } from '@/arcade-games/memory-quiz' +import type { matchingGame } from '@/arcade-games/matching' +import type { cardSortingGame } from '@/arcade-games/card-sorting' +import type { yjsDemoGame } from '@/arcade-games/yjs-demo' +import type { rithmomachiaGame } from '@/arcade-games/rithmomachia' /** * Utility type: Extract config type from a game definition * Uses TypeScript's infer keyword to extract the TConfig generic */ -type InferGameConfig = T extends { defaultConfig: infer Config } - ? Config - : never; +type InferGameConfig = T extends { defaultConfig: infer Config } ? Config : never // ============================================================================ // Modern Games (Type Inference from Game Definitions) @@ -35,31 +33,31 @@ type InferGameConfig = T extends { defaultConfig: infer Config } * Configuration for memory-quiz (soroban lightning) game * INFERRED from memoryQuizGame.defaultConfig */ -export type MemoryQuizGameConfig = InferGameConfig; +export type MemoryQuizGameConfig = InferGameConfig /** * Configuration for matching (memory pairs battle) game * INFERRED from matchingGame.defaultConfig */ -export type MatchingGameConfig = InferGameConfig; +export type MatchingGameConfig = InferGameConfig /** * Configuration for card-sorting (pattern recognition) game * INFERRED from cardSortingGame.defaultConfig */ -export type CardSortingGameConfig = InferGameConfig; +export type CardSortingGameConfig = InferGameConfig /** * Configuration for yjs-demo (Yjs real-time sync demo) game * INFERRED from yjsDemoGame.defaultConfig */ -export type YjsDemoGameConfig = InferGameConfig; +export type YjsDemoGameConfig = InferGameConfig /** * Configuration for rithmomachia (Battle of Numbers) game * INFERRED from rithmomachiaGame.defaultConfig */ -export type RithmomachiaGameConfig = InferGameConfig; +export type RithmomachiaGameConfig = InferGameConfig // ============================================================================ // Legacy Games (Manual Type Definitions) @@ -72,46 +70,39 @@ export type RithmomachiaGameConfig = InferGameConfig; */ export interface ComplementRaceGameConfig { // Game Style (which mode) - style: "practice" | "sprint" | "survival"; + style: 'practice' | 'sprint' | 'survival' // Question Settings - mode: "friends5" | "friends10" | "mixed"; - complementDisplay: "number" | "abacus" | "random"; + mode: 'friends5' | 'friends10' | 'mixed' + complementDisplay: 'number' | 'abacus' | 'random' // Difficulty - timeoutSetting: - | "preschool" - | "kindergarten" - | "relaxed" - | "slow" - | "normal" - | "fast" - | "expert"; + timeoutSetting: 'preschool' | 'kindergarten' | 'relaxed' | 'slow' | 'normal' | 'fast' | 'expert' // AI Settings - enableAI: boolean; - aiOpponentCount: number; // 0-2 for multiplayer, 2 for single-player + enableAI: boolean + aiOpponentCount: number // 0-2 for multiplayer, 2 for single-player // Multiplayer Settings - maxPlayers: number; // 1-4 + maxPlayers: number // 1-4 // Sprint Mode Specific - routeDuration: number; // seconds per route (default 60) - enablePassengers: boolean; - passengerCount: number; // 6-8 passengers per route - maxConcurrentPassengers: number; // 3 per train + routeDuration: number // seconds per route (default 60) + enablePassengers: boolean + passengerCount: number // 6-8 passengers per route + maxConcurrentPassengers: number // 3 per train // Practice/Survival Mode Specific - raceGoal: number; // questions to win practice mode (default 20) + raceGoal: number // questions to win practice mode (default 20) // Win Conditions - winCondition: "route-based" | "score-based" | "time-based" | "infinite"; - targetScore?: number; // for score-based (e.g., 100) - timeLimit?: number; // for time-based (e.g., 300 seconds) - routeCount?: number; // for route-based (e.g., 3 routes) + winCondition: 'route-based' | 'score-based' | 'time-based' | 'infinite' + targetScore?: number // for score-based (e.g., 100) + timeLimit?: number // for time-based (e.g., 300 seconds) + routeCount?: number // for route-based (e.g., 3 routes) // Index signature to satisfy GameConfig constraint - [key: string]: unknown; + [key: string]: unknown } // ============================================================================ @@ -124,15 +115,15 @@ export interface ComplementRaceGameConfig { */ export type GameConfigByName = { // Modern games (inferred types) - "memory-quiz": MemoryQuizGameConfig; - matching: MatchingGameConfig; - "card-sorting": CardSortingGameConfig; - "yjs-demo": YjsDemoGameConfig; - rithmomachia: RithmomachiaGameConfig; + 'memory-quiz': MemoryQuizGameConfig + matching: MatchingGameConfig + 'card-sorting': CardSortingGameConfig + 'yjs-demo': YjsDemoGameConfig + rithmomachia: RithmomachiaGameConfig // Legacy games (manual types) - "complement-race": ComplementRaceGameConfig; -}; + 'complement-race': ComplementRaceGameConfig +} /** * Room's game configuration object (nested by game name) @@ -141,31 +132,31 @@ export type GameConfigByName = { * AUTO-DERIVED: Adding a game to GameConfigByName automatically adds it here */ export type RoomGameConfig = { - [K in keyof GameConfigByName]?: GameConfigByName[K]; -}; + [K in keyof GameConfigByName]?: GameConfigByName[K] +} /** * Default configurations for each game */ export const DEFAULT_MATCHING_CONFIG: MatchingGameConfig = { - gameType: "abacus-numeral", + gameType: 'abacus-numeral', difficulty: 6, turnTimer: 30, -}; +} export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = { selectedCount: 5, displayTime: 2.0, - selectedDifficulty: "easy", - playMode: "cooperative", -}; + selectedDifficulty: 'easy', + playMode: 'cooperative', +} export const DEFAULT_CARD_SORTING_CONFIG: CardSortingGameConfig = { cardCount: 8, showNumbers: true, timeLimit: null, - gameMode: "solo", -}; + gameMode: 'solo', +} export const DEFAULT_RITHMOMACHIA_CONFIG: RithmomachiaGameConfig = { pointWinEnabled: false, @@ -174,23 +165,23 @@ export const DEFAULT_RITHMOMACHIA_CONFIG: RithmomachiaGameConfig = { fiftyMoveRule: true, allowAnySetOnRecheck: true, timeControlMs: null, -}; +} export const DEFAULT_YIJS_DEMO_CONFIG: YjsDemoGameConfig = { gridSize: 8, duration: 60, -}; +} export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = { // Game style - style: "practice", + style: 'practice', // Question settings - mode: "mixed", - complementDisplay: "random", + mode: 'mixed', + complementDisplay: 'random', // Difficulty - timeoutSetting: "normal", + timeoutSetting: 'normal', // AI settings enableAI: true, @@ -209,8 +200,8 @@ export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = { raceGoal: 20, // Win conditions - winCondition: "infinite", // Sprint mode is infinite by default (Steam Sprint) + winCondition: 'infinite', // Sprint mode is infinite by default (Steam Sprint) routeCount: 3, targetScore: 100, timeLimit: 300, -}; +} diff --git a/apps/web/src/lib/arcade/game-registry.ts b/apps/web/src/lib/arcade/game-registry.ts index 6cd04b48..48ce3f11 100644 --- a/apps/web/src/lib/arcade/game-registry.ts +++ b/apps/web/src/lib/arcade/game-registry.ts @@ -5,19 +5,14 @@ * Games are explicitly registered here after being defined. */ -import type { - GameConfig, - GameDefinition, - GameMove, - GameState, -} from "./game-sdk/types"; +import type { GameConfig, GameDefinition, GameMove, GameState } from './game-sdk/types' /** * Global game registry * Maps game name to game definition * Using `any` for generics to allow different game types */ -const registry = new Map>(); +const registry = new Map>() /** * Register a game in the registry @@ -30,27 +25,27 @@ export function registerGame< TState extends GameState, TMove extends GameMove, >(game: GameDefinition): void { - const { name } = game.manifest; + const { name } = game.manifest if (registry.has(name)) { - throw new Error(`Game "${name}" is already registered`); + throw new Error(`Game "${name}" is already registered`) } // Verify validator is also registered server-side try { - const { hasValidator, getValidator } = require("./validators"); + const { hasValidator, getValidator } = require('./validators') if (!hasValidator(name)) { console.error( `⚠️ Game "${name}" registered but validator not found in server registry!` + - `\n Add to src/lib/arcade/validators.ts to enable multiplayer.`, - ); + `\n Add to src/lib/arcade/validators.ts to enable multiplayer.` + ) } else { - const serverValidator = getValidator(name); + const serverValidator = getValidator(name) if (serverValidator !== game.validator) { console.warn( `⚠️ Game "${name}" has different validator instances (client vs server).` + - `\n This may cause issues. Ensure both use the same import.`, - ); + `\n This may cause issues. Ensure both use the same import.` + ) } } } catch (error) { @@ -58,8 +53,8 @@ export function registerGame< // This is expected - validator registry is isomorphic but check only runs server-side } - registry.set(name, game); - console.log(`✅ Registered game: ${name}`); + registry.set(name, game) + console.log(`✅ Registered game: ${name}`) } /** @@ -68,10 +63,8 @@ export function registerGame< * @param gameName - Internal game identifier * @returns Game definition or undefined if not found */ -export function getGame( - gameName: string, -): GameDefinition | undefined { - return registry.get(gameName); +export function getGame(gameName: string): GameDefinition | undefined { + return registry.get(gameName) } /** @@ -80,7 +73,7 @@ export function getGame( * @returns Array of all game definitions */ export function getAllGames(): GameDefinition[] { - return Array.from(registry.values()); + return Array.from(registry.values()) } /** @@ -89,7 +82,7 @@ export function getAllGames(): GameDefinition[] { * @returns Array of available game definitions */ export function getAvailableGames(): GameDefinition[] { - return getAllGames().filter((game) => game.manifest.available); + return getAllGames().filter((game) => game.manifest.available) } /** @@ -99,30 +92,30 @@ export function getAvailableGames(): GameDefinition[] { * @returns true if game is registered */ export function hasGame(gameName: string): boolean { - return registry.has(gameName); + return registry.has(gameName) } /** * Clear all games from registry (used for testing) */ export function clearRegistry(): void { - registry.clear(); + registry.clear() } // ============================================================================ // Game Registrations // ============================================================================ -import { memoryQuizGame } from "@/arcade-games/memory-quiz"; -import { matchingGame } from "@/arcade-games/matching"; -import { complementRaceGame } from "@/arcade-games/complement-race/index"; -import { cardSortingGame } from "@/arcade-games/card-sorting"; -import { yjsDemoGame } from "@/arcade-games/yjs-demo"; -import { rithmomachiaGame } from "@/arcade-games/rithmomachia"; +import { memoryQuizGame } from '@/arcade-games/memory-quiz' +import { matchingGame } from '@/arcade-games/matching' +import { complementRaceGame } from '@/arcade-games/complement-race/index' +import { cardSortingGame } from '@/arcade-games/card-sorting' +import { yjsDemoGame } from '@/arcade-games/yjs-demo' +import { rithmomachiaGame } from '@/arcade-games/rithmomachia' -registerGame(memoryQuizGame); -registerGame(matchingGame); -registerGame(complementRaceGame); -registerGame(cardSortingGame); -registerGame(yjsDemoGame); -registerGame(rithmomachiaGame); +registerGame(memoryQuizGame) +registerGame(matchingGame) +registerGame(complementRaceGame) +registerGame(cardSortingGame) +registerGame(yjsDemoGame) +registerGame(rithmomachiaGame) diff --git a/apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx b/apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx index a2d447e6..ee82af52 100644 --- a/apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx +++ b/apps/web/src/lib/arcade/game-sdk/GameErrorBoundary.tsx @@ -5,35 +5,35 @@ * instead of crashing the entire app. */ -"use client"; +'use client' -import { Component, type ReactNode } from "react"; +import { Component, type ReactNode } from 'react' interface Props { - children: ReactNode; - gameName?: string; + children: ReactNode + gameName?: string } interface State { - hasError: boolean; - error?: Error; + hasError: boolean + error?: Error } export class GameErrorBoundary extends Component { constructor(props: Props) { - super(props); - this.state = { hasError: false }; + super(props) + this.state = { hasError: false } } static getDerivedStateFromError(error: Error): State { return { hasError: true, error, - }; + } } componentDidCatch(error: Error, errorInfo: unknown) { - console.error("Game error:", error, errorInfo); + console.error('Game error:', error, errorInfo) } render() { @@ -41,59 +41,59 @@ export class GameErrorBoundary extends Component { return (
⚠️

Game Error

{this.props.gameName ? `There was an error loading the game "${this.props.gameName}".` - : "There was an error loading the game."} + : 'There was an error loading the game.'}

{this.state.error && (
               {this.state.error.message}
@@ -102,23 +102,23 @@ export class GameErrorBoundary extends Component {
           
         
- ); + ) } - return this.props.children; + return this.props.children } } diff --git a/apps/web/src/lib/arcade/game-sdk/define-game.ts b/apps/web/src/lib/arcade/game-sdk/define-game.ts index 016b2129..58491fd1 100644 --- a/apps/web/src/lib/arcade/game-sdk/define-game.ts +++ b/apps/web/src/lib/arcade/game-sdk/define-game.ts @@ -11,8 +11,8 @@ import type { GameProviderComponent, GameState, GameValidator, -} from "./types"; -import type { GameManifest } from "../manifest-schema"; +} from './types' +import type { GameManifest } from '../manifest-schema' /** * Options for defining a game @@ -23,22 +23,22 @@ export interface DefineGameOptions< TMove extends GameMove, > { /** Game manifest (loaded from game.yaml) */ - manifest: GameManifest; + manifest: GameManifest /** React provider component */ - Provider: GameProviderComponent; + Provider: GameProviderComponent /** Main game UI component */ - GameComponent: GameComponent; + GameComponent: GameComponent /** Server-side validator */ - validator: GameValidator; + validator: GameValidator /** Default configuration for the game */ - defaultConfig: TConfig; + defaultConfig: TConfig /** Optional: Runtime config validation function */ - validateConfig?: (config: unknown) => config is TConfig; + validateConfig?: (config: unknown) => config is TConfig } /** @@ -65,21 +65,12 @@ export function defineGame< TConfig extends GameConfig, TState extends GameState, TMove extends GameMove, ->( - options: DefineGameOptions, -): GameDefinition { - const { - manifest, - Provider, - GameComponent, - validator, - defaultConfig, - validateConfig, - } = options; +>(options: DefineGameOptions): GameDefinition { + const { manifest, Provider, GameComponent, validator, defaultConfig, validateConfig } = options // Validate that manifest.name matches the game identifier if (!manifest.name) { - throw new Error('Game manifest must have a "name" field'); + throw new Error('Game manifest must have a "name" field') } return { @@ -89,5 +80,5 @@ export function defineGame< validator, defaultConfig, validateConfig, - }; + } } diff --git a/apps/web/src/lib/arcade/game-sdk/index.ts b/apps/web/src/lib/arcade/game-sdk/index.ts index 14a3af66..8468b742 100644 --- a/apps/web/src/lib/arcade/game-sdk/index.ts +++ b/apps/web/src/lib/arcade/game-sdk/index.ts @@ -29,11 +29,11 @@ export type { ValidationContext, ValidationResult, TeamMoveSentinel, -} from "./types"; +} from './types' -export { TEAM_MOVE } from "./types"; +export { TEAM_MOVE } from './types' -export type { GameManifest } from "../manifest-schema"; +export type { GameManifest } from '../manifest-schema' // ============================================================================ // React Hooks (Controlled API) @@ -43,22 +43,22 @@ export type { GameManifest } from "../manifest-schema"; * Arcade session management hook * Handles state synchronization, move validation, and multiplayer sync */ -export { useArcadeSession } from "@/hooks/useArcadeSession"; +export { useArcadeSession } from '@/hooks/useArcadeSession' /** * Room data hook - access current room information */ -export { useRoomData, useUpdateGameConfig } from "@/hooks/useRoomData"; +export { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData' /** * Game mode context - access players and game mode */ -export { useGameMode } from "@/contexts/GameModeContext"; +export { useGameMode } from '@/contexts/GameModeContext' /** * Viewer ID hook - get current user's ID */ -export { useViewerId } from "@/hooks/useViewerId"; +export { useViewerId } from '@/hooks/useViewerId' // ============================================================================ // Utilities @@ -70,24 +70,24 @@ export { useViewerId } from "@/hooks/useViewerId"; export { buildPlayerMetadata, buildPlayerOwnershipFromRoomData, -} from "@/lib/arcade/player-ownership.client"; +} from '@/lib/arcade/player-ownership.client' /** * Helper for loading and validating game manifests */ -export { loadManifest } from "./load-manifest"; +export { loadManifest } from './load-manifest' /** * Game definition helper */ -export { defineGame } from "./define-game"; +export { defineGame } from './define-game' /** * Standard color themes for game cards * Use these to ensure consistent appearance across all games */ -export { getGameTheme, GAME_THEMES } from "../game-themes"; -export type { GameTheme, GameThemeName } from "../game-themes"; +export { getGameTheme, GAME_THEMES } from '../game-themes' +export type { GameTheme, GameThemeName } from '../game-themes' // ============================================================================ // Re-exports for convenience @@ -96,4 +96,4 @@ export type { GameTheme, GameThemeName } from "../game-themes"; /** * Common types from contexts */ -export type { Player } from "@/contexts/GameModeContext"; +export type { Player } from '@/contexts/GameModeContext' diff --git a/apps/web/src/lib/arcade/game-sdk/load-manifest.ts b/apps/web/src/lib/arcade/game-sdk/load-manifest.ts index d5efff9b..c59ac7a8 100644 --- a/apps/web/src/lib/arcade/game-sdk/load-manifest.ts +++ b/apps/web/src/lib/arcade/game-sdk/load-manifest.ts @@ -2,10 +2,10 @@ * Manifest loading and validation utilities */ -import yaml from "js-yaml"; -import { readFileSync } from "fs"; -import { join } from "path"; -import { validateManifest, type GameManifest } from "../manifest-schema"; +import yaml from 'js-yaml' +import { readFileSync } from 'fs' +import { join } from 'path' +import { validateManifest, type GameManifest } from '../manifest-schema' /** * Load and validate a game manifest from a YAML file @@ -16,16 +16,14 @@ import { validateManifest, type GameManifest } from "../manifest-schema"; */ export function loadManifest(manifestPath: string): GameManifest { try { - const fileContents = readFileSync(manifestPath, "utf8"); - const data = yaml.load(fileContents); - return validateManifest(data); + const fileContents = readFileSync(manifestPath, 'utf8') + const data = yaml.load(fileContents) + return validateManifest(data) } catch (error) { if (error instanceof Error) { - throw new Error( - `Failed to load manifest from ${manifestPath}: ${error.message}`, - ); + throw new Error(`Failed to load manifest from ${manifestPath}: ${error.message}`) } - throw error; + throw error } } @@ -36,6 +34,6 @@ export function loadManifest(manifestPath: string): GameManifest { * @returns Validated GameManifest object */ export function loadManifestFromDir(gameDir: string): GameManifest { - const manifestPath = join(gameDir, "game.yaml"); - return loadManifest(manifestPath); + const manifestPath = join(gameDir, 'game.yaml') + return loadManifest(manifestPath) } diff --git a/apps/web/src/lib/arcade/game-sdk/types.ts b/apps/web/src/lib/arcade/game-sdk/types.ts index 647c3f68..7576a070 100644 --- a/apps/web/src/lib/arcade/game-sdk/types.ts +++ b/apps/web/src/lib/arcade/game-sdk/types.ts @@ -3,12 +3,9 @@ * These types define the contract that all games must implement */ -import type { ReactNode } from "react"; -import type { GameManifest } from "../manifest-schema"; -import type { - GameMove as BaseGameMove, - GameValidator, -} from "../validation/types"; +import type { ReactNode } from 'react' +import type { GameManifest } from '../manifest-schema' +import type { GameMove as BaseGameMove, GameValidator } from '../validation/types' /** * Re-export base validation types from arcade system @@ -18,35 +15,33 @@ export type { GameValidator, ValidationContext, ValidationResult, -} from "../validation/types"; -export { TEAM_MOVE } from "../validation/types"; -export type { TeamMoveSentinel } from "../validation/types"; +} from '../validation/types' +export { TEAM_MOVE } from '../validation/types' +export type { TeamMoveSentinel } from '../validation/types' /** * Generic game configuration * Each game defines its own specific config type */ -export type GameConfig = Record; +export type GameConfig = Record /** * Generic game state * Each game defines its own specific state type */ -export type GameState = Record; +export type GameState = Record /** * Provider component interface * Each game provides a React context provider that wraps the game UI */ -export type GameProviderComponent = (props: { - children: ReactNode; -}) => JSX.Element; +export type GameProviderComponent = (props: { children: ReactNode }) => JSX.Element /** * Main game component interface * The root component that renders the game UI */ -export type GameComponent = () => JSX.Element; +export type GameComponent = () => JSX.Element /** * Complete game definition @@ -58,19 +53,19 @@ export interface GameDefinition< TMove extends BaseGameMove = BaseGameMove, > { /** Parsed and validated manifest */ - manifest: GameManifest; + manifest: GameManifest /** React provider component */ - Provider: GameProviderComponent; + Provider: GameProviderComponent /** Main game UI component */ - GameComponent: GameComponent; + GameComponent: GameComponent /** Server-side validator */ - validator: GameValidator; + validator: GameValidator /** Default configuration */ - defaultConfig: TConfig; + defaultConfig: TConfig /** * Validate a config object at runtime @@ -79,5 +74,5 @@ export interface GameDefinition< * @param config - Configuration object to validate * @returns true if valid, false otherwise */ - validateConfig?: (config: unknown) => config is TConfig; + validateConfig?: (config: unknown) => config is TConfig } diff --git a/apps/web/src/lib/arcade/game-themes.ts b/apps/web/src/lib/arcade/game-themes.ts index d33fe304..ff053f0d 100644 --- a/apps/web/src/lib/arcade/game-themes.ts +++ b/apps/web/src/lib/arcade/game-themes.ts @@ -8,9 +8,9 @@ */ export interface GameTheme { - color: string; - gradient: string; - borderColor: string; + color: string + gradient: string + borderColor: string } /** @@ -19,58 +19,58 @@ export interface GameTheme { */ export const GAME_THEMES = { blue: { - color: "blue", - gradient: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)", // Vibrant cyan - borderColor: "#00f2fe", + color: 'blue', + gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', // Vibrant cyan + borderColor: '#00f2fe', }, purple: { - color: "purple", - gradient: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", // Vibrant purple - borderColor: "#764ba2", + color: 'purple', + gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', // Vibrant purple + borderColor: '#764ba2', }, green: { - color: "green", - gradient: "linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)", // Vibrant green/teal - borderColor: "#38f9d7", + color: 'green', + gradient: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', // Vibrant green/teal + borderColor: '#38f9d7', }, teal: { - color: "teal", - gradient: "linear-gradient(135deg, #11998e 0%, #38ef7d 100%)", // Vibrant teal - borderColor: "#38ef7d", + color: 'teal', + gradient: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)', // Vibrant teal + borderColor: '#38ef7d', }, indigo: { - color: "indigo", - gradient: "linear-gradient(135deg, #5f72bd 0%, #9b23ea 100%)", // Vibrant indigo - borderColor: "#9b23ea", + color: 'indigo', + gradient: 'linear-gradient(135deg, #5f72bd 0%, #9b23ea 100%)', // Vibrant indigo + borderColor: '#9b23ea', }, pink: { - color: "pink", - gradient: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)", // Vibrant pink - borderColor: "#f5576c", + color: 'pink', + gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', // Vibrant pink + borderColor: '#f5576c', }, orange: { - color: "orange", - gradient: "linear-gradient(135deg, #fa709a 0%, #fee140 100%)", // Vibrant orange/coral - borderColor: "#fee140", + color: 'orange', + gradient: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', // Vibrant orange/coral + borderColor: '#fee140', }, yellow: { - color: "yellow", - gradient: "linear-gradient(135deg, #ffd89b 0%, #19547b 100%)", // Vibrant yellow/blue - borderColor: "#ffd89b", + color: 'yellow', + gradient: 'linear-gradient(135deg, #ffd89b 0%, #19547b 100%)', // Vibrant yellow/blue + borderColor: '#ffd89b', }, red: { - color: "red", - gradient: "linear-gradient(135deg, #f85032 0%, #e73827 100%)", // Vibrant red - borderColor: "#e73827", + color: 'red', + gradient: 'linear-gradient(135deg, #f85032 0%, #e73827 100%)', // Vibrant red + borderColor: '#e73827', }, gray: { - color: "gray", - gradient: "linear-gradient(135deg, #868f96 0%, #596164 100%)", // Vibrant gray - borderColor: "#596164", + color: 'gray', + gradient: 'linear-gradient(135deg, #868f96 0%, #596164 100%)', // Vibrant gray + borderColor: '#596164', }, -} as const satisfies Record; +} as const satisfies Record -export type GameThemeName = keyof typeof GAME_THEMES; +export type GameThemeName = keyof typeof GAME_THEMES /** * Get a standard theme by name @@ -84,5 +84,5 @@ export type GameThemeName = keyof typeof GAME_THEMES; * } */ export function getGameTheme(themeName: GameThemeName): GameTheme { - return GAME_THEMES[themeName]; + return GAME_THEMES[themeName] } diff --git a/apps/web/src/lib/arcade/manifest-schema.ts b/apps/web/src/lib/arcade/manifest-schema.ts index c6fe1249..a3594c3a 100644 --- a/apps/web/src/lib/arcade/manifest-schema.ts +++ b/apps/web/src/lib/arcade/manifest-schema.ts @@ -3,44 +3,36 @@ * Validates game.yaml files using Zod */ -import { z } from "zod"; +import { z } from 'zod' /** * Schema for game manifest (game.yaml) */ export const GameManifestSchema = z.object({ - name: z - .string() - .min(1) - .describe('Internal game identifier (e.g., "matching")'), - displayName: z.string().min(1).describe("Display name shown to users"), - icon: z.string().min(1).describe("Emoji icon for the game"), - description: z.string().min(1).describe("Short description"), - longDescription: z.string().min(1).describe("Detailed description"), - maxPlayers: z - .number() - .int() - .min(1) - .max(10) - .describe("Maximum number of players"), + name: z.string().min(1).describe('Internal game identifier (e.g., "matching")'), + displayName: z.string().min(1).describe('Display name shown to users'), + icon: z.string().min(1).describe('Emoji icon for the game'), + description: z.string().min(1).describe('Short description'), + longDescription: z.string().min(1).describe('Detailed description'), + maxPlayers: z.number().int().min(1).max(10).describe('Maximum number of players'), difficulty: z - .enum(["Beginner", "Intermediate", "Advanced", "Expert"]) - .describe("Difficulty level"), - chips: z.array(z.string()).describe("Feature chips displayed on game card"), + .enum(['Beginner', 'Intermediate', 'Advanced', 'Expert']) + .describe('Difficulty level'), + chips: z.array(z.string()).describe('Feature chips displayed on game card'), color: z.string().min(1).describe('Color theme (e.g., "purple")'), - gradient: z.string().min(1).describe("CSS gradient for card background"), + gradient: z.string().min(1).describe('CSS gradient for card background'), borderColor: z.string().min(1).describe('Border color (e.g., "purple.200")'), - available: z.boolean().describe("Whether game is available to play"), -}); + available: z.boolean().describe('Whether game is available to play'), +}) /** * Inferred TypeScript type from schema */ -export type GameManifest = z.infer; +export type GameManifest = z.infer /** * Validate a parsed manifest object */ export function validateManifest(data: unknown): GameManifest { - return GameManifestSchema.parse(data); + return GameManifestSchema.parse(data) } diff --git a/apps/web/src/lib/arcade/player-manager.ts b/apps/web/src/lib/arcade/player-manager.ts index d70119f9..6291deb9 100644 --- a/apps/web/src/lib/arcade/player-manager.ts +++ b/apps/web/src/lib/arcade/player-manager.ts @@ -3,15 +3,15 @@ * Handles fetching and validating player participation in rooms */ -import { and, eq } from "drizzle-orm"; -import { db, schema } from "@/db"; -import type { Player } from "@/db/schema/players"; +import { and, eq } from 'drizzle-orm' +import { db, schema } from '@/db' +import type { Player } from '@/db/schema/players' // Re-export ownership utilities for convenience export { buildPlayerOwnershipMap, type PlayerOwnershipMap, -} from "./player-ownership"; +} from './player-ownership' /** * Get all players for a user (regardless of isActive status) @@ -21,17 +21,17 @@ export async function getAllPlayers(viewerId: string): Promise { // First get the user record by guestId const user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) if (!user) { - return []; + return [] } // Now query all players by the actual user.id (no isActive filter) return await db.query.players.findMany({ where: eq(schema.players.userId, user.id), orderBy: schema.players.createdAt, - }); + }) } /** @@ -43,20 +43,17 @@ export async function getActivePlayers(viewerId: string): Promise { // First get the user record by guestId const user = await db.query.users.findFirst({ where: eq(schema.users.guestId, viewerId), - }); + }) if (!user) { - return []; + return [] } // Now query players by the actual user.id return await db.query.players.findMany({ - where: and( - eq(schema.players.userId, user.id), - eq(schema.players.isActive, true), - ), + where: and(eq(schema.players.userId, user.id), eq(schema.players.isActive, true)), orderBy: schema.players.createdAt, - }); + }) } /** @@ -64,22 +61,20 @@ export async function getActivePlayers(viewerId: string): Promise { * Returns only players marked isActive=true from each room member * Returns a map of userId -> Player[] */ -export async function getRoomActivePlayers( - roomId: string, -): Promise> { +export async function getRoomActivePlayers(roomId: string): Promise> { // Get all room members const members = await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.roomId, roomId), - }); + }) // Fetch active players for each member (respects isActive flag) - const playerMap = new Map(); + const playerMap = new Map() for (const member of members) { - const players = await getActivePlayers(member.userId); - playerMap.set(member.userId, players); + const players = await getActivePlayers(member.userId) + playerMap.set(member.userId, players) } - return playerMap; + return playerMap } /** @@ -87,39 +82,33 @@ export async function getRoomActivePlayers( * Flattens the player lists from all room members */ export async function getRoomPlayerIds(roomId: string): Promise { - const playerMap = await getRoomActivePlayers(roomId); - const allPlayers: string[] = []; + const playerMap = await getRoomActivePlayers(roomId) + const allPlayers: string[] = [] for (const players of playerMap.values()) { - allPlayers.push(...players.map((p) => p.id)); + allPlayers.push(...players.map((p) => p.id)) } - return allPlayers; + return allPlayers } /** * Validate that a player ID belongs to a user who is a member of a room */ -export async function validatePlayerInRoom( - playerId: string, - roomId: string, -): Promise { +export async function validatePlayerInRoom(playerId: string, roomId: string): Promise { // Get the player const player = await db.query.players.findFirst({ where: eq(schema.players.id, playerId), - }); + }) - if (!player) return false; + if (!player) return false // Check if the player's user is a member of the room const member = await db.query.roomMembers.findFirst({ - where: and( - eq(schema.roomMembers.roomId, roomId), - eq(schema.roomMembers.userId, player.userId), - ), - }); + where: and(eq(schema.roomMembers.roomId, roomId), eq(schema.roomMembers.userId, player.userId)), + }) - return !!member; + return !!member } /** @@ -128,22 +117,22 @@ export async function validatePlayerInRoom( export async function getPlayer(playerId: string): Promise { return await db.query.players.findFirst({ where: eq(schema.players.id, playerId), - }); + }) } /** * Get multiple players by IDs */ export async function getPlayers(playerIds: string[]): Promise { - if (playerIds.length === 0) return []; + if (playerIds.length === 0) return [] - const players: Player[] = []; + const players: Player[] = [] for (const id of playerIds) { - const player = await getPlayer(id); - if (player) players.push(player); + const player = await getPlayer(id) + if (player) players.push(player) } - return players; + return players } /** @@ -154,12 +143,9 @@ export async function getPlayers(playerIds: string[]): Promise { */ export async function setPlayerActiveStatus( playerId: string, - isActive: boolean, + isActive: boolean ): Promise { - await db - .update(schema.players) - .set({ isActive }) - .where(eq(schema.players.id, playerId)); + await db.update(schema.players).set({ isActive }).where(eq(schema.players.id, playerId)) - return await getPlayer(playerId); + return await getPlayer(playerId) } diff --git a/apps/web/src/lib/arcade/player-ownership.client.ts b/apps/web/src/lib/arcade/player-ownership.client.ts index 7376c34e..f10ff6f3 100644 --- a/apps/web/src/lib/arcade/player-ownership.client.ts +++ b/apps/web/src/lib/arcade/player-ownership.client.ts @@ -5,25 +5,25 @@ * from client components without pulling in database code. */ -import type { RoomData } from "@/hooks/useRoomData"; +import type { RoomData } from '@/hooks/useRoomData' /** * Map of player IDs to user IDs * Key: playerId (database player.id) * Value: userId (database user.id) */ -export type PlayerOwnershipMap = Record; +export type PlayerOwnershipMap = Record /** * Player metadata for display purposes * Combines ownership info with display properties */ export interface PlayerMetadata { - id: string; - name: string; - emoji: string; - color: string; - userId: string; + id: string + name: string + emoji: string + color: string + userId: string } // ============================================================================ @@ -46,22 +46,22 @@ export interface PlayerMetadata { * ``` */ export function buildPlayerOwnershipFromRoomData( - roomData: RoomData | null | undefined, + roomData: RoomData | null | undefined ): PlayerOwnershipMap { if (!roomData?.memberPlayers) { - return {}; + return {} } - const ownership: PlayerOwnershipMap = {}; + const ownership: PlayerOwnershipMap = {} // Iterate over each user's players for (const [userId, userPlayers] of Object.entries(roomData.memberPlayers)) { for (const player of userPlayers) { - ownership[player.id] = userId; + ownership[player.id] = userId } } - return ownership; + return ownership } // ============================================================================ @@ -84,9 +84,9 @@ export function buildPlayerOwnershipFromRoomData( export function isPlayerOwnedByUser( playerId: string, userId: string, - ownershipMap: PlayerOwnershipMap, + ownershipMap: PlayerOwnershipMap ): boolean { - return ownershipMap[playerId] === userId; + return ownershipMap[playerId] === userId } /** @@ -106,9 +106,9 @@ export function isPlayerOwnedByUser( */ export function getPlayerOwner( playerId: string, - ownershipMap: PlayerOwnershipMap, + ownershipMap: PlayerOwnershipMap ): string | undefined { - return ownershipMap[playerId]; + return ownershipMap[playerId] } /** @@ -135,15 +135,15 @@ export function buildPlayerMetadata( playerIds: string[], ownershipMap: PlayerOwnershipMap, playersMap: Map, - fallbackUserId?: string, + fallbackUserId?: string ): Record { - const metadata: Record = {}; + const metadata: Record = {} for (const playerId of playerIds) { - const playerData = playersMap.get(playerId); + const playerData = playersMap.get(playerId) if (playerData) { // Get the actual owner userId from ownership map, or use fallback - const ownerUserId = ownershipMap[playerId] || fallbackUserId || ""; + const ownerUserId = ownershipMap[playerId] || fallbackUserId || '' metadata[playerId] = { id: playerId, @@ -151,11 +151,11 @@ export function buildPlayerMetadata( emoji: playerData.emoji, color: playerData.color, userId: ownerUserId, // Correct: Use actual owner's userId - }; + } } } - return metadata; + return metadata } /** @@ -174,9 +174,9 @@ export function buildPlayerMetadata( export function filterPlayersByOwner( playerIds: string[], userId: string, - ownershipMap: PlayerOwnershipMap, + ownershipMap: PlayerOwnershipMap ): string[] { - return playerIds.filter((playerId) => ownershipMap[playerId] === userId); + return playerIds.filter((playerId) => ownershipMap[playerId] === userId) } /** @@ -192,7 +192,7 @@ export function filterPlayersByOwner( * ``` */ export function getUniqueOwners(ownershipMap: PlayerOwnershipMap): string[] { - return Array.from(new Set(Object.values(ownershipMap))); + return Array.from(new Set(Object.values(ownershipMap))) } /** @@ -212,18 +212,18 @@ export function getUniqueOwners(ownershipMap: PlayerOwnershipMap): string[] { */ export function groupPlayersByOwner( playerIds: string[], - ownershipMap: PlayerOwnershipMap, + ownershipMap: PlayerOwnershipMap ): Map { - const groups = new Map(); + const groups = new Map() for (const playerId of playerIds) { - const ownerId = ownershipMap[playerId]; + const ownerId = ownershipMap[playerId] if (ownerId) { - const existing = groups.get(ownerId) || []; - existing.push(playerId); - groups.set(ownerId, existing); + const existing = groups.get(ownerId) || [] + existing.push(playerId) + groups.set(ownerId, existing) } } - return groups; + return groups } diff --git a/apps/web/src/lib/arcade/player-ownership.ts b/apps/web/src/lib/arcade/player-ownership.ts index 4b6cae76..2bc85ae6 100644 --- a/apps/web/src/lib/arcade/player-ownership.ts +++ b/apps/web/src/lib/arcade/player-ownership.ts @@ -5,12 +5,12 @@ * For client-side code, import from player-ownership.client.ts instead. */ -import { eq } from "drizzle-orm"; -import { db, schema } from "@/db"; -import type { PlayerOwnershipMap } from "./player-ownership.client"; +import { eq } from 'drizzle-orm' +import { db, schema } from '@/db' +import type { PlayerOwnershipMap } from './player-ownership.client' // Re-export all client-safe utilities -export * from "./player-ownership.client"; +export * from './player-ownership.client' // ============================================================================ // SERVER-SIDE UTILITIES (async, database-backed) @@ -34,33 +34,31 @@ export * from "./player-ownership.client"; * const roomOwnership = await buildPlayerOwnershipMap('room-123') * ``` */ -export async function buildPlayerOwnershipMap( - roomId?: string, -): Promise { - let players: Array<{ id: string; userId: string }>; +export async function buildPlayerOwnershipMap(roomId?: string): Promise { + let players: Array<{ id: string; userId: string }> if (roomId) { // Fetch players who belong to users that are members of this room const members = await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.roomId, roomId), columns: { userId: true }, - }); + }) - const memberUserIds = members.map((m) => m.userId); + const memberUserIds = members.map((m) => m.userId) // Fetch all players belonging to room members players = await db.query.players.findMany({ columns: { id: true, userId: true }, - }); + }) // Filter to only players owned by room members - players = players.filter((p) => memberUserIds.includes(p.userId)); + players = players.filter((p) => memberUserIds.includes(p.userId)) } else { // Fetch all players players = await db.query.players.findMany({ columns: { id: true, userId: true }, - }); + }) } - return Object.fromEntries(players.map((p) => [p.id, p.userId])); + return Object.fromEntries(players.map((p) => [p.id, p.userId])) } diff --git a/apps/web/src/lib/arcade/room-code.ts b/apps/web/src/lib/arcade/room-code.ts index ef9b7f2c..83f48a8f 100644 --- a/apps/web/src/lib/arcade/room-code.ts +++ b/apps/web/src/lib/arcade/room-code.ts @@ -3,33 +3,33 @@ * Generates short, memorable codes for joining rooms */ -const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Removed ambiguous chars: 0,O,1,I -const CODE_LENGTH = 6; +const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Removed ambiguous chars: 0,O,1,I +const CODE_LENGTH = 6 /** * Generate a random 6-character room code * Format: ABC123 (uppercase letters + numbers, no ambiguous chars) */ export function generateRoomCode(): string { - let code = ""; + let code = '' for (let i = 0; i < CODE_LENGTH; i++) { - const randomIndex = Math.floor(Math.random() * CHARS.length); - code += CHARS[randomIndex]; + const randomIndex = Math.floor(Math.random() * CHARS.length) + code += CHARS[randomIndex] } - return code; + return code } /** * Validate a room code format */ export function isValidRoomCode(code: string): boolean { - if (code.length !== CODE_LENGTH) return false; - return code.split("").every((char) => CHARS.includes(char)); + if (code.length !== CODE_LENGTH) return false + return code.split('').every((char) => CHARS.includes(char)) } /** * Normalize a room code (uppercase, remove spaces/dashes) */ export function normalizeRoomCode(code: string): string { - return code.toUpperCase().replace(/[\s-]/g, ""); + return code.toUpperCase().replace(/[\s-]/g, '') } diff --git a/apps/web/src/lib/arcade/room-invitations.ts b/apps/web/src/lib/arcade/room-invitations.ts index 1ccaa40b..cdf6b353 100644 --- a/apps/web/src/lib/arcade/room-invitations.ts +++ b/apps/web/src/lib/arcade/room-invitations.ts @@ -3,18 +3,18 @@ * Handles invitation logic for room members */ -import { and, eq } from "drizzle-orm"; -import { db, schema } from "@/db"; +import { and, eq } from 'drizzle-orm' +import { db, schema } from '@/db' export interface CreateInvitationParams { - roomId: string; - userId: string; - userName: string; - invitedBy: string; - invitedByName: string; - invitationType: "manual" | "auto-unban" | "auto-create"; - message?: string; - expiresAt?: Date; + roomId: string + userId: string + userName: string + invitedBy: string + invitedByName: string + invitationType: 'manual' | 'auto-unban' | 'auto-create' + message?: string + expiresAt?: Date } /** @@ -22,9 +22,9 @@ export interface CreateInvitationParams { * If a pending invitation exists, it will be replaced */ export async function createInvitation( - params: CreateInvitationParams, + params: CreateInvitationParams ): Promise { - const now = new Date(); + const now = new Date() // Check if there's an existing invitation const existing = await db @@ -33,10 +33,10 @@ export async function createInvitation( .where( and( eq(schema.roomInvitations.roomId, params.roomId), - eq(schema.roomInvitations.userId, params.userId), - ), + eq(schema.roomInvitations.userId, params.userId) + ) ) - .limit(1); + .limit(1) if (existing.length > 0) { // Update existing invitation @@ -48,20 +48,20 @@ export async function createInvitation( invitedByName: params.invitedByName, invitationType: params.invitationType, message: params.message, - status: "pending", // Reset to pending + status: 'pending', // Reset to pending createdAt: now, // Update timestamp respondedAt: null, expiresAt: params.expiresAt, }) .where(eq(schema.roomInvitations.id, existing[0].id)) - .returning(); + .returning() - console.log("[Room Invitations] Updated invitation:", { + console.log('[Room Invitations] Updated invitation:', { userId: params.userId, roomId: params.roomId, - }); + }) - return updated; + return updated } // Create new invitation @@ -75,108 +75,91 @@ export async function createInvitation( invitedByName: params.invitedByName, invitationType: params.invitationType, message: params.message, - status: "pending", + status: 'pending', createdAt: now, expiresAt: params.expiresAt, }) - .returning(); + .returning() - console.log("[Room Invitations] Created invitation:", { + console.log('[Room Invitations] Created invitation:', { userId: params.userId, roomId: params.roomId, - }); + }) - return invitation; + return invitation } /** * Get all pending invitations for a user */ -export async function getUserPendingInvitations( - userId: string, -): Promise { +export async function getUserPendingInvitations(userId: string): Promise { return await db .select() .from(schema.roomInvitations) .where( - and( - eq(schema.roomInvitations.userId, userId), - eq(schema.roomInvitations.status, "pending"), - ), + and(eq(schema.roomInvitations.userId, userId), eq(schema.roomInvitations.status, 'pending')) ) - .orderBy(schema.roomInvitations.createdAt); + .orderBy(schema.roomInvitations.createdAt) } /** * Get all invitations for a room */ -export async function getRoomInvitations( - roomId: string, -): Promise { +export async function getRoomInvitations(roomId: string): Promise { return await db .select() .from(schema.roomInvitations) .where(eq(schema.roomInvitations.roomId, roomId)) - .orderBy(schema.roomInvitations.createdAt); + .orderBy(schema.roomInvitations.createdAt) } /** * Accept an invitation */ -export async function acceptInvitation( - invitationId: string, -): Promise { +export async function acceptInvitation(invitationId: string): Promise { const [invitation] = await db .update(schema.roomInvitations) .set({ - status: "accepted", + status: 'accepted', respondedAt: new Date(), }) .where(eq(schema.roomInvitations.id, invitationId)) - .returning(); + .returning() - console.log("[Room Invitations] Accepted invitation:", invitationId); + console.log('[Room Invitations] Accepted invitation:', invitationId) - return invitation; + return invitation } /** * Decline an invitation */ -export async function declineInvitation( - invitationId: string, -): Promise { +export async function declineInvitation(invitationId: string): Promise { const [invitation] = await db .update(schema.roomInvitations) .set({ - status: "declined", + status: 'declined', respondedAt: new Date(), }) .where(eq(schema.roomInvitations.id, invitationId)) - .returning(); + .returning() - console.log("[Room Invitations] Declined invitation:", invitationId); + console.log('[Room Invitations] Declined invitation:', invitationId) - return invitation; + return invitation } /** * Cancel/delete an invitation */ -export async function cancelInvitation( - roomId: string, - userId: string, -): Promise { +export async function cancelInvitation(roomId: string, userId: string): Promise { await db .delete(schema.roomInvitations) .where( - and( - eq(schema.roomInvitations.roomId, roomId), - eq(schema.roomInvitations.userId, userId), - ), - ); + and(eq(schema.roomInvitations.roomId, roomId), eq(schema.roomInvitations.userId, userId)) + ) - console.log("[Room Invitations] Cancelled invitation:", { userId, roomId }); + console.log('[Room Invitations] Cancelled invitation:', { userId, roomId }) } /** @@ -184,18 +167,15 @@ export async function cancelInvitation( */ export async function getInvitation( roomId: string, - userId: string, + userId: string ): Promise { const results = await db .select() .from(schema.roomInvitations) .where( - and( - eq(schema.roomInvitations.roomId, roomId), - eq(schema.roomInvitations.userId, userId), - ), + and(eq(schema.roomInvitations.roomId, roomId), eq(schema.roomInvitations.userId, userId)) ) - .limit(1); + .limit(1) - return results[0]; + return results[0] } diff --git a/apps/web/src/lib/arcade/room-join-requests.ts b/apps/web/src/lib/arcade/room-join-requests.ts index 6a130250..9cf6926b 100644 --- a/apps/web/src/lib/arcade/room-join-requests.ts +++ b/apps/web/src/lib/arcade/room-join-requests.ts @@ -3,22 +3,22 @@ * Handles join request logic for approval-only rooms */ -import { and, eq } from "drizzle-orm"; -import { db, schema } from "@/db"; +import { and, eq } from 'drizzle-orm' +import { db, schema } from '@/db' export interface CreateJoinRequestParams { - roomId: string; - userId: string; - userName: string; + roomId: string + userId: string + userName: string } /** * Create a join request */ export async function createJoinRequest( - params: CreateJoinRequestParams, + params: CreateJoinRequestParams ): Promise { - const now = new Date(); + const now = new Date() // Check if there's an existing request const existing = await db @@ -27,10 +27,10 @@ export async function createJoinRequest( .where( and( eq(schema.roomJoinRequests.roomId, params.roomId), - eq(schema.roomJoinRequests.userId, params.userId), - ), + eq(schema.roomJoinRequests.userId, params.userId) + ) ) - .limit(1); + .limit(1) if (existing.length > 0) { // Update existing request (reset to pending) @@ -38,16 +38,16 @@ export async function createJoinRequest( .update(schema.roomJoinRequests) .set({ userName: params.userName, - status: "pending", + status: 'pending', requestedAt: now, reviewedAt: null, reviewedBy: null, reviewedByName: null, }) .where(eq(schema.roomJoinRequests.id, existing[0].id)) - .returning(); + .returning() - return updated; + return updated } // Create new request @@ -57,43 +57,36 @@ export async function createJoinRequest( roomId: params.roomId, userId: params.userId, userName: params.userName, - status: "pending", + status: 'pending', requestedAt: now, }) - .returning(); + .returning() - return request; + return request } /** * Get all pending join requests for a room */ -export async function getPendingJoinRequests( - roomId: string, -): Promise { +export async function getPendingJoinRequests(roomId: string): Promise { return await db .select() .from(schema.roomJoinRequests) .where( - and( - eq(schema.roomJoinRequests.roomId, roomId), - eq(schema.roomJoinRequests.status, "pending"), - ), + and(eq(schema.roomJoinRequests.roomId, roomId), eq(schema.roomJoinRequests.status, 'pending')) ) - .orderBy(schema.roomJoinRequests.requestedAt); + .orderBy(schema.roomJoinRequests.requestedAt) } /** * Get all join requests for a room (any status) */ -export async function getAllJoinRequests( - roomId: string, -): Promise { +export async function getAllJoinRequests(roomId: string): Promise { return await db .select() .from(schema.roomJoinRequests) .where(eq(schema.roomJoinRequests.roomId, roomId)) - .orderBy(schema.roomJoinRequests.requestedAt); + .orderBy(schema.roomJoinRequests.requestedAt) } /** @@ -102,20 +95,20 @@ export async function getAllJoinRequests( export async function approveJoinRequest( requestId: string, reviewedBy: string, - reviewedByName: string, + reviewedByName: string ): Promise { const [request] = await db .update(schema.roomJoinRequests) .set({ - status: "approved", + status: 'approved', reviewedAt: new Date(), reviewedBy, reviewedByName, }) .where(eq(schema.roomJoinRequests.id, requestId)) - .returning(); + .returning() - return request; + return request } /** @@ -124,20 +117,20 @@ export async function approveJoinRequest( export async function denyJoinRequest( requestId: string, reviewedBy: string, - reviewedByName: string, + reviewedByName: string ): Promise { const [request] = await db .update(schema.roomJoinRequests) .set({ - status: "denied", + status: 'denied', reviewedAt: new Date(), reviewedBy, reviewedByName, }) .where(eq(schema.roomJoinRequests.id, requestId)) - .returning(); + .returning() - return request; + return request } /** @@ -145,18 +138,15 @@ export async function denyJoinRequest( */ export async function getJoinRequest( roomId: string, - userId: string, + userId: string ): Promise { const results = await db .select() .from(schema.roomJoinRequests) .where( - and( - eq(schema.roomJoinRequests.roomId, roomId), - eq(schema.roomJoinRequests.userId, userId), - ), + and(eq(schema.roomJoinRequests.roomId, roomId), eq(schema.roomJoinRequests.userId, userId)) ) - .limit(1); + .limit(1) - return results[0]; + return results[0] } diff --git a/apps/web/src/lib/arcade/room-manager.ts b/apps/web/src/lib/arcade/room-manager.ts index 8c9abe0b..2b070858 100644 --- a/apps/web/src/lib/arcade/room-manager.ts +++ b/apps/web/src/lib/arcade/room-manager.ts @@ -3,58 +3,50 @@ * Handles database operations for arcade rooms */ -import { and, desc, eq, or } from "drizzle-orm"; -import { db, schema } from "@/db"; -import { generateRoomCode } from "./room-code"; -import type { GameName } from "./validation"; +import { and, desc, eq, or } from 'drizzle-orm' +import { db, schema } from '@/db' +import { generateRoomCode } from './room-code' +import type { GameName } from './validation' export interface CreateRoomOptions { - name: string | null; - createdBy: string; // User/guest ID - creatorName: string; - gameName: GameName; - gameConfig: unknown; - ttlMinutes?: number; // Default: 60 - accessMode?: - | "open" - | "password" - | "approval-only" - | "restricted" - | "locked" - | "retired"; - password?: string; + name: string | null + createdBy: string // User/guest ID + creatorName: string + gameName: GameName + gameConfig: unknown + ttlMinutes?: number // Default: 60 + accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired' + password?: string } export interface UpdateRoomOptions { - name?: string; - status?: "lobby" | "playing" | "finished"; - currentSessionId?: string | null; - totalGamesPlayed?: number; + name?: string + status?: 'lobby' | 'playing' | 'finished' + currentSessionId?: string | null + totalGamesPlayed?: number } /** * Create a new arcade room * Generates a unique room code and creates the room in the database */ -export async function createRoom( - options: CreateRoomOptions, -): Promise { - const now = new Date(); +export async function createRoom(options: CreateRoomOptions): Promise { + const now = new Date() // Generate unique room code (retry up to 5 times if collision) - let code = generateRoomCode(); - let attempts = 0; - const MAX_ATTEMPTS = 5; + let code = generateRoomCode() + let attempts = 0 + const MAX_ATTEMPTS = 5 while (attempts < MAX_ATTEMPTS) { - const existing = await getRoomByCode(code); - if (!existing) break; - code = generateRoomCode(); - attempts++; + const existing = await getRoomByCode(code) + if (!existing) break + code = generateRoomCode() + attempts++ } if (attempts === MAX_ATTEMPTS) { - throw new Error("Failed to generate unique room code"); + throw new Error('Failed to generate unique room code') } const newRoom: schema.NewArcadeRoom = { @@ -65,43 +57,36 @@ export async function createRoom( createdAt: now, lastActivity: now, ttlMinutes: options.ttlMinutes || 60, - accessMode: options.accessMode || "open", // Default to open access + accessMode: options.accessMode || 'open', // Default to open access password: options.password || null, gameName: options.gameName, gameConfig: options.gameConfig as any, - status: "lobby", + status: 'lobby', currentSessionId: null, totalGamesPlayed: 0, - }; + } - const [room] = await db - .insert(schema.arcadeRooms) - .values(newRoom) - .returning(); - console.log("[Room Manager] Created room:", room.id, "code:", room.code); - return room; + const [room] = await db.insert(schema.arcadeRooms).values(newRoom).returning() + console.log('[Room Manager] Created room:', room.id, 'code:', room.code) + return room } /** * Get a room by ID */ -export async function getRoomById( - roomId: string, -): Promise { +export async function getRoomById(roomId: string): Promise { return await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.id, roomId), - }); + }) } /** * Get a room by code */ -export async function getRoomByCode( - code: string, -): Promise { +export async function getRoomByCode(code: string): Promise { return await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.code, code.toUpperCase()), - }); + }) } /** @@ -109,23 +94,23 @@ export async function getRoomByCode( */ export async function updateRoom( roomId: string, - updates: UpdateRoomOptions, + updates: UpdateRoomOptions ): Promise { - const now = new Date(); + const now = new Date() // Always update lastActivity on any room update const updateData = { ...updates, lastActivity: now, - }; + } const [updated] = await db .update(schema.arcadeRooms) .set(updateData) .where(eq(schema.arcadeRooms.id, roomId)) - .returning(); + .returning() - return updated; + return updated } /** @@ -136,7 +121,7 @@ export async function touchRoom(roomId: string): Promise { await db .update(schema.arcadeRooms) .set({ lastActivity: new Date() }) - .where(eq(schema.arcadeRooms.id, roomId)); + .where(eq(schema.arcadeRooms.id, roomId)) } /** @@ -144,8 +129,8 @@ export async function touchRoom(roomId: string): Promise { * Cascade deletes all room members */ export async function deleteRoom(roomId: string): Promise { - await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomId)); - console.log("[Room Manager] Deleted room:", roomId); + await db.delete(schema.arcadeRooms).where(eq(schema.arcadeRooms.id, roomId)) + console.log('[Room Manager] Deleted room:', roomId) } /** @@ -153,34 +138,26 @@ export async function deleteRoom(roomId: string): Promise { * Returns rooms ordered by most recently active * Only returns openly accessible rooms (accessMode: 'open' or 'password') */ -export async function listActiveRooms( - gameName?: GameName, -): Promise { - const whereConditions = []; +export async function listActiveRooms(gameName?: GameName): Promise { + const whereConditions = [] // Filter by game if specified if (gameName) { - whereConditions.push(eq(schema.arcadeRooms.gameName, gameName)); + whereConditions.push(eq(schema.arcadeRooms.gameName, gameName)) } // Only return accessible rooms in lobby or playing status // Exclude locked, retired, restricted, and approval-only rooms whereConditions.push( - or( - eq(schema.arcadeRooms.accessMode, "open"), - eq(schema.arcadeRooms.accessMode, "password"), - ), - or( - eq(schema.arcadeRooms.status, "lobby"), - eq(schema.arcadeRooms.status, "playing"), - ), - ); + or(eq(schema.arcadeRooms.accessMode, 'open'), eq(schema.arcadeRooms.accessMode, 'password')), + or(eq(schema.arcadeRooms.status, 'lobby'), eq(schema.arcadeRooms.status, 'playing')) + ) return await db.query.arcadeRooms.findMany({ where: whereConditions.length > 0 ? and(...whereConditions) : undefined, orderBy: [desc(schema.arcadeRooms.lastActivity)], limit: 50, // Limit to 50 most recent rooms - }); + }) } /** @@ -188,38 +165,31 @@ export async function listActiveRooms( * Delete rooms that have exceeded their TTL */ export async function cleanupExpiredRooms(): Promise { - const now = new Date(); + const now = new Date() // Find rooms where lastActivity + ttlMinutes < now const expiredRooms = await db.query.arcadeRooms.findMany({ columns: { id: true, ttlMinutes: true, lastActivity: true }, - }); + }) const toDelete = expiredRooms.filter((room) => { - const expiresAt = new Date( - room.lastActivity.getTime() + room.ttlMinutes * 60 * 1000, - ); - return expiresAt < now; - }); + const expiresAt = new Date(room.lastActivity.getTime() + room.ttlMinutes * 60 * 1000) + return expiresAt < now + }) if (toDelete.length > 0) { - const ids = toDelete.map((r) => r.id); - await db - .delete(schema.arcadeRooms) - .where(or(...ids.map((id) => eq(schema.arcadeRooms.id, id)))); - console.log(`[Room Manager] Cleaned up ${toDelete.length} expired rooms`); + const ids = toDelete.map((r) => r.id) + await db.delete(schema.arcadeRooms).where(or(...ids.map((id) => eq(schema.arcadeRooms.id, id)))) + console.log(`[Room Manager] Cleaned up ${toDelete.length} expired rooms`) } - return toDelete.length; + return toDelete.length } /** * Check if a user is the creator of a room */ -export async function isRoomCreator( - roomId: string, - userId: string, -): Promise { - const room = await getRoomById(roomId); - return room?.createdBy === userId; +export async function isRoomCreator(roomId: string, userId: string): Promise { + const room = await getRoomById(roomId) + return room?.createdBy === userId } diff --git a/apps/web/src/lib/arcade/room-member-history.ts b/apps/web/src/lib/arcade/room-member-history.ts index 967c5bfe..63a67455 100644 --- a/apps/web/src/lib/arcade/room-member-history.ts +++ b/apps/web/src/lib/arcade/room-member-history.ts @@ -3,20 +3,20 @@ * Tracks all users who have ever been in a room */ -import { and, eq } from "drizzle-orm"; -import { db, schema } from "@/db"; +import { and, eq } from 'drizzle-orm' +import { db, schema } from '@/db' /** * Record or update a user's membership in room history * This is append-only for new users, or updates the lastAction for existing users */ export async function recordRoomMemberHistory(params: { - roomId: string; - userId: string; - displayName: string; - action: "active" | "left" | "kicked" | "banned"; + roomId: string + userId: string + displayName: string + action: 'active' | 'left' | 'kicked' | 'banned' }): Promise { - const now = new Date(); + const now = new Date() // Check if this user already has a history entry for this room const existing = await db @@ -25,10 +25,10 @@ export async function recordRoomMemberHistory(params: { .where( and( eq(schema.roomMemberHistory.roomId, params.roomId), - eq(schema.roomMemberHistory.userId, params.userId), - ), + eq(schema.roomMemberHistory.userId, params.userId) + ) ) - .limit(1); + .limit(1) if (existing.length > 0) { // Update existing record @@ -41,8 +41,8 @@ export async function recordRoomMemberHistory(params: { displayName: params.displayName, // Update display name in case it changed }) .where(eq(schema.roomMemberHistory.id, existing[0].id)) - .returning(); - return updated; + .returning() + return updated } // Create new history record @@ -57,28 +57,26 @@ export async function recordRoomMemberHistory(params: { lastAction: params.action, lastActionAt: now, }) - .returning(); + .returning() - console.log("[Room History] Recorded history:", { + console.log('[Room History] Recorded history:', { userId: params.userId, roomId: params.roomId, action: params.action, - }); + }) - return history; + return history } /** * Get all historical members for a room */ -export async function getRoomMemberHistory( - roomId: string, -): Promise { +export async function getRoomMemberHistory(roomId: string): Promise { return await db .select() .from(schema.roomMemberHistory) .where(eq(schema.roomMemberHistory.roomId, roomId)) - .orderBy(schema.roomMemberHistory.firstJoinedAt); + .orderBy(schema.roomMemberHistory.firstJoinedAt) } /** @@ -86,20 +84,17 @@ export async function getRoomMemberHistory( */ export async function getUserRoomHistory( roomId: string, - userId: string, + userId: string ): Promise { const results = await db .select() .from(schema.roomMemberHistory) .where( - and( - eq(schema.roomMemberHistory.roomId, roomId), - eq(schema.roomMemberHistory.userId, userId), - ), + and(eq(schema.roomMemberHistory.roomId, roomId), eq(schema.roomMemberHistory.userId, userId)) ) - .limit(1); + .limit(1) - return results[0]; + return results[0] } /** @@ -108,9 +103,9 @@ export async function getUserRoomHistory( export async function updateRoomMemberAction( roomId: string, userId: string, - action: "active" | "left" | "kicked" | "banned", + action: 'active' | 'left' | 'kicked' | 'banned' ): Promise { - const now = new Date(); + const now = new Date() await db .update(schema.roomMemberHistory) @@ -120,30 +115,27 @@ export async function updateRoomMemberAction( lastSeenAt: now, }) .where( - and( - eq(schema.roomMemberHistory.roomId, roomId), - eq(schema.roomMemberHistory.userId, userId), - ), - ); + and(eq(schema.roomMemberHistory.roomId, roomId), eq(schema.roomMemberHistory.userId, userId)) + ) - console.log("[Room History] Updated action:", { userId, roomId, action }); + console.log('[Room History] Updated action:', { userId, roomId, action }) } export interface HistoricalMemberWithStatus { - userId: string; - displayName: string; - firstJoinedAt: Date; - lastSeenAt: Date; - status: "active" | "banned" | "kicked" | "left"; - isCurrentlyInRoom: boolean; - isBanned: boolean; + userId: string + displayName: string + firstJoinedAt: Date + lastSeenAt: Date + status: 'active' | 'banned' | 'kicked' | 'left' + isCurrentlyInRoom: boolean + isBanned: boolean banDetails?: { - reason: string; - bannedBy: string; - bannedByName: string; - bannedAt: Date; - }; - invitationStatus?: "pending" | "accepted" | "declined" | "expired" | null; + reason: string + bannedBy: string + bannedByName: string + bannedAt: Date + } + invitationStatus?: 'pending' | 'accepted' | 'declined' | 'expired' | null } /** @@ -151,49 +143,46 @@ export interface HistoricalMemberWithStatus { * Combines data from history, current members, bans, and invitations */ export async function getRoomHistoricalMembersWithStatus( - roomId: string, + roomId: string ): Promise { // Get all historical members - const history = await getRoomMemberHistory(roomId); + const history = await getRoomMemberHistory(roomId) // Get current members const currentMembers = await db .select() .from(schema.roomMembers) - .where(eq(schema.roomMembers.roomId, roomId)); + .where(eq(schema.roomMembers.roomId, roomId)) - const currentMemberIds = new Set(currentMembers.map((m) => m.userId)); + const currentMemberIds = new Set(currentMembers.map((m) => m.userId)) // Get all bans - const bans = await db - .select() - .from(schema.roomBans) - .where(eq(schema.roomBans.roomId, roomId)); + const bans = await db.select().from(schema.roomBans).where(eq(schema.roomBans.roomId, roomId)) - const banMap = new Map(bans.map((ban) => [ban.userId, ban])); + const banMap = new Map(bans.map((ban) => [ban.userId, ban])) // Get all invitations const invitations = await db .select() .from(schema.roomInvitations) - .where(eq(schema.roomInvitations.roomId, roomId)); + .where(eq(schema.roomInvitations.roomId, roomId)) - const invitationMap = new Map(invitations.map((inv) => [inv.userId, inv])); + const invitationMap = new Map(invitations.map((inv) => [inv.userId, inv])) // Combine into result const results: HistoricalMemberWithStatus[] = history.map((h) => { - const isCurrentlyInRoom = currentMemberIds.has(h.userId); - const ban = banMap.get(h.userId); - const invitation = invitationMap.get(h.userId); + const isCurrentlyInRoom = currentMemberIds.has(h.userId) + const ban = banMap.get(h.userId) + const invitation = invitationMap.get(h.userId) // Determine current status - let status: "active" | "banned" | "kicked" | "left"; + let status: 'active' | 'banned' | 'kicked' | 'left' if (ban) { - status = "banned"; + status = 'banned' } else if (isCurrentlyInRoom) { - status = "active"; + status = 'active' } else { - status = h.lastAction; // Use the recorded action from history + status = h.lastAction // Use the recorded action from history } return { @@ -213,8 +202,8 @@ export async function getRoomHistoricalMembersWithStatus( } : undefined, invitationStatus: invitation?.status || null, - }; - }); + } + }) - return results; + return results } diff --git a/apps/web/src/lib/arcade/room-membership.ts b/apps/web/src/lib/arcade/room-membership.ts index c5c43ab1..79d6cf01 100644 --- a/apps/web/src/lib/arcade/room-membership.ts +++ b/apps/web/src/lib/arcade/room-membership.ts @@ -3,20 +3,20 @@ * Handles database operations for room members */ -import { and, eq } from "drizzle-orm"; -import { db, schema } from "@/db"; -import { recordRoomMemberHistory } from "./room-member-history"; +import { and, eq } from 'drizzle-orm' +import { db, schema } from '@/db' +import { recordRoomMemberHistory } from './room-member-history' export interface AddMemberOptions { - roomId: string; - userId: string; // User/guest ID - displayName: string; - isCreator?: boolean; + roomId: string + userId: string // User/guest ID + displayName: string + isCreator?: boolean } export interface AutoLeaveResult { - leftRooms: string[]; // Room IDs user was removed from - previousRoomMembers: Array<{ roomId: string; member: schema.RoomMember }>; + leftRooms: string[] // Room IDs user was removed from + previousRoomMembers: Array<{ roomId: string; member: schema.RoomMember }> } /** @@ -25,12 +25,12 @@ export interface AutoLeaveResult { * Returns the new membership and info about rooms that were auto-left */ export async function addRoomMember( - options: AddMemberOptions, + options: AddMemberOptions ): Promise<{ member: schema.RoomMember; autoLeaveResult?: AutoLeaveResult }> { - const now = new Date(); + const now = new Date() // Check if member already exists in THIS room - const existing = await getRoomMember(options.roomId, options.userId); + const existing = await getRoomMember(options.roomId, options.userId) if (existing) { // Already in this room - just update status (no auto-leave needed) const [updated] = await db @@ -40,34 +40,32 @@ export async function addRoomMember( lastSeen: now, }) .where(eq(schema.roomMembers.id, existing.id)) - .returning(); - return { member: updated }; + .returning() + return { member: updated } } // AUTO-LEAVE LOGIC: Remove from all other rooms before joining this one - const currentRooms = await getUserRooms(options.userId); + const currentRooms = await getUserRooms(options.userId) const autoLeaveResult: AutoLeaveResult = { leftRooms: [], previousRoomMembers: [], - }; + } for (const roomId of currentRooms) { if (roomId !== options.roomId) { // Get member info before removing (for socket events) - const memberToRemove = await getRoomMember(roomId, options.userId); + const memberToRemove = await getRoomMember(roomId, options.userId) if (memberToRemove) { autoLeaveResult.previousRoomMembers.push({ roomId, member: memberToRemove, - }); + }) } // Remove from room - await removeMember(roomId, options.userId); - autoLeaveResult.leftRooms.push(roomId); - console.log( - `[Room Membership] Auto-left room ${roomId} for user ${options.userId}`, - ); + await removeMember(roomId, options.userId) + autoLeaveResult.leftRooms.push(roomId) + console.log(`[Room Membership] Auto-left room ${roomId} for user ${options.userId}`) } } @@ -80,50 +78,38 @@ export async function addRoomMember( joinedAt: now, lastSeen: now, isOnline: true, - }; + } try { - const [member] = await db - .insert(schema.roomMembers) - .values(newMember) - .returning(); - console.log( - "[Room Membership] Added member:", - member.userId, - "to room:", - member.roomId, - ); + const [member] = await db.insert(schema.roomMembers).values(newMember).returning() + console.log('[Room Membership] Added member:', member.userId, 'to room:', member.roomId) // Record in history await recordRoomMemberHistory({ roomId: options.roomId, userId: options.userId, displayName: options.displayName, - action: "active", - }); + action: 'active', + }) return { member, - autoLeaveResult: - autoLeaveResult.leftRooms.length > 0 ? autoLeaveResult : undefined, - }; + autoLeaveResult: autoLeaveResult.leftRooms.length > 0 ? autoLeaveResult : undefined, + } } catch (error: any) { // Handle unique constraint violation // This should rarely happen due to auto-leave logic above, but catch it for safety if ( - error.code === "SQLITE_CONSTRAINT" || - error.message?.includes("UNIQUE") || - error.message?.includes("unique") + error.code === 'SQLITE_CONSTRAINT' || + error.message?.includes('UNIQUE') || + error.message?.includes('unique') ) { - console.error( - "[Room Membership] Unique constraint violation:", - error.message, - ); + console.error('[Room Membership] Unique constraint violation:', error.message) throw new Error( - "ROOM_MEMBERSHIP_CONFLICT: User is already in another room. This should have been handled by auto-leave logic.", - ); + 'ROOM_MEMBERSHIP_CONFLICT: User is already in another room. This should have been handled by auto-leave logic.' + ) } - throw error; + throw error } } @@ -132,41 +118,31 @@ export async function addRoomMember( */ export async function getRoomMember( roomId: string, - userId: string, + userId: string ): Promise { return await db.query.roomMembers.findFirst({ - where: and( - eq(schema.roomMembers.roomId, roomId), - eq(schema.roomMembers.userId, userId), - ), - }); + where: and(eq(schema.roomMembers.roomId, roomId), eq(schema.roomMembers.userId, userId)), + }) } /** * Get all members in a room */ -export async function getRoomMembers( - roomId: string, -): Promise { +export async function getRoomMembers(roomId: string): Promise { return await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.roomId, roomId), orderBy: schema.roomMembers.joinedAt, - }); + }) } /** * Get online members in a room */ -export async function getOnlineRoomMembers( - roomId: string, -): Promise { +export async function getOnlineRoomMembers(roomId: string): Promise { return await db.query.roomMembers.findMany({ - where: and( - eq(schema.roomMembers.roomId, roomId), - eq(schema.roomMembers.isOnline, true), - ), + where: and(eq(schema.roomMembers.roomId, roomId), eq(schema.roomMembers.isOnline, true)), orderBy: schema.roomMembers.joinedAt, - }); + }) } /** @@ -175,7 +151,7 @@ export async function getOnlineRoomMembers( export async function setMemberOnline( roomId: string, userId: string, - isOnline: boolean, + isOnline: boolean ): Promise { await db .update(schema.roomMembers) @@ -183,30 +159,17 @@ export async function setMemberOnline( isOnline, lastSeen: new Date(), }) - .where( - and( - eq(schema.roomMembers.roomId, roomId), - eq(schema.roomMembers.userId, userId), - ), - ); + .where(and(eq(schema.roomMembers.roomId, roomId), eq(schema.roomMembers.userId, userId))) } /** * Update member's last seen timestamp */ -export async function touchMember( - roomId: string, - userId: string, -): Promise { +export async function touchMember(roomId: string, userId: string): Promise { await db .update(schema.roomMembers) .set({ lastSeen: new Date() }) - .where( - and( - eq(schema.roomMembers.roomId, roomId), - eq(schema.roomMembers.userId, userId), - ), - ); + .where(and(eq(schema.roomMembers.roomId, roomId), eq(schema.roomMembers.userId, userId))) } /** @@ -214,27 +177,14 @@ export async function touchMember( * Note: This only removes from active members. History is preserved. * Use updateRoomMemberAction from room-member-history to set the reason (left/kicked/banned) */ -export async function removeMember( - roomId: string, - userId: string, -): Promise { +export async function removeMember(roomId: string, userId: string): Promise { // Get member info before deleting - const member = await getRoomMember(roomId, userId); + const member = await getRoomMember(roomId, userId) await db .delete(schema.roomMembers) - .where( - and( - eq(schema.roomMembers.roomId, roomId), - eq(schema.roomMembers.userId, userId), - ), - ); - console.log( - "[Room Membership] Removed member:", - userId, - "from room:", - roomId, - ); + .where(and(eq(schema.roomMembers.roomId, roomId), eq(schema.roomMembers.userId, userId))) + console.log('[Room Membership] Removed member:', userId, 'from room:', roomId) // Update history to show they left (default action) // This can be overridden by kick/ban functions @@ -243,8 +193,8 @@ export async function removeMember( roomId, userId, displayName: member.displayName, - action: "left", - }); + action: 'left', + }) } } @@ -252,29 +202,24 @@ export async function removeMember( * Remove all members from a room */ export async function removeAllMembers(roomId: string): Promise { - await db - .delete(schema.roomMembers) - .where(eq(schema.roomMembers.roomId, roomId)); - console.log("[Room Membership] Removed all members from room:", roomId); + await db.delete(schema.roomMembers).where(eq(schema.roomMembers.roomId, roomId)) + console.log('[Room Membership] Removed all members from room:', roomId) } /** * Get count of online members in a room */ export async function getOnlineMemberCount(roomId: string): Promise { - const members = await getOnlineRoomMembers(roomId); - return members.length; + const members = await getOnlineRoomMembers(roomId) + return members.length } /** * Check if a user is a member of a room */ -export async function isMember( - roomId: string, - userId: string, -): Promise { - const member = await getRoomMember(roomId, userId); - return !!member; +export async function isMember(roomId: string, userId: string): Promise { + const member = await getRoomMember(roomId, userId) + return !!member } /** @@ -284,6 +229,6 @@ export async function getUserRooms(userId: string): Promise { const memberships = await db.query.roomMembers.findMany({ where: eq(schema.roomMembers.userId, userId), columns: { roomId: true }, - }); - return memberships.map((m) => m.roomId); + }) + return memberships.map((m) => m.roomId) } diff --git a/apps/web/src/lib/arcade/room-moderation.ts b/apps/web/src/lib/arcade/room-moderation.ts index 03883c97..34a7a6db 100644 --- a/apps/web/src/lib/arcade/room-moderation.ts +++ b/apps/web/src/lib/arcade/room-moderation.ts @@ -3,25 +3,22 @@ * Handles reports, bans, and kicks for arcade rooms */ -import { and, desc, eq } from "drizzle-orm"; -import { db } from "@/db"; -import { roomBans, roomMembers, roomReports } from "@/db/schema"; -import { recordRoomMemberHistory } from "./room-member-history"; +import { and, desc, eq } from 'drizzle-orm' +import { db } from '@/db' +import { roomBans, roomMembers, roomReports } from '@/db/schema' +import { recordRoomMemberHistory } from './room-member-history' /** * Check if a user is banned from a room */ -export async function isUserBanned( - roomId: string, - userId: string, -): Promise { +export async function isUserBanned(roomId: string, userId: string): Promise { const ban = await db .select() .from(roomBans) .where(and(eq(roomBans.roomId, roomId), eq(roomBans.userId, userId))) - .limit(1); + .limit(1) - return ban.length > 0; + return ban.length > 0 } /** @@ -32,26 +29,20 @@ export async function getRoomBans(roomId: string) { .select() .from(roomBans) .where(eq(roomBans.roomId, roomId)) - .orderBy(desc(roomBans.createdAt)); + .orderBy(desc(roomBans.createdAt)) } /** * Ban a user from a room */ export async function banUserFromRoom(params: { - roomId: string; - userId: string; - userName: string; - bannedBy: string; - bannedByName: string; - reason: - | "harassment" - | "cheating" - | "inappropriate-name" - | "spam" - | "afk" - | "other"; - notes?: string; + roomId: string + userId: string + userName: string + bannedBy: string + bannedByName: string + reason: 'harassment' | 'cheating' | 'inappropriate-name' | 'spam' | 'afk' | 'other' + notes?: string }) { // Insert ban record (upsert in case they were already banned) const [ban] = await db @@ -75,36 +66,29 @@ export async function banUserFromRoom(params: { createdAt: new Date(), }, }) - .returning(); + .returning() // Remove user from room members await db .delete(roomMembers) - .where( - and( - eq(roomMembers.roomId, params.roomId), - eq(roomMembers.userId, params.userId), - ), - ); + .where(and(eq(roomMembers.roomId, params.roomId), eq(roomMembers.userId, params.userId))) // Record in history await recordRoomMemberHistory({ roomId: params.roomId, userId: params.userId, displayName: params.userName, - action: "banned", - }); + action: 'banned', + }) - return ban; + return ban } /** * Unban a user from a room */ export async function unbanUserFromRoom(roomId: string, userId: string) { - await db - .delete(roomBans) - .where(and(eq(roomBans.roomId, roomId), eq(roomBans.userId, userId))); + await db.delete(roomBans).where(and(eq(roomBans.roomId, roomId), eq(roomBans.userId, userId))) } /** @@ -116,11 +100,11 @@ export async function kickUserFromRoom(roomId: string, userId: string) { .select() .from(roomMembers) .where(and(eq(roomMembers.roomId, roomId), eq(roomMembers.userId, userId))) - .limit(1); + .limit(1) await db .delete(roomMembers) - .where(and(eq(roomMembers.roomId, roomId), eq(roomMembers.userId, userId))); + .where(and(eq(roomMembers.roomId, roomId), eq(roomMembers.userId, userId))) // Record in history if (member.length > 0) { @@ -128,8 +112,8 @@ export async function kickUserFromRoom(roomId: string, userId: string) { roomId, userId, displayName: member[0].displayName, - action: "kicked", - }); + action: 'kicked', + }) } } @@ -137,19 +121,13 @@ export async function kickUserFromRoom(roomId: string, userId: string) { * Submit a report */ export async function createReport(params: { - roomId: string; - reporterId: string; - reporterName: string; - reportedUserId: string; - reportedUserName: string; - reason: - | "harassment" - | "cheating" - | "inappropriate-name" - | "spam" - | "afk" - | "other"; - details?: string; + roomId: string + reporterId: string + reporterName: string + reportedUserId: string + reportedUserName: string + reason: 'harassment' | 'cheating' | 'inappropriate-name' | 'spam' | 'afk' | 'other' + details?: string }) { const [report] = await db .insert(roomReports) @@ -161,11 +139,11 @@ export async function createReport(params: { reportedUserName: params.reportedUserName, reason: params.reason, details: params.details, - status: "pending", + status: 'pending', }) - .returning(); + .returning() - return report; + return report } /** @@ -175,10 +153,8 @@ export async function getPendingReports(roomId: string) { return db .select() .from(roomReports) - .where( - and(eq(roomReports.roomId, roomId), eq(roomReports.status, "pending")), - ) - .orderBy(desc(roomReports.createdAt)); + .where(and(eq(roomReports.roomId, roomId), eq(roomReports.status, 'pending'))) + .orderBy(desc(roomReports.createdAt)) } /** @@ -189,7 +165,7 @@ export async function getAllReports(roomId: string) { .select() .from(roomReports) .where(eq(roomReports.roomId, roomId)) - .orderBy(desc(roomReports.createdAt)); + .orderBy(desc(roomReports.createdAt)) } /** @@ -199,11 +175,11 @@ export async function markReportReviewed(reportId: string, reviewedBy: string) { await db .update(roomReports) .set({ - status: "reviewed", + status: 'reviewed', reviewedAt: new Date(), reviewedBy, }) - .where(eq(roomReports.id, reportId)); + .where(eq(roomReports.id, reportId)) } /** @@ -213,9 +189,9 @@ export async function dismissReport(reportId: string, reviewedBy: string) { await db .update(roomReports) .set({ - status: "dismissed", + status: 'dismissed', reviewedAt: new Date(), reviewedBy, }) - .where(eq(roomReports.id, reportId)); + .where(eq(roomReports.id, reportId)) } diff --git a/apps/web/src/lib/arcade/room-ttl-cleanup.ts b/apps/web/src/lib/arcade/room-ttl-cleanup.ts index ce0204c4..2e7aa9b2 100644 --- a/apps/web/src/lib/arcade/room-ttl-cleanup.ts +++ b/apps/web/src/lib/arcade/room-ttl-cleanup.ts @@ -3,12 +3,12 @@ * Periodically cleans up expired rooms */ -import { cleanupExpiredRooms } from "./room-manager"; +import { cleanupExpiredRooms } from './room-manager' // Cleanup interval: run every 5 minutes -const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 -let cleanupInterval: NodeJS.Timeout | null = null; +let cleanupInterval: NodeJS.Timeout | null = null /** * Start the TTL cleanup scheduler @@ -16,36 +16,34 @@ let cleanupInterval: NodeJS.Timeout | null = null; */ export function startRoomTTLCleanup() { if (cleanupInterval) { - console.log("[Room TTL] Cleanup scheduler already running"); - return; + console.log('[Room TTL] Cleanup scheduler already running') + return } - console.log("[Room TTL] Starting cleanup scheduler (every 5 minutes)"); + console.log('[Room TTL] Starting cleanup scheduler (every 5 minutes)') // Run immediately on start cleanupExpiredRooms() .then((count) => { if (count > 0) { - console.log( - `[Room TTL] Initial cleanup removed ${count} expired rooms`, - ); + console.log(`[Room TTL] Initial cleanup removed ${count} expired rooms`) } }) .catch((error) => { - console.error("[Room TTL] Initial cleanup failed:", error); - }); + console.error('[Room TTL] Initial cleanup failed:', error) + }) // Then run periodically cleanupInterval = setInterval(async () => { try { - const count = await cleanupExpiredRooms(); + const count = await cleanupExpiredRooms() if (count > 0) { - console.log(`[Room TTL] Cleanup removed ${count} expired rooms`); + console.log(`[Room TTL] Cleanup removed ${count} expired rooms`) } } catch (error) { - console.error("[Room TTL] Cleanup failed:", error); + console.error('[Room TTL] Cleanup failed:', error) } - }, CLEANUP_INTERVAL_MS); + }, CLEANUP_INTERVAL_MS) } /** @@ -53,8 +51,8 @@ export function startRoomTTLCleanup() { */ export function stopRoomTTLCleanup() { if (cleanupInterval) { - clearInterval(cleanupInterval); - cleanupInterval = null; - console.log("[Room TTL] Cleanup scheduler stopped"); + clearInterval(cleanupInterval) + cleanupInterval = null + console.log('[Room TTL] Cleanup scheduler stopped') } } diff --git a/apps/web/src/lib/arcade/session-manager.ts b/apps/web/src/lib/arcade/session-manager.ts index 8cf51a55..a541aacb 100644 --- a/apps/web/src/lib/arcade/session-manager.ts +++ b/apps/web/src/lib/arcade/session-manager.ts @@ -3,45 +3,40 @@ * Handles database operations and validation for arcade sessions */ -import { eq, and } from "drizzle-orm"; -import { db, schema } from "@/db"; -import { - buildPlayerOwnershipMap, - type PlayerOwnershipMap, -} from "./player-ownership"; -import { getValidator, type GameName } from "./validators"; -import type { GameMove } from "./validation/types"; +import { eq, and } from 'drizzle-orm' +import { db, schema } from '@/db' +import { buildPlayerOwnershipMap, type PlayerOwnershipMap } from './player-ownership' +import { getValidator, type GameName } from './validators' +import type { GameMove } from './validation/types' export interface CreateSessionOptions { - userId: string; // User who owns/created the session (typically room creator) - gameName: GameName; - gameUrl: string; - initialState: unknown; - activePlayers: string[]; // Player IDs (UUIDs) - roomId: string; // Required - PRIMARY KEY, one session per room + userId: string // User who owns/created the session (typically room creator) + gameName: GameName + gameUrl: string + initialState: unknown + activePlayers: string[] // Player IDs (UUIDs) + roomId: string // Required - PRIMARY KEY, one session per room } export interface SessionUpdateResult { - success: boolean; - error?: string; - session?: schema.ArcadeSession; - versionConflict?: boolean; + success: boolean + error?: string + session?: schema.ArcadeSession + versionConflict?: boolean } -const TTL_HOURS = 24; +const TTL_HOURS = 24 /** * Helper: Get database user ID from guest ID * The API uses guestId (from cookies) but database FKs use the internal user.id */ -async function getUserIdFromGuestId( - guestId: string, -): Promise { +async function getUserIdFromGuestId(guestId: string): Promise { const user = await db.query.users.findFirst({ where: eq(schema.users.guestId, guestId), columns: { id: true }, - }); - return user?.id; + }) + return user?.id } /** @@ -50,27 +45,25 @@ async function getUserIdFromGuestId( * @param roomId - The room ID (primary key) */ export async function getArcadeSessionByRoom( - roomId: string, + roomId: string ): Promise { // roomId is now the PRIMARY KEY, so direct lookup const [session] = await db .select() .from(schema.arcadeSessions) .where(eq(schema.arcadeSessions.roomId, roomId)) - .limit(1); + .limit(1) - if (!session) return undefined; + if (!session) return undefined // Check if session has expired if (session.expiresAt < new Date()) { // Clean up expired room session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.roomId, roomId)); - return undefined; + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId)) + return undefined } - return session; + return session } /** @@ -78,21 +71,21 @@ export async function getArcadeSessionByRoom( * For room-based games, roomId is the PRIMARY KEY ensuring one session per room */ export async function createArcadeSession( - options: CreateSessionOptions, + options: CreateSessionOptions ): Promise { - const now = new Date(); - const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000); + const now = new Date() + const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000) // Check if session already exists for this room (roomId is PRIMARY KEY) - const existingRoomSession = await getArcadeSessionByRoom(options.roomId); + const existingRoomSession = await getArcadeSessionByRoom(options.roomId) if (existingRoomSession) { - return existingRoomSession; + return existingRoomSession } // Find or create user by guest ID let user = await db.query.users.findFirst({ where: eq(schema.users.guestId, options.userId), - }); + }) if (!user) { const [newUser] = await db @@ -101,15 +94,13 @@ export async function createArcadeSession( guestId: options.userId, // Let id auto-generate via $defaultFn createdAt: now, }) - .returning(); - user = newUser; + .returning() + user = newUser } // Delete any existing sessions for this user (to handle UNIQUE constraint on userId) // This ensures the user can start a new game session - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.userId, user.id)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.userId, user.id)) const newSession: schema.NewArcadeSession = { roomId: options.roomId, // PRIMARY KEY - one session per room @@ -123,28 +114,22 @@ export async function createArcadeSession( expiresAt, isActive: true, version: 1, - }; + } try { - const [session] = await db - .insert(schema.arcadeSessions) - .values(newSession) - .returning(); - return session; + const [session] = await db.insert(schema.arcadeSessions).values(newSession).returning() + return session } catch (error) { // Handle PRIMARY KEY constraint violation (UNIQUE constraint on roomId) // This can happen if two users try to create a session for the same room simultaneously - if ( - error instanceof Error && - error.message.includes("UNIQUE constraint failed") - ) { - const existingSession = await getArcadeSessionByRoom(options.roomId); + if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { + const existingSession = await getArcadeSessionByRoom(options.roomId) if (existingSession) { - return existingSession; + return existingSession } } // Re-throw other errors - throw error; + throw error } } @@ -154,11 +139,9 @@ export async function createArcadeSession( * This function finds sessions where the user is associated * @param guestId - The guest ID from the cookie (not the database user.id) */ -export async function getArcadeSession( - guestId: string, -): Promise { - const userId = await getUserIdFromGuestId(guestId); - if (!userId) return undefined; +export async function getArcadeSession(guestId: string): Promise { + const userId = await getUserIdFromGuestId(guestId) + if (!userId) return undefined // Query for sessions where this user is associated // Since roomId is PRIMARY KEY, there can be multiple rooms but only one session per room @@ -166,27 +149,27 @@ export async function getArcadeSession( .select() .from(schema.arcadeSessions) .where(eq(schema.arcadeSessions.userId, userId)) - .limit(1); + .limit(1) - if (!session) return undefined; + if (!session) return undefined // Check if session has expired if (session.expiresAt < new Date()) { - await deleteArcadeSessionByRoom(session.roomId); - return undefined; + await deleteArcadeSessionByRoom(session.roomId) + return undefined } // Verify the room still exists (roomId is now required/PRIMARY KEY) const room = await db.query.arcadeRooms.findFirst({ where: eq(schema.arcadeRooms.id, session.roomId), - }); + }) if (!room) { - await deleteArcadeSessionByRoom(session.roomId); - return undefined; + await deleteArcadeSessionByRoom(session.roomId) + return undefined } - return session; + return session } /** @@ -198,56 +181,48 @@ export async function getArcadeSession( export async function applyGameMove( userId: string, move: GameMove, - roomId?: string, + roomId?: string ): Promise { // For room-based games, look up the shared room session // For solo games, look up the user's personal session - const session = roomId - ? await getArcadeSessionByRoom(roomId) - : await getArcadeSession(userId); + const session = roomId ? await getArcadeSessionByRoom(roomId) : await getArcadeSession(userId) if (!session) { return { success: false, - error: "No active session found", - }; + error: 'No active session found', + } } if (!session.isActive) { return { success: false, - error: "Session is not active", - }; + error: 'Session is not active', + } } // Get the validator for this game - const validator = getValidator(session.currentGame as GameName); + const validator = getValidator(session.currentGame as GameName) // Fetch player ownership for authorization checks (room-based games) - let playerOwnership: PlayerOwnershipMap | undefined; - let internalUserId: string | undefined; + let playerOwnership: PlayerOwnershipMap | undefined + let internalUserId: string | undefined if (session.roomId) { try { // Convert guestId to internal userId for ownership comparison - internalUserId = await getUserIdFromGuestId(userId); + internalUserId = await getUserIdFromGuestId(userId) if (!internalUserId) { - console.error( - "[SessionManager] Failed to convert guestId to userId:", - userId, - ); + console.error('[SessionManager] Failed to convert guestId to userId:', userId) return { success: false, - error: "User not found", - }; + error: 'User not found', + } } // Use centralized ownership utility - playerOwnership = await buildPlayerOwnershipMap(session.roomId); + playerOwnership = await buildPlayerOwnershipMap(session.roomId) } catch (error) { - console.error( - "[SessionManager] Failed to fetch player ownership:", - error, - ); + console.error('[SessionManager] Failed to fetch player ownership:', error) } } @@ -255,18 +230,18 @@ export async function applyGameMove( const validationResult = validator.validateMove(session.gameState, move, { userId: internalUserId || userId, // Use internal userId for room-based games playerOwnership, - }); + }) if (!validationResult.valid) { return { success: false, - error: validationResult.error || "Invalid move", - }; + error: validationResult.error || 'Invalid move', + } } // Update the session with new state (using optimistic locking) - const now = new Date(); - const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000); + const now = new Date() + const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000) try { const [updatedSession] = await db @@ -280,10 +255,10 @@ export async function applyGameMove( .where( and( eq(schema.arcadeSessions.roomId, session.roomId), // Use roomId (PRIMARY KEY) - eq(schema.arcadeSessions.version, session.version), // Optimistic locking - ), + eq(schema.arcadeSessions.version, session.version) // Optimistic locking + ) ) - .returning(); + .returning() if (!updatedSession) { // Version conflict - another move was processed first @@ -292,31 +267,29 @@ export async function applyGameMove( .select() .from(schema.arcadeSessions) .where(eq(schema.arcadeSessions.roomId, session.roomId)) - .limit(1); + .limit(1) - const versionDiff = currentSession - ? currentSession.version - session.version - : "unknown"; + const versionDiff = currentSession ? currentSession.version - session.version : 'unknown' console.warn( - `[SessionManager] VERSION_CONFLICT room=${session.roomId} game=${session.currentGame} expected_v=${session.version} actual_v=${currentSession?.version} diff=${versionDiff} move=${move.type} user=${internalUserId || userId}`, - ); + `[SessionManager] VERSION_CONFLICT room=${session.roomId} game=${session.currentGame} expected_v=${session.version} actual_v=${currentSession?.version} diff=${versionDiff} move=${move.type} user=${internalUserId || userId}` + ) return { success: false, - error: "Version conflict - please retry", + error: 'Version conflict - please retry', versionConflict: true, - }; + } } return { success: true, session: updatedSession, - }; + } } catch (error) { - console.error("Error updating session:", error); + console.error('Error updating session:', error) return { success: false, - error: "Database error", - }; + error: 'Database error', + } } } @@ -325,9 +298,7 @@ export async function applyGameMove( * @param roomId - The room ID (PRIMARY KEY) */ export async function deleteArcadeSessionByRoom(roomId: string): Promise { - await db - .delete(schema.arcadeSessions) - .where(eq(schema.arcadeSessions.roomId, roomId)); + await db.delete(schema.arcadeSessions).where(eq(schema.arcadeSessions.roomId, roomId)) } /** @@ -336,11 +307,11 @@ export async function deleteArcadeSessionByRoom(roomId: string): Promise { */ export async function deleteArcadeSession(guestId: string): Promise { // First find the session to get its roomId - const session = await getArcadeSession(guestId); - if (!session) return; + const session = await getArcadeSession(guestId) + if (!session) return // Delete by roomId (PRIMARY KEY) - await deleteArcadeSessionByRoom(session.roomId); + await deleteArcadeSessionByRoom(session.roomId) } /** @@ -349,11 +320,11 @@ export async function deleteArcadeSession(guestId: string): Promise { */ export async function updateSessionActivity(guestId: string): Promise { // First find the session to get its roomId - const session = await getArcadeSession(guestId); - if (!session) return; + const session = await getArcadeSession(guestId) + if (!session) return - const now = new Date(); - const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000); + const now = new Date() + const expiresAt = new Date(now.getTime() + TTL_HOURS * 60 * 60 * 1000) // Update using roomId (PRIMARY KEY) await db @@ -362,7 +333,7 @@ export async function updateSessionActivity(guestId: string): Promise { lastActivityAt: now, expiresAt, }) - .where(eq(schema.arcadeSessions.roomId, session.roomId)); + .where(eq(schema.arcadeSessions.roomId, session.roomId)) } /** @@ -374,24 +345,24 @@ export async function updateSessionActivity(guestId: string): Promise { */ export async function updateSessionActivePlayers( roomId: string, - playerIds: string[], + playerIds: string[] ): Promise { - const session = await getArcadeSessionByRoom(roomId); - if (!session) return false; + const session = await getArcadeSessionByRoom(roomId) + if (!session) return false // Only update if game is in setup phase (not started yet) - const gameState = session.gameState as any; - if (gameState.gamePhase !== "setup") { - return false; + const gameState = session.gameState as any + if (gameState.gamePhase !== 'setup') { + return false } // Update both the session's activePlayers field AND the game state const updatedGameState = { ...gameState, activePlayers: playerIds, - }; + } - const now = new Date(); + const now = new Date() await db .update(schema.arcadeSessions) .set({ @@ -400,20 +371,20 @@ export async function updateSessionActivePlayers( lastActivityAt: now, version: session.version + 1, }) - .where(eq(schema.arcadeSessions.roomId, roomId)); + .where(eq(schema.arcadeSessions.roomId, roomId)) - return true; + return true } /** * Clean up expired sessions (should be called periodically) */ export async function cleanupExpiredSessions(): Promise { - const now = new Date(); + const now = new Date() const result = await db .delete(schema.arcadeSessions) .where(eq(schema.arcadeSessions.expiresAt, now)) - .returning(); + .returning() - return result.length; + return result.length } diff --git a/apps/web/src/lib/arcade/stats/types.ts b/apps/web/src/lib/arcade/stats/types.ts index c78cd7d0..2aa7004a 100644 --- a/apps/web/src/lib/arcade/stats/types.ts +++ b/apps/web/src/lib/arcade/stats/types.ts @@ -7,7 +7,7 @@ * See: .claude/GAME_STATS_COMPARISON.md for detailed cross-game analysis */ -import type { GameStatsBreakdown } from "@/db/schema/player-stats"; +import type { GameStatsBreakdown } from '@/db/schema/player-stats' /** * Standard game result that all arcade games must provide @@ -21,133 +21,133 @@ import type { GameStatsBreakdown } from "@/db/schema/player-stats"; */ export interface GameResult { // Game identification - gameType: string; // e.g., "matching", "complement-race", "memory-quiz" + gameType: string // e.g., "matching", "complement-race", "memory-quiz" // Player results (supports 1-N players) - playerResults: PlayerGameResult[]; + playerResults: PlayerGameResult[] // Timing - completedAt: number; // timestamp - duration: number; // milliseconds + completedAt: number // timestamp + duration: number // milliseconds // Optional game-specific data metadata?: { // For cooperative games (Memory Quiz, Card Sorting collaborative) // When true: all players share win/loss outcome - isTeamVictory?: boolean; + isTeamVictory?: boolean // For specific win conditions (Rithmomachia) - winCondition?: string; // e.g., "HARMONY", "POINTS", "TIMEOUT" + winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT" // For game modes - gameMode?: string; // e.g., "solo", "competitive", "cooperative" + gameMode?: string // e.g., "solo", "competitive", "cooperative" // Extensible for other game-specific info - [key: string]: unknown; - }; + [key: string]: unknown + } } /** * Individual player result within a game */ export interface PlayerGameResult { - playerId: string; + playerId: string // Outcome - won: boolean; // For cooperative games: all players have same value - placement?: number; // 1st, 2nd, 3rd place (for tournaments with 3+ players) + won: boolean // For cooperative games: all players have same value + placement?: number // 1st, 2nd, 3rd place (for tournaments with 3+ players) // Performance - score?: number; - accuracy?: number; // 0.0 - 1.0 - completionTime?: number; // milliseconds (player-specific) + score?: number + accuracy?: number // 0.0 - 1.0 + completionTime?: number // milliseconds (player-specific) // Game-specific metrics (stored as JSON in DB) metrics?: { // Matching - moves?: number; - matchedPairs?: number; - difficulty?: number; + moves?: number + matchedPairs?: number + difficulty?: number // Complement Race - streak?: number; - correctAnswers?: number; - totalQuestions?: number; + streak?: number + correctAnswers?: number + totalQuestions?: number // Memory Quiz - correct?: number; - incorrect?: number; + correct?: number + incorrect?: number // Card Sorting - exactMatches?: number; - inversions?: number; - lcsLength?: number; + exactMatches?: number + inversions?: number + lcsLength?: number // Rithmomachia - capturedPieces?: number; - points?: number; + capturedPieces?: number + points?: number // Extensible for future games - [key: string]: unknown; - }; + [key: string]: unknown + } } /** * Stats update returned from API after recording a game */ export interface StatsUpdate { - playerId: string; - previousStats: PlayerStatsData; - newStats: PlayerStatsData; + playerId: string + previousStats: PlayerStatsData + newStats: PlayerStatsData changes: { - gamesPlayed: number; - wins: number; - losses: number; - }; + gamesPlayed: number + wins: number + losses: number + } } /** * Complete player stats data (from DB) */ export interface PlayerStatsData { - playerId: string; - gamesPlayed: number; - totalWins: number; - totalLosses: number; - bestTime: number | null; - highestAccuracy: number; - favoriteGameType: string | null; - gameStats: Record; - lastPlayedAt: Date | null; - createdAt: Date; - updatedAt: Date; + playerId: string + gamesPlayed: number + totalWins: number + totalLosses: number + bestTime: number | null + highestAccuracy: number + favoriteGameType: string | null + gameStats: Record + lastPlayedAt: Date | null + createdAt: Date + updatedAt: Date } /** * Request body for recording a game result */ export interface RecordGameRequest { - gameResult: GameResult; + gameResult: GameResult } /** * Response from recording a game result */ export interface RecordGameResponse { - success: boolean; - updates: StatsUpdate[]; + success: boolean + updates: StatsUpdate[] } /** * Response from fetching player stats */ export interface GetPlayerStatsResponse { - stats: PlayerStatsData; + stats: PlayerStatsData } /** * Response from fetching all user's player stats */ export interface GetAllPlayerStatsResponse { - playerStats: PlayerStatsData[]; + playerStats: PlayerStatsData[] } diff --git a/apps/web/src/lib/arcade/validation/index.ts b/apps/web/src/lib/arcade/validation/index.ts index 8a80787a..d26e9994 100644 --- a/apps/web/src/lib/arcade/validation/index.ts +++ b/apps/web/src/lib/arcade/validation/index.ts @@ -13,7 +13,7 @@ export { matchingGameValidator, memoryQuizGameValidator, cardSortingValidator, -} from "../validators"; +} from '../validators' -export type { GameName } from "../validators"; -export * from "./types"; +export type { GameName } from '../validators' +export * from './types' diff --git a/apps/web/src/lib/arcade/validation/types.ts b/apps/web/src/lib/arcade/validation/types.ts index 601fec13..00b53342 100644 --- a/apps/web/src/lib/arcade/validation/types.ts +++ b/apps/web/src/lib/arcade/validation/types.ts @@ -3,34 +3,34 @@ * Used on both client and server for arcade session validation */ -import type { MemoryPairsState } from "@/arcade-games/matching/types"; -import type { MemoryQuizState as SorobanQuizState } from "@/arcade-games/memory-quiz/types"; +import type { MemoryPairsState } from '@/arcade-games/matching/types' +import type { MemoryQuizState as SorobanQuizState } from '@/arcade-games/memory-quiz/types' /** * Game name type - auto-derived from validator registry * @deprecated Import from '@/lib/arcade/validators' instead */ -export type { GameName } from "../validators"; +export type { GameName } from '../validators' export interface ValidationResult { - valid: boolean; - error?: string; - newState?: unknown; + valid: boolean + error?: string + newState?: unknown } /** * Sentinel value for team moves where no specific player can be identified * Used in free-for-all games where all of a user's players act as a team */ -export const TEAM_MOVE = "__TEAM__" as const; -export type TeamMoveSentinel = typeof TEAM_MOVE; +export const TEAM_MOVE = '__TEAM__' as const +export type TeamMoveSentinel = typeof TEAM_MOVE export interface GameMove { - type: string; - playerId: string | TeamMoveSentinel; // Individual player (turn-based) or __TEAM__ (free-for-all) - userId: string; // Room member/viewer who made the move - timestamp: number; - data: unknown; + type: string + playerId: string | TeamMoveSentinel // Individual player (turn-based) or __TEAM__ (free-for-all) + userId: string // Room member/viewer who made the move + timestamp: number + data: unknown } /** @@ -38,56 +38,49 @@ export interface GameMove { * This maintains a single source of truth (game types) while providing * convenient access for validation code. */ -export type { MatchingMove } from "@/arcade-games/matching/types"; -export type { MemoryQuizMove } from "@/arcade-games/memory-quiz/types"; -export type { CardSortingMove } from "@/arcade-games/card-sorting/types"; -export type { ComplementRaceMove } from "@/arcade-games/complement-race/types"; +export type { MatchingMove } from '@/arcade-games/matching/types' +export type { MemoryQuizMove } from '@/arcade-games/memory-quiz/types' +export type { CardSortingMove } from '@/arcade-games/card-sorting/types' +export type { ComplementRaceMove } from '@/arcade-games/complement-race/types' /** * Re-export game-specific state types from their respective modules */ -export type { MatchingState } from "@/arcade-games/matching/types"; -export type { MemoryQuizState } from "@/arcade-games/memory-quiz/types"; -export type { CardSortingState } from "@/arcade-games/card-sorting/types"; -export type { ComplementRaceState } from "@/arcade-games/complement-race/types"; +export type { MatchingState } from '@/arcade-games/matching/types' +export type { MemoryQuizState } from '@/arcade-games/memory-quiz/types' +export type { CardSortingState } from '@/arcade-games/card-sorting/types' +export type { ComplementRaceState } from '@/arcade-games/complement-race/types' // Generic game state union (for backwards compatibility) -export type GameState = MemoryPairsState | SorobanQuizState; // Add other game states as union later +export type GameState = MemoryPairsState | SorobanQuizState // Add other game states as union later /** * Validation context for authorization checks */ export interface ValidationContext { - userId?: string; - playerOwnership?: Record; // playerId -> userId mapping + userId?: string + playerOwnership?: Record // playerId -> userId mapping } /** * Base validator interface that all games must implement */ -export interface GameValidator< - TState = unknown, - TMove extends GameMove = GameMove, -> { +export interface GameValidator { /** * Validate a game move and return the new state if valid * @param state Current game state * @param move The move to validate * @param context Optional validation context for authorization checks */ - validateMove( - state: TState, - move: TMove, - context?: ValidationContext, - ): ValidationResult; + validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult /** * Check if the game is in a terminal state (completed) */ - isGameComplete(state: TState): boolean; + isGameComplete(state: TState): boolean /** * Get initial state for a new game */ - getInitialState(config: unknown): TState; + getInitialState(config: unknown): TState } diff --git a/apps/web/src/lib/arcade/validators.ts b/apps/web/src/lib/arcade/validators.ts index 85355229..9c4f1898 100644 --- a/apps/web/src/lib/arcade/validators.ts +++ b/apps/web/src/lib/arcade/validators.ts @@ -10,13 +10,13 @@ * 3. GameName type will auto-update */ -import { matchingGameValidator } from "@/arcade-games/matching/Validator"; -import { memoryQuizGameValidator } from "@/arcade-games/memory-quiz/Validator"; -import { complementRaceValidator } from "@/arcade-games/complement-race/Validator"; -import { cardSortingValidator } from "@/arcade-games/card-sorting/Validator"; -import { yjsDemoValidator } from "@/arcade-games/yjs-demo/Validator"; -import { rithmomachiaValidator } from "@/arcade-games/rithmomachia/Validator"; -import type { GameValidator } from "./validation/types"; +import { matchingGameValidator } from '@/arcade-games/matching/Validator' +import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator' +import { complementRaceValidator } from '@/arcade-games/complement-race/Validator' +import { cardSortingValidator } from '@/arcade-games/card-sorting/Validator' +import { yjsDemoValidator } from '@/arcade-games/yjs-demo/Validator' +import { rithmomachiaValidator } from '@/arcade-games/rithmomachia/Validator' +import type { GameValidator } from './validation/types' /** * Central registry of all game validators @@ -25,47 +25,47 @@ import type { GameValidator } from "./validation/types"; */ export const validatorRegistry = { matching: matchingGameValidator, - "memory-quiz": memoryQuizGameValidator, - "complement-race": complementRaceValidator, - "card-sorting": cardSortingValidator, - "yjs-demo": yjsDemoValidator, + 'memory-quiz': memoryQuizGameValidator, + 'complement-race': complementRaceValidator, + 'card-sorting': cardSortingValidator, + 'yjs-demo': yjsDemoValidator, rithmomachia: rithmomachiaValidator, // Add new games here - GameName type will auto-update -} as const; +} as const /** * Auto-derived game name type from registry * No need to manually update this! */ -export type GameName = keyof typeof validatorRegistry; +export type GameName = keyof typeof validatorRegistry /** * Get validator for a game * @throws Error if game not found (fail fast) */ export function getValidator(gameName: string): GameValidator { - const validator = validatorRegistry[gameName as GameName]; + const validator = validatorRegistry[gameName as GameName] if (!validator) { throw new Error( `No validator found for game: ${gameName}. ` + - `Available games: ${Object.keys(validatorRegistry).join(", ")}`, - ); + `Available games: ${Object.keys(validatorRegistry).join(', ')}` + ) } - return validator; + return validator } /** * Check if a game has a registered validator */ export function hasValidator(gameName: string): gameName is GameName { - return gameName in validatorRegistry; + return gameName in validatorRegistry } /** * Get all registered game names */ export function getRegisteredGameNames(): GameName[] { - return Object.keys(validatorRegistry) as GameName[]; + return Object.keys(validatorRegistry) as GameName[] } /** @@ -76,7 +76,7 @@ export function getRegisteredGameNames(): GameName[] { * @returns true if game has a registered validator */ export function isValidGameName(gameName: unknown): gameName is GameName { - return typeof gameName === "string" && hasValidator(gameName); + return typeof gameName === 'string' && hasValidator(gameName) } /** @@ -85,13 +85,11 @@ export function isValidGameName(gameName: unknown): gameName is GameName { * @param gameName - Game name to validate * @throws Error if game name is invalid */ -export function assertValidGameName( - gameName: unknown, -): asserts gameName is GameName { +export function assertValidGameName(gameName: unknown): asserts gameName is GameName { if (!isValidGameName(gameName)) { throw new Error( - `Invalid game name: ${gameName}. Must be one of: ${getRegisteredGameNames().join(", ")}`, - ); + `Invalid game name: ${gameName}. Must be one of: ${getRegisteredGameNames().join(', ')}` + ) } } @@ -105,4 +103,4 @@ export { cardSortingValidator, yjsDemoValidator, rithmomachiaValidator, -}; +} diff --git a/apps/web/src/lib/arcade/yjs-persistence.ts b/apps/web/src/lib/arcade/yjs-persistence.ts index 79329af0..a45cca72 100644 --- a/apps/web/src/lib/arcade/yjs-persistence.ts +++ b/apps/web/src/lib/arcade/yjs-persistence.ts @@ -3,8 +3,8 @@ * Sync Y.Doc state with arcade sessions database */ -import type * as Y from "yjs"; -import type { GridCell } from "@/arcade-games/yjs-demo/types"; +import type * as Y from 'yjs' +import type { GridCell } from '@/arcade-games/yjs-demo/types' /** * Extract grid cells from a Y.Doc for persistence @@ -12,15 +12,15 @@ import type { GridCell } from "@/arcade-games/yjs-demo/types"; * @param arrayName - Name of the Y.Array containing cells (default: 'cells') * @returns Array of grid cells */ -export function extractCellsFromDoc(doc: any, arrayName = "cells"): GridCell[] { - const cellsArray = doc.getArray(arrayName); - if (!cellsArray) return []; +export function extractCellsFromDoc(doc: any, arrayName = 'cells'): GridCell[] { + const cellsArray = doc.getArray(arrayName) + if (!cellsArray) return [] - const cells: GridCell[] = []; + const cells: GridCell[] = [] cellsArray.forEach((cell: GridCell) => { - cells.push(cell); - }); - return cells; + cells.push(cell) + }) + return cells } /** @@ -29,21 +29,17 @@ export function extractCellsFromDoc(doc: any, arrayName = "cells"): GridCell[] { * @param cells - Array of grid cells to restore * @param arrayName - Name of the Y.Array to populate (default: 'cells') */ -export function populateDocWithCells( - doc: any, - cells: GridCell[], - arrayName = "cells", -): void { - const cellsArray = doc.getArray(arrayName); +export function populateDocWithCells(doc: any, cells: GridCell[], arrayName = 'cells'): void { + const cellsArray = doc.getArray(arrayName) // Clear existing cells first - cellsArray.delete(0, cellsArray.length); + cellsArray.delete(0, cellsArray.length) // Add persisted cells if (cells.length > 0) { doc.transact(() => { - cellsArray.push(cells); - }); + cellsArray.push(cells) + }) } } @@ -54,9 +50,9 @@ export function populateDocWithCells( * @returns Base64-encoded document state */ export function serializeDoc(doc: any): string { - const Y = require("yjs"); - const state = Y.encodeStateAsUpdate(doc); - return Buffer.from(state).toString("base64"); + const Y = require('yjs') + const state = Y.encodeStateAsUpdate(doc) + return Buffer.from(state).toString('base64') } /** @@ -65,7 +61,7 @@ export function serializeDoc(doc: any): string { * @param serialized - Base64-encoded document state */ export function deserializeDoc(doc: any, serialized: string): void { - const Y = require("yjs"); - const state = Buffer.from(serialized, "base64"); - Y.applyUpdate(doc, state); + const Y = require('yjs') + const state = Buffer.from(serialized, 'base64') + Y.applyUpdate(doc, state) } diff --git a/apps/web/src/lib/asset-store.ts b/apps/web/src/lib/asset-store.ts index def3f65b..26ef867f 100644 --- a/apps/web/src/lib/asset-store.ts +++ b/apps/web/src/lib/asset-store.ts @@ -1,112 +1,107 @@ // File-based asset store for generated files -import * as fs from "fs"; -import * as path from "path"; -import { promisify } from "util"; +import * as fs from 'fs' +import * as path from 'path' +import { promisify } from 'util' -const writeFile = promisify(fs.writeFile); -const readFile = promisify(fs.readFile); -const unlink = promisify(fs.unlink); -const readdir = promisify(fs.readdir); -const stat = promisify(fs.stat); +const writeFile = promisify(fs.writeFile) +const readFile = promisify(fs.readFile) +const unlink = promisify(fs.unlink) +const readdir = promisify(fs.readdir) +const stat = promisify(fs.stat) export interface StoredAsset { - data: Buffer; - filename: string; - mimeType: string; - createdAt: Date; + data: Buffer + filename: string + mimeType: string + createdAt: Date } // Use temp directory for storing generated assets -const ASSETS_DIR = path.join(process.cwd(), ".tmp/assets"); +const ASSETS_DIR = path.join(process.cwd(), '.tmp/assets') // Ensure assets directory exists if (!fs.existsSync(ASSETS_DIR)) { - fs.mkdirSync(ASSETS_DIR, { recursive: true }); + fs.mkdirSync(ASSETS_DIR, { recursive: true }) } export const assetStore = { async set(id: string, asset: StoredAsset): Promise { - const assetPath = path.join(ASSETS_DIR, `${id}.bin`); - const metaPath = path.join(ASSETS_DIR, `${id}.meta.json`); + const assetPath = path.join(ASSETS_DIR, `${id}.bin`) + const metaPath = path.join(ASSETS_DIR, `${id}.meta.json`) // Store binary data - await writeFile(assetPath, asset.data); + await writeFile(assetPath, asset.data) // Store metadata const metadata = { filename: asset.filename, mimeType: asset.mimeType, createdAt: asset.createdAt.toISOString(), - }; - await writeFile(metaPath, JSON.stringify(metadata)); - console.log("💾 Asset stored to file:", assetPath); + } + await writeFile(metaPath, JSON.stringify(metadata)) + console.log('💾 Asset stored to file:', assetPath) }, async get(id: string): Promise { - const assetPath = path.join(ASSETS_DIR, `${id}.bin`); - const metaPath = path.join(ASSETS_DIR, `${id}.meta.json`); + const assetPath = path.join(ASSETS_DIR, `${id}.bin`) + const metaPath = path.join(ASSETS_DIR, `${id}.meta.json`) try { - const data = await readFile(assetPath); - const metaData = JSON.parse(await readFile(metaPath, "utf-8")); + const data = await readFile(assetPath) + const metaData = JSON.parse(await readFile(metaPath, 'utf-8')) return { data, filename: metaData.filename, mimeType: metaData.mimeType, createdAt: new Date(metaData.createdAt), - }; + } } catch (_error) { - console.log("❌ Asset not found in file system:", assetPath); - return undefined; + console.log('❌ Asset not found in file system:', assetPath) + return undefined } }, async keys(): Promise { try { - const files = await readdir(ASSETS_DIR); - return files - .filter((f) => f.endsWith(".bin")) - .map((f) => f.replace(".bin", "")); + const files = await readdir(ASSETS_DIR) + return files.filter((f) => f.endsWith('.bin')).map((f) => f.replace('.bin', '')) } catch { - return []; + return [] } }, get size(): number { try { - return fs.readdirSync(ASSETS_DIR).filter((f) => f.endsWith(".bin")) - .length; + return fs.readdirSync(ASSETS_DIR).filter((f) => f.endsWith('.bin')).length } catch { - return 0; + return 0 } }, -}; +} // Clean up old assets every hour setInterval( async () => { - const cutoff = Date.now() - 60 * 60 * 1000; // 1 hour ago + const cutoff = Date.now() - 60 * 60 * 1000 // 1 hour ago try { - const files = await readdir(ASSETS_DIR); + const files = await readdir(ASSETS_DIR) for (const file of files) { - if (!file.endsWith(".bin")) continue; + if (!file.endsWith('.bin')) continue - const filePath = path.join(ASSETS_DIR, file); - const stats = await stat(filePath); + const filePath = path.join(ASSETS_DIR, file) + const stats = await stat(filePath) if (stats.mtime.getTime() < cutoff) { - const id = file.replace(".bin", ""); - await unlink(filePath).catch(() => {}); - await unlink(path.join(ASSETS_DIR, `${id}.meta.json`)).catch( - () => {}, - ); - console.log("🗑️ Cleaned up old asset:", id); + const id = file.replace('.bin', '') + await unlink(filePath).catch(() => {}) + await unlink(path.join(ASSETS_DIR, `${id}.meta.json`)).catch(() => {}) + console.log('🗑️ Cleaned up old asset:', id) } } } catch (error) { - console.error("❌ Error cleaning up assets:", error); + console.error('❌ Error cleaning up assets:', error) } }, - 60 * 60 * 1000, -); + 60 * 60 * 1000 +) diff --git a/apps/web/src/lib/blog.ts b/apps/web/src/lib/blog.ts index 6af4bf1e..75edd5d1 100644 --- a/apps/web/src/lib/blog.ts +++ b/apps/web/src/lib/blog.ts @@ -1,27 +1,27 @@ -import fs from "fs"; -import path from "path"; -import matter from "gray-matter"; -import { remark } from "remark"; -import remarkGfm from "remark-gfm"; -import remarkHtml from "remark-html"; +import fs from 'fs' +import path from 'path' +import matter from 'gray-matter' +import { remark } from 'remark' +import remarkGfm from 'remark-gfm' +import remarkHtml from 'remark-html' -const postsDirectory = path.join(process.cwd(), "content", "blog"); +const postsDirectory = path.join(process.cwd(), 'content', 'blog') export interface BlogPost { - slug: string; - title: string; - description: string; - author: string; - publishedAt: string; - updatedAt: string; - tags: string[]; - featured: boolean; - content: string; - html: string; + slug: string + title: string + description: string + author: string + publishedAt: string + updatedAt: string + tags: string[] + featured: boolean + content: string + html: string } -export interface BlogPostMetadata extends Omit { - excerpt?: string; +export interface BlogPostMetadata extends Omit { + excerpt?: string } /** @@ -29,13 +29,13 @@ export interface BlogPostMetadata extends Omit { */ export function getAllPostSlugs(): string[] { try { - const fileNames = fs.readdirSync(postsDirectory); + const fileNames = fs.readdirSync(postsDirectory) return fileNames - .filter((fileName) => fileName.endsWith(".md")) - .map((fileName) => fileName.replace(/\.md$/, "")); + .filter((fileName) => fileName.endsWith('.md')) + .map((fileName) => fileName.replace(/\.md$/, '')) } catch { // Directory doesn't exist yet or is empty - return []; + return [] } } @@ -43,70 +43,68 @@ export function getAllPostSlugs(): string[] { * Get metadata for all posts (without full content) */ export async function getAllPostsMetadata(): Promise { - const slugs = getAllPostSlugs(); + const slugs = getAllPostSlugs() const posts = await Promise.all( slugs.map(async (slug) => { - const post = await getPostBySlug(slug); - const { content, html, ...metadata } = post; + const post = await getPostBySlug(slug) + const { content, html, ...metadata } = post // Create excerpt from first paragraph - const firstPara = content.split("\n\n")[0]; - const excerpt = `${firstPara.replace(/^#+\s+/, "").substring(0, 200)}...`; - return { ...metadata, excerpt }; - }), - ); + const firstPara = content.split('\n\n')[0] + const excerpt = `${firstPara.replace(/^#+\s+/, '').substring(0, 200)}...` + return { ...metadata, excerpt } + }) + ) // Sort by published date, newest first return posts.sort((a, b) => { - return ( - new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime() - ); - }); + return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime() + }) } /** * Get a single post by slug with full content */ export async function getPostBySlug(slug: string): Promise { - const fullPath = path.join(postsDirectory, `${slug}.md`); - const fileContents = fs.readFileSync(fullPath, "utf8"); + const fullPath = path.join(postsDirectory, `${slug}.md`) + const fileContents = fs.readFileSync(fullPath, 'utf8') // Parse frontmatter - const { data, content } = matter(fileContents); + const { data, content } = matter(fileContents) // Convert markdown to HTML const processedContent = await remark() .use(remarkGfm) // GitHub Flavored Markdown (tables, strikethrough, etc.) .use(remarkHtml, { sanitize: false }) - .process(content); + .process(content) - const html = processedContent.toString(); + const html = processedContent.toString() return { slug, - title: data.title || "Untitled", - description: data.description || "", - author: data.author || "Anonymous", + title: data.title || 'Untitled', + description: data.description || '', + author: data.author || 'Anonymous', publishedAt: data.publishedAt || new Date().toISOString(), updatedAt: data.updatedAt || data.publishedAt || new Date().toISOString(), tags: data.tags || [], featured: data.featured || false, content, html, - }; + } } /** * Get featured posts for homepage */ export async function getFeaturedPosts(): Promise { - const allPosts = await getAllPostsMetadata(); - return allPosts.filter((post) => post.featured).slice(0, 3); + const allPosts = await getAllPostsMetadata() + return allPosts.filter((post) => post.featured).slice(0, 3) } /** * Get posts by tag */ export async function getPostsByTag(tag: string): Promise { - const allPosts = await getAllPostsMetadata(); - return allPosts.filter((post) => post.tags.includes(tag)); + const allPosts = await getAllPostsMetadata() + return allPosts.filter((post) => post.tags.includes(tag)) } diff --git a/apps/web/src/lib/guest-token.ts b/apps/web/src/lib/guest-token.ts index 3b8a770f..8e87a1da 100644 --- a/apps/web/src/lib/guest-token.ts +++ b/apps/web/src/lib/guest-token.ts @@ -1,4 +1,4 @@ -import { jwtVerify, SignJWT } from "jose"; +import { jwtVerify, SignJWT } from 'jose' /** * Guest token utilities for stateless guest session management @@ -10,18 +10,17 @@ import { jwtVerify, SignJWT } from "jose"; // Cookie name with __Host- prefix for security in production // __Host- prefix requires: Secure, Path=/, no Domain // In development (http://localhost), __Host- won't work without Secure flag -export const GUEST_COOKIE_NAME = - process.env.NODE_ENV === "production" ? "__Host-guest" : "guest"; +export const GUEST_COOKIE_NAME = process.env.NODE_ENV === 'production' ? '__Host-guest' : 'guest' /** * Get the secret key for signing/verifying JWTs */ function getKey(): Uint8Array { - const secret = process.env.AUTH_SECRET; + const secret = process.env.AUTH_SECRET if (!secret) { - throw new Error("AUTH_SECRET environment variable is required"); + throw new Error('AUTH_SECRET environment variable is required') } - return new TextEncoder().encode(secret); + return new TextEncoder().encode(secret) } /** @@ -33,15 +32,15 @@ function getKey(): Uint8Array { */ export async function createGuestToken( sid: string, - maxAgeSec = 60 * 60 * 24 * 30, // 30 days + maxAgeSec = 60 * 60 * 24 * 30 // 30 days ): Promise { - const now = Math.floor(Date.now() / 1000); + const now = Math.floor(Date.now() / 1000) - return await new SignJWT({ typ: "guest", sid }) - .setProtectedHeader({ alg: "HS256" }) + return await new SignJWT({ typ: 'guest', sid }) + .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt(now) .setExpirationTime(now + maxAgeSec) - .sign(getKey()); + .sign(getKey()) } /** @@ -52,23 +51,23 @@ export async function createGuestToken( * @throws Error if token is invalid or expired */ export async function verifyGuestToken(token: string): Promise<{ - sid: string; - iat: number; - exp: number; + sid: string + iat: number + exp: number }> { try { - const { payload } = await jwtVerify(token, getKey()); + const { payload } = await jwtVerify(token, getKey()) - if (payload.typ !== "guest" || typeof payload.sid !== "string") { - throw new Error("Invalid guest token payload"); + if (payload.typ !== 'guest' || typeof payload.sid !== 'string') { + throw new Error('Invalid guest token payload') } return { sid: payload.sid as string, iat: payload.iat as number, exp: payload.exp as number, - }; + } } catch (error) { - throw new Error(`Guest token verification failed: ${error}`); + throw new Error(`Guest token verification failed: ${error}`) } } diff --git a/apps/web/src/lib/memory-quiz-utils.test.ts b/apps/web/src/lib/memory-quiz-utils.test.ts index 6110ebe6..7e539c09 100644 --- a/apps/web/src/lib/memory-quiz-utils.test.ts +++ b/apps/web/src/lib/memory-quiz-utils.test.ts @@ -1,272 +1,270 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest' import { couldBePrefix, isCompleteWrongNumber, isCorrectAndAvailable, isPrefix, shouldTriggerIncorrectGuess, -} from "./memory-quiz-utils"; +} from './memory-quiz-utils' -describe("Memory Quiz Utils", () => { - describe("isPrefix", () => { - it("should return true when input is a prefix of target numbers", () => { - const targets = [555, 123, 789]; - const found: number[] = []; +describe('Memory Quiz Utils', () => { + describe('isPrefix', () => { + it('should return true when input is a prefix of target numbers', () => { + const targets = [555, 123, 789] + const found: number[] = [] - expect(isPrefix("5", targets, found)).toBe(true); - expect(isPrefix("55", targets, found)).toBe(true); - expect(isPrefix("1", targets, found)).toBe(true); - expect(isPrefix("12", targets, found)).toBe(true); - }); + expect(isPrefix('5', targets, found)).toBe(true) + expect(isPrefix('55', targets, found)).toBe(true) + expect(isPrefix('1', targets, found)).toBe(true) + expect(isPrefix('12', targets, found)).toBe(true) + }) - it("should return false when input is an exact match", () => { - const targets = [555, 123, 789]; - const found: number[] = []; + it('should return false when input is an exact match', () => { + const targets = [555, 123, 789] + const found: number[] = [] - expect(isPrefix("555", targets, found)).toBe(false); - expect(isPrefix("123", targets, found)).toBe(false); - }); + expect(isPrefix('555', targets, found)).toBe(false) + expect(isPrefix('123', targets, found)).toBe(false) + }) - it("should return false when input is not a prefix", () => { - const targets = [555, 123, 789]; - const found: number[] = []; + it('should return false when input is not a prefix', () => { + const targets = [555, 123, 789] + const found: number[] = [] - expect(isPrefix("6", targets, found)).toBe(false); - expect(isPrefix("13", targets, found)).toBe(false); - expect(isPrefix("999", targets, found)).toBe(false); - }); + expect(isPrefix('6', targets, found)).toBe(false) + expect(isPrefix('13', targets, found)).toBe(false) + expect(isPrefix('999', targets, found)).toBe(false) + }) - it("should exclude found numbers from prefix checking - BUG FIX: 55/555 scenario", () => { - const targets = [55, 555, 123]; - const found = [55]; // 55 is already found + it('should exclude found numbers from prefix checking - BUG FIX: 55/555 scenario', () => { + const targets = [55, 555, 123] + const found = [55] // 55 is already found // After finding 55, typing "55" should still be a prefix of unfound 555 - expect(isPrefix("55", targets, found)).toBe(true); // prefix of 555 + expect(isPrefix('55', targets, found)).toBe(true) // prefix of 555 // "5" should also be a prefix of 555 (since 555 is not found) - expect(isPrefix("5", targets, found)).toBe(true); - }); + expect(isPrefix('5', targets, found)).toBe(true) + }) - it("should handle multiple found numbers correctly", () => { - const targets = [5, 55, 555, 5555]; - const found = [5, 55]; // First two are found + it('should handle multiple found numbers correctly', () => { + const targets = [5, 55, 555, 5555] + const found = [5, 55] // First two are found // Found numbers can still be prefixes of unfound numbers - expect(isPrefix("5", targets, found)).toBe(true); // prefix of 555, 5555 - expect(isPrefix("55", targets, found)).toBe(true); // prefix of 555, 5555 - expect(isPrefix("555", targets, found)).toBe(true); // prefix of 5555 - }); + expect(isPrefix('5', targets, found)).toBe(true) // prefix of 555, 5555 + expect(isPrefix('55', targets, found)).toBe(true) // prefix of 555, 5555 + expect(isPrefix('555', targets, found)).toBe(true) // prefix of 5555 + }) - it("should handle when all potential targets are found", () => { - const targets = [5, 55, 555]; - const found = [55, 555]; // All numbers that start with 5 are found + it('should handle when all potential targets are found', () => { + const targets = [5, 55, 555] + const found = [55, 555] // All numbers that start with 5 are found - expect(isPrefix("5", targets, found)).toBe(false); // 5 is not found, but exact match - }); - }); + expect(isPrefix('5', targets, found)).toBe(false) // 5 is not found, but exact match + }) + }) - describe("couldBePrefix", () => { - it("should return true for valid prefixes", () => { - const targets = [123, 456, 789]; + describe('couldBePrefix', () => { + it('should return true for valid prefixes', () => { + const targets = [123, 456, 789] - expect(couldBePrefix("1", targets)).toBe(true); - expect(couldBePrefix("12", targets)).toBe(true); - expect(couldBePrefix("4", targets)).toBe(true); - expect(couldBePrefix("45", targets)).toBe(true); - }); + expect(couldBePrefix('1', targets)).toBe(true) + expect(couldBePrefix('12', targets)).toBe(true) + expect(couldBePrefix('4', targets)).toBe(true) + expect(couldBePrefix('45', targets)).toBe(true) + }) - it("should return false for invalid prefixes", () => { - const targets = [123, 456, 789]; + it('should return false for invalid prefixes', () => { + const targets = [123, 456, 789] - expect(couldBePrefix("2", targets)).toBe(false); - expect(couldBePrefix("13", targets)).toBe(false); - expect(couldBePrefix("999", targets)).toBe(false); - }); + expect(couldBePrefix('2', targets)).toBe(false) + expect(couldBePrefix('13', targets)).toBe(false) + expect(couldBePrefix('999', targets)).toBe(false) + }) - it("should return true for exact matches", () => { - const targets = [123, 456, 789]; + it('should return true for exact matches', () => { + const targets = [123, 456, 789] - expect(couldBePrefix("123", targets)).toBe(true); - expect(couldBePrefix("456", targets)).toBe(true); - }); - }); + expect(couldBePrefix('123', targets)).toBe(true) + expect(couldBePrefix('456', targets)).toBe(true) + }) + }) - describe("isCompleteWrongNumber", () => { - it("should return true for clearly wrong numbers with length >= 2", () => { - const targets = [123, 456, 789]; + describe('isCompleteWrongNumber', () => { + it('should return true for clearly wrong numbers with length >= 2', () => { + const targets = [123, 456, 789] - expect(isCompleteWrongNumber("99", targets)).toBe(true); - expect(isCompleteWrongNumber("999", targets)).toBe(true); - expect(isCompleteWrongNumber("13", targets)).toBe(true); // not 123 - }); + expect(isCompleteWrongNumber('99', targets)).toBe(true) + expect(isCompleteWrongNumber('999', targets)).toBe(true) + expect(isCompleteWrongNumber('13', targets)).toBe(true) // not 123 + }) - it("should return true for single digits that cannot be prefixes", () => { - const targets = [123, 456, 789]; + it('should return true for single digits that cannot be prefixes', () => { + const targets = [123, 456, 789] - expect(isCompleteWrongNumber("2", targets)).toBe(true); - expect(isCompleteWrongNumber("9", targets)).toBe(true); - }); + expect(isCompleteWrongNumber('2', targets)).toBe(true) + expect(isCompleteWrongNumber('9', targets)).toBe(true) + }) - it("should return false for valid prefixes", () => { - const targets = [123, 456, 789]; + it('should return false for valid prefixes', () => { + const targets = [123, 456, 789] - expect(isCompleteWrongNumber("1", targets)).toBe(false); // prefix of 123 - expect(isCompleteWrongNumber("12", targets)).toBe(false); // prefix of 123 - expect(isCompleteWrongNumber("4", targets)).toBe(false); // prefix of 456 - }); + expect(isCompleteWrongNumber('1', targets)).toBe(false) // prefix of 123 + expect(isCompleteWrongNumber('12', targets)).toBe(false) // prefix of 123 + expect(isCompleteWrongNumber('4', targets)).toBe(false) // prefix of 456 + }) - it("should return false for exact matches", () => { - const targets = [123, 456, 789]; + it('should return false for exact matches', () => { + const targets = [123, 456, 789] - expect(isCompleteWrongNumber("123", targets)).toBe(false); - expect(isCompleteWrongNumber("456", targets)).toBe(false); - }); + expect(isCompleteWrongNumber('123', targets)).toBe(false) + expect(isCompleteWrongNumber('456', targets)).toBe(false) + }) - it("should handle numbers that cannot be prefixes regardless of length", () => { - const targets = [123, 456, 789]; + it('should handle numbers that cannot be prefixes regardless of length', () => { + const targets = [123, 456, 789] // Numbers that can't be prefixes are always wrong regardless of length - expect(isCompleteWrongNumber("2", targets)).toBe(true); // can't be prefix of any target - expect(isCompleteWrongNumber("99", targets)).toBe(true); // can't be prefix of any target - expect(isCompleteWrongNumber("999", targets)).toBe(true); // can't be prefix of any target - }); - }); + expect(isCompleteWrongNumber('2', targets)).toBe(true) // can't be prefix of any target + expect(isCompleteWrongNumber('99', targets)).toBe(true) // can't be prefix of any target + expect(isCompleteWrongNumber('999', targets)).toBe(true) // can't be prefix of any target + }) + }) - describe("shouldTriggerIncorrectGuess", () => { - it("should not trigger for correct answers", () => { - const targets = [55, 555, 123]; - const found: number[] = []; + describe('shouldTriggerIncorrectGuess', () => { + it('should not trigger for correct answers', () => { + const targets = [55, 555, 123] + const found: number[] = [] - expect(shouldTriggerIncorrectGuess("55", targets, found)).toBe(false); - expect(shouldTriggerIncorrectGuess("555", targets, found)).toBe(false); - expect(shouldTriggerIncorrectGuess("123", targets, found)).toBe(false); - }); + expect(shouldTriggerIncorrectGuess('55', targets, found)).toBe(false) + expect(shouldTriggerIncorrectGuess('555', targets, found)).toBe(false) + expect(shouldTriggerIncorrectGuess('123', targets, found)).toBe(false) + }) - it("should not trigger for correct answers even if already found", () => { - const targets = [55, 555, 123]; - const found = [55]; + it('should not trigger for correct answers even if already found', () => { + const targets = [55, 555, 123] + const found = [55] // Should not trigger even though 55 is already found - expect(shouldTriggerIncorrectGuess("55", targets, found)).toBe(false); - }); + expect(shouldTriggerIncorrectGuess('55', targets, found)).toBe(false) + }) - it("should trigger for clearly wrong numbers", () => { - const targets = [55, 555, 123]; - const found: number[] = []; + it('should trigger for clearly wrong numbers', () => { + const targets = [55, 555, 123] + const found: number[] = [] - expect(shouldTriggerIncorrectGuess("99", targets, found)).toBe(true); - expect(shouldTriggerIncorrectGuess("999", targets, found)).toBe(true); - expect(shouldTriggerIncorrectGuess("12", targets, found)).toBe(true); // not 123 or any other target - }); + expect(shouldTriggerIncorrectGuess('99', targets, found)).toBe(true) + expect(shouldTriggerIncorrectGuess('999', targets, found)).toBe(true) + expect(shouldTriggerIncorrectGuess('12', targets, found)).toBe(true) // not 123 or any other target + }) - it("should trigger for single digits that cannot be prefixes", () => { - const targets = [123, 456, 789]; - const found: number[] = []; + it('should trigger for single digits that cannot be prefixes', () => { + const targets = [123, 456, 789] + const found: number[] = [] - expect(shouldTriggerIncorrectGuess("2", targets, found)).toBe(true); - expect(shouldTriggerIncorrectGuess("9", targets, found)).toBe(true); - }); + expect(shouldTriggerIncorrectGuess('2', targets, found)).toBe(true) + expect(shouldTriggerIncorrectGuess('9', targets, found)).toBe(true) + }) - it("should not trigger for valid prefixes", () => { - const targets = [555, 123, 789]; - const found: number[] = []; + it('should not trigger for valid prefixes', () => { + const targets = [555, 123, 789] + const found: number[] = [] - expect(shouldTriggerIncorrectGuess("5", targets, found)).toBe(false); - expect(shouldTriggerIncorrectGuess("55", targets, found)).toBe(false); - expect(shouldTriggerIncorrectGuess("1", targets, found)).toBe(false); - }); + expect(shouldTriggerIncorrectGuess('5', targets, found)).toBe(false) + expect(shouldTriggerIncorrectGuess('55', targets, found)).toBe(false) + expect(shouldTriggerIncorrectGuess('1', targets, found)).toBe(false) + }) - it("should not trigger when no guesses remaining", () => { - const targets = [123, 456, 789]; - const found: number[] = []; + it('should not trigger when no guesses remaining', () => { + const targets = [123, 456, 789] + const found: number[] = [] - expect(shouldTriggerIncorrectGuess("99", targets, found, false)).toBe( - false, - ); - }); + expect(shouldTriggerIncorrectGuess('99', targets, found, false)).toBe(false) + }) - it("should handle the 55/555 bug scenario correctly", () => { - const targets = [55, 555, 123]; - const found = [55]; // 55 already found + it('should handle the 55/555 bug scenario correctly', () => { + const targets = [55, 555, 123] + const found = [55] // 55 already found // After finding 55, user types "555" - should not trigger incorrect guess - expect(shouldTriggerIncorrectGuess("555", targets, found)).toBe(false); + expect(shouldTriggerIncorrectGuess('555', targets, found)).toBe(false) // User types "99" - should trigger incorrect guess - expect(shouldTriggerIncorrectGuess("99", targets, found)).toBe(true); - }); - }); + expect(shouldTriggerIncorrectGuess('99', targets, found)).toBe(true) + }) + }) - describe("isCorrectAndAvailable", () => { - it("should return true for correct unfound numbers", () => { - const targets = [55, 555, 123]; - const found: number[] = []; + describe('isCorrectAndAvailable', () => { + it('should return true for correct unfound numbers', () => { + const targets = [55, 555, 123] + const found: number[] = [] - expect(isCorrectAndAvailable(55, targets, found)).toBe(true); - expect(isCorrectAndAvailable(555, targets, found)).toBe(true); - expect(isCorrectAndAvailable(123, targets, found)).toBe(true); - }); + expect(isCorrectAndAvailable(55, targets, found)).toBe(true) + expect(isCorrectAndAvailable(555, targets, found)).toBe(true) + expect(isCorrectAndAvailable(123, targets, found)).toBe(true) + }) - it("should return false for already found numbers", () => { - const targets = [55, 555, 123]; - const found = [55, 123]; + it('should return false for already found numbers', () => { + const targets = [55, 555, 123] + const found = [55, 123] - expect(isCorrectAndAvailable(55, targets, found)).toBe(false); - expect(isCorrectAndAvailable(123, targets, found)).toBe(false); - expect(isCorrectAndAvailable(555, targets, found)).toBe(true); // still available - }); + expect(isCorrectAndAvailable(55, targets, found)).toBe(false) + expect(isCorrectAndAvailable(123, targets, found)).toBe(false) + expect(isCorrectAndAvailable(555, targets, found)).toBe(true) // still available + }) - it("should return false for incorrect numbers", () => { - const targets = [55, 555, 123]; - const found: number[] = []; + it('should return false for incorrect numbers', () => { + const targets = [55, 555, 123] + const found: number[] = [] - expect(isCorrectAndAvailable(99, targets, found)).toBe(false); - expect(isCorrectAndAvailable(999, targets, found)).toBe(false); - }); - }); + expect(isCorrectAndAvailable(99, targets, found)).toBe(false) + expect(isCorrectAndAvailable(999, targets, found)).toBe(false) + }) + }) - describe("Integration scenarios", () => { - it("should handle the reported 55/555 bug correctly", () => { - const targets = [55, 555, 789]; - let found: number[] = []; + describe('Integration scenarios', () => { + it('should handle the reported 55/555 bug correctly', () => { + const targets = [55, 555, 789] + let found: number[] = [] // User types "55" - should be accepted - expect(isCorrectAndAvailable(55, targets, found)).toBe(true); - expect(isPrefix("55", targets, found)).toBe(true); // prefix of 555 + expect(isCorrectAndAvailable(55, targets, found)).toBe(true) + expect(isPrefix('55', targets, found)).toBe(true) // prefix of 555 // Accept "55" - found = [55]; + found = [55] // Now user tries to type "555" // First they type "5" - should be valid prefix of 555 - expect(isPrefix("5", targets, found)).toBe(true); // still prefix of 555 - expect(shouldTriggerIncorrectGuess("5", targets, found)).toBe(false); + expect(isPrefix('5', targets, found)).toBe(true) // still prefix of 555 + expect(shouldTriggerIncorrectGuess('5', targets, found)).toBe(false) // Then "55" - should still be considered prefix of 555 - expect(isPrefix("55", targets, found)).toBe(true); // still prefix of 555 - expect(shouldTriggerIncorrectGuess("55", targets, found)).toBe(false); // and still correct answer + expect(isPrefix('55', targets, found)).toBe(true) // still prefix of 555 + expect(shouldTriggerIncorrectGuess('55', targets, found)).toBe(false) // and still correct answer // Finally "555" - should be accepted - expect(isCorrectAndAvailable(555, targets, found)).toBe(true); - expect(shouldTriggerIncorrectGuess("555", targets, found)).toBe(false); - }); + expect(isCorrectAndAvailable(555, targets, found)).toBe(true) + expect(shouldTriggerIncorrectGuess('555', targets, found)).toBe(false) + }) - it("should handle multiple overlapping prefixes", () => { - const targets = [1, 12, 123, 1234]; - let found: number[] = []; + it('should handle multiple overlapping prefixes', () => { + const targets = [1, 12, 123, 1234] + let found: number[] = [] // All should be prefixes initially - expect(isPrefix("1", targets, found)).toBe(true); // prefix of 12, 123, 1234 - expect(isPrefix("12", targets, found)).toBe(true); // prefix of 123, 1234 - expect(isPrefix("123", targets, found)).toBe(true); // prefix of 1234 + expect(isPrefix('1', targets, found)).toBe(true) // prefix of 12, 123, 1234 + expect(isPrefix('12', targets, found)).toBe(true) // prefix of 123, 1234 + expect(isPrefix('123', targets, found)).toBe(true) // prefix of 1234 // Find 1 and 123 - found = [1, 123]; + found = [1, 123] // Check prefixes with some found - expect(isPrefix("1", targets, found)).toBe(true); // still prefix of 12, 1234 - expect(isPrefix("12", targets, found)).toBe(true); // still prefix of 12, 1234 - expect(isPrefix("123", targets, found)).toBe(true); // still prefix of 1234 - }); - }); -}); + expect(isPrefix('1', targets, found)).toBe(true) // still prefix of 12, 1234 + expect(isPrefix('12', targets, found)).toBe(true) // still prefix of 12, 1234 + expect(isPrefix('123', targets, found)).toBe(true) // still prefix of 1234 + }) + }) +}) diff --git a/apps/web/src/lib/memory-quiz-utils.ts b/apps/web/src/lib/memory-quiz-utils.ts index 2f2873e6..8d472353 100644 --- a/apps/web/src/lib/memory-quiz-utils.ts +++ b/apps/web/src/lib/memory-quiz-utils.ts @@ -6,22 +6,18 @@ * Check if an input string is a prefix of any numbers in the target list, * excluding already found numbers */ -export function isPrefix( - input: string, - targetNumbers: number[], - foundNumbers: number[], -): boolean { +export function isPrefix(input: string, targetNumbers: number[], foundNumbers: number[]): boolean { // Original logic: check if input is a prefix of any unfound numbers return targetNumbers .filter((n) => !foundNumbers.includes(n)) // Only consider unfound numbers - .some((n) => n.toString().startsWith(input) && n.toString() !== input); + .some((n) => n.toString().startsWith(input) && n.toString() !== input) } /** * Check if an input could be a valid prefix of any target numbers */ export function couldBePrefix(input: string, targetNumbers: number[]): boolean { - return targetNumbers.some((n) => n.toString().startsWith(input)); + return targetNumbers.some((n) => n.toString().startsWith(input)) } /** @@ -30,16 +26,16 @@ export function couldBePrefix(input: string, targetNumbers: number[]): boolean { export function isCompleteWrongNumber( input: string, targetNumbers: number[], - _minLengthForWrong: number = 2, + _minLengthForWrong: number = 2 ): boolean { - const number = parseInt(input, 10); - if (Number.isNaN(number)) return false; + const number = parseInt(input, 10) + if (Number.isNaN(number)) return false - const isNotTarget = !targetNumbers.includes(number); - const cannotBePrefix = !couldBePrefix(input, targetNumbers); + const isNotTarget = !targetNumbers.includes(number) + const cannotBePrefix = !couldBePrefix(input, targetNumbers) // It's a complete wrong number if it's not a target AND it cannot be a prefix of any target - return isNotTarget && cannotBePrefix; + return isNotTarget && cannotBePrefix } /** @@ -49,23 +45,20 @@ export function shouldTriggerIncorrectGuess( input: string, targetNumbers: number[], _foundNumbers: number[], - hasGuessesRemaining: boolean = true, + hasGuessesRemaining: boolean = true ): boolean { - if (!hasGuessesRemaining) return false; + if (!hasGuessesRemaining) return false - const number = parseInt(input, 10); - if (Number.isNaN(number)) return false; + const number = parseInt(input, 10) + if (Number.isNaN(number)) return false // Don't trigger if it's a correct answer (even if already found) - if (targetNumbers.includes(number)) return false; + if (targetNumbers.includes(number)) return false - const couldBeValidPrefix = couldBePrefix(input, targetNumbers); + const couldBeValidPrefix = couldBePrefix(input, targetNumbers) // Trigger if it clearly cannot be a valid prefix, OR if it's a multi-digit partial input - return ( - !couldBeValidPrefix || - (input.length >= 2 && !targetNumbers.includes(number)) - ); + return !couldBeValidPrefix || (input.length >= 2 && !targetNumbers.includes(number)) } /** @@ -74,7 +67,7 @@ export function shouldTriggerIncorrectGuess( export function isCorrectAndAvailable( number: number, targetNumbers: number[], - foundNumbers: number[], + foundNumbers: number[] ): boolean { - return targetNumbers.includes(number) && !foundNumbers.includes(number); + return targetNumbers.includes(number) && !foundNumbers.includes(number) } diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts index ecf6a64c..8215ef0c 100644 --- a/apps/web/src/lib/queryClient.ts +++ b/apps/web/src/lib/queryClient.ts @@ -1,4 +1,4 @@ -import { type DefaultOptions, QueryClient } from "@tanstack/react-query"; +import { type DefaultOptions, QueryClient } from '@tanstack/react-query' const queryConfig: DefaultOptions = { queries: { @@ -7,9 +7,9 @@ const queryConfig: DefaultOptions = { // Retry failed requests once retry: 1, // Refetch on window focus in production only - refetchOnWindowFocus: process.env.NODE_ENV === "production", + refetchOnWindowFocus: process.env.NODE_ENV === 'production', }, -}; +} /** * Creates a new QueryClient instance with default configuration. @@ -18,7 +18,7 @@ const queryConfig: DefaultOptions = { export function createQueryClient() { return new QueryClient({ defaultOptions: queryConfig, - }); + }) } /** @@ -36,8 +36,8 @@ export function createQueryClient() { */ export function apiUrl(path: string): string { // Remove leading slash if present to avoid double slashes - const cleanPath = path.startsWith("/") ? path.slice(1) : path; - return `/api/${cleanPath}`; + const cleanPath = path.startsWith('/') ? path.slice(1) : path + return `/api/${cleanPath}` } /** @@ -63,5 +63,5 @@ export function apiUrl(path: string): string { * ``` */ export function api(path: string, options?: RequestInit): Promise { - return fetch(apiUrl(path), options); + return fetch(apiUrl(path), options) } diff --git a/apps/web/src/lib/socket-io.ts b/apps/web/src/lib/socket-io.ts index 7bcdae28..00d0ba40 100644 --- a/apps/web/src/lib/socket-io.ts +++ b/apps/web/src/lib/socket-io.ts @@ -4,10 +4,10 @@ * to broadcast real-time updates. */ -import type { Server as SocketIOServerType } from "socket.io"; +import type { Server as SocketIOServerType } from 'socket.io' // Cache for the socket server module -let socketServerModule: any = null; +let socketServerModule: any = null /** * Get the socket.io server instance @@ -15,31 +15,26 @@ let socketServerModule: any = null; */ export async function getSocketIO(): Promise { // Client-side: return null - if (typeof window !== "undefined") { - return null; + if (typeof window !== 'undefined') { + return null } // Lazy-load the socket server module on first call if (!socketServerModule) { try { // Dynamic import to avoid bundling issues - socketServerModule = await import("../socket-server"); + socketServerModule = await import('../socket-server') } catch (error) { - console.error("[Socket IO] Failed to load socket server:", error); - return null; + console.error('[Socket IO] Failed to load socket server:', error) + return null } } // Call the exported getSocketIO function from the module - if ( - socketServerModule && - typeof socketServerModule.getSocketIO === "function" - ) { - return socketServerModule.getSocketIO(); + if (socketServerModule && typeof socketServerModule.getSocketIO === 'function') { + return socketServerModule.getSocketIO() } - console.warn( - "[Socket IO] getSocketIO function not found in socket-server module", - ); - return null; + console.warn('[Socket IO] getSocketIO function not found in socket-server module') + return null } diff --git a/apps/web/src/lib/viewer.ts b/apps/web/src/lib/viewer.ts index b1da0a92..216eb42e 100644 --- a/apps/web/src/lib/viewer.ts +++ b/apps/web/src/lib/viewer.ts @@ -1,6 +1,6 @@ -import { cookies, headers } from "next/headers"; -import { auth } from "@/auth"; -import { GUEST_COOKIE_NAME, verifyGuestToken } from "./guest-token"; +import { cookies, headers } from 'next/headers' +import { auth } from '@/auth' +import { GUEST_COOKIE_NAME, verifyGuestToken } from './guest-token' /** * Unified viewer utility for server components @@ -11,36 +11,36 @@ import { GUEST_COOKIE_NAME, verifyGuestToken } from "./guest-token"; * @returns Viewer information with discriminated union type */ export async function getViewer(): Promise< - | { kind: "user"; session: Awaited> } - | { kind: "guest"; guestId: string } - | { kind: "unknown" } + | { kind: 'user'; session: Awaited> } + | { kind: 'guest'; guestId: string } + | { kind: 'unknown' } > { // Check if user is authenticated via NextAuth - const session = await auth(); + const session = await auth() if (session) { - return { kind: "user", session }; + return { kind: 'user', session } } // Check for guest ID in header (set by middleware) - const headerStore = await headers(); - const headerGuestId = headerStore.get("x-guest-id"); + const headerStore = await headers() + const headerGuestId = headerStore.get('x-guest-id') if (headerGuestId) { - return { kind: "guest", guestId: headerGuestId }; + return { kind: 'guest', guestId: headerGuestId } } // Fallback: check for guest cookie - const cookieStore = await cookies(); - const guestCookie = cookieStore.get(GUEST_COOKIE_NAME)?.value; + const cookieStore = await cookies() + const guestCookie = cookieStore.get(GUEST_COOKIE_NAME)?.value if (!guestCookie) { - return { kind: "unknown" }; + return { kind: 'unknown' } } try { - const { sid } = await verifyGuestToken(guestCookie); - return { kind: "guest", guestId: sid }; + const { sid } = await verifyGuestToken(guestCookie) + return { kind: 'guest', guestId: sid } } catch { - return { kind: "unknown" }; + return { kind: 'unknown' } } } @@ -54,14 +54,14 @@ export async function getViewer(): Promise< * @throws Error if no valid viewer found */ export async function getViewerId(): Promise { - const viewer = await getViewer(); + const viewer = await getViewer() switch (viewer.kind) { - case "user": - return viewer.session!.user!.id; - case "guest": - return viewer.guestId; - case "unknown": - throw new Error("No valid viewer session found"); + case 'user': + return viewer.session!.user!.id + case 'guest': + return viewer.guestId + case 'unknown': + throw new Error('No valid viewer session found') } } diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index ec552e50..5a2dc52d 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,11 +1,6 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { createGuestToken, GUEST_COOKIE_NAME } from "./lib/guest-token"; -import { - defaultLocale, - LOCALE_COOKIE_NAME, - locales, - type Locale, -} from "./i18n/routing"; +import { type NextRequest, NextResponse } from 'next/server' +import { createGuestToken, GUEST_COOKIE_NAME } from './lib/guest-token' +import { defaultLocale, LOCALE_COOKIE_NAME, locales, type Locale } from './i18n/routing' /** * Middleware to: @@ -14,82 +9,80 @@ import { * 3. Add pathname and locale to headers for Server Components */ export async function middleware(request: NextRequest) { - const response = NextResponse.next(); + const response = NextResponse.next() // Detect locale from cookie or Accept-Language header - let locale = request.cookies.get(LOCALE_COOKIE_NAME)?.value as - | Locale - | undefined; + let locale = request.cookies.get(LOCALE_COOKIE_NAME)?.value as Locale | undefined if (!locale || !locales.includes(locale)) { // Parse Accept-Language header - const acceptLanguage = request.headers.get("accept-language"); + const acceptLanguage = request.headers.get('accept-language') if (acceptLanguage) { const preferred = acceptLanguage - .split(",") - .map((lang) => lang.split(";")[0].trim().slice(0, 2)) - .find((lang) => locales.includes(lang as Locale)); - locale = (preferred as Locale) || defaultLocale; + .split(',') + .map((lang) => lang.split(';')[0].trim().slice(0, 2)) + .find((lang) => locales.includes(lang as Locale)) + locale = (preferred as Locale) || defaultLocale } else { - locale = defaultLocale; + locale = defaultLocale } // Set locale cookie response.cookies.set(LOCALE_COOKIE_NAME, locale, { - path: "/", + path: '/', maxAge: 60 * 60 * 24 * 365, // 1 year - sameSite: "lax", - }); + sameSite: 'lax', + }) } // Add locale to headers for Server Components - response.headers.set("x-locale", locale); + response.headers.set('x-locale', locale) // Add pathname to headers so Server Components can access it - response.headers.set("x-pathname", request.nextUrl.pathname); + response.headers.set('x-pathname', request.nextUrl.pathname) // Check if guest cookie already exists - let existing = request.cookies.get(GUEST_COOKIE_NAME)?.value; - let guestId: string | null = null; + let existing = request.cookies.get(GUEST_COOKIE_NAME)?.value + let guestId: string | null = null if (existing) { // Verify and extract guest ID from existing token try { - const { verifyGuestToken } = await import("./lib/guest-token"); - const verified = await verifyGuestToken(existing); - guestId = verified.sid; + const { verifyGuestToken } = await import('./lib/guest-token') + const verified = await verifyGuestToken(existing) + guestId = verified.sid } catch { // Invalid token, will create new one - existing = undefined; + existing = undefined } } if (!existing) { // Generate new stable session ID - const sid = crypto.randomUUID(); - guestId = sid; + const sid = crypto.randomUUID() + guestId = sid // Create signed guest token - const token = await createGuestToken(sid); + const token = await createGuestToken(sid) // Set cookie with security flags response.cookies.set({ name: GUEST_COOKIE_NAME, value: token, httpOnly: true, // Not accessible via JavaScript - secure: process.env.NODE_ENV === "production", // HTTPS only in production - sameSite: "lax", // CSRF protection - path: "/", // Required for __Host- prefix + secure: process.env.NODE_ENV === 'production', // HTTPS only in production + sameSite: 'lax', // CSRF protection + path: '/', // Required for __Host- prefix maxAge: 60 * 60 * 24 * 30, // 30 days - }); + }) } // Pass guest ID to route handlers via header if (guestId) { - response.headers.set("x-guest-id", guestId); + response.headers.set('x-guest-id', guestId) } - return response; + return response } export const config = { @@ -103,6 +96,6 @@ export const config = { * * Note: This matcher handles both i18n routing and guest tokens */ - "/((?!api|_next|_vercel|.*\\..*).*)", + '/((?!api|_next|_vercel|.*\\..*).*)', ], -}; +} diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts index 498872a2..16edb8ac 100644 --- a/apps/web/src/socket-server.ts +++ b/apps/web/src/socket-server.ts @@ -1,7 +1,7 @@ -import type { Server as HTTPServer, IncomingMessage } from "http"; -import { Server as SocketIOServer } from "socket.io"; -import type { Server as SocketIOServerType } from "socket.io"; -import { WebSocketServer, type WebSocket } from "ws"; +import type { Server as HTTPServer, IncomingMessage } from 'http' +import { Server as SocketIOServer } from 'socket.io' +import type { Server as SocketIOServerType } from 'socket.io' +import { WebSocketServer, type WebSocket } from 'ws' import { applyGameMove, createArcadeSession, @@ -10,33 +10,26 @@ import { getArcadeSessionByRoom, updateSessionActivity, updateSessionActivePlayers, -} from "./lib/arcade/session-manager"; -import { createRoom, getRoomById } from "./lib/arcade/room-manager"; -import { - getRoomMembers, - getUserRooms, - setMemberOnline, -} from "./lib/arcade/room-membership"; -import { - getRoomActivePlayers, - getRoomPlayerIds, -} from "./lib/arcade/player-manager"; -import { getValidator, type GameName } from "./lib/arcade/validators"; -import type { GameMove } from "./lib/arcade/validation/types"; -import { getGameConfig } from "./lib/arcade/game-config-helpers"; +} from './lib/arcade/session-manager' +import { createRoom, getRoomById } from './lib/arcade/room-manager' +import { getRoomMembers, getUserRooms, setMemberOnline } from './lib/arcade/room-membership' +import { getRoomActivePlayers, getRoomPlayerIds } from './lib/arcade/player-manager' +import { getValidator, type GameName } from './lib/arcade/validators' +import type { GameMove } from './lib/arcade/validation/types' +import { getGameConfig } from './lib/arcade/game-config-helpers' // Yjs server-side imports -import * as Y from "yjs"; -import * as awarenessProtocol from "y-protocols/awareness"; -import * as syncProtocol from "y-protocols/sync"; -import * as encoding from "lib0/encoding"; -import * as decoding from "lib0/decoding"; +import * as Y from 'yjs' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' // Use globalThis to store socket.io instance to avoid module isolation issues // This ensures the same instance is accessible across dynamic imports declare global { - var __socketIO: SocketIOServerType | undefined; - var __yjsRooms: Map | undefined; // Map + var __socketIO: SocketIOServerType | undefined + var __yjsRooms: Map | undefined // Map } /** @@ -44,7 +37,7 @@ declare global { * Returns null if not initialized */ export function getSocketIO(): SocketIOServerType | null { - return globalThis.__socketIO || null; + return globalThis.__socketIO || null } /** @@ -56,195 +49,184 @@ export function getSocketIO(): SocketIOServerType | null { function initializeYjsServer(io: SocketIOServerType) { // Room state storage (keyed by arcade room ID) interface RoomState { - doc: Y.Doc; - awareness: awarenessProtocol.Awareness; - connections: Set; // Socket IDs + doc: Y.Doc + awareness: awarenessProtocol.Awareness + connections: Set // Socket IDs } - const rooms = new Map(); // Map - const socketToRoom = new Map(); // Map + const rooms = new Map() // Map + const socketToRoom = new Map() // Map // Store rooms globally for persistence access - globalThis.__yjsRooms = rooms; + globalThis.__yjsRooms = rooms function getOrCreateRoom(roomName: string): RoomState { if (!rooms.has(roomName)) { - const doc = new Y.Doc(); - const awareness = new awarenessProtocol.Awareness(doc); + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) // Broadcast document updates to all clients via Socket.IO - doc.on("update", (update: Uint8Array, origin: any) => { + doc.on('update', (update: Uint8Array, origin: any) => { // Origin is the socket ID that sent the update, don't echo back to sender - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, 0); // messageSync - syncProtocol.writeUpdate(encoder, update); - const message = encoding.toUint8Array(encoder); + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, 0) // messageSync + syncProtocol.writeUpdate(encoder, update) + const message = encoding.toUint8Array(encoder) // Broadcast to all sockets in this room except origin io.to(`yjs:${roomName}`) .except(origin as string) - .emit("yjs-update", Array.from(message)); - }); + .emit('yjs-update', Array.from(message)) + }) // Broadcast awareness updates to all clients via Socket.IO - awareness.on( - "update", - ({ added, updated, removed }: any, origin: any) => { - const changedClients = added.concat(updated).concat(removed); - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, 1); // messageAwareness - encoding.writeVarUint8Array( - encoder, - awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients), - ); - const message = encoding.toUint8Array(encoder); + awareness.on('update', ({ added, updated, removed }: any, origin: any) => { + const changedClients = added.concat(updated).concat(removed) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, 1) // messageAwareness + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients) + ) + const message = encoding.toUint8Array(encoder) - // Broadcast to all sockets in this room except origin - io.to(`yjs:${roomName}`) - .except(origin as string) - .emit("yjs-awareness", Array.from(message)); - }, - ); + // Broadcast to all sockets in this room except origin + io.to(`yjs:${roomName}`) + .except(origin as string) + .emit('yjs-awareness', Array.from(message)) + }) const roomState: RoomState = { doc, awareness, connections: new Set(), - }; - rooms.set(roomName, roomState); - console.log(`✅ Created Y.Doc for room: ${roomName}`); + } + rooms.set(roomName, roomState) + console.log(`✅ Created Y.Doc for room: ${roomName}`) // Load persisted state asynchronously (don't block connection) void loadPersistedYjsState(roomName).catch((err) => { - console.error( - `Failed to load persisted state for room ${roomName}:`, - err, - ); - }); + console.error(`Failed to load persisted state for room ${roomName}:`, err) + }) } - return rooms.get(roomName)!; + return rooms.get(roomName)! } // Handle Yjs connections via Socket.IO - io.on("connection", (socket) => { + io.on('connection', (socket) => { // Join Yjs room - socket.on("yjs-join", async (roomId: string) => { - const room = getOrCreateRoom(roomId); + socket.on('yjs-join', async (roomId: string) => { + const room = getOrCreateRoom(roomId) // Join Socket.IO room - await socket.join(`yjs:${roomId}`); - room.connections.add(socket.id); - socketToRoom.set(socket.id, roomId); + await socket.join(`yjs:${roomId}`) + room.connections.add(socket.id) + socketToRoom.set(socket.id, roomId) - console.log( - `🔗 Client connected to Yjs room: ${roomId} (${room.connections.size} clients)`, - ); + console.log(`🔗 Client connected to Yjs room: ${roomId} (${room.connections.size} clients)`) // Send initial sync (SyncStep1) - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, 0); // messageSync - syncProtocol.writeSyncStep1(encoder, room.doc); - socket.emit("yjs-sync", Array.from(encoding.toUint8Array(encoder))); + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, 0) // messageSync + syncProtocol.writeSyncStep1(encoder, room.doc) + socket.emit('yjs-sync', Array.from(encoding.toUint8Array(encoder))) // Send current awareness state - const awarenessStates = room.awareness.getStates(); + const awarenessStates = room.awareness.getStates() if (awarenessStates.size > 0) { - const awarenessEncoder = encoding.createEncoder(); - encoding.writeVarUint(awarenessEncoder, 1); // messageAwareness + const awarenessEncoder = encoding.createEncoder() + encoding.writeVarUint(awarenessEncoder, 1) // messageAwareness encoding.writeVarUint8Array( awarenessEncoder, awarenessProtocol.encodeAwarenessUpdate( room.awareness, - Array.from(awarenessStates.keys()), - ), - ); - socket.emit( - "yjs-awareness", - Array.from(encoding.toUint8Array(awarenessEncoder)), - ); + Array.from(awarenessStates.keys()) + ) + ) + socket.emit('yjs-awareness', Array.from(encoding.toUint8Array(awarenessEncoder))) } - }); + }) // Handle Yjs sync messages - socket.on("yjs-update", (data: number[]) => { - const roomId = socketToRoom.get(socket.id); - if (!roomId) return; + socket.on('yjs-update', (data: number[]) => { + const roomId = socketToRoom.get(socket.id) + if (!roomId) return - const room = rooms.get(roomId); - if (!room) return; + const room = rooms.get(roomId) + if (!room) return - const uint8Data = new Uint8Array(data); - const decoder = decoding.createDecoder(uint8Data); - const messageType = decoding.readVarUint(decoder); + const uint8Data = new Uint8Array(data) + const decoder = decoding.createDecoder(uint8Data) + const messageType = decoding.readVarUint(decoder) if (messageType === 0) { // Sync protocol - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, 0); - syncProtocol.readSyncMessage(decoder, encoder, room.doc, socket.id); + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, 0) + syncProtocol.readSyncMessage(decoder, encoder, room.doc, socket.id) // Send response if there's content if (encoding.length(encoder) > 1) { - socket.emit("yjs-sync", Array.from(encoding.toUint8Array(encoder))); + socket.emit('yjs-sync', Array.from(encoding.toUint8Array(encoder))) } } - }); + }) // Handle awareness updates - socket.on("yjs-awareness", (data: number[]) => { - const roomId = socketToRoom.get(socket.id); - if (!roomId) return; + socket.on('yjs-awareness', (data: number[]) => { + const roomId = socketToRoom.get(socket.id) + if (!roomId) return - const room = rooms.get(roomId); - if (!room) return; + const room = rooms.get(roomId) + if (!room) return - const uint8Data = new Uint8Array(data); - const decoder = decoding.createDecoder(uint8Data); - const messageType = decoding.readVarUint(decoder); + const uint8Data = new Uint8Array(data) + const decoder = decoding.createDecoder(uint8Data) + const messageType = decoding.readVarUint(decoder) if (messageType === 1) { awarenessProtocol.applyAwarenessUpdate( room.awareness, decoding.readVarUint8Array(decoder), - socket.id, - ); + socket.id + ) } - }); + }) // Cleanup on disconnect - socket.on("disconnect", () => { - const roomId = socketToRoom.get(socket.id); + socket.on('disconnect', () => { + const roomId = socketToRoom.get(socket.id) if (roomId) { - const room = rooms.get(roomId); + const room = rooms.get(roomId) if (room) { - room.connections.delete(socket.id); + room.connections.delete(socket.id) console.log( - `🔌 Client disconnected from Yjs room: ${roomId} (${room.connections.size} remain)`, - ); + `🔌 Client disconnected from Yjs room: ${roomId} (${room.connections.size} remain)` + ) // Clean up empty rooms after grace period if (room.connections.size === 0) { setTimeout(() => { if (room.connections.size === 0) { - room.awareness.destroy(); - room.doc.destroy(); - rooms.delete(roomId); - console.log(`🗑️ Cleaned up room: ${roomId}`); + room.awareness.destroy() + room.doc.destroy() + rooms.delete(roomId) + console.log(`🗑️ Cleaned up room: ${roomId}`) } - }, 30000); + }, 30000) } } - socketToRoom.delete(socket.id); + socketToRoom.delete(socket.id) } - }); - }); + }) + }) - console.log("✅ Yjs over Socket.IO initialized"); + console.log('✅ Yjs over Socket.IO initialized') // Periodic persistence: sync Y.Doc state to arcade_sessions every 30 seconds setInterval(async () => { - await persistAllYjsRooms(); - }, 30000); + await persistAllYjsRooms() + }, 30000) } /** @@ -252,11 +234,11 @@ function initializeYjsServer(io: SocketIOServerType) { * Returns null if room doesn't exist */ export function getYjsDoc(roomId: string): Y.Doc | null { - const rooms = globalThis.__yjsRooms; - if (!rooms) return null; + const rooms = globalThis.__yjsRooms + if (!rooms) return null - const room = rooms.get(roomId); - return room ? room.doc : null; + const room = rooms.get(roomId) + return room ? room.doc : null } /** @@ -264,23 +246,19 @@ export function getYjsDoc(roomId: string): Y.Doc | null { * Should be called when creating a new room that has persisted state */ export async function loadPersistedYjsState(roomId: string): Promise { - const { extractCellsFromDoc, populateDocWithCells } = await import( - "./lib/arcade/yjs-persistence" - ); + const { extractCellsFromDoc, populateDocWithCells } = await import('./lib/arcade/yjs-persistence') - const doc = getYjsDoc(roomId); - if (!doc) return; + const doc = getYjsDoc(roomId) + if (!doc) return // Get the arcade session for this room - const session = await getArcadeSessionByRoom(roomId); - if (!session) return; + const session = await getArcadeSessionByRoom(roomId) + if (!session) return - const gameState = session.gameState as any; + const gameState = session.gameState as any if (gameState.cells && Array.isArray(gameState.cells)) { - console.log( - `📥 Loading ${gameState.cells.length} persisted cells for room: ${roomId}`, - ); - populateDocWithCells(doc, gameState.cells); + console.log(`📥 Loading ${gameState.cells.length} persisted cells for room: ${roomId}`) + populateDocWithCells(doc, gameState.cells) } } @@ -288,25 +266,25 @@ export async function loadPersistedYjsState(roomId: string): Promise { * Persist Y.Doc cells for a specific room to arcade_sessions */ export async function persistYjsRoom(roomId: string): Promise { - const { extractCellsFromDoc } = await import("./lib/arcade/yjs-persistence"); - const { db, schema } = await import("@/db"); - const { eq } = await import("drizzle-orm"); + const { extractCellsFromDoc } = await import('./lib/arcade/yjs-persistence') + const { db, schema } = await import('@/db') + const { eq } = await import('drizzle-orm') - const doc = getYjsDoc(roomId); - if (!doc) return; + const doc = getYjsDoc(roomId) + if (!doc) return - const session = await getArcadeSessionByRoom(roomId); - if (!session) return; + const session = await getArcadeSessionByRoom(roomId) + if (!session) return // Extract cells from Y.Doc - const cells = extractCellsFromDoc(doc, "cells"); + const cells = extractCellsFromDoc(doc, 'cells') // Update the gameState with current cells - const currentState = session.gameState as Record; + const currentState = session.gameState as Record const updatedGameState = { ...currentState, cells, - }; + } // Save to database try { @@ -316,9 +294,9 @@ export async function persistYjsRoom(roomId: string): Promise { gameState: updatedGameState as any, lastActivityAt: new Date(), }) - .where(eq(schema.arcadeSessions.roomId, roomId)); + .where(eq(schema.arcadeSessions.roomId, roomId)) } catch (error) { - console.error(`Error persisting Yjs room ${roomId}:`, error); + console.error(`Error persisting Yjs room ${roomId}:`, error) } } @@ -326,44 +304,44 @@ export async function persistYjsRoom(roomId: string): Promise { * Persist all active Yjs rooms */ export async function persistAllYjsRooms(): Promise { - const rooms = globalThis.__yjsRooms; - if (!rooms || rooms.size === 0) return; + const rooms = globalThis.__yjsRooms + if (!rooms || rooms.size === 0) return - const roomIds = Array.from(rooms.keys()); + const roomIds = Array.from(rooms.keys()) for (const roomId of roomIds) { // Only persist rooms with active connections - const room = rooms.get(roomId); + const room = rooms.get(roomId) if (room && room.connections.size > 0) { - await persistYjsRoom(roomId); + await persistYjsRoom(roomId) } } } export function initializeSocketServer(httpServer: HTTPServer) { const io = new SocketIOServer(httpServer, { - path: "/api/socket", + path: '/api/socket', cors: { - origin: process.env.NEXT_PUBLIC_URL || "http://localhost:3000", + origin: process.env.NEXT_PUBLIC_URL || 'http://localhost:3000', credentials: true, }, - }); + }) // Initialize Yjs server over Socket.IO - initializeYjsServer(io); + initializeYjsServer(io) - io.on("connection", (socket) => { - let currentUserId: string | null = null; + io.on('connection', (socket) => { + let currentUserId: string | null = null // Join arcade session room socket.on( - "join-arcade-session", + 'join-arcade-session', async ({ userId, roomId }: { userId: string; roomId?: string }) => { - currentUserId = userId; - socket.join(`arcade:${userId}`); + currentUserId = userId + socket.join(`arcade:${userId}`) // If this session is part of a room, also join the game room for multi-user sync if (roomId) { - socket.join(`game:${roomId}`); + socket.join(`game:${roomId}`) } // Send current session state if exists @@ -371,377 +349,348 @@ export function initializeSocketServer(httpServer: HTTPServer) { try { let session = roomId ? await getArcadeSessionByRoom(roomId) - : await getArcadeSession(userId); + : await getArcadeSession(userId) // If no session exists for this room, create one in setup phase // This allows users to send SET_CONFIG moves before starting the game if (!session && roomId) { // Get the room to determine game type and config - const room = await getRoomById(roomId); + const room = await getRoomById(roomId) if (room) { // Fetch all active player IDs from room members (respects isActive flag) - const roomPlayerIds = await getRoomPlayerIds(roomId); + const roomPlayerIds = await getRoomPlayerIds(roomId) // Get initial state from the correct validator based on game type - const validator = getValidator(room.gameName as GameName); + const validator = getValidator(room.gameName as GameName) // Get game-specific config from database (type-safe) - const gameConfig = await getGameConfig( - roomId, - room.gameName as GameName, - ); - const initialState = validator.getInitialState(gameConfig); + const gameConfig = await getGameConfig(roomId, room.gameName as GameName) + const initialState = validator.getInitialState(gameConfig) session = await createArcadeSession({ userId, gameName: room.gameName as GameName, - gameUrl: "/arcade", + gameUrl: '/arcade', initialState, activePlayers: roomPlayerIds, // Include all room members' active players roomId: room.id, - }); + }) } } if (session) { - socket.emit("session-state", { + socket.emit('session-state', { gameState: session.gameState, currentGame: session.currentGame, gameUrl: session.gameUrl, activePlayers: session.activePlayers, version: session.version, - }); + }) } else { - socket.emit("no-active-session"); + socket.emit('no-active-session') } } catch (error) { - console.error("Error fetching session:", error); - socket.emit("session-error", { error: "Failed to fetch session" }); + console.error('Error fetching session:', error) + socket.emit('session-error', { error: 'Failed to fetch session' }) } - }, - ); + } + ) // Handle game moves - socket.on( - "game-move", - async (data: { userId: string; move: GameMove; roomId?: string }) => { - try { - // Special handling for START_GAME - create session if it doesn't exist - if (data.move.type === "START_GAME") { - // For room-based games, check if room session exists - const existingSession = data.roomId - ? await getArcadeSessionByRoom(data.roomId) - : await getArcadeSession(data.userId); + socket.on('game-move', async (data: { userId: string; move: GameMove; roomId?: string }) => { + try { + // Special handling for START_GAME - create session if it doesn't exist + if (data.move.type === 'START_GAME') { + // For room-based games, check if room session exists + const existingSession = data.roomId + ? await getArcadeSessionByRoom(data.roomId) + : await getArcadeSession(data.userId) - if (!existingSession) { - // activePlayers must be provided in the START_GAME move data - const activePlayers = (data.move.data as any)?.activePlayers; - if (!activePlayers || activePlayers.length === 0) { - socket.emit("move-rejected", { - error: "START_GAME requires at least one active player", - move: data.move, - }); - return; + if (!existingSession) { + // activePlayers must be provided in the START_GAME move data + const activePlayers = (data.move.data as any)?.activePlayers + if (!activePlayers || activePlayers.length === 0) { + socket.emit('move-rejected', { + error: 'START_GAME requires at least one active player', + move: data.move, + }) + return + } + + // Get initial state from validator (this code path is matching-game specific) + const matchingValidator = getValidator('matching') + const initialState = matchingValidator.getInitialState({ + difficulty: 6, + gameType: 'abacus-numeral', + turnTimer: 30, + }) + + // Check if user is already in a room for this game + const userRoomIds = await getUserRooms(data.userId) + let room = null + + // Look for an existing active room for this game + for (const roomId of userRoomIds) { + const existingRoom = await getRoomById(roomId) + if ( + existingRoom && + existingRoom.gameName === 'matching' && + existingRoom.status !== 'finished' + ) { + room = existingRoom + break } + } - // Get initial state from validator (this code path is matching-game specific) - const matchingValidator = getValidator("matching"); - const initialState = matchingValidator.getInitialState({ - difficulty: 6, - gameType: "abacus-numeral", - turnTimer: 30, - }); + // If no suitable room exists, create a new one + if (!room) { + room = await createRoom({ + name: 'Auto-generated Room', + createdBy: data.userId, + creatorName: 'Player', + gameName: 'matching' as GameName, + gameConfig: { + difficulty: 6, + gameType: 'abacus-numeral', + turnTimer: 30, + }, + ttlMinutes: 60, + }) + } - // Check if user is already in a room for this game - const userRoomIds = await getUserRooms(data.userId); - let room = null; + // Now create the session linked to the room + await createArcadeSession({ + userId: data.userId, + gameName: 'matching', + gameUrl: '/arcade', // Room-based sessions use /arcade + initialState, + activePlayers, + roomId: room.id, + }) - // Look for an existing active room for this game - for (const roomId of userRoomIds) { - const existingRoom = await getRoomById(roomId); - if ( - existingRoom && - existingRoom.gameName === "matching" && - existingRoom.status !== "finished" - ) { - room = existingRoom; - break; - } - } - - // If no suitable room exists, create a new one - if (!room) { - room = await createRoom({ - name: "Auto-generated Room", - createdBy: data.userId, - creatorName: "Player", - gameName: "matching" as GameName, - gameConfig: { - difficulty: 6, - gameType: "abacus-numeral", - turnTimer: 30, - }, - ttlMinutes: 60, - }); - } - - // Now create the session linked to the room - await createArcadeSession({ - userId: data.userId, - gameName: "matching", - gameUrl: "/arcade", // Room-based sessions use /arcade - initialState, - activePlayers, - roomId: room.id, - }); - - // Notify all connected clients about the new session - const newSession = await getArcadeSession(data.userId); - if (newSession) { - io!.to(`arcade:${data.userId}`).emit("session-state", { - gameState: newSession.gameState, - currentGame: newSession.currentGame, - gameUrl: newSession.gameUrl, - activePlayers: newSession.activePlayers, - version: newSession.version, - }); - } + // Notify all connected clients about the new session + const newSession = await getArcadeSession(data.userId) + if (newSession) { + io!.to(`arcade:${data.userId}`).emit('session-state', { + gameState: newSession.gameState, + currentGame: newSession.currentGame, + gameUrl: newSession.gameUrl, + activePlayers: newSession.activePlayers, + version: newSession.version, + }) } } - - // Apply game move - use roomId for room-based games to access shared session - const result = await applyGameMove( - data.userId, - data.move, - data.roomId, - ); - - if (result.success && result.session) { - const moveAcceptedData = { - gameState: result.session.gameState, - version: result.session.version, - move: data.move, - }; - - // Broadcast the updated state to all devices for this user - io! - .to(`arcade:${data.userId}`) - .emit("move-accepted", moveAcceptedData); - - // If this is a room-based session, ALSO broadcast to all users in the room - if (result.session.roomId) { - io! - .to(`game:${result.session.roomId}`) - .emit("move-accepted", moveAcceptedData); - } - - // Update activity timestamp - await updateSessionActivity(data.userId); - } else { - // Send rejection only to the requesting socket - if (result.versionConflict) { - console.warn( - `[SocketServer] VERSION_CONFLICT_REJECTED room=${data.roomId} move=${data.move.type} user=${data.userId} socket=${socket.id}`, - ); - } - socket.emit("move-rejected", { - error: result.error, - move: data.move, - versionConflict: result.versionConflict, - }); - } - } catch (error) { - console.error("Error processing move:", error); - socket.emit("move-rejected", { - error: "Server error processing move", - move: data.move, - }); } - }, - ); + + // Apply game move - use roomId for room-based games to access shared session + const result = await applyGameMove(data.userId, data.move, data.roomId) + + if (result.success && result.session) { + const moveAcceptedData = { + gameState: result.session.gameState, + version: result.session.version, + move: data.move, + } + + // Broadcast the updated state to all devices for this user + io!.to(`arcade:${data.userId}`).emit('move-accepted', moveAcceptedData) + + // If this is a room-based session, ALSO broadcast to all users in the room + if (result.session.roomId) { + io!.to(`game:${result.session.roomId}`).emit('move-accepted', moveAcceptedData) + } + + // Update activity timestamp + await updateSessionActivity(data.userId) + } else { + // Send rejection only to the requesting socket + if (result.versionConflict) { + console.warn( + `[SocketServer] VERSION_CONFLICT_REJECTED room=${data.roomId} move=${data.move.type} user=${data.userId} socket=${socket.id}` + ) + } + socket.emit('move-rejected', { + error: result.error, + move: data.move, + versionConflict: result.versionConflict, + }) + } + } catch (error) { + console.error('Error processing move:', error) + socket.emit('move-rejected', { + error: 'Server error processing move', + move: data.move, + }) + } + }) // Handle session exit - socket.on("exit-arcade-session", async ({ userId }: { userId: string }) => { + socket.on('exit-arcade-session', async ({ userId }: { userId: string }) => { try { - await deleteArcadeSession(userId); - io!.to(`arcade:${userId}`).emit("session-ended"); + await deleteArcadeSession(userId) + io!.to(`arcade:${userId}`).emit('session-ended') } catch (error) { - console.error("Error ending session:", error); - socket.emit("session-error", { error: "Failed to end session" }); + console.error('Error ending session:', error) + socket.emit('session-error', { error: 'Failed to end session' }) } - }); + }) // Keep-alive ping - socket.on("ping-session", async ({ userId }: { userId: string }) => { + socket.on('ping-session', async ({ userId }: { userId: string }) => { try { - await updateSessionActivity(userId); - socket.emit("pong-session"); + await updateSessionActivity(userId) + socket.emit('pong-session') } catch (error) { - console.error("Error updating activity:", error); + console.error('Error updating activity:', error) } - }); + }) // Room: Join - socket.on( - "join-room", - async ({ roomId, userId }: { roomId: string; userId: string }) => { - try { - // Join the socket room - socket.join(`room:${roomId}`); + socket.on('join-room', async ({ roomId, userId }: { roomId: string; userId: string }) => { + try { + // Join the socket room + socket.join(`room:${roomId}`) - // Mark member as online - await setMemberOnline(roomId, userId, true); + // Mark member as online + await setMemberOnline(roomId, userId, true) - // Get room data - const members = await getRoomMembers(roomId); - const memberPlayers = await getRoomActivePlayers(roomId); + // Get room data + const members = await getRoomMembers(roomId) + const memberPlayers = await getRoomActivePlayers(roomId) - // Convert memberPlayers Map to object for JSON serialization - const memberPlayersObj: Record = {}; - for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; - } - - // Update session's activePlayers if game hasn't started yet - // This ensures new members' players are included in the session - const roomPlayerIds = await getRoomPlayerIds(roomId); - const sessionUpdated = await updateSessionActivePlayers( - roomId, - roomPlayerIds, - ); - - if (sessionUpdated) { - // Broadcast updated session state to all users in the game room - const updatedSession = await getArcadeSessionByRoom(roomId); - if (updatedSession) { - io!.to(`game:${roomId}`).emit("session-state", { - gameState: updatedSession.gameState, - currentGame: updatedSession.currentGame, - gameUrl: updatedSession.gameUrl, - activePlayers: updatedSession.activePlayers, - version: updatedSession.version, - }); - } - } - - // Send current room state to the joining user - socket.emit("room-joined", { - roomId, - members, - memberPlayers: memberPlayersObj, - }); - - // Notify all other members in the room - socket.to(`room:${roomId}`).emit("member-joined", { - roomId, - userId, - members, - memberPlayers: memberPlayersObj, - }); - } catch (error) { - console.error("Error joining room:", error); - socket.emit("room-error", { error: "Failed to join room" }); + // Convert memberPlayers Map to object for JSON serialization + const memberPlayersObj: Record = {} + for (const [uid, players] of memberPlayers.entries()) { + memberPlayersObj[uid] = players } - }, - ); + + // Update session's activePlayers if game hasn't started yet + // This ensures new members' players are included in the session + const roomPlayerIds = await getRoomPlayerIds(roomId) + const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds) + + if (sessionUpdated) { + // Broadcast updated session state to all users in the game room + const updatedSession = await getArcadeSessionByRoom(roomId) + if (updatedSession) { + io!.to(`game:${roomId}`).emit('session-state', { + gameState: updatedSession.gameState, + currentGame: updatedSession.currentGame, + gameUrl: updatedSession.gameUrl, + activePlayers: updatedSession.activePlayers, + version: updatedSession.version, + }) + } + } + + // Send current room state to the joining user + socket.emit('room-joined', { + roomId, + members, + memberPlayers: memberPlayersObj, + }) + + // Notify all other members in the room + socket.to(`room:${roomId}`).emit('member-joined', { + roomId, + userId, + members, + memberPlayers: memberPlayersObj, + }) + } catch (error) { + console.error('Error joining room:', error) + socket.emit('room-error', { error: 'Failed to join room' }) + } + }) // User Channel: Join (for moderation events) - socket.on("join-user-channel", async ({ userId }: { userId: string }) => { + socket.on('join-user-channel', async ({ userId }: { userId: string }) => { try { // Join user-specific channel for moderation notifications - socket.join(`user:${userId}`); + socket.join(`user:${userId}`) } catch (error) { - console.error("Error joining user channel:", error); + console.error('Error joining user channel:', error) } - }); + }) // Room: Leave - socket.on( - "leave-room", - async ({ roomId, userId }: { roomId: string; userId: string }) => { - try { - // Leave the socket room - socket.leave(`room:${roomId}`); + socket.on('leave-room', async ({ roomId, userId }: { roomId: string; userId: string }) => { + try { + // Leave the socket room + socket.leave(`room:${roomId}`) - // Mark member as offline - await setMemberOnline(roomId, userId, false); + // Mark member as offline + await setMemberOnline(roomId, userId, false) - // Get updated members - const members = await getRoomMembers(roomId); - const memberPlayers = await getRoomActivePlayers(roomId); + // Get updated members + const members = await getRoomMembers(roomId) + const memberPlayers = await getRoomActivePlayers(roomId) - // Convert memberPlayers Map to object - const memberPlayersObj: Record = {}; - for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; - } - - // Notify remaining members - io!.to(`room:${roomId}`).emit("member-left", { - roomId, - userId, - members, - memberPlayers: memberPlayersObj, - }); - } catch (error) { - console.error("Error leaving room:", error); + // Convert memberPlayers Map to object + const memberPlayersObj: Record = {} + for (const [uid, players] of memberPlayers.entries()) { + memberPlayersObj[uid] = players } - }, - ); + + // Notify remaining members + io!.to(`room:${roomId}`).emit('member-left', { + roomId, + userId, + members, + memberPlayers: memberPlayersObj, + }) + } catch (error) { + console.error('Error leaving room:', error) + } + }) // Room: Players updated - socket.on( - "players-updated", - async ({ roomId, userId }: { roomId: string; userId: string }) => { - try { - // Get updated player data - const memberPlayers = await getRoomActivePlayers(roomId); + socket.on('players-updated', async ({ roomId, userId }: { roomId: string; userId: string }) => { + try { + // Get updated player data + const memberPlayers = await getRoomActivePlayers(roomId) - // Convert memberPlayers Map to object - const memberPlayersObj: Record = {}; - for (const [uid, players] of memberPlayers.entries()) { - memberPlayersObj[uid] = players; - } - - // Update session's activePlayers if game hasn't started yet - const roomPlayerIds = await getRoomPlayerIds(roomId); - const sessionUpdated = await updateSessionActivePlayers( - roomId, - roomPlayerIds, - ); - - if (sessionUpdated) { - // Broadcast updated session state to all users in the game room - const updatedSession = await getArcadeSessionByRoom(roomId); - if (updatedSession) { - io!.to(`game:${roomId}`).emit("session-state", { - gameState: updatedSession.gameState, - currentGame: updatedSession.currentGame, - gameUrl: updatedSession.gameUrl, - activePlayers: updatedSession.activePlayers, - version: updatedSession.version, - }); - } - } - - // Broadcast to all members in the room (including sender) - io!.to(`room:${roomId}`).emit("room-players-updated", { - roomId, - memberPlayers: memberPlayersObj, - }); - } catch (error) { - console.error("Error updating room players:", error); - socket.emit("room-error", { error: "Failed to update players" }); + // Convert memberPlayers Map to object + const memberPlayersObj: Record = {} + for (const [uid, players] of memberPlayers.entries()) { + memberPlayersObj[uid] = players } - }, - ); - socket.on("disconnect", () => { + // Update session's activePlayers if game hasn't started yet + const roomPlayerIds = await getRoomPlayerIds(roomId) + const sessionUpdated = await updateSessionActivePlayers(roomId, roomPlayerIds) + + if (sessionUpdated) { + // Broadcast updated session state to all users in the game room + const updatedSession = await getArcadeSessionByRoom(roomId) + if (updatedSession) { + io!.to(`game:${roomId}`).emit('session-state', { + gameState: updatedSession.gameState, + currentGame: updatedSession.currentGame, + gameUrl: updatedSession.gameUrl, + activePlayers: updatedSession.activePlayers, + version: updatedSession.version, + }) + } + } + + // Broadcast to all members in the room (including sender) + io!.to(`room:${roomId}`).emit('room-players-updated', { + roomId, + memberPlayers: memberPlayersObj, + }) + } catch (error) { + console.error('Error updating room players:', error) + socket.emit('room-error', { error: 'Failed to update players' }) + } + }) + + socket.on('disconnect', () => { // Don't delete session on disconnect - it persists across devices - }); - }); + }) + }) // Store in globalThis to make accessible across module boundaries - globalThis.__socketIO = io; - return io; + globalThis.__socketIO = io + return io } diff --git a/apps/web/src/stories/AbacusReact.progressive-test-suite.stories.tsx b/apps/web/src/stories/AbacusReact.progressive-test-suite.stories.tsx index 41be34bd..78f15e4d 100644 --- a/apps/web/src/stories/AbacusReact.progressive-test-suite.stories.tsx +++ b/apps/web/src/stories/AbacusReact.progressive-test-suite.stories.tsx @@ -1,128 +1,111 @@ -import { AbacusReact } from "@soroban/abacus-react"; -import type { Meta, StoryObj } from "@storybook/react"; -import React, { useCallback, useState } from "react"; -import { generateAbacusInstructions } from "../utils/abacusInstructionGenerator"; +import { AbacusReact } from '@soroban/abacus-react' +import type { Meta, StoryObj } from '@storybook/react' +import React, { useCallback, useState } from 'react' +import { generateAbacusInstructions } from '../utils/abacusInstructionGenerator' // Use the real instruction generator - much cleaner! const _getRealInstructions = (startValue: number, targetValue: number) => { - console.log( - `🔄 Generating REAL instructions: ${startValue} → ${targetValue}`, - ); + console.log(`🔄 Generating REAL instructions: ${startValue} → ${targetValue}`) - const realInstruction = generateAbacusInstructions(startValue, targetValue); + const realInstruction = generateAbacusInstructions(startValue, targetValue) return { stepBeadHighlights: realInstruction.stepBeadHighlights || [], totalSteps: realInstruction.totalSteps || 0, multiStepInstructions: realInstruction.multiStepInstructions || [], - }; -}; + } +} // Reusable story component const ProgressiveTestComponent: React.FC<{ - title: string; - startValue: number; - targetValue: number; - columns?: number; - description?: string; + title: string + startValue: number + targetValue: number + columns?: number + description?: string }> = ({ title, startValue, targetValue, columns = 2, description }) => { - const [currentValue, setCurrentValue] = useState(startValue); - const [currentStep, setCurrentStep] = useState(0); + const [currentValue, setCurrentValue] = useState(startValue) + const [currentStep, setCurrentStep] = useState(0) - const userHasInteracted = React.useRef(false); - const lastValueForStepAdvancement = React.useRef(currentValue); + const userHasInteracted = React.useRef(false) + const lastValueForStepAdvancement = React.useRef(currentValue) // Generate expected steps from the real instruction generator const fullInstruction = React.useMemo(() => { - return generateAbacusInstructions(startValue, targetValue); - }, [startValue, targetValue]); + return generateAbacusInstructions(startValue, targetValue) + }, [startValue, targetValue]) const expectedSteps = React.useMemo(() => { - if ( - !fullInstruction.stepBeadHighlights || - !fullInstruction.multiStepInstructions - ) { - return []; + if (!fullInstruction.stepBeadHighlights || !fullInstruction.multiStepInstructions) { + return [] } // Extract unique step indices and create milestones by simulating bead movements const stepIndices = [ - ...new Set( - fullInstruction.stepBeadHighlights.map((bead) => bead.stepIndex), - ), - ].sort(); - const steps = []; - let currentAbacusValue = startValue; + ...new Set(fullInstruction.stepBeadHighlights.map((bead) => bead.stepIndex)), + ].sort() + const steps = [] + let currentAbacusValue = startValue stepIndices.forEach((stepIndex, i) => { - const description = - fullInstruction.multiStepInstructions?.[i] || `Step ${i + 1}`; + const description = fullInstruction.multiStepInstructions?.[i] || `Step ${i + 1}` const stepBeads = - fullInstruction.stepBeadHighlights?.filter( - (bead) => bead.stepIndex === stepIndex, - ) || []; + fullInstruction.stepBeadHighlights?.filter((bead) => bead.stepIndex === stepIndex) || [] // Calculate the value change for this step by applying all bead movements - let valueChange = 0; + let valueChange = 0 stepBeads.forEach((bead) => { - const placeMultiplier = 10 ** bead.placeValue; + const placeMultiplier = 10 ** bead.placeValue - if (bead.beadType === "heaven") { + if (bead.beadType === 'heaven') { // Heaven bead is worth 5 in its place value valueChange += - bead.direction === "activate" - ? 5 * placeMultiplier - : -(5 * placeMultiplier); + bead.direction === 'activate' ? 5 * placeMultiplier : -(5 * placeMultiplier) } else { // Earth bead is worth 1 in its place value - valueChange += - bead.direction === "activate" ? placeMultiplier : -placeMultiplier; + valueChange += bead.direction === 'activate' ? placeMultiplier : -placeMultiplier } - }); + }) - currentAbacusValue += valueChange; + currentAbacusValue += valueChange steps.push({ index: i, targetValue: currentAbacusValue, description: description, - }); - }); + }) + }) - console.log("📋 Generated expected steps with calculated values:", steps); - return steps; - }, [fullInstruction, startValue]); + console.log('📋 Generated expected steps with calculated values:', steps) + return steps + }, [fullInstruction, startValue]) const getCurrentStepBeads = useCallback(() => { - if (currentValue === targetValue) return undefined; + if (currentValue === targetValue) return undefined - const currentExpectedStep = expectedSteps[currentStep]; - if (!currentExpectedStep) return undefined; + const currentExpectedStep = expectedSteps[currentStep] + if (!currentExpectedStep) return undefined // CRITICAL FIX: If we've already reached the current step's target, don't show arrows if (currentValue === currentExpectedStep.targetValue) { - console.log( - "🎯 Current step completed, hiding arrows until step advances", - ); - return undefined; + console.log('🎯 Current step completed, hiding arrows until step advances') + return undefined } try { // Generate arrows to get from current value to current expected step's target const dynamicInstruction = generateAbacusInstructions( currentValue, - currentExpectedStep.targetValue, - ); + currentExpectedStep.targetValue + ) // CRITICAL FIX: Set all stepIndex to match currentStep for arrow display - const adjustedStepBeads = dynamicInstruction.stepBeadHighlights?.map( - (bead) => ({ - ...bead, - stepIndex: currentStep, // Force stepIndex to match currentStep - }), - ); + const adjustedStepBeads = dynamicInstruction.stepBeadHighlights?.map((bead) => ({ + ...bead, + stepIndex: currentStep, // Force stepIndex to match currentStep + })) - console.log("🔄 Dynamic instruction:", { + console.log('🔄 Dynamic instruction:', { from: currentValue, to: currentExpectedStep.targetValue, expectedStepIndex: currentStep, @@ -130,28 +113,28 @@ const ProgressiveTestComponent: React.FC<{ originalStepBeads: dynamicInstruction.stepBeadHighlights, adjustedStepBeads: adjustedStepBeads, stepCount: adjustedStepBeads?.length || 0, - }); - return adjustedStepBeads; + }) + return adjustedStepBeads } catch (error) { - console.error("Failed to generate dynamic instruction:", error); - return undefined; + console.error('Failed to generate dynamic instruction:', error) + return undefined } - }, [currentValue, currentStep, expectedSteps, targetValue]); + }, [currentValue, currentStep, expectedSteps, targetValue]) - const currentStepBeads = getCurrentStepBeads(); + const currentStepBeads = getCurrentStepBeads() const handleValueChange = (newValue: number) => { - console.log("👆 User clicked, value changed:", currentValue, "→", newValue); - userHasInteracted.current = true; - setCurrentValue(newValue); - }; + console.log('👆 User clicked, value changed:', currentValue, '→', newValue) + userHasInteracted.current = true + setCurrentValue(newValue) + } // Auto-advancement logic (restored from working version) React.useEffect(() => { - const valueChanged = currentValue !== lastValueForStepAdvancement.current; - const currentExpectedStep = expectedSteps[currentStep]; + const valueChanged = currentValue !== lastValueForStepAdvancement.current + const currentExpectedStep = expectedSteps[currentStep] - console.log("🔍 Expected step advancement check:", { + console.log('🔍 Expected step advancement check:', { currentValue, lastValue: lastValueForStepAdvancement.current, valueChanged, @@ -163,7 +146,7 @@ const ProgressiveTestComponent: React.FC<{ : false, totalExpectedSteps: expectedSteps.length, finalTargetReached: currentValue === targetValue, - }); + }) if ( valueChanged && @@ -172,69 +155,58 @@ const ProgressiveTestComponent: React.FC<{ currentExpectedStep ) { if (currentValue === currentExpectedStep.targetValue) { - const hasMoreExpectedSteps = currentStep < expectedSteps.length - 1; + const hasMoreExpectedSteps = currentStep < expectedSteps.length - 1 - console.log("🎯 Expected step completed:", { + console.log('🎯 Expected step completed:', { completedStep: currentStep, targetReached: currentExpectedStep.targetValue, hasMoreSteps: hasMoreExpectedSteps, willAdvance: hasMoreExpectedSteps, - }); + }) if (hasMoreExpectedSteps) { const timeoutId = setTimeout(() => { - console.log( - "⚡ Advancing to next expected step:", - currentStep, - "→", - currentStep + 1, - ); - setCurrentStep((prev) => prev + 1); - lastValueForStepAdvancement.current = currentValue; - }, 500); // Reduced delay for better UX + console.log('⚡ Advancing to next expected step:', currentStep, '→', currentStep + 1) + setCurrentStep((prev) => prev + 1) + lastValueForStepAdvancement.current = currentValue + }, 500) // Reduced delay for better UX - return () => clearTimeout(timeoutId); + return () => clearTimeout(timeoutId) } } } - }, [currentValue, currentStep, expectedSteps, targetValue]); + }, [currentValue, currentStep, expectedSteps, targetValue]) // Update reference when step changes React.useEffect(() => { - lastValueForStepAdvancement.current = currentValue; - userHasInteracted.current = false; - }, [currentValue]); + lastValueForStepAdvancement.current = currentValue + userHasInteracted.current = false + }, [currentValue]) const resetDemo = () => { - setCurrentValue(startValue); - setCurrentStep(0); - userHasInteracted.current = false; - lastValueForStepAdvancement.current = startValue; - console.log("🔄 Reset demo"); - }; + setCurrentValue(startValue) + setCurrentStep(0) + userHasInteracted.current = false + lastValueForStepAdvancement.current = startValue + console.log('🔄 Reset demo') + } - const progress = - expectedSteps.length > 0 - ? ((currentStep + 1) / expectedSteps.length) * 100 - : 0; + const progress = expectedSteps.length > 0 ? ((currentStep + 1) / expectedSteps.length) * 100 : 0 return ( -
+

{title}

{description && ( -

- {description} -

+

{description}

)} -
+

- Progress: {currentStep + 1} of{" "} - {expectedSteps.length || 1} steps ({Math.round(progress)}%) + Progress: {currentStep + 1} of {expectedSteps.length || 1} steps ( + {Math.round(progress)}%)

- Current Value: {currentValue} |{" "} - Target: {targetValue} + Current Value: {currentValue} | Target: {targetValue}

@@ -251,16 +223,16 @@ const ProgressiveTestComponent: React.FC<{ onValueChange={handleValueChange} /> -
+
- ); + ) } // Rapid-fire simple tests export const RapidTests: Story = { render: () => , -}; +} // Advanced challenging problems with pedagogical expansion displays export const AdvancedComplement: Story = { render: () => { // 🎯 COMPLETELY AUTO-GENERATED: Change these numbers and watch the expansion adjust! - const startValue = 27; - const targetValue = 65; // Changed from 63 to 65 to demonstrate auto-generation - const instruction = generateAbacusInstructions(startValue, targetValue); + const startValue = 27 + const targetValue = 65 // Changed from 63 to 65 to demonstrate auto-generation + const instruction = generateAbacusInstructions(startValue, targetValue) return ( -
+

Advanced: {startValue} + {targetValue - startValue} = {targetValue}

-

+

Multi-place operation with multiple complement steps

- 🤖 100% AUTO-GENERATED: Change the numbers above - and all content updates automatically! + 🤖 100% AUTO-GENERATED: Change the numbers above and all content + updates automatically!

📐 Mathematical Breakdown:

@@ -603,12 +570,8 @@ export const AdvancedComplement: Story = { {instruction.errorMessages.hint}

-

- 📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps): -

-
    +

    📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps):

    +
      {instruction.multiStepInstructions?.map((step, i) => (
    1. {step}
    2. ))} @@ -622,27 +585,27 @@ export const AdvancedComplement: Story = { description="Follow the steps above to complete this problem" />
- ); + ) }, -}; +} export const CrossingHundreds: Story = { render: () => { - const instruction = generateAbacusInstructions(87, 113); + const instruction = generateAbacusInstructions(87, 113) return ( -
+

Crossing Hundreds: 87 + 26 = 113

-

+

Complex operation crossing into hundreds place with complements

📐 Mathematical Breakdown:

@@ -653,12 +616,8 @@ export const CrossingHundreds: Story = { {instruction.errorMessages.hint}

-

- 📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps): -

-
    +

    📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps):

    +
      {instruction.multiStepInstructions?.map((step, i) => (
    1. {step}
    2. ))} @@ -672,27 +631,27 @@ export const CrossingHundreds: Story = { description="Follow the steps above to complete this problem" />
- ); + ) }, -}; +} export const ChallengeNinetyNine: Story = { render: () => { - const instruction = generateAbacusInstructions(99, 100); + const instruction = generateAbacusInstructions(99, 100) return ( -
+

Challenge: 99 + 1 = 100

-

+

Recursive complement: adding 1 when all lower places are at capacity

📐 Mathematical Breakdown:

@@ -703,12 +662,8 @@ export const ChallengeNinetyNine: Story = { {instruction.errorMessages.hint}

-

- 📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps): -

-
    +

    📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps):

    +
      {instruction.multiStepInstructions?.map((step, i) => (
    1. {step}
    2. ))} @@ -722,27 +677,27 @@ export const ChallengeNinetyNine: Story = { description="Follow the steps above to complete this problem" />
- ); + ) }, -}; +} export const MultiStepChain: Story = { render: () => { - const instruction = generateAbacusInstructions(45, 112); + const instruction = generateAbacusInstructions(45, 112) return ( -
+

Multi-Step Chain: 45 + 67 = 112

-

+

Complex multi-step operation requiring multiple place value operations

📐 Mathematical Breakdown:

@@ -753,12 +708,8 @@ export const MultiStepChain: Story = { {instruction.errorMessages.hint}

-

- 📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps): -

-
    +

    📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps):

    +
      {instruction.multiStepInstructions?.map((step, i) => (
    1. {step}
    2. ))} @@ -772,27 +723,27 @@ export const MultiStepChain: Story = { description="Follow the steps above to complete this problem" />
- ); + ) }, -}; +} export const ExtremeCase: Story = { render: () => { - const instruction = generateAbacusInstructions(89, 112); + const instruction = generateAbacusInstructions(89, 112) return ( -
+

Extreme: 89 + 23 = 112

-

+

Advanced complement operations across multiple place values

📐 Mathematical Breakdown:

@@ -803,12 +754,8 @@ export const ExtremeCase: Story = { {instruction.errorMessages.hint}

-

- 📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps): -

-
    +

    📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps):

    +
      {instruction.multiStepInstructions?.map((step, i) => (
    1. {step}
    2. ))} @@ -822,32 +769,30 @@ export const ExtremeCase: Story = { description="Follow the steps above to complete this problem" />
- ); + ) }, -}; +} // Pedagogical showcase of our original problem export const ShowcaseOriginalProblem: Story = { render: () => { - const instruction = generateAbacusInstructions(3, 17); + const instruction = generateAbacusInstructions(3, 17) return ( -
+

🎯 Showcase: The Original Fixed Problem

-

- This demonstrates the pedagogical step ordering that was broken and is - now fixed. +

+ This demonstrates the pedagogical step ordering that was broken and is now fixed.
- Note the proper ordering: tens place first, then ones place - operations. + Note the proper ordering: tens place first, then ones place operations.

📐 Auto-Generated Mathematical Breakdown:

@@ -858,19 +803,14 @@ export const ShowcaseOriginalProblem: Story = { {instruction.errorMessages.hint}

-

- 📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps): -

-
    +

    📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps):

    +
      {instruction.multiStepInstructions?.map((step, i) => (
    1. {step}
    2. ))}
    -

    - This ordering follows proper abacus pedagogy: highest place value - operations first. +

    + This ordering follows proper abacus pedagogy: highest place value operations first.

@@ -881,39 +821,38 @@ export const ShowcaseOriginalProblem: Story = { description="Follow the auto-generated steps above" />
- ); + ) }, -}; +} // Extract component wrapper for hooks function DynamicAutoGenerationComponent() { - const [startValue, setStartValue] = useState(42); - const [targetValue, setTargetValue] = useState(89); + const [startValue, setStartValue] = useState(42) + const [targetValue, setTargetValue] = useState(89) - const instruction = generateAbacusInstructions(startValue, targetValue); + const instruction = generateAbacusInstructions(startValue, targetValue) return ( -
+

🤖 Live Auto-Generation Demo

-

- Change the numbers below and watch the pedagogical expansion update in - real-time! +

+ Change the numbers below and watch the pedagogical expansion update in real-time!

@@ -922,7 +861,7 @@ function DynamicAutoGenerationComponent() { type="number" value={startValue} onChange={(e) => setStartValue(parseInt(e.target.value, 10) || 0)} - style={{ width: "80px", padding: "5px" }} + style={{ width: '80px', padding: '5px' }} min="0" max="99" /> @@ -932,10 +871,8 @@ function DynamicAutoGenerationComponent() { - setTargetValue(parseInt(e.target.value, 10) || 0) - } - style={{ width: "80px", padding: "5px" }} + onChange={(e) => setTargetValue(parseInt(e.target.value, 10) || 0)} + style={{ width: '80px', padding: '5px' }} min="0" max="999" /> @@ -949,22 +886,22 @@ function DynamicAutoGenerationComponent() {
- ⚡ REAL-TIME AUTO-GENERATION: All content below - updates automatically as you change the numbers! + ⚡ REAL-TIME AUTO-GENERATION: All content below updates automatically as + you change the numbers!

📐 Mathematical Breakdown:

@@ -975,10 +912,8 @@ function DynamicAutoGenerationComponent() { {instruction.errorMessages.hint}

-

- 📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps): -

-
    +

    📋 Auto-Generated Expected Steps ({instruction.totalSteps} steps):

    +
      {instruction.multiStepInstructions?.map((step, i) => (
    1. {step}
    2. ))} @@ -993,62 +928,55 @@ function DynamicAutoGenerationComponent() { description="Try the auto-generated steps above!" />
- ); + ) } // Dynamic demonstration of auto-generation export const DynamicAutoGeneration: Story = { render: () => , -}; +} export const ExpectedStatesCalculation: Story = { render: () => { - const startValue = 3; - const targetValue = 17; - const instruction = generateAbacusInstructions(startValue, targetValue); + const startValue = 3 + const targetValue = 17 + const instruction = generateAbacusInstructions(startValue, targetValue) // Calculate expected states for each step (same logic as tutorial editor) - const expectedStates: number[] = []; + const expectedStates: number[] = [] if (instruction.stepBeadHighlights && instruction.multiStepInstructions) { const stepIndices = [ - ...new Set( - instruction.stepBeadHighlights.map((bead) => bead.stepIndex), - ), - ].sort(); - let currentValue = startValue; + ...new Set(instruction.stepBeadHighlights.map((bead) => bead.stepIndex)), + ].sort() + let currentValue = startValue stepIndices.forEach((stepIndex, _i) => { const stepBeads = instruction.stepBeadHighlights!.filter( - (bead) => bead.stepIndex === stepIndex, - ); - let valueChange = 0; + (bead) => bead.stepIndex === stepIndex + ) + let valueChange = 0 stepBeads.forEach((bead) => { - const placeMultiplier = 10 ** bead.placeValue; - if (bead.beadType === "heaven") { + const placeMultiplier = 10 ** bead.placeValue + if (bead.beadType === 'heaven') { valueChange += - bead.direction === "activate" - ? 5 * placeMultiplier - : -(5 * placeMultiplier); + bead.direction === 'activate' ? 5 * placeMultiplier : -(5 * placeMultiplier) } else { - valueChange += - bead.direction === "activate" - ? placeMultiplier - : -placeMultiplier; + valueChange += bead.direction === 'activate' ? placeMultiplier : -placeMultiplier } - }); + }) - currentValue += valueChange; - expectedStates.push(currentValue); - }); + currentValue += valueChange + expectedStates.push(currentValue) + }) } return ( -
+

🎯 Expected States Calculation Demo

-

- This demonstrates the expected state calculation feature now available - in the tutorial editor +

+ This demonstrates the expected state calculation feature now available in the tutorial + editor

@@ -1060,52 +988,48 @@ export const ExpectedStatesCalculation: Story = {

-

- 📊 Step-by-Step Expected States: -

+

📊 Step-by-Step Expected States:

-
Step
-
Instruction
-
Expected State
+
Step
+
Instruction
+
Expected State
Initial
Starting position
-
- {startValue} -
+
{startValue}
{instruction.multiStepInstructions?.map((step, index) => ( <>
Step {index + 1}
{step}
-
- {expectedStates[index] || "?"} +
+ {expectedStates[index] || '?'}
))} @@ -1114,17 +1038,17 @@ export const ExpectedStatesCalculation: Story = {

✨ Tutorial Editor Enhancement:

-

- Fixed major bug: multiStepInstructions now match stepBeadHighlights - in pedagogical order. The tutorial editor shows expected states - below each multi-step instruction in the correct sequence. +

+ Fixed major bug: multiStepInstructions now match stepBeadHighlights in pedagogical + order. The tutorial editor shows expected states below each multi-step instruction in + the correct sequence.

@@ -1135,6 +1059,6 @@ export const ExpectedStatesCalculation: Story = { description="Follow the steps above to see the progression" />
- ); + ) }, -}; +} diff --git a/apps/web/src/stories/Button.stories.ts b/apps/web/src/stories/Button.stories.ts index d0e26710..e01cc6d7 100644 --- a/apps/web/src/stories/Button.stories.ts +++ b/apps/web/src/stories/Button.stories.ts @@ -1,54 +1,54 @@ -import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { Meta, StoryObj } from '@storybook/nextjs' -import { fn } from "storybook/test"; +import { fn } from 'storybook/test' -import { Button } from "./Button"; +import { Button } from './Button' // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = { - title: "Example/Button", + title: 'Example/Button', component: Button, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout - layout: "centered", + layout: 'centered', }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs - tags: ["autodocs"], + tags: ['autodocs'], // More on argTypes: https://storybook.js.org/docs/api/argtypes argTypes: { - backgroundColor: { control: "color" }, + backgroundColor: { control: 'color' }, }, // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args args: { onClick: fn() }, -} satisfies Meta; +} satisfies Meta -export default meta; -type Story = StoryObj; +export default meta +type Story = StoryObj // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args export const Primary: Story = { args: { primary: true, - label: "Button", + label: 'Button', }, -}; +} export const Secondary: Story = { args: { - label: "Button", + label: 'Button', }, -}; +} export const Large: Story = { args: { - size: "large", - label: "Button", + size: 'large', + label: 'Button', }, -}; +} export const Small: Story = { args: { - size: "small", - label: "Button", + size: 'small', + label: 'Button', }, -}; +} diff --git a/apps/web/src/stories/Button.tsx b/apps/web/src/stories/Button.tsx index 0773d460..a1b1a564 100644 --- a/apps/web/src/stories/Button.tsx +++ b/apps/web/src/stories/Button.tsx @@ -1,35 +1,31 @@ -import "./button.css"; +import './button.css' export interface ButtonProps { /** Is this the principal call to action on the page? */ - primary?: boolean; + primary?: boolean /** What background color to use */ - backgroundColor?: string; + backgroundColor?: string /** How large should the button be? */ - size?: "small" | "medium" | "large"; + size?: 'small' | 'medium' | 'large' /** Button contents */ - label: string; + label: string /** Optional click handler */ - onClick?: () => void; + onClick?: () => void } /** Primary UI component for user interaction */ export const Button = ({ primary = false, - size = "medium", + size = 'medium', backgroundColor, label, ...props }: ButtonProps) => { - const mode = primary - ? "storybook-button--primary" - : "storybook-button--secondary"; + const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary' return ( - ); -}; + ) +} diff --git a/apps/web/src/stories/Header.stories.ts b/apps/web/src/stories/Header.stories.ts index c5a31d2d..097c36f6 100644 --- a/apps/web/src/stories/Header.stories.ts +++ b/apps/web/src/stories/Header.stories.ts @@ -1,34 +1,34 @@ -import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { Meta, StoryObj } from '@storybook/nextjs' -import { fn } from "storybook/test"; +import { fn } from 'storybook/test' -import { Header } from "./Header"; +import { Header } from './Header' const meta = { - title: "Example/Header", + title: 'Example/Header', component: Header, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs - tags: ["autodocs"], + tags: ['autodocs'], parameters: { // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout - layout: "fullscreen", + layout: 'fullscreen', }, args: { onLogin: fn(), onLogout: fn(), onCreateAccount: fn(), }, -} satisfies Meta; +} satisfies Meta -export default meta; -type Story = StoryObj; +export default meta +type Story = StoryObj export const LoggedIn: Story = { args: { user: { - name: "Jane Doe", + name: 'Jane Doe', }, }, -}; +} -export const LoggedOut: Story = {}; +export const LoggedOut: Story = {} diff --git a/apps/web/src/stories/Header.tsx b/apps/web/src/stories/Header.tsx index d3f3ccf0..94359e96 100644 --- a/apps/web/src/stories/Header.tsx +++ b/apps/web/src/stories/Header.tsx @@ -1,32 +1,22 @@ -import { Button } from "./Button"; -import "./header.css"; +import { Button } from './Button' +import './header.css' type User = { - name: string; -}; - -export interface HeaderProps { - user?: User; - onLogin?: () => void; - onLogout?: () => void; - onCreateAccount?: () => void; + name: string } -export const Header = ({ - user, - onLogin, - onLogout, - onCreateAccount, -}: HeaderProps) => ( +export interface HeaderProps { + user?: User + onLogin?: () => void + onLogout?: () => void + onCreateAccount?: () => void +} + +export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
- + - + )}
-); +) diff --git a/apps/web/src/stories/Page.stories.ts b/apps/web/src/stories/Page.stories.ts index 78d61ea6..65b7a87d 100644 --- a/apps/web/src/stories/Page.stories.ts +++ b/apps/web/src/stories/Page.stories.ts @@ -1,33 +1,33 @@ -import type { Meta, StoryObj } from "@storybook/nextjs"; +import type { Meta, StoryObj } from '@storybook/nextjs' -import { expect, userEvent, within } from "storybook/test"; +import { expect, userEvent, within } from 'storybook/test' -import { Page } from "./Page"; +import { Page } from './Page' const meta = { - title: "Example/Page", + title: 'Example/Page', component: Page, parameters: { // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout - layout: "fullscreen", + layout: 'fullscreen', }, -} satisfies Meta; +} satisfies Meta -export default meta; -type Story = StoryObj; +export default meta +type Story = StoryObj -export const LoggedOut: Story = {}; +export const LoggedOut: Story = {} // More on component testing: https://storybook.js.org/docs/writing-tests/interaction-testing export const LoggedIn: Story = { play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const loginButton = canvas.getByRole("button", { name: /Log in/i }); - await expect(loginButton).toBeInTheDocument(); - await userEvent.click(loginButton); - await expect(loginButton).not.toBeInTheDocument(); + const canvas = within(canvasElement) + const loginButton = canvas.getByRole('button', { name: /Log in/i }) + await expect(loginButton).toBeInTheDocument() + await userEvent.click(loginButton) + await expect(loginButton).not.toBeInTheDocument() - const logoutButton = canvas.getByRole("button", { name: /Log out/i }); - await expect(logoutButton).toBeInTheDocument(); + const logoutButton = canvas.getByRole('button', { name: /Log out/i }) + await expect(logoutButton).toBeInTheDocument() }, -}; +} diff --git a/apps/web/src/stories/Page.tsx b/apps/web/src/stories/Page.tsx index 3a9df545..dd2b6b50 100644 --- a/apps/web/src/stories/Page.tsx +++ b/apps/web/src/stories/Page.tsx @@ -1,80 +1,62 @@ -import React from "react"; +import React from 'react' -import { Header } from "./Header"; -import "./page.css"; +import { Header } from './Header' +import './page.css' type User = { - name: string; -}; + name: string +} export const Page: React.FC = () => { - const [user, setUser] = React.useState(); + const [user, setUser] = React.useState() return (
setUser({ name: "Jane Doe" })} + onLogin={() => setUser({ name: 'Jane Doe' })} onLogout={() => setUser(undefined)} - onCreateAccount={() => setUser({ name: "Jane Doe" })} + onCreateAccount={() => setUser({ name: 'Jane Doe' })} />

Pages in Storybook

- We recommend building UIs with a{" "} - + We recommend building UIs with a{' '} + component-driven - {" "} + {' '} process starting with atomic components and ending with pages.

- Render pages with mock data. This makes it easy to build and review - page states without needing to navigate to them in your app. Here are - some handy patterns for managing page data in Storybook: + Render pages with mock data. This makes it easy to build and review page states without + needing to navigate to them in your app. Here are some handy patterns for managing page + data in Storybook:

  • - Use a higher-level connected component. Storybook helps you compose - such data from the "args" of child component stories + Use a higher-level connected component. Storybook helps you compose such data from the + "args" of child component stories
  • - Assemble data in the page component from your services. You can mock - these services out using Storybook. + Assemble data in the page component from your services. You can mock these services out + using Storybook.

- Get a guided tutorial on component-driven development at{" "} - + Get a guided tutorial on component-driven development at{' '} + Storybook tutorials - . Read more in the{" "} - + . Read more in the{' '} + docs .

- Tip Adjust the width of the canvas with - the{" "} - + Tip Adjust the width of the canvas with the{' '} + {
- ); -}; + ) +} diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index d0de870d..c44951a6 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -1 +1 @@ -import "@testing-library/jest-dom"; +import '@testing-library/jest-dom' diff --git a/apps/web/src/types/build-info.d.ts b/apps/web/src/types/build-info.d.ts index f490465d..273af60a 100644 --- a/apps/web/src/types/build-info.d.ts +++ b/apps/web/src/types/build-info.d.ts @@ -1,20 +1,20 @@ -declare module "@/generated/build-info.json" { +declare module '@/generated/build-info.json' { interface BuildInfo { - version: string; - buildTime: string; - buildTimestamp: number; + version: string + buildTime: string + buildTimestamp: number git: { - commit: string | null; - commitShort: string | null; - branch: string | null; - tag: string | null; - isDirty: boolean; - }; - environment: string; - buildNumber: string | null; - nodeVersion: string; + commit: string | null + commitShort: string | null + branch: string | null + tag: string | null + isDirty: boolean + } + environment: string + buildNumber: string | null + nodeVersion: string } - const buildInfo: BuildInfo; - export default buildInfo; + const buildInfo: BuildInfo + export default buildInfo } diff --git a/apps/web/src/types/player.ts b/apps/web/src/types/player.ts index afeba882..c34ef7fe 100644 --- a/apps/web/src/types/player.ts +++ b/apps/web/src/types/player.ts @@ -2,47 +2,45 @@ * Default player colors (used during player creation) */ export const DEFAULT_PLAYER_COLORS = [ - "#3b82f6", // Blue - "#8b5cf6", // Purple - "#10b981", // Green - "#f59e0b", // Orange - "#ef4444", // Red - "#14b8a6", // Teal - "#f97316", // Deep Orange - "#6366f1", // Indigo - "#ec4899", // Pink - "#84cc16", // Lime -]; + '#3b82f6', // Blue + '#8b5cf6', // Purple + '#10b981', // Green + '#f59e0b', // Orange + '#ef4444', // Red + '#14b8a6', // Teal + '#f97316', // Deep Orange + '#6366f1', // Indigo + '#ec4899', // Pink + '#84cc16', // Lime +] /** * Client-side Player type (for contexts/components) * Matches database Player type but with flexible createdAt */ export interface Player { - id: string; - name: string; - emoji: string; - color: string; - createdAt: Date | number; - isActive?: boolean; - isLocal?: boolean; + id: string + name: string + emoji: string + color: string + createdAt: Date | number + isActive?: boolean + isLocal?: boolean } /** * Get a color for a new player (cycles through defaults) */ export function getNextPlayerColor(existingPlayers: Player[]): string { - const usedColors = new Set(existingPlayers.map((p) => p.color)); + const usedColors = new Set(existingPlayers.map((p) => p.color)) // Find first unused color for (const color of DEFAULT_PLAYER_COLORS) { if (!usedColors.has(color)) { - return color; + return color } } // If all colors used, cycle back - return DEFAULT_PLAYER_COLORS[ - existingPlayers.length % DEFAULT_PLAYER_COLORS.length - ]; + return DEFAULT_PLAYER_COLORS[existingPlayers.length % DEFAULT_PLAYER_COLORS.length] } diff --git a/apps/web/src/types/soroban-abacus-react.d.ts b/apps/web/src/types/soroban-abacus-react.d.ts index b0b5a079..6132633c 100644 --- a/apps/web/src/types/soroban-abacus-react.d.ts +++ b/apps/web/src/types/soroban-abacus-react.d.ts @@ -1,37 +1,33 @@ -declare module "@soroban/abacus-react" { +declare module '@soroban/abacus-react' { export interface AbacusProps { - value: number; - style?: string; - size?: number; + value: number + style?: string + size?: number beadHighlights?: Array<{ - placeValue: number; - beadType: "earth" | "heaven"; - position?: number; - }>; - readonly?: boolean; - showPlaceValues?: boolean; - onBeadClick?: ( - placeValue: number, - beadType: "earth" | "heaven", - position: number, - ) => void; + placeValue: number + beadType: 'earth' | 'heaven' + position?: number + }> + readonly?: boolean + showPlaceValues?: boolean + onBeadClick?: (placeValue: number, beadType: 'earth' | 'heaven', position: number) => void } - export const Abacus: React.ComponentType; + export const Abacus: React.ComponentType export interface BeadPosition { - placeValue: number; - beadType: "earth" | "heaven"; - position?: number; + placeValue: number + beadType: 'earth' | 'heaven' + position?: number } export interface AbacusState { - beads: BeadPosition[]; - value: number; + beads: BeadPosition[] + value: number } - export function createAbacusState(value: number): AbacusState; - export function calculateValue(state: AbacusState): number; - export function addToAbacus(state: AbacusState, amount: number): AbacusState; - export function resetAbacus(): AbacusState; + export function createAbacusState(value: number): AbacusState + export function calculateValue(state: AbacusState): number + export function addToAbacus(state: AbacusState, amount: number): AbacusState + export function resetAbacus(): AbacusState } diff --git a/apps/web/src/types/tutorial.ts b/apps/web/src/types/tutorial.ts index 3144f02c..c06ba1f9 100644 --- a/apps/web/src/types/tutorial.ts +++ b/apps/web/src/types/tutorial.ts @@ -1,98 +1,98 @@ // Tutorial system type definitions export interface TutorialStep { - id: string; - title: string; - problem: string; - description: string; - startValue: number; - targetValue: number; + id: string + title: string + problem: string + description: string + startValue: number + targetValue: number highlightBeads?: Array<{ - placeValue: number; - beadType: "heaven" | "earth"; - position?: number; // for earth beads, 0-3 - }>; + placeValue: number + beadType: 'heaven' | 'earth' + position?: number // for earth beads, 0-3 + }> // Progressive step-based highlighting with directions stepBeadHighlights?: Array<{ - placeValue: number; - beadType: "heaven" | "earth"; - position?: number; // for earth beads, 0-3 - stepIndex: number; // Which instruction step this bead belongs to - direction: "up" | "down" | "activate" | "deactivate"; // Movement direction - order?: number; // Order within the step (for multiple beads per step) - }>; - totalSteps?: number; // Total number of instruction steps - expectedAction: "add" | "remove" | "multi-step"; - actionDescription: string; + placeValue: number + beadType: 'heaven' | 'earth' + position?: number // for earth beads, 0-3 + stepIndex: number // Which instruction step this bead belongs to + direction: 'up' | 'down' | 'activate' | 'deactivate' // Movement direction + order?: number // Order within the step (for multiple beads per step) + }> + totalSteps?: number // Total number of instruction steps + expectedAction: 'add' | 'remove' | 'multi-step' + actionDescription: string tooltip: { - content: string; - explanation: string; - }; + content: string + explanation: string + } // errorMessages removed - bead diff tooltip provides better guidance - multiStepInstructions?: string[]; - position?: number; // Position in unified tutorial flow + multiStepInstructions?: string[] + position?: number // Position in unified tutorial flow } // Skill-based system for practice problem generation export interface SkillSet { // Five complements (single-column operations) fiveComplements: { - "4=5-1": boolean; - "3=5-2": boolean; - "2=5-3": boolean; - "1=5-4": boolean; - }; + '4=5-1': boolean + '3=5-2': boolean + '2=5-3': boolean + '1=5-4': boolean + } // Ten complements (carrying operations) tenComplements: { - "9=10-1": boolean; - "8=10-2": boolean; - "7=10-3": boolean; - "6=10-4": boolean; - "5=10-5": boolean; - "4=10-6": boolean; - "3=10-7": boolean; - "2=10-8": boolean; - "1=10-9": boolean; - }; + '9=10-1': boolean + '8=10-2': boolean + '7=10-3': boolean + '6=10-4': boolean + '5=10-5': boolean + '4=10-6': boolean + '3=10-7': boolean + '2=10-8': boolean + '1=10-9': boolean + } // Basic operations basic: { - directAddition: boolean; // Can add 1-4 directly - heavenBead: boolean; // Can use heaven bead (5) - simpleCombinations: boolean; // Can do 6-9 without complements - }; + directAddition: boolean // Can add 1-4 directly + heavenBead: boolean // Can use heaven bead (5) + simpleCombinations: boolean // Can do 6-9 without complements + } } export interface PracticeStep { - id: string; - title: string; - description: string; + id: string + title: string + description: string // Problem generation settings - problemCount: number; - maxTerms: number; // max numbers to add in a single problem + problemCount: number + maxTerms: number // max numbers to add in a single problem // Skill-based constraints - requiredSkills: SkillSet; // Skills user must know - targetSkills?: Partial; // Skills to specifically practice (optional) - forbiddenSkills?: Partial; // Skills user hasn't learned yet (optional) + requiredSkills: SkillSet // Skills user must know + targetSkills?: Partial // Skills to specifically practice (optional) + forbiddenSkills?: Partial // Skills user hasn't learned yet (optional) // Advanced constraints (optional) - numberRange?: { min: number; max: number }; - sumConstraints?: { maxSum: number; minSum?: number }; + numberRange?: { min: number; max: number } + sumConstraints?: { maxSum: number; minSum?: number } // Legacy support for existing system - skillLevel?: "basic" | "heaven" | "five-complements" | "mixed"; + skillLevel?: 'basic' | 'heaven' | 'five-complements' | 'mixed' // Tutorial integration - position?: number; // Where in tutorial flow this appears + position?: number // Where in tutorial flow this appears } export interface Problem { - id: string; - terms: number[]; - userAnswer?: number; - isCorrect?: boolean; + id: string + terms: number[] + userAnswer?: number + isCorrect?: boolean } // Utility functions for skill management @@ -104,23 +104,23 @@ export function createEmptySkillSet(): SkillSet { simpleCombinations: false, }, fiveComplements: { - "4=5-1": false, - "3=5-2": false, - "2=5-3": false, - "1=5-4": false, + '4=5-1': false, + '3=5-2': false, + '2=5-3': false, + '1=5-4': false, }, tenComplements: { - "9=10-1": false, - "8=10-2": false, - "7=10-3": false, - "6=10-4": false, - "5=10-5": false, - "4=10-6": false, - "3=10-7": false, - "2=10-8": false, - "1=10-9": false, + '9=10-1': false, + '8=10-2': false, + '7=10-3': false, + '6=10-4': false, + '5=10-5': false, + '4=10-6': false, + '3=10-7': false, + '2=10-8': false, + '1=10-9': false, }, - }; + } } export function createBasicSkillSet(): SkillSet { @@ -131,183 +131,183 @@ export function createBasicSkillSet(): SkillSet { simpleCombinations: false, }, fiveComplements: { - "4=5-1": false, - "3=5-2": false, - "2=5-3": false, - "1=5-4": false, + '4=5-1': false, + '3=5-2': false, + '2=5-3': false, + '1=5-4': false, }, tenComplements: { - "9=10-1": false, - "8=10-2": false, - "7=10-3": false, - "6=10-4": false, - "5=10-5": false, - "4=10-6": false, - "3=10-7": false, - "2=10-8": false, - "1=10-9": false, + '9=10-1': false, + '8=10-2': false, + '7=10-3': false, + '6=10-4': false, + '5=10-5': false, + '4=10-6': false, + '3=10-7': false, + '2=10-8': false, + '1=10-9': false, }, - }; + } } export interface Tutorial { - id: string; - title: string; - description: string; - category: string; - difficulty: "beginner" | "intermediate" | "advanced"; - estimatedDuration: number; // in minutes - steps: TutorialStep[]; - practiceSteps?: PracticeStep[]; - tags: string[]; - author: string; - version: string; - createdAt: Date; - updatedAt: Date; - isPublished: boolean; + id: string + title: string + description: string + category: string + difficulty: 'beginner' | 'intermediate' | 'advanced' + estimatedDuration: number // in minutes + steps: TutorialStep[] + practiceSteps?: PracticeStep[] + tags: string[] + author: string + version: string + createdAt: Date + updatedAt: Date + isPublished: boolean } export interface TutorialProgress { - tutorialId: string; - userId?: string; - currentStepIndex: number; - completedSteps: string[]; // step ids - startedAt: Date; - lastAccessedAt: Date; - completedAt?: Date; - score?: number; - timeSpent: number; // in seconds + tutorialId: string + userId?: string + currentStepIndex: number + completedSteps: string[] // step ids + startedAt: Date + lastAccessedAt: Date + completedAt?: Date + score?: number + timeSpent: number // in seconds } export interface TutorialSession { - id: string; - tutorial: Tutorial; - progress: TutorialProgress; - currentStep: TutorialStep; - isDebugMode: boolean; + id: string + tutorial: Tutorial + progress: TutorialProgress + currentStep: TutorialStep + isDebugMode: boolean debugHistory: Array<{ - stepId: string; - timestamp: Date; - action: string; - value: number; - success: boolean; - }>; + stepId: string + timestamp: Date + action: string + value: number + success: boolean + }> } // Editor specific types export interface TutorialTemplate { - name: string; - description: string; - defaultSteps: Partial[]; + name: string + description: string + defaultSteps: Partial[] } export interface StepValidationError { - stepId: string; - field: string; - message: string; - severity: "error" | "warning"; + stepId: string + field: string + message: string + severity: 'error' | 'warning' } export interface TutorialValidation { - isValid: boolean; - errors: StepValidationError[]; - warnings: StepValidationError[]; + isValid: boolean + errors: StepValidationError[] + warnings: StepValidationError[] } // Access control types (future-ready) export interface UserRole { - id: string; - name: string; - permissions: Permission[]; + id: string + name: string + permissions: Permission[] } export interface Permission { - resource: "tutorial" | "step" | "user" | "system"; - actions: ("create" | "read" | "update" | "delete" | "publish")[]; - conditions?: Record; + resource: 'tutorial' | 'step' | 'user' | 'system' + actions: ('create' | 'read' | 'update' | 'delete' | 'publish')[] + conditions?: Record } export interface AccessContext { - userId?: string; - roles: UserRole[]; - isAuthenticated: boolean; - isAdmin: boolean; - canEdit: boolean; - canPublish: boolean; - canDelete: boolean; + userId?: string + roles: UserRole[] + isAuthenticated: boolean + isAdmin: boolean + canEdit: boolean + canPublish: boolean + canDelete: boolean } // Navigation and UI state types export interface NavigationState { - currentStepIndex: number; - canGoNext: boolean; - canGoPrevious: boolean; - totalSteps: number; - completionPercentage: number; + currentStepIndex: number + canGoNext: boolean + canGoPrevious: boolean + totalSteps: number + completionPercentage: number } export interface UIState { - isPlaying: boolean; - isPaused: boolean; - isEditing: boolean; - showDebugPanel: boolean; - showStepList: boolean; - autoAdvance: boolean; - playbackSpeed: number; + isPlaying: boolean + isPaused: boolean + isEditing: boolean + showDebugPanel: boolean + showStepList: boolean + autoAdvance: boolean + playbackSpeed: number } // Event types for tutorial interaction export type TutorialEvent = - | { type: "STEP_STARTED"; stepId: string; timestamp: Date } + | { type: 'STEP_STARTED'; stepId: string; timestamp: Date } | { - type: "STEP_COMPLETED"; - stepId: string; - success: boolean; - timestamp: Date; + type: 'STEP_COMPLETED' + stepId: string + success: boolean + timestamp: Date } - | { type: "BEAD_CLICKED"; stepId: string; beadInfo: any; timestamp: Date } + | { type: 'BEAD_CLICKED'; stepId: string; beadInfo: any; timestamp: Date } | { - type: "VALUE_CHANGED"; - stepId: string; - oldValue: number; - newValue: number; - timestamp: Date; + type: 'VALUE_CHANGED' + stepId: string + oldValue: number + newValue: number + timestamp: Date } - | { type: "ERROR_OCCURRED"; stepId: string; error: string; timestamp: Date } + | { type: 'ERROR_OCCURRED'; stepId: string; error: string; timestamp: Date } | { - type: "TUTORIAL_COMPLETED"; - tutorialId: string; - score: number; - timestamp: Date; + type: 'TUTORIAL_COMPLETED' + tutorialId: string + score: number + timestamp: Date } | { - type: "TUTORIAL_ABANDONED"; - tutorialId: string; - lastStepId: string; - timestamp: Date; - }; + type: 'TUTORIAL_ABANDONED' + tutorialId: string + lastStepId: string + timestamp: Date + } // API response types export interface TutorialListResponse { - tutorials: Tutorial[]; - total: number; - page: number; - limit: number; - hasMore: boolean; + tutorials: Tutorial[] + total: number + page: number + limit: number + hasMore: boolean } export interface TutorialDetailResponse { - tutorial: Tutorial; - userProgress?: TutorialProgress; - canEdit: boolean; - canDelete: boolean; + tutorial: Tutorial + userProgress?: TutorialProgress + canEdit: boolean + canDelete: boolean } export interface SaveTutorialRequest { - tutorial: Omit; + tutorial: Omit } export interface SaveTutorialResponse { - tutorial: Tutorial; - validation: TutorialValidation; + tutorial: Tutorial + validation: TutorialValidation } diff --git a/apps/web/src/types/vitest.d.ts b/apps/web/src/types/vitest.d.ts index 7d8bb4d3..f717c1fb 100644 --- a/apps/web/src/types/vitest.d.ts +++ b/apps/web/src/types/vitest.d.ts @@ -6,4 +6,4 @@ declare global { // This file ensures TypeScript recognizes describe, it, expect, etc. } -export {}; +export {} diff --git a/apps/web/src/types/yjs-cjs.d.ts b/apps/web/src/types/yjs-cjs.d.ts index ba86268d..a2e45f50 100644 --- a/apps/web/src/types/yjs-cjs.d.ts +++ b/apps/web/src/types/yjs-cjs.d.ts @@ -3,22 +3,22 @@ * These packages provide ESM types but we need to import CJS builds for Node.js server compatibility */ -declare module "y-protocols/dist/sync.cjs" { - import type * as syncProtocol from "y-protocols/sync"; - export = syncProtocol; +declare module 'y-protocols/dist/sync.cjs' { + import type * as syncProtocol from 'y-protocols/sync' + export = syncProtocol } -declare module "y-protocols/dist/awareness.cjs" { - import type * as awarenessProtocol from "y-protocols/awareness"; - export = awarenessProtocol; +declare module 'y-protocols/dist/awareness.cjs' { + import type * as awarenessProtocol from 'y-protocols/awareness' + export = awarenessProtocol } -declare module "lib0/dist/encoding.cjs" { - import type * as encoding from "lib0/encoding"; - export = encoding; +declare module 'lib0/dist/encoding.cjs' { + import type * as encoding from 'lib0/encoding' + export = encoding } -declare module "lib0/dist/decoding.cjs" { - import type * as decoding from "lib0/decoding"; - export = decoding; +declare module 'lib0/dist/decoding.cjs' { + import type * as decoding from 'lib0/decoding' + export = decoding } diff --git a/apps/web/src/utils/__tests__/pedagogicalCore.test.ts b/apps/web/src/utils/__tests__/pedagogicalCore.test.ts index 81257c95..02b1d683 100644 --- a/apps/web/src/utils/__tests__/pedagogicalCore.test.ts +++ b/apps/web/src/utils/__tests__/pedagogicalCore.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { generateUnifiedInstructionSequence } from "../unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import { generateUnifiedInstructionSequence } from '../unifiedStepGenerator' /** * Lean, focused tests for pedagogical algorithm with explicit invariants. @@ -7,9 +7,7 @@ import { generateUnifiedInstructionSequence } from "../unifiedStepGenerator"; */ // Helper to create stable, lean snapshots -function createStableSnapshot( - result: ReturnType, -) { +function createStableSnapshot(result: ReturnType) { return { startValue: result.startValue, targetValue: result.targetValue, @@ -23,304 +21,286 @@ function createStableSnapshot( expectedValue: s.expectedValue, isValid: s.isValid, })), - }; + } } // Helper to validate term-to-position mapping -function assertTermMapping( - seq: ReturnType, -) { - const text = seq.fullDecomposition; +function assertTermMapping(seq: ReturnType) { + const text = seq.fullDecomposition seq.steps.forEach((step, i) => { - const slice = text.slice( - step.termPosition.startIndex, - step.termPosition.endIndex, - ); - const normalized = step.mathematicalTerm.startsWith("-") + const slice = text.slice(step.termPosition.startIndex, step.termPosition.endIndex) + const normalized = step.mathematicalTerm.startsWith('-') ? step.mathematicalTerm.slice(1) - : step.mathematicalTerm; + : step.mathematicalTerm expect( slice, - `Step ${i} term "${step.mathematicalTerm}" should map to "${normalized}" but got "${slice}"`, - ).toBe(normalized); - }); + `Step ${i} term "${step.mathematicalTerm}" should map to "${normalized}" but got "${slice}"` + ).toBe(normalized) + }) } // Helper to validate arithmetic invariant -function assertArithmeticInvariant( - seq: ReturnType, -) { - let currentValue = seq.startValue; +function assertArithmeticInvariant(seq: ReturnType) { + let currentValue = seq.startValue seq.steps.forEach((step, i) => { - const term = step.mathematicalTerm; - const delta = term.startsWith("-") - ? -parseInt(term.slice(1), 10) - : parseInt(term, 10); - currentValue += delta; + const term = step.mathematicalTerm + const delta = term.startsWith('-') ? -parseInt(term.slice(1), 10) : parseInt(term, 10) + currentValue += delta expect( currentValue, - `Step ${i}: ${seq.startValue} + terms should equal ${step.expectedValue}`, - ).toBe(step.expectedValue); - }); - expect(currentValue).toBe(seq.targetValue); + `Step ${i}: ${seq.startValue} + terms should equal ${step.expectedValue}` + ).toBe(step.expectedValue) + }) + expect(currentValue).toBe(seq.targetValue) } // Helper to validate complement shape contract -function assertComplementShape( - seq: ReturnType, -) { - const decomposition = seq.fullDecomposition; - const middlePart = decomposition.split(" = ")[1]?.split(" = ")[0]; +function assertComplementShape(seq: ReturnType) { + const decomposition = seq.fullDecomposition + const middlePart = decomposition.split(' = ')[1]?.split(' = ')[0] // Should contain proper parenthesized complements - const complementMatches = - middlePart?.match(/\((\d+) - (\d+(?:\s-\s\d+)*)\)/g) || []; + const complementMatches = middlePart?.match(/\((\d+) - (\d+(?:\s-\s\d+)*)\)/g) || [] complementMatches.forEach((match) => { // Each complement should start with a power of 10 - const [, positive] = match.match(/\((\d+)/) || []; - const num = parseInt(positive, 10); - expect( - num, - `Complement positive part "${positive}" should be power of 10`, - ).toSatisfy((n: number) => { - if (n < 10) return n === 5; // Five-complements use 5 - return /^10+$/.test(n.toString()); // Ten-complements use 10, 100, 1000, etc. - }); - }); + const [, positive] = match.match(/\((\d+)/) || [] + const num = parseInt(positive, 10) + expect(num, `Complement positive part "${positive}" should be power of 10`).toSatisfy( + (n: number) => { + if (n < 10) return n === 5 // Five-complements use 5 + return /^10+$/.test(n.toString()) // Ten-complements use 10, 100, 1000, etc. + } + ) + }) } // Helper to validate meaningfulness function assertMeaningfulness( seq: ReturnType, - expectedMeaningful: boolean, + expectedMeaningful: boolean ) { expect( seq.isMeaningfulDecomposition, - `${seq.startValue}→${seq.targetValue}: "${seq.fullDecomposition}" meaningfulness`, - ).toBe(expectedMeaningful); + `${seq.startValue}→${seq.targetValue}: "${seq.fullDecomposition}" meaningfulness` + ).toBe(expectedMeaningful) if (expectedMeaningful) { - expect(seq.fullDecomposition).toMatch(/\(/); // Should have parentheses for complements + expect(seq.fullDecomposition).toMatch(/\(/) // Should have parentheses for complements } } -describe("Pedagogical Algorithm - Core Validation", () => { - describe("Term-Position Mapping Invariants", () => { +describe('Pedagogical Algorithm - Core Validation', () => { + describe('Term-Position Mapping Invariants', () => { const criticalCases = [ [3456, 3500], // Complex complement: "3456 + 44 = 3456 + 40 + (100 - 90 - 6) = 3500" [3, 17], // Mixed segments: "3 + 14 = 3 + 10 + (5 - 1) = 17" [9999, 10007], // Multi-cascade: "9999 + 8 = 9999 + (10000 - 1000 - 100 - 10 - 1) = 10007" [4, 7], // Simple five-complement: "4 + 3 = 4 + (5 - 2) = 7" [7, 15], // Simple ten-complement: "7 + 8 = 7 + (10 - 2) = 15" - ]; + ] criticalCases.forEach(([start, target]) => { it(`maps terms correctly for ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - assertTermMapping(result); - assertArithmeticInvariant(result); - assertComplementShape(result); - }); - }); - }); + const result = generateUnifiedInstructionSequence(start, target) + assertTermMapping(result) + assertArithmeticInvariant(result) + assertComplementShape(result) + }) + }) + }) - describe("Meaningfulness Detection", () => { - it("detects non-meaningful decompositions", () => { + describe('Meaningfulness Detection', () => { + it('detects non-meaningful decompositions', () => { const nonMeaningful = [ [0, 0], // Zero case [5, 5], // No change [123, 123], // No change multi-digit - ]; + ] nonMeaningful.forEach(([start, target]) => { - const result = generateUnifiedInstructionSequence(start, target); - assertMeaningfulness(result, false); - expect(result.fullDecomposition).not.toMatch(/\(/); // Should not contain parentheses - }); - }); + const result = generateUnifiedInstructionSequence(start, target) + assertMeaningfulness(result, false) + expect(result.fullDecomposition).not.toMatch(/\(/) // Should not contain parentheses + }) + }) - it("detects meaningful decompositions with explicit parentheses check", () => { + it('detects meaningful decompositions with explicit parentheses check', () => { const meaningful = [ [4, 7], // Five-complement: (5 - 2) [7, 15], // Ten-complement: (10 - 2) [99, 107], // Cascading: (100 - 90 - 2) [3, 6], // Five-complement: (5 - 2) [8, 15], // Ten-complement: (10 - 3) - ]; + ] meaningful.forEach(([start, target]) => { - const result = generateUnifiedInstructionSequence(start, target); - assertMeaningfulness(result, true); + const result = generateUnifiedInstructionSequence(start, target) + assertMeaningfulness(result, true) expect( result.fullDecomposition, - `${start}→${target} should have parentheses for complement`, - ).toMatch(/\(/); - expect(result.isMeaningfulDecomposition).toBe(true); - }); - }); + `${start}→${target} should have parentheses for complement` + ).toMatch(/\(/) + expect(result.isMeaningfulDecomposition).toBe(true) + }) + }) - it("validates specific meaningfulness edge cases", () => { + it('validates specific meaningfulness edge cases', () => { // Test case that should be meaningful due to complement structure - const result = generateUnifiedInstructionSequence(0, 6); - if (result.fullDecomposition.includes("(")) { + const result = generateUnifiedInstructionSequence(0, 6) + if (result.fullDecomposition.includes('(')) { // If it uses complements (5 + 1), it should be meaningful - expect(result.isMeaningfulDecomposition).toBe(true); + expect(result.isMeaningfulDecomposition).toBe(true) } else { // If it's direct addition, it should be non-meaningful - expect(result.isMeaningfulDecomposition).toBe(false); + expect(result.isMeaningfulDecomposition).toBe(false) } - }); - }); + }) + }) - describe("Representative Snapshots (Lean)", () => { + describe('Representative Snapshots (Lean)', () => { // One golden anchor per major pattern type - it("direct entry pattern", () => { - const result = generateUnifiedInstructionSequence(0, 3); - expect(createStableSnapshot(result)).toMatchSnapshot(); - }); + it('direct entry pattern', () => { + const result = generateUnifiedInstructionSequence(0, 3) + expect(createStableSnapshot(result)).toMatchSnapshot() + }) - it("five-complement pattern", () => { - const result = generateUnifiedInstructionSequence(4, 7); - expect(createStableSnapshot(result)).toMatchSnapshot(); - }); + it('five-complement pattern', () => { + const result = generateUnifiedInstructionSequence(4, 7) + expect(createStableSnapshot(result)).toMatchSnapshot() + }) - it("ten-complement pattern", () => { - const result = generateUnifiedInstructionSequence(7, 15); - expect(createStableSnapshot(result)).toMatchSnapshot(); - }); + it('ten-complement pattern', () => { + const result = generateUnifiedInstructionSequence(7, 15) + expect(createStableSnapshot(result)).toMatchSnapshot() + }) - it("cascading complement pattern", () => { - const result = generateUnifiedInstructionSequence(99, 107); - expect(createStableSnapshot(result)).toMatchSnapshot(); - }); + it('cascading complement pattern', () => { + const result = generateUnifiedInstructionSequence(99, 107) + expect(createStableSnapshot(result)).toMatchSnapshot() + }) - it("mixed operations pattern", () => { - const result = generateUnifiedInstructionSequence(43, 51); - expect(createStableSnapshot(result)).toMatchSnapshot(); - }); + it('mixed operations pattern', () => { + const result = generateUnifiedInstructionSequence(43, 51) + expect(createStableSnapshot(result)).toMatchSnapshot() + }) - it("complex multi-cascade pattern", () => { - const result = generateUnifiedInstructionSequence(9999, 10007); - expect(createStableSnapshot(result)).toMatchSnapshot(); - }); - }); + it('complex multi-cascade pattern', () => { + const result = generateUnifiedInstructionSequence(9999, 10007) + expect(createStableSnapshot(result)).toMatchSnapshot() + }) + }) - describe("Edge Cases & Boundary Conditions", () => { + describe('Edge Cases & Boundary Conditions', () => { const edgeCases = [ [0, 0], // Zero operation [9, 10], // Place boundary [99, 100], // Double place boundary [999, 1000], // Triple place boundary - ]; + ] edgeCases.forEach(([start, target]) => { it(`handles edge case ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); + const result = generateUnifiedInstructionSequence(start, target) // Validate basic invariants - expect(result.startValue).toBe(start); - expect(result.targetValue).toBe(target); - expect(result.steps.length).toBeGreaterThanOrEqual(0); + expect(result.startValue).toBe(start) + expect(result.targetValue).toBe(target) + expect(result.steps.length).toBeGreaterThanOrEqual(0) if (result.steps.length > 0) { - assertTermMapping(result); - assertArithmeticInvariant(result); + assertTermMapping(result) + assertArithmeticInvariant(result) } - }); - }); - }); + }) + }) + }) - describe("Micro-Invariant Tests (Critical Behaviors)", () => { + describe('Micro-Invariant Tests (Critical Behaviors)', () => { // Lock specific invariants without snapshots - it("validates term substring mapping precision", () => { + it('validates term substring mapping precision', () => { const cases = [ [3456, 3500], // Complex complement: "3456 + 44 = 3456 + 40 + (100 - 90 - 6) = 3500" [3, 17], // Mixed: "3 + 14 = 3 + 10 + (5 - 1) = 17" [9999, 10007], // Multi-cascade: "9999 + 8 = 9999 + (10000 - 9000 - 900 - 90 - 2) = 10007" - ]; + ] cases.forEach(([start, target]) => { - const result = generateUnifiedInstructionSequence(start, target); - assertTermMapping(result); - }); - }); + const result = generateUnifiedInstructionSequence(start, target) + assertTermMapping(result) + }) + }) - it("validates arithmetic step-by-step consistency", () => { - const result = generateUnifiedInstructionSequence(1234, 1279); + it('validates arithmetic step-by-step consistency', () => { + const result = generateUnifiedInstructionSequence(1234, 1279) - let runningValue = result.startValue; + let runningValue = result.startValue result.steps.forEach((step, i) => { - const term = step.mathematicalTerm; - const delta = term.startsWith("-") - ? -parseInt(term.slice(1), 10) - : parseInt(term, 10); - runningValue += delta; + const term = step.mathematicalTerm + const delta = term.startsWith('-') ? -parseInt(term.slice(1), 10) : parseInt(term, 10) + runningValue += delta - expect( - runningValue, - `Step ${i + 1}: After applying "${term}" to running total`, - ).toBe(step.expectedValue); - }); + expect(runningValue, `Step ${i + 1}: After applying "${term}" to running total`).toBe( + step.expectedValue + ) + }) - expect(runningValue).toBe(result.targetValue); - }); + expect(runningValue).toBe(result.targetValue) + }) - it("validates complement shape contracts", () => { + it('validates complement shape contracts', () => { const complementCases = [ [4, 7], // (5 - 2) [7, 15], // (10 - 2) [99, 107], // (100 - 90 - 2) [9999, 10007], // (10000 - 9000 - 900 - 90 - 2) - ]; + ] complementCases.forEach(([start, target]) => { - const result = generateUnifiedInstructionSequence(start, target); - const decomp = result.fullDecomposition; + const result = generateUnifiedInstructionSequence(start, target) + const decomp = result.fullDecomposition // Extract all complement patterns - const complementMatches = decomp.match(/\((\d+) - ([\d\s-]+)\)/g) || []; + const complementMatches = decomp.match(/\((\d+) - ([\d\s-]+)\)/g) || [] complementMatches.forEach((match) => { // Each complement should start with power of 10 or 5 - const [, positive] = match.match(/\((\d+)/) || []; - const num = parseInt(positive, 10); + const [, positive] = match.match(/\((\d+)/) || [] + const num = parseInt(positive, 10) - const isValidStart = - num === 5 || (num >= 10 && /^10+$/.test(num.toString())); + const isValidStart = num === 5 || (num >= 10 && /^10+$/.test(num.toString())) expect( isValidStart, - `Complement "${match}" should start with 5 or power of 10, got ${num}`, - ).toBe(true); + `Complement "${match}" should start with 5 or power of 10, got ${num}` + ).toBe(true) // All subsequent parts should be subtraction - expect( - match, - "Complement should contain only subtraction after first term", - ).toMatch(/\(\d+ - [\d\s-]+\)/); - }); - }); - }); + expect(match, 'Complement should contain only subtraction after first term').toMatch( + /\(\d+ - [\d\s-]+\)/ + ) + }) + }) + }) - it("validates step progression consistency", () => { - const result = generateUnifiedInstructionSequence(123, 456); + it('validates step progression consistency', () => { + const result = generateUnifiedInstructionSequence(123, 456) // Each step should be mathematically valid (arithmetic already tested) // and the final value should match target - expect(result.steps.length).toBeGreaterThan(0); + expect(result.steps.length).toBeGreaterThan(0) - const finalStep = result.steps[result.steps.length - 1]; - expect(finalStep.expectedValue).toBe(result.targetValue); + const finalStep = result.steps[result.steps.length - 1] + expect(finalStep.expectedValue).toBe(result.targetValue) // All steps should be valid result.steps.forEach((step, i) => { - expect(step.isValid, `Step ${i + 1} should be valid`).toBe(true); - }); - }); - }); + expect(step.isValid, `Step ${i + 1} should be valid`).toBe(true) + }) + }) + }) - describe("Stress Test Coverage (No Snapshots)", () => { + describe('Stress Test Coverage (No Snapshots)', () => { // Fast invariant-only validation for broad coverage const stressCases = [ // Various differences from different starting points @@ -338,210 +318,182 @@ describe("Pedagogical Algorithm - Core Validation", () => { [189, 197], [1234, 1279], [9999, 10017], - ]; + ] stressCases.forEach(([start, target]) => { it(`validates invariants for ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); + const result = generateUnifiedInstructionSequence(start, target) // Fast validation - no snapshots - expect(result.startValue).toBe(start); - expect(result.targetValue).toBe(target); + expect(result.startValue).toBe(start) + expect(result.targetValue).toBe(target) if (result.steps.length > 0) { - assertArithmeticInvariant(result); - assertTermMapping(result); + assertArithmeticInvariant(result) + assertTermMapping(result) } // All steps should be valid result.steps.forEach((step) => { - expect( - step.isValid, - `Step with term "${step.mathematicalTerm}" should be valid`, - ).toBe(true); - }); - }); - }); - }); + expect(step.isValid, `Step with term "${step.mathematicalTerm}" should be valid`).toBe( + true + ) + }) + }) + }) + }) - describe("Pedagogical Segments - Basic Validation", () => { + describe('Pedagogical Segments - Basic Validation', () => { const segmentTestCases = [ [4, 7], // Five-complement [7, 15], // Ten-complement [0, 3], // Direct entry [99, 107], // Cascading - ]; + ] segmentTestCases.forEach(([start, target]) => { it(`generates valid segments for ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); + const result = generateUnifiedInstructionSequence(start, target) // Basic segment validation - expect(result.segments).toBeDefined(); - expect(Array.isArray(result.segments)).toBe(true); - expect(result.segments.length).toBeGreaterThan(0); + expect(result.segments).toBeDefined() + expect(Array.isArray(result.segments)).toBe(true) + expect(result.segments.length).toBeGreaterThan(0) // Each segment should have required properties result.segments.forEach((segment, i) => { - expect(segment.id, `Segment ${i} should have id`).toBeDefined(); - expect(segment.goal, `Segment ${i} should have goal`).toBeDefined(); - expect(segment.plan, `Segment ${i} should have plan`).toBeDefined(); - expect( - segment.expression, - `Segment ${i} should have expression`, - ).toBeDefined(); - expect( - segment.startValue, - `Segment ${i} should have startValue`, - ).toBeTypeOf("number"); - expect( - segment.endValue, - `Segment ${i} should have endValue`, - ).toBeTypeOf("number"); - expect( - segment.stepIndices, - `Segment ${i} should have stepIndices`, - ).toBeDefined(); + expect(segment.id, `Segment ${i} should have id`).toBeDefined() + expect(segment.goal, `Segment ${i} should have goal`).toBeDefined() + expect(segment.plan, `Segment ${i} should have plan`).toBeDefined() + expect(segment.expression, `Segment ${i} should have expression`).toBeDefined() + expect(segment.startValue, `Segment ${i} should have startValue`).toBeTypeOf('number') + expect(segment.endValue, `Segment ${i} should have endValue`).toBeTypeOf('number') + expect(segment.stepIndices, `Segment ${i} should have stepIndices`).toBeDefined() // Segment progression should be mathematically sound - expect(segment.startValue).toBeLessThanOrEqual(segment.endValue); - }); + expect(segment.startValue).toBeLessThanOrEqual(segment.endValue) + }) // Segments should cover all operations - const allStepIndices = result.segments.flatMap((s) => s.stepIndices); - expect( - allStepIndices.length, - "Segments should cover all steps", - ).toEqual(result.steps.length); - }); - }); + const allStepIndices = result.segments.flatMap((s) => s.stepIndices) + expect(allStepIndices.length, 'Segments should cover all steps').toEqual( + result.steps.length + ) + }) + }) - it("validates segment decision rules are coherent", () => { - const result = generateUnifiedInstructionSequence(4, 7); // Five-complement case + it('validates segment decision rules are coherent', () => { + const result = generateUnifiedInstructionSequence(4, 7) // Five-complement case - const segment = result.segments[0]; - expect(segment.plan.length).toBeGreaterThan(0); + const segment = result.segments[0] + expect(segment.plan.length).toBeGreaterThan(0) - const decision = segment.plan[0]; - expect(decision.rule).toEqual("FiveComplement"); - expect(decision.conditions).toBeDefined(); - expect(decision.explanation).toBeDefined(); - expect(decision.explanation.length).toBeGreaterThan(0); - }); + const decision = segment.plan[0] + expect(decision.rule).toEqual('FiveComplement') + expect(decision.conditions).toBeDefined() + expect(decision.explanation).toBeDefined() + expect(decision.explanation.length).toBeGreaterThan(0) + }) - it("validates segment term ranges map to decomposition string", () => { - const result = generateUnifiedInstructionSequence(99, 107); // Complex cascading case + it('validates segment term ranges map to decomposition string', () => { + const result = generateUnifiedInstructionSequence(99, 107) // Complex cascading case result.segments.forEach((segment, i) => { - const { termRange } = segment; + const { termRange } = segment expect( termRange.startIndex, - `Segment ${i} should have valid start index`, - ).toBeGreaterThanOrEqual(0); + `Segment ${i} should have valid start index` + ).toBeGreaterThanOrEqual(0) + expect(termRange.endIndex, `Segment ${i} should have valid end index`).toBeGreaterThan( + termRange.startIndex + ) expect( termRange.endIndex, - `Segment ${i} should have valid end index`, - ).toBeGreaterThan(termRange.startIndex); - expect( - termRange.endIndex, - `Segment ${i} end should not exceed decomposition length`, - ).toBeLessThanOrEqual(result.fullDecomposition.length); + `Segment ${i} end should not exceed decomposition length` + ).toBeLessThanOrEqual(result.fullDecomposition.length) // The substring should not be empty - const substring = result.fullDecomposition.slice( - termRange.startIndex, - termRange.endIndex, - ); - expect( - substring.length, - `Segment ${i} should map to non-empty substring`, - ).toBeGreaterThan(0); - }); - }); - }); + const substring = result.fullDecomposition.slice(termRange.startIndex, termRange.endIndex) + expect(substring.length, `Segment ${i} should map to non-empty substring`).toBeGreaterThan( + 0 + ) + }) + }) + }) - describe("Pedagogical Segments - Production Pedagogy & Range Tests", () => { - it("five-complement at ones (3 + 2 = 5)", () => { - const seq = generateUnifiedInstructionSequence(3, 5); - const seg = seq.segments.find((s) => s.place === 0)!; - expect(seg.plan.some((p) => p.rule === "FiveComplement")).toBe(true); + describe('Pedagogical Segments - Production Pedagogy & Range Tests', () => { + it('five-complement at ones (3 + 2 = 5)', () => { + const seq = generateUnifiedInstructionSequence(3, 5) + const seg = seq.segments.find((s) => s.place === 0)! + expect(seg.plan.some((p) => p.rule === 'FiveComplement')).toBe(true) - const txt = seq.fullDecomposition.slice( - seg.termRange.startIndex, - seg.termRange.endIndex, - ); - expect(txt.startsWith("(") && txt.endsWith(")")).toBe(true); - }); + const txt = seq.fullDecomposition.slice(seg.termRange.startIndex, seg.termRange.endIndex) + expect(txt.startsWith('(') && txt.endsWith(')')).toBe(true) + }) - it("ten-complement without cascade (19 + 1 = 20)", () => { - const seq = generateUnifiedInstructionSequence(19, 20); - const seg = seq.segments.find((s) => s.place === 0)!; - expect(seg.plan.some((p) => p.rule === "TenComplement")).toBe(true); - expect(seg.plan.some((p) => p.rule === "Cascade")).toBe(false); - }); + it('ten-complement without cascade (19 + 1 = 20)', () => { + const seq = generateUnifiedInstructionSequence(19, 20) + const seg = seq.segments.find((s) => s.place === 0)! + expect(seg.plan.some((p) => p.rule === 'TenComplement')).toBe(true) + expect(seg.plan.some((p) => p.rule === 'Cascade')).toBe(false) + }) - it("ten-complement with cascade (199 + 1 = 200)", () => { - const seq = generateUnifiedInstructionSequence(199, 200); - const seg = seq.segments.find((s) => s.place === 0)!; - expect(seg.plan.some((p) => p.rule === "Cascade")).toBe(true); - }); + it('ten-complement with cascade (199 + 1 = 200)', () => { + const seq = generateUnifiedInstructionSequence(199, 200) + const seg = seq.segments.find((s) => s.place === 0)! + expect(seg.plan.some((p) => p.rule === 'Cascade')).toBe(true) + }) - it("segment range covers only its group; steps lie inside range", () => { - const seq = generateUnifiedInstructionSequence(3478, 3500); // +22 - const tensSeg = seq.segments.find((s) => s.place === 1)!; + it('segment range covers only its group; steps lie inside range', () => { + const seq = generateUnifiedInstructionSequence(3478, 3500) // +22 + const tensSeg = seq.segments.find((s) => s.place === 1)! const segText = seq.fullDecomposition.slice( tensSeg.termRange.startIndex, - tensSeg.termRange.endIndex, - ); - expect(segText.includes("20")).toBe(true); + tensSeg.termRange.endIndex + ) + expect(segText.includes('20')).toBe(true) tensSeg.stepIndices.forEach((i) => { - const { startIndex, endIndex } = seq.steps[i].termPosition; - expect(startIndex >= tensSeg.termRange.startIndex).toBe(true); - expect(endIndex <= tensSeg.termRange.endIndex).toBe(true); - }); - }); + const { startIndex, endIndex } = seq.steps[i].termPosition + expect(startIndex >= tensSeg.termRange.startIndex).toBe(true) + expect(endIndex <= tensSeg.termRange.endIndex).toBe(true) + }) + }) - it("invariant: all steps with segmentId are included in their segments", () => { - const seq = generateUnifiedInstructionSequence(9999, 10007); - const segMap = new Map(seq.segments.map((s) => [s.id, s])); + it('invariant: all steps with segmentId are included in their segments', () => { + const seq = generateUnifiedInstructionSequence(9999, 10007) + const segMap = new Map(seq.segments.map((s) => [s.id, s])) seq.steps.forEach((step, i) => { if (step.segmentId) { - const segment = segMap.get(step.segmentId); - expect( - segment, - `Step ${i} references unknown segment ${step.segmentId}`, - ).toBeDefined(); + const segment = segMap.get(step.segmentId) + expect(segment, `Step ${i} references unknown segment ${step.segmentId}`).toBeDefined() expect( segment!.stepIndices.includes(i), - `Step ${i} not included in segment ${step.segmentId}`, - ).toBe(true); + `Step ${i} not included in segment ${step.segmentId}` + ).toBe(true) } - }); - }); + }) + }) - it("invariant: segment ranges are non-empty and well-formed", () => { - const seq = generateUnifiedInstructionSequence(123, 456); + it('invariant: segment ranges are non-empty and well-formed', () => { + const seq = generateUnifiedInstructionSequence(123, 456) seq.segments.forEach((seg) => { - expect( - seg.stepIndices.length, - `Segment ${seg.id} should have steps`, - ).toBeGreaterThan(0); + expect(seg.stepIndices.length, `Segment ${seg.id} should have steps`).toBeGreaterThan(0) expect( seg.termRange.endIndex, - `Segment ${seg.id} should have non-empty range`, - ).toBeGreaterThan(seg.termRange.startIndex); + `Segment ${seg.id} should have non-empty range` + ).toBeGreaterThan(seg.termRange.startIndex) expect( seg.termRange.startIndex, - `Segment ${seg.id} range should be valid`, - ).toBeGreaterThanOrEqual(0); + `Segment ${seg.id} range should be valid` + ).toBeGreaterThanOrEqual(0) expect( seg.termRange.endIndex, - `Segment ${seg.id} range should not exceed decomposition`, - ).toBeLessThanOrEqual(seq.fullDecomposition.length); - }); - }); - }); -}); + `Segment ${seg.id} range should not exceed decomposition` + ).toBeLessThanOrEqual(seq.fullDecomposition.length) + }) + }) + }) +}) diff --git a/apps/web/src/utils/__tests__/pedagogicalSnapshot.test.ts b/apps/web/src/utils/__tests__/pedagogicalSnapshot.test.ts index b45d3510..9f3814a9 100644 --- a/apps/web/src/utils/__tests__/pedagogicalSnapshot.test.ts +++ b/apps/web/src/utils/__tests__/pedagogicalSnapshot.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { generateUnifiedInstructionSequence } from "../unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import { generateUnifiedInstructionSequence } from '../unifiedStepGenerator' /** * LEGACY: Comprehensive snapshot tests (292 tests, ~40k lines of snapshots) @@ -10,465 +10,462 @@ import { generateUnifiedInstructionSequence } from "../unifiedStepGenerator"; * This suite is now gated behind LEGACY_SNAPSHOTS=1 environment variable. * Run with: LEGACY_SNAPSHOTS=1 pnpm test pedagogicalSnapshot.test.ts */ -const runLegacySnapshots = process.env.LEGACY_SNAPSHOTS === "1"; +const runLegacySnapshots = process.env.LEGACY_SNAPSHOTS === '1' -describe.skipIf(!runLegacySnapshots)( - "Pedagogical Algorithm Snapshot Tests (Legacy)", - () => { - describe("Direct Entry Cases (No Complements)", () => { - const directCases = [ - // Single digits - [0, 1], - [0, 2], - [0, 3], - [0, 4], - [0, 5], - [1, 2], - [1, 3], - [1, 4], - [2, 3], - [2, 4], - [3, 4], - [5, 6], - [5, 7], - [5, 8], - [5, 9], - // Tens place - [0, 10], - [0, 20], - [0, 30], - [0, 40], - [0, 50], - [10, 20], - [20, 30], - [30, 40], - [40, 50], - // Hundreds place - [0, 100], - [0, 200], - [0, 300], - [100, 200], - // Multi-place without complements - [11, 22], - [22, 33], - [123, 234], - ]; +describe.skipIf(!runLegacySnapshots)('Pedagogical Algorithm Snapshot Tests (Legacy)', () => { + describe('Direct Entry Cases (No Complements)', () => { + const directCases = [ + // Single digits + [0, 1], + [0, 2], + [0, 3], + [0, 4], + [0, 5], + [1, 2], + [1, 3], + [1, 4], + [2, 3], + [2, 4], + [3, 4], + [5, 6], + [5, 7], + [5, 8], + [5, 9], + // Tens place + [0, 10], + [0, 20], + [0, 30], + [0, 40], + [0, 50], + [10, 20], + [20, 30], + [30, 40], + [40, 50], + // Hundreds place + [0, 100], + [0, 200], + [0, 300], + [100, 200], + // Multi-place without complements + [11, 22], + [22, 33], + [123, 234], + ] - directCases.forEach(([start, target]) => { - it(`should handle direct entry: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + directCases.forEach(([start, target]) => { + it(`should handle direct entry: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Five-Complement Cases", () => { - const fiveComplementCases = [ - // Basic five-complements (ones place) - [4, 7], - [3, 6], - [2, 5], - [1, 4], - [4, 8], - [3, 7], - [2, 6], - [1, 5], - [4, 9], - [3, 8], - [2, 7], - [1, 6], - // Multi-place with five-complements - [14, 17], - [23, 26], - [31, 34], - [42, 45], - [54, 57], - [134, 137], - [223, 226], - [314, 317], - [425, 428], - // Complex multi-place five-complements - [1234, 1237], - [2341, 2344], - [3452, 3455], - // Five-complements in different places - [40, 70], - [400, 700], - [1400, 1700], - [24000, 27000], - ]; + describe('Five-Complement Cases', () => { + const fiveComplementCases = [ + // Basic five-complements (ones place) + [4, 7], + [3, 6], + [2, 5], + [1, 4], + [4, 8], + [3, 7], + [2, 6], + [1, 5], + [4, 9], + [3, 8], + [2, 7], + [1, 6], + // Multi-place with five-complements + [14, 17], + [23, 26], + [31, 34], + [42, 45], + [54, 57], + [134, 137], + [223, 226], + [314, 317], + [425, 428], + // Complex multi-place five-complements + [1234, 1237], + [2341, 2344], + [3452, 3455], + // Five-complements in different places + [40, 70], + [400, 700], + [1400, 1700], + [24000, 27000], + ] - fiveComplementCases.forEach(([start, target]) => { - it(`should handle five-complement: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + fiveComplementCases.forEach(([start, target]) => { + it(`should handle five-complement: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Ten-Complement Cases", () => { - const tenComplementCases = [ - // Basic ten-complements (ones place) - [4, 11], - [6, 13], - [7, 14], - [8, 15], - [9, 16], - [3, 12], - [2, 11], - [1, 10], - [8, 17], - [7, 16], - // Ten-complements crossing places - [19, 26], - [28, 35], - [37, 44], - [46, 53], - [55, 62], - [64, 71], - [73, 80], - [82, 89], - [91, 98], - // Multi-place ten-complements - [94, 101], - [193, 200], - [294, 301], - [395, 402], - [496, 503], - [597, 604], - [698, 705], - [799, 806], - // Complex ten-complements - [1294, 1301], - [2395, 2402], - [3496, 3503], - [4597, 4604], - ]; + describe('Ten-Complement Cases', () => { + const tenComplementCases = [ + // Basic ten-complements (ones place) + [4, 11], + [6, 13], + [7, 14], + [8, 15], + [9, 16], + [3, 12], + [2, 11], + [1, 10], + [8, 17], + [7, 16], + // Ten-complements crossing places + [19, 26], + [28, 35], + [37, 44], + [46, 53], + [55, 62], + [64, 71], + [73, 80], + [82, 89], + [91, 98], + // Multi-place ten-complements + [94, 101], + [193, 200], + [294, 301], + [395, 402], + [496, 503], + [597, 604], + [698, 705], + [799, 806], + // Complex ten-complements + [1294, 1301], + [2395, 2402], + [3496, 3503], + [4597, 4604], + ] - tenComplementCases.forEach(([start, target]) => { - it(`should handle ten-complement: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + tenComplementCases.forEach(([start, target]) => { + it(`should handle ten-complement: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Cascading Complement Cases", () => { - const cascadingCases = [ - // Single 9 cascades - [99, 107], - [199, 207], - [299, 307], - [399, 407], - [499, 507], - [599, 607], - [699, 707], - [799, 807], - // Double 9 cascades - [999, 1007], - [1999, 2007], - [2999, 3007], - // Triple 9 cascades - [9999, 10007], - [19999, 20007], - // Complex cascading with different digits - [89, 97], - [189, 197], - [289, 297], - [389, 397], - [98, 105], - [198, 205], - [298, 305], - [398, 405], - [979, 986], - [1979, 1986], - [2979, 2986], - [989, 996], - [1989, 1996], - [2989, 2996], - // Mixed place cascading - [1899, 1907], - [2899, 2907], - [12899, 12907], - [9899, 9907], - [19899, 19907], - ]; + describe('Cascading Complement Cases', () => { + const cascadingCases = [ + // Single 9 cascades + [99, 107], + [199, 207], + [299, 307], + [399, 407], + [499, 507], + [599, 607], + [699, 707], + [799, 807], + // Double 9 cascades + [999, 1007], + [1999, 2007], + [2999, 3007], + // Triple 9 cascades + [9999, 10007], + [19999, 20007], + // Complex cascading with different digits + [89, 97], + [189, 197], + [289, 297], + [389, 397], + [98, 105], + [198, 205], + [298, 305], + [398, 405], + [979, 986], + [1979, 1986], + [2979, 2986], + [989, 996], + [1989, 1996], + [2989, 2996], + // Mixed place cascading + [1899, 1907], + [2899, 2907], + [12899, 12907], + [9899, 9907], + [19899, 19907], + ] - cascadingCases.forEach(([start, target]) => { - it(`should handle cascading complement: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + cascadingCases.forEach(([start, target]) => { + it(`should handle cascading complement: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Mixed Operation Cases", () => { - const mixedCases = [ - // Five + ten complement combinations - [43, 51], - [134, 142], - [243, 251], - [352, 360], - // Multi-place with various complements - [1234, 1279], - [2345, 2383], - [3456, 3497], - [4567, 4605], - [5678, 5719], - [6789, 6827], - // Complex mixed operations - [12345, 12389], - [23456, 23497], - [34567, 34605], - [45678, 45719], - [56789, 56827], - [67890, 67935], - // Large number operations - [123456, 123497], - [234567, 234605], - [345678, 345719], - [456789, 456827], - [567890, 567935], - [678901, 678943], - ]; + describe('Mixed Operation Cases', () => { + const mixedCases = [ + // Five + ten complement combinations + [43, 51], + [134, 142], + [243, 251], + [352, 360], + // Multi-place with various complements + [1234, 1279], + [2345, 2383], + [3456, 3497], + [4567, 4605], + [5678, 5719], + [6789, 6827], + // Complex mixed operations + [12345, 12389], + [23456, 23497], + [34567, 34605], + [45678, 45719], + [56789, 56827], + [67890, 67935], + // Large number operations + [123456, 123497], + [234567, 234605], + [345678, 345719], + [456789, 456827], + [567890, 567935], + [678901, 678943], + ] - mixedCases.forEach(([start, target]) => { - it(`should handle mixed operations: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + mixedCases.forEach(([start, target]) => { + it(`should handle mixed operations: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Edge Cases and Boundary Conditions", () => { - const edgeCases = [ - // Zero operations - [0, 0], - [5, 5], - [10, 10], - [123, 123], - // Single unit additions - [0, 1], - [1, 2], - [9, 10], - [99, 100], - [999, 1000], - // Maximum single-place values - [0, 9], - [10, 19], - [90, 99], - [900, 909], - // Place value boundaries - [9, 10], - [99, 100], - [999, 1000], - [9999, 10000], - [19, 20], - [199, 200], - [1999, 2000], - [19999, 20000], - // All 9s patterns - [9, 18], - [99, 108], - [999, 1008], - [9999, 10008], - // Alternating patterns - [1357, 1369], - [2468, 2479], - [13579, 13591], - // Repeated digit patterns - [1111, 1123], - [2222, 2234], - [3333, 3345], - [11111, 11123], - [22222, 22234], - ]; + describe('Edge Cases and Boundary Conditions', () => { + const edgeCases = [ + // Zero operations + [0, 0], + [5, 5], + [10, 10], + [123, 123], + // Single unit additions + [0, 1], + [1, 2], + [9, 10], + [99, 100], + [999, 1000], + // Maximum single-place values + [0, 9], + [10, 19], + [90, 99], + [900, 909], + // Place value boundaries + [9, 10], + [99, 100], + [999, 1000], + [9999, 10000], + [19, 20], + [199, 200], + [1999, 2000], + [19999, 20000], + // All 9s patterns + [9, 18], + [99, 108], + [999, 1008], + [9999, 10008], + // Alternating patterns + [1357, 1369], + [2468, 2479], + [13579, 13591], + // Repeated digit patterns + [1111, 1123], + [2222, 2234], + [3333, 3345], + [11111, 11123], + [22222, 22234], + ] - edgeCases.forEach(([start, target]) => { - it(`should handle edge case: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + edgeCases.forEach(([start, target]) => { + it(`should handle edge case: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Large Number Operations", () => { - const largeNumberCases = [ - // Five-digit operations - [12345, 12378], - [23456, 23489], - [34567, 34599], - [45678, 45711], - [56789, 56822], - [67890, 67923], - // Six-digit operations - [123456, 123489], - [234567, 234599], - [345678, 345711], - [456789, 456822], - [567890, 567923], - [678901, 678934], - // Seven-digit operations (millions) - [1234567, 1234599], - [2345678, 2345711], - [3456789, 3456822], - ]; + describe('Large Number Operations', () => { + const largeNumberCases = [ + // Five-digit operations + [12345, 12378], + [23456, 23489], + [34567, 34599], + [45678, 45711], + [56789, 56822], + [67890, 67923], + // Six-digit operations + [123456, 123489], + [234567, 234599], + [345678, 345711], + [456789, 456822], + [567890, 567923], + [678901, 678934], + // Seven-digit operations (millions) + [1234567, 1234599], + [2345678, 2345711], + [3456789, 3456822], + ] - largeNumberCases.forEach(([start, target]) => { - it(`should handle large numbers: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + largeNumberCases.forEach(([start, target]) => { + it(`should handle large numbers: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Systematic Coverage by Difference", () => { - const systematicCases = [ - // Difference of 1-9 (various starting points) - [0, 1], - [5, 6], - [9, 10], - [15, 16], - [99, 100], - [0, 2], - [3, 5], - [8, 10], - [14, 16], - [98, 100], - [0, 3], - [2, 5], - [7, 10], - [13, 16], - [97, 100], - [0, 4], - [1, 5], - [6, 10], - [12, 16], - [96, 100], - [0, 5], - [0, 6], - [0, 7], - [0, 8], - [0, 9], + describe('Systematic Coverage by Difference', () => { + const systematicCases = [ + // Difference of 1-9 (various starting points) + [0, 1], + [5, 6], + [9, 10], + [15, 16], + [99, 100], + [0, 2], + [3, 5], + [8, 10], + [14, 16], + [98, 100], + [0, 3], + [2, 5], + [7, 10], + [13, 16], + [97, 100], + [0, 4], + [1, 5], + [6, 10], + [12, 16], + [96, 100], + [0, 5], + [0, 6], + [0, 7], + [0, 8], + [0, 9], - // Difference of 10-19 - [0, 10], - [5, 15], - [90, 100], - [195, 205], - [0, 11], - [4, 15], - [89, 100], - [194, 205], - [0, 12], - [3, 15], - [88, 100], - [193, 205], - [0, 13], - [2, 15], - [87, 100], - [192, 205], - [0, 14], - [1, 15], - [86, 100], - [191, 205], - [0, 15], - [0, 16], - [0, 17], - [0, 18], - [0, 19], + // Difference of 10-19 + [0, 10], + [5, 15], + [90, 100], + [195, 205], + [0, 11], + [4, 15], + [89, 100], + [194, 205], + [0, 12], + [3, 15], + [88, 100], + [193, 205], + [0, 13], + [2, 15], + [87, 100], + [192, 205], + [0, 14], + [1, 15], + [86, 100], + [191, 205], + [0, 15], + [0, 16], + [0, 17], + [0, 18], + [0, 19], - // Difference of 20-29 - [0, 20], - [5, 25], - [80, 100], - [185, 205], - [0, 25], - [0, 27], - [0, 29], - [75, 100], - [180, 205], + // Difference of 20-29 + [0, 20], + [5, 25], + [80, 100], + [185, 205], + [0, 25], + [0, 27], + [0, 29], + [75, 100], + [180, 205], - // Difference of larger amounts - [0, 50], - [50, 100], - [0, 100], - [900, 1000], - [0, 123], - [100, 223], - [877, 1000], - [1877, 2000], - ]; + // Difference of larger amounts + [0, 50], + [50, 100], + [0, 100], + [900, 1000], + [0, 123], + [100, 223], + [877, 1000], + [1877, 2000], + ] - systematicCases.forEach(([start, target]) => { - it(`should handle systematic case: ${start} → ${target} (diff: ${target - start})`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + systematicCases.forEach(([start, target]) => { + it(`should handle systematic case: ${start} → ${target} (diff: ${target - start})`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Stress Test Cases", () => { - const stressCases = [ - // Maximum complexity cascades - [99999, 100008], - [199999, 200008], - [999999, 1000008], - // Multiple cascade triggers - [9999, 10017], - [99999, 100026], - [999999, 1000035], - // Complex multi-place with all complement types - [49999, 50034], - [149999, 150034], - [249999, 250034], - // Alternating complement patterns - [4949, 4983], - [14949, 14983], - [24949, 24983], - ]; + describe('Stress Test Cases', () => { + const stressCases = [ + // Maximum complexity cascades + [99999, 100008], + [199999, 200008], + [999999, 1000008], + // Multiple cascade triggers + [9999, 10017], + [99999, 100026], + [999999, 1000035], + // Complex multi-place with all complement types + [49999, 50034], + [149999, 150034], + [249999, 250034], + // Alternating complement patterns + [4949, 4983], + [14949, 14983], + [24949, 24983], + ] - stressCases.forEach(([start, target]) => { - it(`should handle stress test: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); + stressCases.forEach(([start, target]) => { + it(`should handle stress test: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) - describe("Regression Prevention Cases", () => { - // These are the exact cases from the original pedagogical tests - // to ensure we never regress from the current perfect state - const regressionCases = [ - [0, 1], - [1, 3], - [0, 4], - [0, 5], - [5, 7], - [0, 10], - [4, 7], - [3, 5], - [2, 3], - [0, 6], - [1, 5], - [4, 11], - [6, 15], - [7, 15], - [5, 9], - [9, 18], - [12, 34], - [23, 47], - [34, 78], - [89, 97], - [99, 107], - ]; + describe('Regression Prevention Cases', () => { + // These are the exact cases from the original pedagogical tests + // to ensure we never regress from the current perfect state + const regressionCases = [ + [0, 1], + [1, 3], + [0, 4], + [0, 5], + [5, 7], + [0, 10], + [4, 7], + [3, 5], + [2, 3], + [0, 6], + [1, 5], + [4, 11], + [6, 15], + [7, 15], + [5, 9], + [9, 18], + [12, 34], + [23, 47], + [34, 78], + [89, 97], + [99, 107], + ] - regressionCases.forEach(([start, target]) => { - it(`should maintain regression case: ${start} → ${target}`, () => { - const result = generateUnifiedInstructionSequence(start, target); - expect(result).toMatchSnapshot(); - }); - }); - }); - }, -); + regressionCases.forEach(([start, target]) => { + it(`should maintain regression case: ${start} → ${target}`, () => { + const result = generateUnifiedInstructionSequence(start, target) + expect(result).toMatchSnapshot() + }) + }) + }) +}) diff --git a/apps/web/src/utils/__tests__/playerNames.test.ts b/apps/web/src/utils/__tests__/playerNames.test.ts index f3ac923a..17300d59 100644 --- a/apps/web/src/utils/__tests__/playerNames.test.ts +++ b/apps/web/src/utils/__tests__/playerNames.test.ts @@ -1,94 +1,92 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest' import { generatePlayerName, generateUniquePlayerName, generateUniquePlayerNames, -} from "../playerNames"; +} from '../playerNames' -describe("playerNames", () => { - describe("generatePlayerName", () => { - it("should generate a player name with adjective and noun", () => { - const name = generatePlayerName(); - expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/); // e.g., "Swift Ninja" - expect(name.split(" ")).toHaveLength(2); - }); +describe('playerNames', () => { + describe('generatePlayerName', () => { + it('should generate a player name with adjective and noun', () => { + const name = generatePlayerName() + expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/) // e.g., "Swift Ninja" + expect(name.split(' ')).toHaveLength(2) + }) - it("should generate different names on multiple calls", () => { - const names = new Set(); + it('should generate different names on multiple calls', () => { + const names = new Set() // Generate 50 names and expect at least some variety for (let i = 0; i < 50; i++) { - names.add(generatePlayerName()); + names.add(generatePlayerName()) } // With 50 adjectives and 50 nouns, we should get many unique combinations - expect(names.size).toBeGreaterThan(30); - }); - }); + expect(names.size).toBeGreaterThan(30) + }) + }) - describe("generateUniquePlayerName", () => { - it("should generate a unique name not in existing names", () => { - const existingNames = ["Swift Ninja", "Cosmic Wizard", "Radiant Dragon"]; - const newName = generateUniquePlayerName(existingNames); + describe('generateUniquePlayerName', () => { + it('should generate a unique name not in existing names', () => { + const existingNames = ['Swift Ninja', 'Cosmic Wizard', 'Radiant Dragon'] + const newName = generateUniquePlayerName(existingNames) - expect(existingNames).not.toContain(newName); - }); + expect(existingNames).not.toContain(newName) + }) - it("should be case-insensitive when checking uniqueness", () => { - const existingNames = ["swift ninja", "COSMIC WIZARD"]; - const newName = generateUniquePlayerName(existingNames); + it('should be case-insensitive when checking uniqueness', () => { + const existingNames = ['swift ninja', 'COSMIC WIZARD'] + const newName = generateUniquePlayerName(existingNames) - expect(existingNames.map((n) => n.toLowerCase())).not.toContain( - newName.toLowerCase(), - ); - }); + expect(existingNames.map((n) => n.toLowerCase())).not.toContain(newName.toLowerCase()) + }) - it("should handle empty existing names array", () => { - const name = generateUniquePlayerName([]); - expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/); - }); + it('should handle empty existing names array', () => { + const name = generateUniquePlayerName([]) + expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/) + }) - it("should append number if all combinations are exhausted", () => { + it('should append number if all combinations are exhausted', () => { // Create a mock with limited attempts - const existingNames = ["Swift Ninja"]; - const name = generateUniquePlayerName(existingNames, undefined, 1); + const existingNames = ['Swift Ninja'] + const name = generateUniquePlayerName(existingNames, undefined, 1) // Should either be unique or have a number appended - expect(name).toBeTruthy(); - expect(name).not.toBe("Swift Ninja"); - }); - }); + expect(name).toBeTruthy() + expect(name).not.toBe('Swift Ninja') + }) + }) - describe("generateUniquePlayerNames", () => { - it("should generate the requested number of unique names", () => { - const names = generateUniquePlayerNames(4); - expect(names).toHaveLength(4); + describe('generateUniquePlayerNames', () => { + it('should generate the requested number of unique names', () => { + const names = generateUniquePlayerNames(4) + expect(names).toHaveLength(4) // All names should be unique - const uniqueNames = new Set(names); - expect(uniqueNames.size).toBe(4); - }); + const uniqueNames = new Set(names) + expect(uniqueNames.size).toBe(4) + }) - it("should generate unique names across all entries", () => { - const names = generateUniquePlayerNames(10); - expect(names).toHaveLength(10); + it('should generate unique names across all entries', () => { + const names = generateUniquePlayerNames(10) + expect(names).toHaveLength(10) // Check uniqueness (case-insensitive) - const uniqueNames = new Set(names.map((n) => n.toLowerCase())); - expect(uniqueNames.size).toBe(10); - }); + const uniqueNames = new Set(names.map((n) => n.toLowerCase())) + expect(uniqueNames.size).toBe(10) + }) - it("should handle generating zero names", () => { - const names = generateUniquePlayerNames(0); - expect(names).toHaveLength(0); - expect(names).toEqual([]); - }); + it('should handle generating zero names', () => { + const names = generateUniquePlayerNames(0) + expect(names).toHaveLength(0) + expect(names).toEqual([]) + }) - it("should generate names with expected format", () => { - const names = generateUniquePlayerNames(5); + it('should generate names with expected format', () => { + const names = generateUniquePlayerNames(5) for (const name of names) { - expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+( \d+)?$/); - expect(name.split(" ").length).toBeGreaterThanOrEqual(2); + expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+( \d+)?$/) + expect(name.split(' ').length).toBeGreaterThanOrEqual(2) } - }); - }); -}); + }) + }) +}) diff --git a/apps/web/src/utils/__tests__/provenance.integration.test.ts b/apps/web/src/utils/__tests__/provenance.integration.test.ts index e36b6103..8f2ba19d 100644 --- a/apps/web/src/utils/__tests__/provenance.integration.test.ts +++ b/apps/web/src/utils/__tests__/provenance.integration.test.ts @@ -1,123 +1,113 @@ -import { describe, expect, it } from "vitest"; -import { generateUnifiedInstructionSequence } from "../unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import { generateUnifiedInstructionSequence } from '../unifiedStepGenerator' -describe("Provenance Integration Test - 3475 + 25 = 3500", () => { - it("should generate complete provenance data for the tooltip system", () => { +describe('Provenance Integration Test - 3475 + 25 = 3500', () => { + it('should generate complete provenance data for the tooltip system', () => { // Generate the instruction sequence for the exact example from the user - const result = generateUnifiedInstructionSequence(3475, 3500); + const result = generateUnifiedInstructionSequence(3475, 3500) // Verify we have steps and segments - expect(result.steps.length).toBeGreaterThan(0); - expect(result.segments.length).toBeGreaterThan(0); + expect(result.steps.length).toBeGreaterThan(0) + expect(result.segments.length).toBeGreaterThan(0) // Log the actual generated data - console.log("\n=== Complete Integration Test Results ==="); - console.log("Full decomposition:", result.fullDecomposition); - console.log("Total steps:", result.steps.length); - console.log("Total segments:", result.segments.length); + console.log('\n=== Complete Integration Test Results ===') + console.log('Full decomposition:', result.fullDecomposition) + console.log('Total steps:', result.steps.length) + console.log('Total segments:', result.segments.length) // Find the "20" step (tens digit) - const twentyStep = result.steps.find( - (step) => step.mathematicalTerm === "20", - ); - expect(twentyStep).toBeDefined(); - expect(twentyStep?.provenance).toBeDefined(); + const twentyStep = result.steps.find((step) => step.mathematicalTerm === '20') + expect(twentyStep).toBeDefined() + expect(twentyStep?.provenance).toBeDefined() if (twentyStep?.provenance) { - console.log('\n=== "20" Step Provenance ==='); - console.log("Mathematical term:", twentyStep.mathematicalTerm); - console.log("Provenance data:", twentyStep.provenance); + console.log('\n=== "20" Step Provenance ===') + console.log('Mathematical term:', twentyStep.mathematicalTerm) + console.log('Provenance data:', twentyStep.provenance) // Verify the provenance data matches our expectations - expect(twentyStep.provenance.rhs).toBe(25); - expect(twentyStep.provenance.rhsDigit).toBe(2); - expect(twentyStep.provenance.rhsPlace).toBe(1); - expect(twentyStep.provenance.rhsPlaceName).toBe("tens"); - expect(twentyStep.provenance.rhsValue).toBe(20); - expect(twentyStep.provenance.rhsDigitIndex).toBe(0); // '2' is first char in '25' + expect(twentyStep.provenance.rhs).toBe(25) + expect(twentyStep.provenance.rhsDigit).toBe(2) + expect(twentyStep.provenance.rhsPlace).toBe(1) + expect(twentyStep.provenance.rhsPlaceName).toBe('tens') + expect(twentyStep.provenance.rhsValue).toBe(20) + expect(twentyStep.provenance.rhsDigitIndex).toBe(0) // '2' is first char in '25' } // Find the corresponding segment - const tensSegment = result.segments.find( - (seg) => seg.place === 1 && seg.digit === 2, - ); - expect(tensSegment).toBeDefined(); + const tensSegment = result.segments.find((seg) => seg.place === 1 && seg.digit === 2) + expect(tensSegment).toBeDefined() if (tensSegment) { - console.log("\n=== Tens Segment ==="); - console.log("Segment rule:", tensSegment.plan[0]?.rule); - console.log("Step indices:", tensSegment.stepIndices); - console.log("Readable title:", tensSegment.readable?.title); + console.log('\n=== Tens Segment ===') + console.log('Segment rule:', tensSegment.plan[0]?.rule) + console.log('Step indices:', tensSegment.stepIndices) + console.log('Readable title:', tensSegment.readable?.title) // Verify the segment contains our step - expect(tensSegment.stepIndices).toContain(twentyStep!.stepIndex); - expect(tensSegment.plan[0]?.rule).toBe("Direct"); + expect(tensSegment.stepIndices).toContain(twentyStep!.stepIndex) + expect(tensSegment.plan[0]?.rule).toBe('Direct') } // Test the tooltip content generation logic if (twentyStep?.provenance && tensSegment) { - const provenance = twentyStep.provenance; + const provenance = twentyStep.provenance // Generate the enhanced tooltip content (this is what the ReasonTooltip should show) - const expectedTitle = `Add the ${provenance.rhsPlaceName} digit — ${provenance.rhsDigit} ${provenance.rhsPlaceName} (${provenance.rhsValue})`; - const expectedSubtitle = `From addend ${provenance.rhs}`; - const expectedExplanation = `We're adding the ${provenance.rhsPlaceName} digit of ${provenance.rhs} → ${provenance.rhsDigit} ${provenance.rhsPlaceName}.`; + const expectedTitle = `Add the ${provenance.rhsPlaceName} digit — ${provenance.rhsDigit} ${provenance.rhsPlaceName} (${provenance.rhsValue})` + const expectedSubtitle = `From addend ${provenance.rhs}` + const expectedExplanation = `We're adding the ${provenance.rhsPlaceName} digit of ${provenance.rhs} → ${provenance.rhsDigit} ${provenance.rhsPlaceName}.` - console.log("\n=== Expected Tooltip Content ==="); - console.log("Title:", expectedTitle); - console.log("Subtitle:", expectedSubtitle); - console.log("Explanation:", expectedExplanation); + console.log('\n=== Expected Tooltip Content ===') + console.log('Title:', expectedTitle) + console.log('Subtitle:', expectedSubtitle) + console.log('Explanation:', expectedExplanation) // Verify these match what the user is expecting - expect(expectedTitle).toBe("Add the tens digit — 2 tens (20)"); - expect(expectedSubtitle).toBe("From addend 25"); - expect(expectedExplanation).toBe( - "We're adding the tens digit of 25 → 2 tens.", - ); + expect(expectedTitle).toBe('Add the tens digit — 2 tens (20)') + expect(expectedSubtitle).toBe('From addend 25') + expect(expectedExplanation).toBe("We're adding the tens digit of 25 → 2 tens.") } // Verify equation anchors for digit highlighting - expect(result.equationAnchors).toBeDefined(); + expect(result.equationAnchors).toBeDefined() if (result.equationAnchors) { - console.log("\n=== Equation Anchors for Highlighting ==="); - console.log("Difference text:", result.equationAnchors.differenceText); - console.log("Digit positions:", result.equationAnchors.rhsDigitPositions); + console.log('\n=== Equation Anchors for Highlighting ===') + console.log('Difference text:', result.equationAnchors.differenceText) + console.log('Digit positions:', result.equationAnchors.rhsDigitPositions) - expect(result.equationAnchors.differenceText).toBe("25"); - expect(result.equationAnchors.rhsDigitPositions).toHaveLength(2); + expect(result.equationAnchors.differenceText).toBe('25') + expect(result.equationAnchors.rhsDigitPositions).toHaveLength(2) // Position for '2' (tens digit) - expect(result.equationAnchors.rhsDigitPositions[0].digitIndex).toBe(0); + expect(result.equationAnchors.rhsDigitPositions[0].digitIndex).toBe(0) // Position for '5' (ones digit) - expect(result.equationAnchors.rhsDigitPositions[1].digitIndex).toBe(1); + expect(result.equationAnchors.rhsDigitPositions[1].digitIndex).toBe(1) } - console.log( - "\n✅ Integration test complete - all provenance data is correctly generated", - ); - }); + console.log('\n✅ Integration test complete - all provenance data is correctly generated') + }) - it("should provide data for UI requirements", () => { - const result = generateUnifiedInstructionSequence(3475, 3500); - const twentyStep = result.steps.find( - (step) => step.mathematicalTerm === "20", - ); + it('should provide data for UI requirements', () => { + const result = generateUnifiedInstructionSequence(3475, 3500) + const twentyStep = result.steps.find((step) => step.mathematicalTerm === '20') // Verify we have all the data needed for the UI requirements - expect(twentyStep?.provenance).toBeDefined(); - expect(result.equationAnchors).toBeDefined(); - expect(result.segments.length).toBeGreaterThan(0); + expect(twentyStep?.provenance).toBeDefined() + expect(result.equationAnchors).toBeDefined() + expect(result.segments.length).toBeGreaterThan(0) - console.log("\n=== UI Implementation Ready ==="); - console.log("✅ Provenance data: Available for enhanced tooltips"); - console.log("✅ Equation anchors: Available for digit highlighting"); - console.log("✅ Character positions: Available for visual connectors"); - console.log("✅ Segment mapping: Available for tooltip content"); + console.log('\n=== UI Implementation Ready ===') + console.log('✅ Provenance data: Available for enhanced tooltips') + console.log('✅ Equation anchors: Available for digit highlighting') + console.log('✅ Character positions: Available for visual connectors') + console.log('✅ Segment mapping: Available for tooltip content') // The ReasonTooltip component now has all the data it needs to show: // - "Add the tens digit — 2 tens (20)" (title) // - "From addend 25" (subtitle) // - "We're adding the tens digit of 25 → 2 tens." (explanation) // - Breadcrumb chips showing the digit transformation - }); -}); + }) +}) diff --git a/apps/web/src/utils/__tests__/provenance.test.ts b/apps/web/src/utils/__tests__/provenance.test.ts index 396709d4..1885ea1f 100644 --- a/apps/web/src/utils/__tests__/provenance.test.ts +++ b/apps/web/src/utils/__tests__/provenance.test.ts @@ -1,162 +1,152 @@ -import { generateUnifiedInstructionSequence } from "../unifiedStepGenerator"; +import { generateUnifiedInstructionSequence } from '../unifiedStepGenerator' -describe("Provenance System", () => { - describe("3475 + 25 = 3500 example", () => { - let result: ReturnType; +describe('Provenance System', () => { + describe('3475 + 25 = 3500 example', () => { + let result: ReturnType beforeAll(() => { - result = generateUnifiedInstructionSequence(3475, 3500); - }); + result = generateUnifiedInstructionSequence(3475, 3500) + }) - it("should generate steps with provenance data", () => { - expect(result.steps.length).toBeGreaterThan(0); + it('should generate steps with provenance data', () => { + expect(result.steps.length).toBeGreaterThan(0) // Log for debugging - console.log("Generated steps:"); + console.log('Generated steps:') result.steps.forEach((step, index) => { - console.log(`Step ${index}: ${step.mathematicalTerm}`); - console.log(` - segmentId: ${step.segmentId}`); + console.log(`Step ${index}: ${step.mathematicalTerm}`) + console.log(` - segmentId: ${step.segmentId}`) if (step.provenance) { - console.log(` - rhs: ${step.provenance.rhs}`); - console.log(` - rhsDigit: ${step.provenance.rhsDigit}`); - console.log(` - rhsPlace: ${step.provenance.rhsPlace}`); - console.log(` - rhsPlaceName: ${step.provenance.rhsPlaceName}`); - console.log(` - rhsValue: ${step.provenance.rhsValue}`); + console.log(` - rhs: ${step.provenance.rhs}`) + console.log(` - rhsDigit: ${step.provenance.rhsDigit}`) + console.log(` - rhsPlace: ${step.provenance.rhsPlace}`) + console.log(` - rhsPlaceName: ${step.provenance.rhsPlaceName}`) + console.log(` - rhsValue: ${step.provenance.rhsValue}`) if (step.provenance.groupId) { - console.log(` - groupId: ${step.provenance.groupId}`); + console.log(` - groupId: ${step.provenance.groupId}`) } } else { - console.log(" - No provenance data"); + console.log(' - No provenance data') } - }); + }) // Log segments to see the rules - console.log("\nGenerated segments:"); + console.log('\nGenerated segments:') result.segments.forEach((segment, index) => { - console.log( - `Segment ${index}: place=${segment.place}, digit=${segment.digit}`, - ); - console.log(` - rule: ${segment.plan[0]?.rule}`); - console.log(` - stepIndices: [${segment.stepIndices.join(", ")}]`); - }); - }); + console.log(`Segment ${index}: place=${segment.place}, digit=${segment.digit}`) + console.log(` - rule: ${segment.plan[0]?.rule}`) + console.log(` - stepIndices: [${segment.stepIndices.join(', ')}]`) + }) + }) it('should have provenance for the "20" term (tens digit)', () => { - const twentyStep = result.steps.find( - (step) => step.mathematicalTerm === "20", - ); - expect(twentyStep).toBeDefined(); - expect(twentyStep?.provenance).toBeDefined(); + const twentyStep = result.steps.find((step) => step.mathematicalTerm === '20') + expect(twentyStep).toBeDefined() + expect(twentyStep?.provenance).toBeDefined() if (twentyStep?.provenance) { - expect(twentyStep.provenance.rhs).toBe(25); - expect(twentyStep.provenance.rhsDigit).toBe(2); - expect(twentyStep.provenance.rhsPlace).toBe(1); - expect(twentyStep.provenance.rhsPlaceName).toBe("tens"); - expect(twentyStep.provenance.rhsValue).toBe(20); - expect(twentyStep.provenance.rhsDigitIndex).toBe(0); // '2' is first digit in '25' + expect(twentyStep.provenance.rhs).toBe(25) + expect(twentyStep.provenance.rhsDigit).toBe(2) + expect(twentyStep.provenance.rhsPlace).toBe(1) + expect(twentyStep.provenance.rhsPlaceName).toBe('tens') + expect(twentyStep.provenance.rhsValue).toBe(20) + expect(twentyStep.provenance.rhsDigitIndex).toBe(0) // '2' is first digit in '25' } - }); + }) it('should have provenance for the "5" term (ones digit)', () => { - const fiveStep = result.steps.find( - (step) => step.mathematicalTerm === "5", - ); - expect(fiveStep).toBeDefined(); - expect(fiveStep?.provenance).toBeDefined(); + const fiveStep = result.steps.find((step) => step.mathematicalTerm === '5') + expect(fiveStep).toBeDefined() + expect(fiveStep?.provenance).toBeDefined() if (fiveStep?.provenance) { - expect(fiveStep.provenance.rhs).toBe(25); - expect(fiveStep.provenance.rhsDigit).toBe(5); - expect(fiveStep.provenance.rhsPlace).toBe(0); - expect(fiveStep.provenance.rhsPlaceName).toBe("ones"); - expect(fiveStep.provenance.rhsValue).toBe(5); - expect(fiveStep.provenance.rhsDigitIndex).toBe(1); // '5' is second digit in '25' + expect(fiveStep.provenance.rhs).toBe(25) + expect(fiveStep.provenance.rhsDigit).toBe(5) + expect(fiveStep.provenance.rhsPlace).toBe(0) + expect(fiveStep.provenance.rhsPlaceName).toBe('ones') + expect(fiveStep.provenance.rhsValue).toBe(5) + expect(fiveStep.provenance.rhsDigitIndex).toBe(1) // '5' is second digit in '25' } - }); + }) - it("should generate equation anchors", () => { - expect(result.equationAnchors).toBeDefined(); - expect(result.equationAnchors?.differenceText).toBe("25"); - expect(result.equationAnchors?.rhsDigitPositions).toHaveLength(2); + it('should generate equation anchors', () => { + expect(result.equationAnchors).toBeDefined() + expect(result.equationAnchors?.differenceText).toBe('25') + expect(result.equationAnchors?.rhsDigitPositions).toHaveLength(2) // First digit (2) expect(result.equationAnchors?.rhsDigitPositions[0]).toEqual({ digitIndex: 0, startIndex: expect.any(Number), endIndex: expect.any(Number), - }); + }) // Second digit (5) expect(result.equationAnchors?.rhsDigitPositions[1]).toEqual({ digitIndex: 1, startIndex: expect.any(Number), endIndex: expect.any(Number), - }); - }); + }) + }) - it("should have segments with proper step mapping", () => { - expect(result.segments.length).toBeGreaterThan(0); + it('should have segments with proper step mapping', () => { + expect(result.segments.length).toBeGreaterThan(0) // Find segment for tens place (digit 2) - const tensSegment = result.segments.find( - (seg) => seg.place === 1 && seg.digit === 2, - ); - expect(tensSegment).toBeDefined(); - expect(tensSegment?.stepIndices.length).toBeGreaterThan(0); + const tensSegment = result.segments.find((seg) => seg.place === 1 && seg.digit === 2) + expect(tensSegment).toBeDefined() + expect(tensSegment?.stepIndices.length).toBeGreaterThan(0) // Find segment for ones place (digit 5) - const onesSegment = result.segments.find( - (seg) => seg.place === 0 && seg.digit === 5, - ); - expect(onesSegment).toBeDefined(); - expect(onesSegment?.stepIndices.length).toBeGreaterThan(0); - }); + const onesSegment = result.segments.find((seg) => seg.place === 0 && seg.digit === 5) + expect(onesSegment).toBeDefined() + expect(onesSegment?.stepIndices.length).toBeGreaterThan(0) + }) - it("should maintain consistency between steps and segments", () => { + it('should maintain consistency between steps and segments', () => { result.segments.forEach((segment) => { segment.stepIndices.forEach((stepIndex) => { - const step = result.steps[stepIndex]; - expect(step).toBeDefined(); - expect(step.segmentId).toBe(segment.id); + const step = result.steps[stepIndex] + expect(step).toBeDefined() + expect(step.segmentId).toBe(segment.id) // Check provenance consistency if (step.provenance) { - expect(step.provenance.rhsPlace).toBe(segment.place); - expect(step.provenance.rhsDigit).toBe(segment.digit); + expect(step.provenance.rhsPlace).toBe(segment.place) + expect(step.provenance.rhsDigit).toBe(segment.digit) } - }); - }); - }); - }); + }) + }) + }) + }) - describe("Edge cases", () => { - it("should handle single digit addition", () => { - const result = generateUnifiedInstructionSequence(10, 13); - const steps = result.steps.filter((step) => step.provenance); - expect(steps.length).toBeGreaterThan(0); + describe('Edge cases', () => { + it('should handle single digit addition', () => { + const result = generateUnifiedInstructionSequence(10, 13) + const steps = result.steps.filter((step) => step.provenance) + expect(steps.length).toBeGreaterThan(0) steps.forEach((step) => { - expect(step.provenance?.rhs).toBe(3); - expect(step.provenance?.rhsDigit).toBe(3); - expect(step.provenance?.rhsPlace).toBe(0); - expect(step.provenance?.rhsPlaceName).toBe("ones"); - }); - }); + expect(step.provenance?.rhs).toBe(3) + expect(step.provenance?.rhsDigit).toBe(3) + expect(step.provenance?.rhsPlace).toBe(0) + expect(step.provenance?.rhsPlaceName).toBe('ones') + }) + }) - it("should handle complement operations with group IDs", () => { + it('should handle complement operations with group IDs', () => { // This might trigger a complement operation - const result = generateUnifiedInstructionSequence(0, 7); + const result = generateUnifiedInstructionSequence(0, 7) const complementSteps = result.steps.filter((step) => - step.provenance?.groupId?.includes("comp"), - ); + step.provenance?.groupId?.includes('comp') + ) if (complementSteps.length > 0) { - const firstGroupId = complementSteps[0].provenance?.groupId; + const firstGroupId = complementSteps[0].provenance?.groupId complementSteps.forEach((step) => { - expect(step.provenance?.groupId).toBe(firstGroupId); - }); + expect(step.provenance?.groupId).toBe(firstGroupId) + }) } - }); - }); -}); + }) + }) +}) diff --git a/apps/web/src/utils/__tests__/termPositionBuilder.test.ts b/apps/web/src/utils/__tests__/termPositionBuilder.test.ts index 2529ffdf..0e9ab32e 100644 --- a/apps/web/src/utils/__tests__/termPositionBuilder.test.ts +++ b/apps/web/src/utils/__tests__/termPositionBuilder.test.ts @@ -1,130 +1,101 @@ -import { describe, expect, it } from "vitest"; -import { buildFullDecompositionWithPositions } from "../unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import { buildFullDecompositionWithPositions } from '../unifiedStepGenerator' /** * Unit tests for the generalized segment-based term position builder. * Verifies that complement grouping and position mapping work correctly. */ -describe("buildFullDecompositionWithPositions", () => { - it("should correctly group cascading complement terms and map positions", () => { - const seq = buildFullDecompositionWithPositions(3456, 3500, [ - "40", - "100", - "-90", - "-6", - ]); +describe('buildFullDecompositionWithPositions', () => { + it('should correctly group cascading complement terms and map positions', () => { + const seq = buildFullDecompositionWithPositions(3456, 3500, ['40', '100', '-90', '-6']) // Verify the full decomposition string format - expect(seq.fullDecomposition).toBe( - "3456 + 44 = 3456 + 40 + (100 - 90 - 6) = 3500", - ); + expect(seq.fullDecomposition).toBe('3456 + 44 = 3456 + 40 + (100 - 90 - 6) = 3500') // Verify term position mapping - const [p40, p100, p90, p6] = seq.termPositions; + const [p40, p100, p90, p6] = seq.termPositions // Each term should map to the correct substring (exclusive end indices) - expect(seq.fullDecomposition.slice(p40.startIndex, p40.endIndex)).toBe( - "40", - ); - expect(seq.fullDecomposition.slice(p100.startIndex, p100.endIndex)).toBe( - "100", - ); - expect(seq.fullDecomposition.slice(p90.startIndex, p90.endIndex)).toBe( - "90", - ); // Maps to number only, not "-90" - expect(seq.fullDecomposition.slice(p6.startIndex, p6.endIndex)).toBe("6"); // Maps to number only, not "-6" - }); + expect(seq.fullDecomposition.slice(p40.startIndex, p40.endIndex)).toBe('40') + expect(seq.fullDecomposition.slice(p100.startIndex, p100.endIndex)).toBe('100') + expect(seq.fullDecomposition.slice(p90.startIndex, p90.endIndex)).toBe('90') // Maps to number only, not "-90" + expect(seq.fullDecomposition.slice(p6.startIndex, p6.endIndex)).toBe('6') // Maps to number only, not "-6" + }) - it("should handle simple five-complement grouping", () => { - const seq = buildFullDecompositionWithPositions(4, 7, ["5", "-2"]); + it('should handle simple five-complement grouping', () => { + const seq = buildFullDecompositionWithPositions(4, 7, ['5', '-2']) - expect(seq.fullDecomposition).toBe("4 + 3 = 4 + (5 - 2) = 7"); + expect(seq.fullDecomposition).toBe('4 + 3 = 4 + (5 - 2) = 7') - const [p5, p2] = seq.termPositions; - expect(seq.fullDecomposition.slice(p5.startIndex, p5.endIndex)).toBe("5"); - expect(seq.fullDecomposition.slice(p2.startIndex, p2.endIndex)).toBe("2"); - }); + const [p5, p2] = seq.termPositions + expect(seq.fullDecomposition.slice(p5.startIndex, p5.endIndex)).toBe('5') + expect(seq.fullDecomposition.slice(p2.startIndex, p2.endIndex)).toBe('2') + }) - it("should handle ten-complement grouping", () => { - const seq = buildFullDecompositionWithPositions(7, 15, ["10", "-2"]); + it('should handle ten-complement grouping', () => { + const seq = buildFullDecompositionWithPositions(7, 15, ['10', '-2']) - expect(seq.fullDecomposition).toBe("7 + 8 = 7 + (10 - 2) = 15"); + expect(seq.fullDecomposition).toBe('7 + 8 = 7 + (10 - 2) = 15') - const [p10, p2] = seq.termPositions; - expect(seq.fullDecomposition.slice(p10.startIndex, p10.endIndex)).toBe( - "10", - ); - expect(seq.fullDecomposition.slice(p2.startIndex, p2.endIndex)).toBe("2"); - }); + const [p10, p2] = seq.termPositions + expect(seq.fullDecomposition.slice(p10.startIndex, p10.endIndex)).toBe('10') + expect(seq.fullDecomposition.slice(p2.startIndex, p2.endIndex)).toBe('2') + }) - it("should handle mixed segments (single terms + complements)", () => { - const seq = buildFullDecompositionWithPositions(12, 34, ["20", "5", "-3"]); + it('should handle mixed segments (single terms + complements)', () => { + const seq = buildFullDecompositionWithPositions(12, 34, ['20', '5', '-3']) - expect(seq.fullDecomposition).toBe("12 + 22 = 12 + 20 + (5 - 3) = 34"); + expect(seq.fullDecomposition).toBe('12 + 22 = 12 + 20 + (5 - 3) = 34') - const [p20, p5, p3] = seq.termPositions; - expect(seq.fullDecomposition.slice(p20.startIndex, p20.endIndex)).toBe( - "20", - ); - expect(seq.fullDecomposition.slice(p5.startIndex, p5.endIndex)).toBe("5"); - expect(seq.fullDecomposition.slice(p3.startIndex, p3.endIndex)).toBe("3"); - }); + const [p20, p5, p3] = seq.termPositions + expect(seq.fullDecomposition.slice(p20.startIndex, p20.endIndex)).toBe('20') + expect(seq.fullDecomposition.slice(p5.startIndex, p5.endIndex)).toBe('5') + expect(seq.fullDecomposition.slice(p3.startIndex, p3.endIndex)).toBe('3') + }) - it("should handle standalone negative terms", () => { - const seq = buildFullDecompositionWithPositions(10, 5, ["-5"]); + it('should handle standalone negative terms', () => { + const seq = buildFullDecompositionWithPositions(10, 5, ['-5']) - expect(seq.fullDecomposition).toBe("10 + -5 = 10 + -5 = 5"); + expect(seq.fullDecomposition).toBe('10 + -5 = 10 + -5 = 5') - const [p5] = seq.termPositions; - expect(seq.fullDecomposition.slice(p5.startIndex, p5.endIndex)).toBe("5"); // Maps to number only - }); + const [p5] = seq.termPositions + expect(seq.fullDecomposition.slice(p5.startIndex, p5.endIndex)).toBe('5') // Maps to number only + }) - it("should handle zero difference case", () => { - const seq = buildFullDecompositionWithPositions(5, 5, []); + it('should handle zero difference case', () => { + const seq = buildFullDecompositionWithPositions(5, 5, []) - expect(seq.fullDecomposition).toBe("5 + 0 = 5"); - expect(seq.termPositions).toEqual([]); - }); + expect(seq.fullDecomposition).toBe('5 + 0 = 5') + expect(seq.termPositions).toEqual([]) + }) - it("should handle complex cascading with multiple negative terms", () => { + it('should handle complex cascading with multiple negative terms', () => { const seq = buildFullDecompositionWithPositions(9999, 10007, [ - "10000", - "-1000", - "-100", - "-10", - "-1", - ]); + '10000', + '-1000', + '-100', + '-10', + '-1', + ]) - expect(seq.fullDecomposition).toBe( - "9999 + 8 = 9999 + (10000 - 1000 - 100 - 10 - 1) = 10007", - ); + expect(seq.fullDecomposition).toBe('9999 + 8 = 9999 + (10000 - 1000 - 100 - 10 - 1) = 10007') - const [p10000, p1000, p100, p10, p1] = seq.termPositions; - expect( - seq.fullDecomposition.slice(p10000.startIndex, p10000.endIndex), - ).toBe("10000"); - expect(seq.fullDecomposition.slice(p1000.startIndex, p1000.endIndex)).toBe( - "1000", - ); - expect(seq.fullDecomposition.slice(p100.startIndex, p100.endIndex)).toBe( - "100", - ); - expect(seq.fullDecomposition.slice(p10.startIndex, p10.endIndex)).toBe( - "10", - ); - expect(seq.fullDecomposition.slice(p1.startIndex, p1.endIndex)).toBe("1"); - }); + const [p10000, p1000, p100, p10, p1] = seq.termPositions + expect(seq.fullDecomposition.slice(p10000.startIndex, p10000.endIndex)).toBe('10000') + expect(seq.fullDecomposition.slice(p1000.startIndex, p1000.endIndex)).toBe('1000') + expect(seq.fullDecomposition.slice(p100.startIndex, p100.endIndex)).toBe('100') + expect(seq.fullDecomposition.slice(p10.startIndex, p10.endIndex)).toBe('10') + expect(seq.fullDecomposition.slice(p1.startIndex, p1.endIndex)).toBe('1') + }) - it("should handle mixed segments (single term + complement)", () => { - const seq = buildFullDecompositionWithPositions(3, 17, ["10", "5", "-1"]); + it('should handle mixed segments (single term + complement)', () => { + const seq = buildFullDecompositionWithPositions(3, 17, ['10', '5', '-1']) - expect(seq.fullDecomposition).toBe("3 + 14 = 3 + 10 + (5 - 1) = 17"); + expect(seq.fullDecomposition).toBe('3 + 14 = 3 + 10 + (5 - 1) = 17') - const [p10, p5, p1] = seq.termPositions; - expect(seq.fullDecomposition.slice(p10.startIndex, p10.endIndex)).toBe( - "10", - ); - expect(seq.fullDecomposition.slice(p5.startIndex, p5.endIndex)).toBe("5"); - expect(seq.fullDecomposition.slice(p1.startIndex, p1.endIndex)).toBe("1"); - }); -}); + const [p10, p5, p1] = seq.termPositions + expect(seq.fullDecomposition.slice(p10.startIndex, p10.endIndex)).toBe('10') + expect(seq.fullDecomposition.slice(p5.startIndex, p5.endIndex)).toBe('5') + expect(seq.fullDecomposition.slice(p1.startIndex, p1.endIndex)).toBe('1') + }) +}) diff --git a/apps/web/src/utils/__tests__/unifiedStepGenerator.correctness.test.ts b/apps/web/src/utils/__tests__/unifiedStepGenerator.correctness.test.ts index 394648e7..8ced810e 100644 --- a/apps/web/src/utils/__tests__/unifiedStepGenerator.correctness.test.ts +++ b/apps/web/src/utils/__tests__/unifiedStepGenerator.correctness.test.ts @@ -1,84 +1,84 @@ -import { describe, expect, it } from "vitest"; -import { generateUnifiedInstructionSequence } from "../unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import { generateUnifiedInstructionSequence } from '../unifiedStepGenerator' -describe("expressions & rule classification", () => { +describe('expressions & rule classification', () => { it('direct 8 at ones: expression "5 + 3", rule Direct', () => { - const seq = generateUnifiedInstructionSequence(0, 8); - const seg = seq.segments.find((s) => s.place === 0)!; - expect(seg.expression.replace(/\s+/g, "")).toBe("5+3"); - expect(seg.plan.some((p) => p.rule === "Direct")).toBe(true); - expect(seg.plan.some((p) => p.rule === "FiveComplement")).toBe(false); - expect(seg.plan.some((p) => p.rule === "TenComplement")).toBe(false); - }); + const seq = generateUnifiedInstructionSequence(0, 8) + const seg = seq.segments.find((s) => s.place === 0)! + expect(seg.expression.replace(/\s+/g, '')).toBe('5+3') + expect(seg.plan.some((p) => p.rule === 'Direct')).toBe(true) + expect(seg.plan.some((p) => p.rule === 'FiveComplement')).toBe(false) + expect(seg.plan.some((p) => p.rule === 'TenComplement')).toBe(false) + }) it('five complement (2 + 3): expression "(5 - 2)" and FiveComplement', () => { - const seq = generateUnifiedInstructionSequence(2, 5); - const seg = seq.segments.find((s) => s.place === 0)!; - expect(seg.expression.replace(/\s+/g, "")).toBe("(5-2)"); - expect(seg.plan.some((p) => p.rule === "FiveComplement")).toBe(true); - }); + const seq = generateUnifiedInstructionSequence(2, 5) + const seg = seq.segments.find((s) => s.place === 0)! + expect(seg.expression.replace(/\s+/g, '')).toBe('(5-2)') + expect(seg.plan.some((p) => p.rule === 'FiveComplement')).toBe(true) + }) - it("ten complement no cascade (19 + 1): TenComplement only", () => { - const seq = generateUnifiedInstructionSequence(19, 20); - const onesSeg = seq.segments.find((s) => s.place === 0)!; - expect(onesSeg.plan.some((p) => p.rule === "TenComplement")).toBe(true); - expect(onesSeg.plan.some((p) => p.rule === "Cascade")).toBe(false); + it('ten complement no cascade (19 + 1): TenComplement only', () => { + const seq = generateUnifiedInstructionSequence(19, 20) + const onesSeg = seq.segments.find((s) => s.place === 0)! + expect(onesSeg.plan.some((p) => p.rule === 'TenComplement')).toBe(true) + expect(onesSeg.plan.some((p) => p.rule === 'Cascade')).toBe(false) // Expression should be "(10 - 9)" - expect(onesSeg.expression.replace(/\s+/g, "")).toBe("(10-9)"); - }); + expect(onesSeg.expression.replace(/\s+/g, '')).toBe('(10-9)') + }) - it("ten complement with cascade (99 + 1): TenComplement + Cascade", () => { - const seq = generateUnifiedInstructionSequence(99, 100); - const onesSeg = seq.segments.find((s) => s.place === 0)!; - expect(onesSeg.plan.some((p) => p.rule === "TenComplement")).toBe(true); - expect(onesSeg.plan.some((p) => p.rule === "Cascade")).toBe(true); + it('ten complement with cascade (99 + 1): TenComplement + Cascade', () => { + const seq = generateUnifiedInstructionSequence(99, 100) + const onesSeg = seq.segments.find((s) => s.place === 0)! + expect(onesSeg.plan.some((p) => p.rule === 'TenComplement')).toBe(true) + expect(onesSeg.plan.some((p) => p.rule === 'Cascade')).toBe(true) // Expression should be "(100 - 90 - 9)" - expect(onesSeg.expression.replace(/\s+/g, "")).toBe("(100-90-9)"); - }); + expect(onesSeg.expression.replace(/\s+/g, '')).toBe('(100-90-9)') + }) - it("term ranges include all step term positions", () => { - const seq = generateUnifiedInstructionSequence(3478, 3500); // +22 - const tensSeg = seq.segments.find((s) => s.place === 1)!; + it('term ranges include all step term positions', () => { + const seq = generateUnifiedInstructionSequence(3478, 3500) // +22 + const tensSeg = seq.segments.find((s) => s.place === 1)! tensSeg.stepIndices.forEach((i) => { - const r = seq.steps[i].termPosition; - expect(r.startIndex >= tensSeg.termRange.startIndex).toBe(true); - expect(r.endIndex <= tensSeg.termRange.endIndex).toBe(true); - }); - }); + const r = seq.steps[i].termPosition + expect(r.startIndex >= tensSeg.termRange.startIndex).toBe(true) + expect(r.endIndex <= tensSeg.termRange.endIndex).toBe(true) + }) + }) // Guard tests to prevent specific regressions - it("prevents false FiveComplement on direct 6-9 adds", () => { + it('prevents false FiveComplement on direct 6-9 adds', () => { for (let digit = 6; digit <= 9; digit++) { - const seq = generateUnifiedInstructionSequence(0, digit); - const seg = seq.segments.find((s) => s.place === 0)!; - expect(seg.plan.some((p) => p.rule === "Direct")).toBe(true); - expect(seg.plan.some((p) => p.rule === "FiveComplement")).toBe(false); - expect(seg.expression).toMatch(/^5 \+ \d+$/); + const seq = generateUnifiedInstructionSequence(0, digit) + const seg = seq.segments.find((s) => s.place === 0)! + expect(seg.plan.some((p) => p.rule === 'Direct')).toBe(true) + expect(seg.plan.some((p) => p.rule === 'FiveComplement')).toBe(false) + expect(seg.expression).toMatch(/^5 \+ \d+$/) } - }); + }) - it("validates expressions use correct format (parentheses for complements only)", () => { + it('validates expressions use correct format (parentheses for complements only)', () => { // Direct additions: no parentheses - const directSeq = generateUnifiedInstructionSequence(0, 8); - const directSeg = directSeq.segments.find((s) => s.place === 0)!; - expect(directSeg.expression).not.toMatch(/^\(.+\)$/); + const directSeq = generateUnifiedInstructionSequence(0, 8) + const directSeg = directSeq.segments.find((s) => s.place === 0)! + expect(directSeg.expression).not.toMatch(/^\(.+\)$/) // Five complement: parentheses - const fiveCompSeq = generateUnifiedInstructionSequence(2, 5); - const fiveCompSeg = fiveCompSeq.segments.find((s) => s.place === 0)!; - expect(fiveCompSeg.expression).toMatch(/^\(.+\)$/); + const fiveCompSeq = generateUnifiedInstructionSequence(2, 5) + const fiveCompSeg = fiveCompSeq.segments.find((s) => s.place === 0)! + expect(fiveCompSeg.expression).toMatch(/^\(.+\)$/) // Ten complement: parentheses - const tenCompSeq = generateUnifiedInstructionSequence(19, 20); - const tenCompSeg = tenCompSeq.segments.find((s) => s.place === 0)!; - expect(tenCompSeg.expression).toMatch(/^\(.+\)$/); - }); + const tenCompSeq = generateUnifiedInstructionSequence(19, 20) + const tenCompSeg = tenCompSeq.segments.find((s) => s.place === 0)! + expect(tenCompSeg.expression).toMatch(/^\(.+\)$/) + }) - it("handles more complex cascade scenarios", () => { + it('handles more complex cascade scenarios', () => { // 999 + 1 = 1000 (cascade through all places) - const seq = generateUnifiedInstructionSequence(999, 1000); - const onesSeg = seq.segments.find((s) => s.place === 0)!; - expect(onesSeg.plan.some((p) => p.rule === "Cascade")).toBe(true); - expect(onesSeg.expression).toMatch(/^\(1000 - 900 - 90 - 9\)$/); - }); -}); + const seq = generateUnifiedInstructionSequence(999, 1000) + const onesSeg = seq.segments.find((s) => s.place === 0)! + expect(onesSeg.plan.some((p) => p.rule === 'Cascade')).toBe(true) + expect(onesSeg.expression).toMatch(/^\(1000 - 900 - 90 - 9\)$/) + }) +}) diff --git a/apps/web/src/utils/abacusInstructionGenerator.ts b/apps/web/src/utils/abacusInstructionGenerator.ts index 2378a0fb..209f99e5 100644 --- a/apps/web/src/utils/abacusInstructionGenerator.ts +++ b/apps/web/src/utils/abacusInstructionGenerator.ts @@ -1,50 +1,44 @@ // Automatic instruction generator for abacus tutorial steps // Re-exports core types and functions from abacus-react -export type { ValidPlaceValues } from "@soroban/abacus-react"; +export type { ValidPlaceValues } from '@soroban/abacus-react' export { type BeadState, type AbacusState, type PlaceValueBasedBead as BeadHighlight, numberToAbacusState, calculateBeadChanges, -} from "@soroban/abacus-react"; +} from '@soroban/abacus-react' -import type { - ValidPlaceValues, - PlaceValueBasedBead, -} from "@soroban/abacus-react"; -import { - numberToAbacusState, - calculateBeadChanges, -} from "@soroban/abacus-react"; +import type { ValidPlaceValues, PlaceValueBasedBead } from '@soroban/abacus-react' +import { numberToAbacusState, calculateBeadChanges } from '@soroban/abacus-react' // Type alias for internal use -type BeadHighlight = PlaceValueBasedBead; +type BeadHighlight = PlaceValueBasedBead // App-specific extension for step-based tutorial highlighting export interface StepBeadHighlight extends PlaceValueBasedBead { - stepIndex: number; // Which instruction step this bead belongs to - direction: "up" | "down" | "activate" | "deactivate"; // Movement direction - order?: number; // Order within the step (for multiple beads per step) + stepIndex: number // Which instruction step this bead belongs to + direction: 'up' | 'down' | 'activate' | 'deactivate' // Movement direction + order?: number // Order within the step (for multiple beads per step) } export interface GeneratedInstruction { - highlightBeads: PlaceValueBasedBead[]; - expectedAction: "add" | "remove" | "multi-step"; - actionDescription: string; - multiStepInstructions?: string[]; - stepBeadHighlights?: StepBeadHighlight[]; // NEW: beads grouped by step - totalSteps?: number; // NEW: total number of steps + highlightBeads: PlaceValueBasedBead[] + expectedAction: 'add' | 'remove' | 'multi-step' + actionDescription: string + multiStepInstructions?: string[] + stepBeadHighlights?: StepBeadHighlight[] // NEW: beads grouped by step + totalSteps?: number // NEW: total number of steps tooltip: { - content: string; - explanation: string; - }; + content: string + explanation: string + } errorMessages: { - wrongBead: string; - wrongAction: string; - hint: string; - }; + wrongBead: string + wrongAction: string + hint: string + } } // Note: numberToAbacusState and calculateBeadChanges are now re-exported from @soroban/abacus-react above @@ -54,26 +48,26 @@ function generateProperComplementDescription( startValue: number, targetValue: number, _additions: BeadHighlight[], - _removals: BeadHighlight[], + _removals: BeadHighlight[] ): { description: string; decomposition: any } { - const difference = targetValue - startValue; + const difference = targetValue - startValue // Find the optimal complement decomposition with context - const decomposition = findOptimalDecomposition(difference, { startValue }); + const decomposition = findOptimalDecomposition(difference, { startValue }) if (decomposition) { - const { addTerm, subtractTerm, compactMath, isRecursive } = decomposition; + const { addTerm, subtractTerm, compactMath, isRecursive } = decomposition return { description: `${startValue} + ${difference} = ${startValue} + ${compactMath} = ${targetValue}`, decomposition: { ...decomposition, isRecursive }, - }; + } } // Fallback to simple description return { description: `${startValue} + ${difference} = ${targetValue}`, decomposition: null, - }; + } } // Generate enhanced step-by-step instructions with complement explanations @@ -83,165 +77,139 @@ function generateEnhancedStepInstructions( additions: BeadHighlight[], removals: BeadHighlight[], decomposition: any, - stepBeadHighlights?: StepBeadHighlight[], + stepBeadHighlights?: StepBeadHighlight[] ): string[] { - const instructions: string[] = []; + const instructions: string[] = [] if (decomposition && stepBeadHighlights) { - const { addTerm, subtractTerm, isRecursive } = decomposition; + const { addTerm, subtractTerm, isRecursive } = decomposition // Generate instructions based on step groupings from stepBeadHighlights - const stepIndices = [ - ...new Set(stepBeadHighlights.map((bead) => bead.stepIndex)), - ].sort(); + const stepIndices = [...new Set(stepBeadHighlights.map((bead) => bead.stepIndex))].sort() stepIndices.forEach((stepIndex) => { - const stepBeads = stepBeadHighlights.filter( - (bead) => bead.stepIndex === stepIndex, - ); + const stepBeads = stepBeadHighlights.filter((bead) => bead.stepIndex === stepIndex) // Group beads by place and direction within this step const stepByPlace: { [place: number]: { - additions: StepBeadHighlight[]; - removals: StepBeadHighlight[]; - }; - } = {}; + additions: StepBeadHighlight[] + removals: StepBeadHighlight[] + } + } = {} stepBeads.forEach((bead) => { if (!stepByPlace[bead.placeValue]) { - stepByPlace[bead.placeValue] = { additions: [], removals: [] }; + stepByPlace[bead.placeValue] = { additions: [], removals: [] } } - if (bead.direction === "activate") { - stepByPlace[bead.placeValue].additions.push(bead); + if (bead.direction === 'activate') { + stepByPlace[bead.placeValue].additions.push(bead) } else { - stepByPlace[bead.placeValue].removals.push(bead); + stepByPlace[bead.placeValue].removals.push(bead) } - }); + }) // Process places in descending order (pedagogical: highest place first) const places = Object.keys(stepByPlace) .map((p) => parseInt(p, 10)) - .sort((a, b) => b - a); + .sort((a, b) => b - a) places.forEach((place) => { const placeName = - place === 0 - ? "ones" - : place === 1 - ? "tens" - : place === 2 - ? "hundreds" - : `place ${place}`; - const placeData = stepByPlace[place]; + place === 0 ? 'ones' : place === 1 ? 'tens' : place === 2 ? 'hundreds' : `place ${place}` + const placeData = stepByPlace[place] // Handle additions for this place if (placeData.additions.length > 0) { - const beads = placeData.additions; - let totalValue = 0; - let hasHeaven = false; - let earthCount = 0; + const beads = placeData.additions + let totalValue = 0 + let hasHeaven = false + let earthCount = 0 beads.forEach((bead) => { - if (bead.beadType === "heaven") { - hasHeaven = true; - totalValue += 5 * 10 ** place; + if (bead.beadType === 'heaven') { + hasHeaven = true + totalValue += 5 * 10 ** place } else { - earthCount++; - totalValue += 1 * 10 ** place; + earthCount++ + totalValue += 1 * 10 ** place } - }); + }) // Generate consolidated instruction for this place's additions if (place === 2 && addTerm === 100) { - instructions.push( - `Click earth bead 1 in the hundreds column to add it`, - ); + instructions.push(`Click earth bead 1 in the hundreds column to add it`) } else if (place === 1 && addTerm === 10) { - instructions.push( - `Click earth bead 1 in the tens column to add it`, - ); + instructions.push(`Click earth bead 1 in the tens column to add it`) } else if (place === 0 && addTerm === 5) { - instructions.push( - `Click the heaven bead in the ones column to add it`, - ); + instructions.push(`Click the heaven bead in the ones column to add it`) } else if (hasHeaven && earthCount > 0) { instructions.push( - `Add ${totalValue} to ${placeName} column (heaven bead + ${earthCount} earth beads)`, - ); + `Add ${totalValue} to ${placeName} column (heaven bead + ${earthCount} earth beads)` + ) } else if (hasHeaven) { - instructions.push( - `Click the heaven bead in the ${placeName} column to add it`, - ); + instructions.push(`Click the heaven bead in the ${placeName} column to add it`) } else if (earthCount === 1) { - instructions.push( - `Click earth bead 1 in the ${placeName} column to add it`, - ); + instructions.push(`Click earth bead 1 in the ${placeName} column to add it`) } else { instructions.push( - `Add ${totalValue} to ${placeName} column (${earthCount} earth beads)`, - ); + `Add ${totalValue} to ${placeName} column (${earthCount} earth beads)` + ) } } // Handle removals for this place if (placeData.removals.length > 0) { - const beads = placeData.removals; - let totalValue = 0; - let hasHeaven = false; - let earthCount = 0; + const beads = placeData.removals + let totalValue = 0 + let hasHeaven = false + let earthCount = 0 beads.forEach((bead) => { - if (bead.beadType === "heaven") { - hasHeaven = true; - totalValue += 5 * 10 ** place; + if (bead.beadType === 'heaven') { + hasHeaven = true + totalValue += 5 * 10 ** place } else { - earthCount++; - totalValue += 1 * 10 ** place; + earthCount++ + totalValue += 1 * 10 ** place } - }); + }) // Generate consolidated instruction for this place's removals if (isRecursive && place === 1 && totalValue === 90) { instructions.push( - `Remove 90 from tens column (subtracting first part of decomposition)`, - ); + `Remove 90 from tens column (subtracting first part of decomposition)` + ) } else if (isRecursive && place === 0 && totalValue === 9) { instructions.push( - `Remove 9 from ones column (subtracting second part of decomposition)`, - ); + `Remove 9 from ones column (subtracting second part of decomposition)` + ) } else if (place === 0 && totalValue === subtractTerm) { instructions.push( - `Remove ${subtractTerm} from ones column (subtracting ${subtractTerm} from complement)`, - ); + `Remove ${subtractTerm} from ones column (subtracting ${subtractTerm} from complement)` + ) } else if (hasHeaven && earthCount > 0) { instructions.push( - `Remove ${totalValue} from ${placeName} column (heaven bead + ${earthCount} earth beads)`, - ); + `Remove ${totalValue} from ${placeName} column (heaven bead + ${earthCount} earth beads)` + ) } else if (hasHeaven) { - instructions.push( - `Click heaven bead in the ${placeName} column to remove`, - ); + instructions.push(`Click heaven bead in the ${placeName} column to remove`) } else if (earthCount === 1) { - instructions.push( - `Click earth bead 1 in the ${placeName} column to remove`, - ); + instructions.push(`Click earth bead 1 in the ${placeName} column to remove`) } else { instructions.push( - `Remove ${totalValue} from ${placeName} column (${earthCount} earth beads)`, - ); + `Remove ${totalValue} from ${placeName} column (${earthCount} earth beads)` + ) } } - }); - }); + }) + }) } else { // Fallback to standard instructions - return generateStepInstructions(additions, removals, false); + return generateStepInstructions(additions, removals, false) } - return instructions.length > 0 - ? instructions - : ["No bead movements required"]; + return instructions.length > 0 ? instructions : ['No bead movements required'] } // Generate step-by-step bead highlighting mapping @@ -251,60 +219,54 @@ function generateStepBeadMapping( additions: BeadHighlight[], removals: BeadHighlight[], decomposition: any, - multiStepInstructions: string[], + multiStepInstructions: string[] ): StepBeadHighlight[] { - const stepBeadHighlights: StepBeadHighlight[] = []; + const stepBeadHighlights: StepBeadHighlight[] = [] - if ( - !decomposition || - !multiStepInstructions || - multiStepInstructions.length === 0 - ) { + if (!decomposition || !multiStepInstructions || multiStepInstructions.length === 0) { // Fallback: assign all beads to step 0 additions.forEach((bead, index) => { stepBeadHighlights.push({ ...bead, stepIndex: 0, - direction: "activate", + direction: 'activate', order: index, - }); - }); + }) + }) removals.forEach((bead, index) => { stepBeadHighlights.push({ ...bead, stepIndex: 0, - direction: "deactivate", + direction: 'deactivate', order: additions.length + index, - }); - }); - return stepBeadHighlights; + }) + }) + return stepBeadHighlights } - const { addTerm, subtractTerm, isRecursive } = decomposition; + const { addTerm, subtractTerm, isRecursive } = decomposition // Group beads by place value for easier processing - const additionsByPlace: { [place: number]: BeadHighlight[] } = {}; - const removalsByPlace: { [place: number]: BeadHighlight[] } = {}; + const additionsByPlace: { [place: number]: BeadHighlight[] } = {} + const removalsByPlace: { [place: number]: BeadHighlight[] } = {} additions.forEach((bead) => { - if (!additionsByPlace[bead.placeValue]) - additionsByPlace[bead.placeValue] = []; - additionsByPlace[bead.placeValue].push(bead); - }); + if (!additionsByPlace[bead.placeValue]) additionsByPlace[bead.placeValue] = [] + additionsByPlace[bead.placeValue].push(bead) + }) removals.forEach((bead) => { - if (!removalsByPlace[bead.placeValue]) - removalsByPlace[bead.placeValue] = []; - removalsByPlace[bead.placeValue].push(bead); - }); + if (!removalsByPlace[bead.placeValue]) removalsByPlace[bead.placeValue] = [] + removalsByPlace[bead.placeValue].push(bead) + }) - let currentStepIndex = 0; - let currentOrder = 0; + let currentStepIndex = 0 + let currentOrder = 0 // Pedagogical step ordering: Process from highest place value to lowest, separating additions and subtractions const placeValues = Object.keys({ ...additionsByPlace, ...removalsByPlace }) .map((p) => parseInt(p, 10)) - .sort((a, b) => b - a); + .sort((a, b) => b - a) for (const place of placeValues) { // First: Add any additions for this place value @@ -313,11 +275,11 @@ function generateStepBeadMapping( stepBeadHighlights.push({ ...bead, stepIndex: currentStepIndex, - direction: "activate", + direction: 'activate', order: currentOrder++, - }); - }); - currentStepIndex++; + }) + }) + currentStepIndex++ } } @@ -328,61 +290,58 @@ function generateStepBeadMapping( stepBeadHighlights.push({ ...bead, stepIndex: currentStepIndex, - direction: "deactivate", + direction: 'deactivate', order: currentOrder++, - }); - }); - currentStepIndex++; + }) + }) + currentStepIndex++ } } - return stepBeadHighlights; + return stepBeadHighlights } // Find optimal decomposition that maps 1:1 to bead movements function findOptimalDecomposition( value: number, - context?: { startValue?: number; placeCapacity?: number }, + context?: { startValue?: number; placeCapacity?: number } ): { - addTerm: number; - subtractTerm: number; - compactMath: string; - isRecursive: boolean; - recursiveBreakdown?: string; - decompositionTerms?: string[]; + addTerm: number + subtractTerm: number + compactMath: string + isRecursive: boolean + recursiveBreakdown?: string + decompositionTerms?: string[] } | null { // Special case for 99 + 1: Force using recursive breakdown if (context?.startValue === 99 && value === 1) { return { addTerm: 100, subtractTerm: 99, - compactMath: "(100 - 90) - 9", + compactMath: '(100 - 90) - 9', isRecursive: true, - recursiveBreakdown: "((100 - 90) - 9)", - decompositionTerms: ["(100 - 90)", "- 9"], - }; + recursiveBreakdown: '((100 - 90) - 9)', + decompositionTerms: ['(100 - 90)', '- 9'], + } } // Analyze actual bead movements to determine proper decomposition if (context?.startValue !== undefined) { - const startState = numberToAbacusState(context.startValue); - const targetState = numberToAbacusState(context.startValue + value); - const { additions, removals } = calculateBeadChanges( - startState, - targetState, - ); + const startState = numberToAbacusState(context.startValue) + const targetState = numberToAbacusState(context.startValue + value) + const { additions, removals } = calculateBeadChanges(startState, targetState) - const decompositionTerms: string[] = []; + const decompositionTerms: string[] = [] // Group changes by place value const changesByPlace: { [place: number]: { - adds: number; - removes: number; - addHeaven: boolean; - removeHeaven: boolean; - }; - } = {}; + adds: number + removes: number + addHeaven: boolean + removeHeaven: boolean + } + } = {} additions.forEach((bead) => { if (!changesByPlace[bead.placeValue]) { @@ -391,15 +350,15 @@ function findOptimalDecomposition( removes: 0, addHeaven: false, removeHeaven: false, - }; + } } - if (bead.beadType === "heaven") { - changesByPlace[bead.placeValue].addHeaven = true; - changesByPlace[bead.placeValue].adds += 5 * 10 ** bead.placeValue; + if (bead.beadType === 'heaven') { + changesByPlace[bead.placeValue].addHeaven = true + changesByPlace[bead.placeValue].adds += 5 * 10 ** bead.placeValue } else { - changesByPlace[bead.placeValue].adds += 1 * 10 ** bead.placeValue; + changesByPlace[bead.placeValue].adds += 1 * 10 ** bead.placeValue } - }); + }) removals.forEach((bead) => { if (!changesByPlace[bead.placeValue]) { @@ -408,84 +367,84 @@ function findOptimalDecomposition( removes: 0, addHeaven: false, removeHeaven: false, - }; + } } - if (bead.beadType === "heaven") { - changesByPlace[bead.placeValue].removeHeaven = true; - changesByPlace[bead.placeValue].removes += 5 * 10 ** bead.placeValue; + if (bead.beadType === 'heaven') { + changesByPlace[bead.placeValue].removeHeaven = true + changesByPlace[bead.placeValue].removes += 5 * 10 ** bead.placeValue } else { - changesByPlace[bead.placeValue].removes += 1 * 10 ** bead.placeValue; + changesByPlace[bead.placeValue].removes += 1 * 10 ** bead.placeValue } - }); + }) // Process places in descending order const places = Object.keys(changesByPlace) .map((p) => parseInt(p, 10)) - .sort((a, b) => b - a); + .sort((a, b) => b - a) for (const place of places) { - const changes = changesByPlace[place]; - const _netValue = changes.adds - changes.removes; + const changes = changesByPlace[place] + const _netValue = changes.adds - changes.removes if (changes.adds > 0 && changes.removes > 0) { // Complement operation - show as (add - remove) - decompositionTerms.push(`(${changes.adds} - ${changes.removes})`); + decompositionTerms.push(`(${changes.adds} - ${changes.removes})`) } else if (changes.adds > 0) { // Pure addition if (place === 1 && changes.adds >= 5) { // Five complement in ones place - const earthValue = changes.adds % 5; + const earthValue = changes.adds % 5 if (earthValue > 0) { - decompositionTerms.push(`(5 - ${5 - earthValue})`); + decompositionTerms.push(`(5 - ${5 - earthValue})`) } else { - decompositionTerms.push(`${changes.adds}`); + decompositionTerms.push(`${changes.adds}`) } } else { - decompositionTerms.push(`${changes.adds}`); + decompositionTerms.push(`${changes.adds}`) } } else if (changes.removes > 0) { // Pure subtraction - decompositionTerms.push(`-${changes.removes}`); + decompositionTerms.push(`-${changes.removes}`) } } // If we have decomposition terms, format them properly if (decompositionTerms.length > 0) { - const compactMath = decompositionTerms.join(" + ").replace("+ -", "- "); + const compactMath = decompositionTerms.join(' + ').replace('+ -', '- ') return { addTerm: value, subtractTerm: 0, compactMath, isRecursive: false, decompositionTerms, - }; + } } } // Fallback: Break down by place value without context - const decompositionTerms: string[] = []; - let remainingValue = value; - const placeValues = [100, 10, 1]; + const decompositionTerms: string[] = [] + let remainingValue = value + const placeValues = [100, 10, 1] for (const placeValue of placeValues) { if (remainingValue >= placeValue) { - const digitNeeded = Math.floor(remainingValue / placeValue); - remainingValue = remainingValue % placeValue; - decompositionTerms.push(`${digitNeeded * placeValue}`); + const digitNeeded = Math.floor(remainingValue / placeValue) + remainingValue = remainingValue % placeValue + decompositionTerms.push(`${digitNeeded * placeValue}`) } } // If we have decomposition terms, format them properly if (decompositionTerms.length > 0) { - const compactMath = decompositionTerms.join(" + "); + const compactMath = decompositionTerms.join(' + ') // For simple single complement operations, return in the expected format if ( decompositionTerms.length === 1 && - decompositionTerms[0].includes("(") && - decompositionTerms[0].includes(" - ") + decompositionTerms[0].includes('(') && + decompositionTerms[0].includes(' - ') ) { - const match = decompositionTerms[0].match(/\((\d+) - (\d+)\)/); + const match = decompositionTerms[0].match(/\((\d+) - (\d+)\)/) if (match) { return { addTerm: parseInt(match[1], 10), @@ -493,7 +452,7 @@ function findOptimalDecomposition( compactMath: decompositionTerms[0], isRecursive: false, decompositionTerms, - }; + } } } @@ -503,7 +462,7 @@ function findOptimalDecomposition( compactMath, isRecursive: false, decompositionTerms, - }; + } } // Fallback: use simple complement for small values @@ -514,10 +473,10 @@ function findOptimalDecomposition( compactMath: `(5 - ${5 - value})`, isRecursive: false, decompositionTerms: [`(5 - ${5 - value})`], - }; + } } - return null; + return null } // Generate recursive complement description for complex multi-place operations @@ -525,67 +484,63 @@ function _generateRecursiveComplementDescription( startValue: number, targetValue: number, _additions: BeadHighlight[], - _removals: BeadHighlight[], + _removals: BeadHighlight[] ): string { - const difference = targetValue - startValue; + const difference = targetValue - startValue // Simulate the abacus addition step by step - const steps: string[] = []; - let carry = 0; + const steps: string[] = [] + let carry = 0 // Process each digit from ones to hundreds for (let place = 0; place <= 2; place++) { - const placeValue = 10 ** place; - const placeName = place === 0 ? "ones" : place === 1 ? "tens" : "hundreds"; + const placeValue = 10 ** place + const placeName = place === 0 ? 'ones' : place === 1 ? 'tens' : 'hundreds' - const startDigit = Math.floor(startValue / placeValue) % 10; - const addDigit = Math.floor(difference / placeValue) % 10; - const totalNeeded = startDigit + addDigit + carry; + const startDigit = Math.floor(startValue / placeValue) % 10 + const addDigit = Math.floor(difference / placeValue) % 10 + const totalNeeded = startDigit + addDigit + carry if (totalNeeded === 0) { - carry = 0; - continue; + carry = 0 + continue } if (totalNeeded >= 10) { // Need complement in this place - const finalDigit = totalNeeded % 10; - const newCarry = Math.floor(totalNeeded / 10); + const finalDigit = totalNeeded % 10 + const newCarry = Math.floor(totalNeeded / 10) if (place === 0 && addDigit > 0) { // Ones place - show the actual complement operation - const complement = 10 - addDigit; - steps.push( - `${placeName}: ${addDigit} = 10 - ${complement} (complement creates carry)`, - ); + const complement = 10 - addDigit + steps.push(`${placeName}: ${addDigit} = 10 - ${complement} (complement creates carry)`) } else if (place > 0) { // Higher places - show the carry logic if (addDigit > 0 && carry > 0) { steps.push( - `${placeName}: ${addDigit} + ${carry} carry = ${totalNeeded} = 10 - ${10 - finalDigit} (complement creates carry)`, - ); + `${placeName}: ${addDigit} + ${carry} carry = ${totalNeeded} = 10 - ${10 - finalDigit} (complement creates carry)` + ) } else if (carry > 0) { - steps.push(`${placeName}: ${carry} carry creates complement`); + steps.push(`${placeName}: ${carry} carry creates complement`) } } - carry = newCarry; + carry = newCarry } else if (totalNeeded > 0) { // Direct addition if (addDigit > 0 && carry > 0) { - steps.push( - `${placeName}: ${addDigit} + ${carry} carry = ${totalNeeded}`, - ); + steps.push(`${placeName}: ${addDigit} + ${carry} carry = ${totalNeeded}`) } else if (addDigit > 0) { - steps.push(`${placeName}: add ${addDigit}`); + steps.push(`${placeName}: add ${addDigit}`) } else if (carry > 0) { - steps.push(`${placeName}: ${carry} carry`); + steps.push(`${placeName}: ${carry} carry`) } - carry = 0; + carry = 0 } } - const breakdown = steps.join(", "); - return `${startValue} + ${difference} requires complements: ${breakdown}, giving us ${targetValue}`; + const breakdown = steps.join(', ') + return `${startValue} + ${difference} requires complements: ${breakdown}, giving us ${targetValue}` } // Generate comprehensive complement description for multi-place operations @@ -593,90 +548,79 @@ function _generateMultiPlaceComplementDescription( startValue: number, targetValue: number, additions: BeadHighlight[], - removals: BeadHighlight[], + removals: BeadHighlight[] ): string { - const difference = targetValue - startValue; + const difference = targetValue - startValue // For simple five complement if (difference <= 4 && startValue < 10 && targetValue < 10) { - return `if ${difference} = 5 - ${5 - difference}, then ${startValue} + ${difference} = ${startValue} + (5 - ${5 - difference}) = ${targetValue}`; + return `if ${difference} = 5 - ${5 - difference}, then ${startValue} + ${difference} = ${startValue} + (5 - ${5 - difference}) = ${targetValue}` } // For multi-place operations, analyze what's happening in each place - const explanations: string[] = []; + const explanations: string[] = [] // Group movements by place value - const placeMovements: { [place: number]: { adds: number; removes: number } } = - {}; + const placeMovements: { [place: number]: { adds: number; removes: number } } = {} additions.forEach((bead) => { - if (!placeMovements[bead.placeValue]) - placeMovements[bead.placeValue] = { adds: 0, removes: 0 }; - placeMovements[bead.placeValue].adds++; - }); + if (!placeMovements[bead.placeValue]) placeMovements[bead.placeValue] = { adds: 0, removes: 0 } + placeMovements[bead.placeValue].adds++ + }) removals.forEach((bead) => { - if (!placeMovements[bead.placeValue]) - placeMovements[bead.placeValue] = { adds: 0, removes: 0 }; - placeMovements[bead.placeValue].removes++; - }); + if (!placeMovements[bead.placeValue]) placeMovements[bead.placeValue] = { adds: 0, removes: 0 } + placeMovements[bead.placeValue].removes++ + }) // Analyze each place value to understand the complement logic Object.keys(placeMovements) .sort((a, b) => parseInt(b, 10) - parseInt(a, 10)) .forEach((placeStr) => { - const place = parseInt(placeStr, 10); - const movement = placeMovements[place]; + const place = parseInt(placeStr, 10) + const movement = placeMovements[place] const placeName = - place === 0 - ? "ones" - : place === 1 - ? "tens" - : place === 2 - ? "hundreds" - : `place ${place}`; + place === 0 ? 'ones' : place === 1 ? 'tens' : place === 2 ? 'hundreds' : `place ${place}` if (movement.adds > 0 && movement.removes === 0) { // Pure addition - explain why we need this place if (place >= 2) { explanations.push( - `hundreds place needed because we cross from ${Math.floor(startValue / 100) * 100 + 99} to ${Math.floor(targetValue / 100) * 100}`, - ); + `hundreds place needed because we cross from ${Math.floor(startValue / 100) * 100 + 99} to ${Math.floor(targetValue / 100) * 100}` + ) } else if (place === 1) { - explanations.push(`tens carry from complement operation`); + explanations.push(`tens carry from complement operation`) } } else if (movement.adds > 0 && movement.removes > 0) { // Complement operation in this place - const complement = movement.removes; - const net = movement.adds - movement.removes; + const complement = movement.removes + const net = movement.adds - movement.removes if (net > 0) { - explanations.push( - `${placeName}: ${net + complement} = 10 - ${10 - (net + complement)}`, - ); + explanations.push(`${placeName}: ${net + complement} = 10 - ${10 - (net + complement)}`) } } - }); + }) // For the ones place complement, always include the traditional explanation - const onesMovement = placeMovements[0]; + const onesMovement = placeMovements[0] if (onesMovement && onesMovement.removes > 0) { - const onesDigitTarget = difference % 10; + const onesDigitTarget = difference % 10 if (onesDigitTarget > 0) { - const complement = onesMovement.removes; - explanations.push(`ones: ${onesDigitTarget} = 10 - ${complement}`); + const complement = onesMovement.removes + explanations.push(`ones: ${onesDigitTarget} = 10 - ${complement}`) } } // Build final explanation if (explanations.length > 0) { - const breakdown = explanations.join(", "); - return `${startValue} + ${difference} requires complements: ${breakdown}, giving us ${targetValue}`; + const breakdown = explanations.join(', ') + return `${startValue} + ${difference} requires complements: ${breakdown}, giving us ${targetValue}` } // Fallback for simple ten complement - const targetDifference = difference % 10; - const complement = 10 - targetDifference; - return `if ${targetDifference} = 10 - ${complement}, then ${startValue} + ${targetDifference} = ${startValue} + (10 - ${complement}) = ${startValue + targetDifference}`; + const targetDifference = difference % 10 + const complement = 10 - targetDifference + return `if ${targetDifference} = 10 - ${complement}, then ${startValue} + ${targetDifference} = ${startValue} + (10 - ${complement}) = ${startValue + targetDifference}` } // Generate traditional abacus complement description @@ -684,32 +628,32 @@ function generateComplementDescription( startValue: number, _targetValue: number, difference: number, - complementType: "five" | "ten", + complementType: 'five' | 'ten', _addValue: number, - _subtractValue: number, + _subtractValue: number ): string { // Use the same logic as generateProperComplementDescription for consistency - const decomposition = findOptimalDecomposition(difference, { startValue }); + const decomposition = findOptimalDecomposition(difference, { startValue }) if (decomposition) { - const { addTerm, subtractTerm, compactMath, isRecursive } = decomposition; + const { addTerm, subtractTerm, compactMath, isRecursive } = decomposition if (isRecursive) { // For recursive cases like 99 + 1, provide the full breakdown - return `if ${difference} = ${compactMath.replace(/[()]/g, "")}, then ${startValue} + ${difference} = ${startValue} + ${compactMath}`; + return `if ${difference} = ${compactMath.replace(/[()]/g, '')}, then ${startValue} + ${difference} = ${startValue} + ${compactMath}` } else { // For simple complement cases - return `if ${difference} = ${addTerm} - ${subtractTerm}, then ${startValue} + ${difference} = ${startValue} + (${addTerm} - ${subtractTerm})`; + return `if ${difference} = ${addTerm} - ${subtractTerm}, then ${startValue} + ${difference} = ${startValue} + (${addTerm} - ${subtractTerm})` } } // Fallback to old logic if no decomposition found - if (complementType === "five") { - return `if ${difference} = 5 - ${5 - difference}, then ${startValue} + ${difference} = ${startValue} + (5 - ${5 - difference})`; + if (complementType === 'five') { + return `if ${difference} = 5 - ${5 - difference}, then ${startValue} + ${difference} = ${startValue} + (5 - ${5 - difference})` } else { - const targetDifference = difference % 10; - const complement = 10 - targetDifference; - return `if ${targetDifference} = 10 - ${complement}, then ${startValue} + ${targetDifference} = ${startValue} + (10 - ${complement})`; + const targetDifference = difference % 10 + const complement = 10 - targetDifference + return `if ${targetDifference} = 10 - ${complement}, then ${startValue} + ${targetDifference} = ${startValue} + (10 - ${complement})` } } @@ -717,35 +661,34 @@ function generateComplementDescription( export function detectComplementOperation( startValue: number, targetValue: number, - placeValue: number, + placeValue: number ): { - needsComplement: boolean; - complementType: "five" | "ten" | "none"; + needsComplement: boolean + complementType: 'five' | 'ten' | 'none' complementDetails?: { - addValue: number; - subtractValue: number; - description: string; - }; + addValue: number + subtractValue: number + description: string + } } { - const difference = targetValue - startValue; + const difference = targetValue - startValue // Ten complement detection (carrying to next place) - check this FIRST if (difference > 0) { // Check if we're crossing a multiple of 10 boundary - const startDigit = startValue % 10; - const _targetDigit = targetValue % 10; + const startDigit = startValue % 10 + const _targetDigit = targetValue % 10 // If we go from single digits to teens, or cross any 10s boundary with insufficient space if ( (startValue < 10 && targetValue >= 10) || - (startDigit + difference > 9 && - Math.floor(startValue / 10) !== Math.floor(targetValue / 10)) + (startDigit + difference > 9 && Math.floor(startValue / 10) !== Math.floor(targetValue / 10)) ) { - const addValue = 10; - const subtractValue = 10 - (difference % 10); + const addValue = 10 + const subtractValue = 10 - (difference % 10) return { needsComplement: true, - complementType: "ten", + complementType: 'ten', complementDetails: { addValue, subtractValue, @@ -753,31 +696,26 @@ export function detectComplementOperation( startValue, targetValue, difference, - "ten", + 'ten', addValue, - subtractValue, + subtractValue ), }, - }; + } } } // Five complement detection (within same place) if (placeValue === 0 && difference > 0) { - const startDigit = startValue % 10; - const earthSpaceAvailable = - 4 - (startDigit >= 5 ? startDigit - 5 : startDigit); + const startDigit = startValue % 10 + const earthSpaceAvailable = 4 - (startDigit >= 5 ? startDigit - 5 : startDigit) - if ( - difference > earthSpaceAvailable && - difference <= 4 && - targetValue < 10 - ) { - const addValue = 5; - const subtractValue = 5 - difference; + if (difference > earthSpaceAvailable && difference <= 4 && targetValue < 10) { + const addValue = 5 + const subtractValue = 5 - difference return { needsComplement: true, - complementType: "five", + complementType: 'five', complementDetails: { addValue, subtractValue, @@ -785,211 +723,183 @@ export function detectComplementOperation( startValue, targetValue, difference, - "five", + 'five', addValue, - subtractValue, + subtractValue ), }, - }; + } } } - return { needsComplement: false, complementType: "none" }; + return { needsComplement: false, complementType: 'none' } } // Generate step-by-step instructions export function generateStepInstructions( additions: BeadHighlight[], removals: BeadHighlight[], - isComplement: boolean, + isComplement: boolean ): string[] { - const instructions: string[] = []; + const instructions: string[] = [] if (isComplement) { // For complement operations, order matters: additions first, then removals additions.forEach((bead) => { const placeDesc = bead.placeValue === 0 - ? "ones" + ? 'ones' : bead.placeValue === 1 - ? "tens" + ? 'tens' : bead.placeValue === 2 - ? "hundreds" - : `place ${bead.placeValue}`; + ? 'hundreds' + : `place ${bead.placeValue}` - if (bead.beadType === "heaven") { - instructions.push( - `Click the heaven bead in the ${placeDesc} column to add it`, - ); + if (bead.beadType === 'heaven') { + instructions.push(`Click the heaven bead in the ${placeDesc} column to add it`) } else { instructions.push( - `Click earth bead ${bead.position! + 1} in the ${placeDesc} column to add it`, - ); + `Click earth bead ${bead.position! + 1} in the ${placeDesc} column to add it` + ) } - }); + }) removals.forEach((bead) => { const placeDesc = bead.placeValue === 0 - ? "ones" + ? 'ones' : bead.placeValue === 1 - ? "tens" + ? 'tens' : bead.placeValue === 2 - ? "hundreds" - : `place ${bead.placeValue}`; + ? 'hundreds' + : `place ${bead.placeValue}` - if (bead.beadType === "heaven") { - instructions.push( - `Click the heaven bead in the ${placeDesc} column to remove it`, - ); + if (bead.beadType === 'heaven') { + instructions.push(`Click the heaven bead in the ${placeDesc} column to remove it`) } else { instructions.push( - `Click earth bead ${bead.position! + 1} in the ${placeDesc} column to remove it`, - ); + `Click earth bead ${bead.position! + 1} in the ${placeDesc} column to remove it` + ) } - }); + }) } else { // For non-complement operations, handle both additions and removals additions.forEach((bead) => { const placeDesc = bead.placeValue === 0 - ? "ones" + ? 'ones' : bead.placeValue === 1 - ? "tens" + ? 'tens' : bead.placeValue === 2 - ? "hundreds" - : `place ${bead.placeValue}`; + ? 'hundreds' + : `place ${bead.placeValue}` - if (bead.beadType === "heaven") { - instructions.push( - `Click the heaven bead in the ${placeDesc} column to add it`, - ); + if (bead.beadType === 'heaven') { + instructions.push(`Click the heaven bead in the ${placeDesc} column to add it`) } else { instructions.push( - `Click earth bead ${bead.position! + 1} in the ${placeDesc} column to add it`, - ); + `Click earth bead ${bead.position! + 1} in the ${placeDesc} column to add it` + ) } - }); + }) removals.forEach((bead) => { const placeDesc = bead.placeValue === 0 - ? "ones" + ? 'ones' : bead.placeValue === 1 - ? "tens" + ? 'tens' : bead.placeValue === 2 - ? "hundreds" - : `place ${bead.placeValue}`; + ? 'hundreds' + : `place ${bead.placeValue}` - if (bead.beadType === "heaven") { - instructions.push( - `Click the heaven bead in the ${placeDesc} column to remove it`, - ); + if (bead.beadType === 'heaven') { + instructions.push(`Click the heaven bead in the ${placeDesc} column to remove it`) } else { instructions.push( - `Click earth bead ${bead.position! + 1} in the ${placeDesc} column to remove it`, - ); + `Click earth bead ${bead.position! + 1} in the ${placeDesc} column to remove it` + ) } - }); + }) } // Always return at least one instruction, even if empty if (instructions.length === 0) { - instructions.push("No bead movements required"); + instructions.push('No bead movements required') } - return instructions; + return instructions } // Main function to generate complete instructions export function generateAbacusInstructions( startValue: number, targetValue: number, - operation?: string, + operation?: string ): GeneratedInstruction { - const startState = numberToAbacusState(startValue); - const targetState = numberToAbacusState(targetValue); - const { additions, removals, placeValue } = calculateBeadChanges( - startState, - targetState, - ); - const complement = detectComplementOperation( - startValue, - targetValue, - placeValue, - ); + const startState = numberToAbacusState(startValue) + const targetState = numberToAbacusState(targetValue) + const { additions, removals, placeValue } = calculateBeadChanges(startState, targetState) + const complement = detectComplementOperation(startValue, targetValue, placeValue) - const difference = targetValue - startValue; - const isAddition = difference > 0; - const operationSymbol = isAddition ? "+" : "-"; - const operationWord = isAddition ? "add" : "subtract"; - const _actualOperation = - operation || `${startValue} ${operationSymbol} ${Math.abs(difference)}`; + const difference = targetValue - startValue + const isAddition = difference > 0 + const operationSymbol = isAddition ? '+' : '-' + const operationWord = isAddition ? 'add' : 'subtract' + const _actualOperation = operation || `${startValue} ${operationSymbol} ${Math.abs(difference)}` // Always calculate the correct operation for the hint message, regardless of passed operation - const correctOperation = `${startValue} ${operationSymbol} ${Math.abs(difference)}`; + const correctOperation = `${startValue} ${operationSymbol} ${Math.abs(difference)}` // Combine all beads that need to be highlighted - const allHighlights = [...additions, ...removals]; + const allHighlights = [...additions, ...removals] // Handle zero difference case if (difference === 0) { return { highlightBeads: [], - expectedAction: "add", - actionDescription: "No change needed - already at target value", + expectedAction: 'add', + actionDescription: 'No change needed - already at target value', tooltip: { - content: "No Operation Required", - explanation: "The abacus already shows the target value", + content: 'No Operation Required', + explanation: 'The abacus already shows the target value', }, errorMessages: { - wrongBead: "No beads need to be moved", - wrongAction: "No action required", + wrongBead: 'No beads need to be moved', + wrongAction: 'No action required', hint: `${startValue} is already at the target value`, }, - }; + } } // Determine action type - const actionType = - allHighlights.length === 1 ? (isAddition ? "add" : "remove") : "multi-step"; + const actionType = allHighlights.length === 1 ? (isAddition ? 'add' : 'remove') : 'multi-step' // Generate action description - let actionDescription: string; - let stepInstructions: string[]; - let decomposition: any = null; - let stepBeadMapping: StepBeadHighlight[] | undefined; + let actionDescription: string + let stepInstructions: string[] + let decomposition: any = null + let stepBeadMapping: StepBeadHighlight[] | undefined // Check if this is a complex multi-place operation requiring comprehensive explanation - const hasMultiplePlaces = - new Set(allHighlights.map((bead) => bead.placeValue)).size > 1; - const hasComplementMovements = additions.length > 0 && removals.length > 0; - const crossesHundreds = - Math.floor(startValue / 100) !== Math.floor(targetValue / 100); + const hasMultiplePlaces = new Set(allHighlights.map((bead) => bead.placeValue)).size > 1 + const hasComplementMovements = additions.length > 0 && removals.length > 0 + const crossesHundreds = Math.floor(startValue / 100) !== Math.floor(targetValue / 100) if (hasMultiplePlaces && hasComplementMovements && crossesHundreds) { // Use proper complement breakdown for complex operations - const result = generateProperComplementDescription( - startValue, - targetValue, - additions, - removals, - ); - actionDescription = result.description; - decomposition = result.decomposition; + const result = generateProperComplementDescription(startValue, targetValue, additions, removals) + actionDescription = result.description + decomposition = result.decomposition // First generate step bead mapping to understand step groupings - const tempStepInstructions = generateStepInstructions( - additions, - removals, - false, - ); + const tempStepInstructions = generateStepInstructions(additions, removals, false) stepBeadMapping = generateStepBeadMapping( startValue, targetValue, additions, removals, decomposition, - tempStepInstructions, - ); + tempStepInstructions + ) // Then generate enhanced instructions based on step groupings stepInstructions = generateEnhancedStepInstructions( startValue, @@ -997,32 +907,23 @@ export function generateAbacusInstructions( additions, removals, decomposition, - stepBeadMapping, - ); + stepBeadMapping + ) } else if (complement.needsComplement) { // Use proper complement breakdown for simple operations too - const result = generateProperComplementDescription( - startValue, - targetValue, - additions, - removals, - ); - actionDescription = result.description; - decomposition = result.decomposition; + const result = generateProperComplementDescription(startValue, targetValue, additions, removals) + actionDescription = result.description + decomposition = result.decomposition // First generate step bead mapping to understand step groupings - const tempStepInstructions = generateStepInstructions( - additions, - removals, - false, - ); + const tempStepInstructions = generateStepInstructions(additions, removals, false) stepBeadMapping = generateStepBeadMapping( startValue, targetValue, additions, removals, decomposition, - tempStepInstructions, - ); + tempStepInstructions + ) // Then generate enhanced instructions based on step groupings stepInstructions = generateEnhancedStepInstructions( startValue, @@ -1030,51 +931,43 @@ export function generateAbacusInstructions( additions, removals, decomposition, - stepBeadMapping, - ); + stepBeadMapping + ) } else if (additions.length === 1 && removals.length === 0) { - const bead = additions[0]; - actionDescription = `Click the ${bead.beadType} bead to ${operationWord} ${Math.abs(difference)}`; - stepInstructions = generateStepInstructions(additions, removals, false); + const bead = additions[0] + actionDescription = `Click the ${bead.beadType} bead to ${operationWord} ${Math.abs(difference)}` + stepInstructions = generateStepInstructions(additions, removals, false) } else if (additions.length > 1 && removals.length === 0) { - actionDescription = `Click ${additions.length} beads to ${operationWord} ${Math.abs(difference)}`; - stepInstructions = generateStepInstructions(additions, removals, false); + actionDescription = `Click ${additions.length} beads to ${operationWord} ${Math.abs(difference)}` + stepInstructions = generateStepInstructions(additions, removals, false) } else { - actionDescription = `Multi-step operation: ${operationWord} ${Math.abs(difference)}`; - stepInstructions = generateStepInstructions( - additions, - removals, - complement.needsComplement, - ); + actionDescription = `Multi-step operation: ${operationWord} ${Math.abs(difference)}` + stepInstructions = generateStepInstructions(additions, removals, complement.needsComplement) } // Generate tooltip const tooltip = { content: complement.needsComplement - ? `${complement.complementType === "five" ? "Five" : "Ten"} Complement Operation` - : `Direct ${isAddition ? "Addition" : "Subtraction"}`, + ? `${complement.complementType === 'five' ? 'Five' : 'Ten'} Complement Operation` + : `Direct ${isAddition ? 'Addition' : 'Subtraction'}`, explanation: complement.needsComplement ? `When direct ${operationWord} isn't possible, use complement: ${complement.complementDetails!.description}` - : `${isAddition ? "Add" : "Remove"} beads directly to represent ${Math.abs(difference)}`, - }; + : `${isAddition ? 'Add' : 'Remove'} beads directly to represent ${Math.abs(difference)}`, + } // Generate error messages const errorMessages = { wrongBead: complement.needsComplement - ? "Follow the complement sequence: " + - (additions.length > 0 - ? "add first, then remove" - : "use the highlighted beads") - : `Click the highlighted ${allHighlights.length === 1 ? "bead" : "beads"}`, + ? 'Follow the complement sequence: ' + + (additions.length > 0 ? 'add first, then remove' : 'use the highlighted beads') + : `Click the highlighted ${allHighlights.length === 1 ? 'bead' : 'beads'}`, wrongAction: complement.needsComplement ? `Use ${complement.complementType} complement method` - : `${isAddition ? "Move beads UP to add" : "Move beads DOWN to remove"}`, + : `${isAddition ? 'Move beads UP to add' : 'Move beads DOWN to remove'}`, hint: `${correctOperation} = ${targetValue}` + - (complement.needsComplement - ? `, using ${complement.complementDetails!.description}` - : ""), - }; + (complement.needsComplement ? `, using ${complement.complementDetails!.description}` : ''), + } // Generate step-by-step bead mapping for ALL instructions (both single and multi-step) const stepBeadHighlights = @@ -1086,101 +979,97 @@ export function generateAbacusInstructions( additions, removals, decomposition, - stepInstructions, + stepInstructions ) - : undefined); + : undefined) return { highlightBeads: allHighlights, expectedAction: actionType, actionDescription, - multiStepInstructions: - actionType === "multi-step" ? stepInstructions : undefined, + multiStepInstructions: actionType === 'multi-step' ? stepInstructions : undefined, stepBeadHighlights, totalSteps: stepInstructions ? stepInstructions.length : undefined, tooltip, errorMessages, - }; + } } // Utility function to validate generated instructions export function validateInstruction( instruction: GeneratedInstruction, startValue: number, - targetValue: number, + targetValue: number ): { - isValid: boolean; - issues: string[]; + isValid: boolean + issues: string[] } { - const issues: string[] = []; + const issues: string[] = [] // Check if highlights exist (only if values are different) if ( startValue !== targetValue && (!instruction.highlightBeads || instruction.highlightBeads.length === 0) ) { - issues.push("No beads highlighted for non-zero operation"); + issues.push('No beads highlighted for non-zero operation') } // Check for multi-step consistency - if ( - instruction.expectedAction === "multi-step" && - !instruction.multiStepInstructions - ) { - issues.push("Multi-step action without step instructions"); + if (instruction.expectedAction === 'multi-step' && !instruction.multiStepInstructions) { + issues.push('Multi-step action without step instructions') } // Check place value validity instruction.highlightBeads.forEach((bead) => { if (bead.placeValue < 0 || bead.placeValue > 4) { - issues.push(`Invalid place value: ${bead.placeValue}`); + issues.push(`Invalid place value: ${bead.placeValue}`) } if ( - bead.beadType === "earth" && + bead.beadType === 'earth' && (bead.position === undefined || bead.position < 0 || bead.position > 3) ) { - issues.push(`Invalid earth bead position: ${bead.position}`); + issues.push(`Invalid earth bead position: ${bead.position}`) } - }); + }) return { isValid: issues.length === 0, issues, - }; + } } // Example usage and testing export function testInstructionGenerator(): void { - console.log("🧪 Testing Automatic Instruction Generator\n"); + console.log('🧪 Testing Automatic Instruction Generator\n') const testCases = [ - { start: 0, target: 1, description: "Basic addition" }, - { start: 0, target: 5, description: "Heaven bead introduction" }, - { start: 3, target: 7, description: "Five complement (3+4)" }, - { start: 2, target: 5, description: "Five complement (2+3)" }, - { start: 6, target: 8, description: "Direct addition" }, - { start: 7, target: 11, description: "Ten complement" }, - { start: 5, target: 2, description: "Subtraction" }, - { start: 12, target: 25, description: "Multi-place operation" }, - ]; + { start: 0, target: 1, description: 'Basic addition' }, + { start: 0, target: 5, description: 'Heaven bead introduction' }, + { start: 3, target: 7, description: 'Five complement (3+4)' }, + { start: 2, target: 5, description: 'Five complement (2+3)' }, + { start: 6, target: 8, description: 'Direct addition' }, + { start: 7, target: 11, description: 'Ten complement' }, + { start: 5, target: 2, description: 'Subtraction' }, + { start: 12, target: 25, description: 'Multi-place operation' }, + ] testCases.forEach(({ start, target, description }, index) => { - console.log(`\n${index + 1}. ${description}: ${start} → ${target}`); - const instruction = generateAbacusInstructions(start, target); - console.log(` Action: ${instruction.actionDescription}`); - console.log(` Highlights: ${instruction.highlightBeads.length} beads`); - console.log(` Type: ${instruction.expectedAction}`); + console.log(`\n${index + 1}. ${description}: ${start} → ${target}`) + const instruction = generateAbacusInstructions(start, target) + console.log(` Action: ${instruction.actionDescription}`) + console.log(` Highlights: ${instruction.highlightBeads.length} beads`) + console.log(` Type: ${instruction.expectedAction}`) if (instruction.multiStepInstructions) { - console.log(` Steps: ${instruction.multiStepInstructions.length}`); + console.log(` Steps: ${instruction.multiStepInstructions.length}`) } - const validation = validateInstruction(instruction, start, target); - console.log(` Valid: ${validation.isValid ? "✅" : "❌"}`); + const validation = validateInstruction(instruction, start, target) + console.log(` Valid: ${validation.isValid ? '✅' : '❌'}`) if (!validation.isValid) { - console.log(` Issues: ${validation.issues.join(", ")}`); + console.log(` Issues: ${validation.issues.join(', ')}`) } - }); + }) } diff --git a/apps/web/src/utils/beadDiff.ts b/apps/web/src/utils/beadDiff.ts index f1e58ce4..07745939 100644 --- a/apps/web/src/utils/beadDiff.ts +++ b/apps/web/src/utils/beadDiff.ts @@ -9,14 +9,10 @@ export { areStatesEqual, type AbacusState, type BeadState, -} from "@soroban/abacus-react"; +} from '@soroban/abacus-react' -import type { - BeadDiffOutput, - BeadDiffResult, - AbacusState, -} from "@soroban/abacus-react"; -import { calculateBeadDiffFromValues } from "@soroban/abacus-react"; +import type { BeadDiffOutput, BeadDiffResult, AbacusState } from '@soroban/abacus-react' +import { calculateBeadDiffFromValues } from '@soroban/abacus-react' /** * Calculate step-by-step bead diffs for multi-step operations @@ -27,19 +23,19 @@ import { calculateBeadDiffFromValues } from "@soroban/abacus-react"; */ export function calculateMultiStepBeadDiffs( startValue: number, - steps: Array<{ expectedValue: number; instruction: string }>, + steps: Array<{ expectedValue: number; instruction: string }> ): Array<{ - stepIndex: number; - instruction: string; - diff: BeadDiffOutput; - fromValue: number; - toValue: number; + stepIndex: number + instruction: string + diff: BeadDiffOutput + fromValue: number + toValue: number }> { - const stepDiffs = []; - let currentValue = startValue; + const stepDiffs = [] + let currentValue = startValue steps.forEach((step, index) => { - const diff = calculateBeadDiffFromValues(currentValue, step.expectedValue); + const diff = calculateBeadDiffFromValues(currentValue, step.expectedValue) stepDiffs.push({ stepIndex: index, @@ -47,12 +43,12 @@ export function calculateMultiStepBeadDiffs( diff, fromValue: currentValue, toValue: step.expectedValue, - }); + }) - currentValue = step.expectedValue; - }); + currentValue = step.expectedValue + }) - return stepDiffs; + return stepDiffs } /** @@ -61,51 +57,47 @@ export function calculateMultiStepBeadDiffs( * APP-SPECIFIC FUNCTION - not in core abacus-react */ export function validateBeadDiff(diff: BeadDiffOutput): { - isValid: boolean; - errors: string[]; + isValid: boolean + errors: string[] } { - const errors: string[] = []; + const errors: string[] = [] // Check for impossible earth bead counts - const earthChanges = diff.changes.filter((c) => c.beadType === "earth"); - const earthByPlace = groupByPlace(earthChanges); + const earthChanges = diff.changes.filter((c) => c.beadType === 'earth') + const earthByPlace = groupByPlace(earthChanges) Object.entries(earthByPlace).forEach(([place, changes]) => { - const activations = changes.filter( - (c) => c.direction === "activate", - ).length; - const deactivations = changes.filter( - (c) => c.direction === "deactivate", - ).length; - const netChange = activations - deactivations; + const activations = changes.filter((c) => c.direction === 'activate').length + const deactivations = changes.filter((c) => c.direction === 'deactivate').length + const netChange = activations - deactivations if (netChange > 4) { - errors.push(`Place ${place}: Cannot have more than 4 earth beads`); + errors.push(`Place ${place}: Cannot have more than 4 earth beads`) } if (netChange < 0) { - errors.push(`Place ${place}: Cannot have negative earth beads`); + errors.push(`Place ${place}: Cannot have negative earth beads`) } - }); + }) return { isValid: errors.length === 0, errors, - }; + } } // Helper function for validation function groupByPlace(changes: BeadDiffResult[]): { - [place: string]: BeadDiffResult[]; + [place: string]: BeadDiffResult[] } { return changes.reduce( (groups, change) => { - const place = change.placeValue.toString(); + const place = change.placeValue.toString() if (!groups[place]) { - groups[place] = []; + groups[place] = [] } - groups[place].push(change); - return groups; + groups[place].push(change) + return groups }, - {} as { [place: string]: BeadDiffResult[] }, - ); + {} as { [place: string]: BeadDiffResult[] } + ) } diff --git a/apps/web/src/utils/calendar/generateCalendarAbacus.tsx b/apps/web/src/utils/calendar/generateCalendarAbacus.tsx index cc48b172..3ab36c05 100644 --- a/apps/web/src/utils/calendar/generateCalendarAbacus.tsx +++ b/apps/web/src/utils/calendar/generateCalendarAbacus.tsx @@ -3,8 +3,8 @@ * Uses AbacusStatic for server-side rendering (no client hooks) */ -import React from "react"; -import { AbacusStatic } from "@soroban/abacus-react/static"; +import React from 'react' +import { AbacusStatic } from '@soroban/abacus-react/static' export function generateAbacusElement(value: number, columns: number) { return ( @@ -15,5 +15,5 @@ export function generateAbacusElement(value: number, columns: number) { showNumbers={false} frameVisible={true} /> - ); + ) } diff --git a/apps/web/src/utils/calendar/generateCalendarComposite.tsx b/apps/web/src/utils/calendar/generateCalendarComposite.tsx index 2112ad5f..bd5b4a1f 100644 --- a/apps/web/src/utils/calendar/generateCalendarComposite.tsx +++ b/apps/web/src/utils/calendar/generateCalendarComposite.tsx @@ -3,64 +3,59 @@ * This prevents multi-page overflow - one image scales to fit */ -import type React from "react"; -import { - AbacusStatic, - calculateAbacusDimensions, -} from "@soroban/abacus-react/static"; +import type React from 'react' +import { AbacusStatic, calculateAbacusDimensions } from '@soroban/abacus-react/static' interface CalendarCompositeOptions { - month: number; - year: number; - renderToString: (element: React.ReactElement) => string; + month: number + year: number + renderToString: (element: React.ReactElement) => string } const MONTH_NAMES = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] -const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] function getDaysInMonth(year: number, month: number): number { - return new Date(year, month, 0).getDate(); + return new Date(year, month, 0).getDate() } function getFirstDayOfWeek(year: number, month: number): number { - return new Date(year, month - 1, 1).getDay(); + return new Date(year, month - 1, 1).getDay() } -export function generateCalendarComposite( - options: CalendarCompositeOptions, -): string { - const { month, year, renderToString } = options; - const daysInMonth = getDaysInMonth(year, month); - const firstDayOfWeek = getFirstDayOfWeek(year, month); - const monthName = MONTH_NAMES[month - 1]; +export function generateCalendarComposite(options: CalendarCompositeOptions): string { + const { month, year, renderToString } = options + const daysInMonth = getDaysInMonth(year, month) + const firstDayOfWeek = getFirstDayOfWeek(year, month) + const monthName = MONTH_NAMES[month - 1] // Layout constants for US Letter aspect ratio (8.5 x 11) - const WIDTH = 850; - const HEIGHT = 1100; - const MARGIN = 50; - const CONTENT_WIDTH = WIDTH - MARGIN * 2; - const CONTENT_HEIGHT = HEIGHT - MARGIN * 2; + const WIDTH = 850 + const HEIGHT = 1100 + const MARGIN = 50 + const CONTENT_WIDTH = WIDTH - MARGIN * 2 + const CONTENT_HEIGHT = HEIGHT - MARGIN * 2 // Abacus natural size is 120x230 at scale=1 - const ABACUS_NATURAL_WIDTH = 120; - const ABACUS_NATURAL_HEIGHT = 230; + const ABACUS_NATURAL_WIDTH = 120 + const ABACUS_NATURAL_HEIGHT = 230 // Calculate how many columns needed for year - const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))); + const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))) // Year abacus dimensions (calculate first to determine header height) // Use the shared dimension calculator so we stay in sync with AbacusStatic @@ -69,56 +64,51 @@ export function generateCalendarComposite( columns: yearColumns, showNumbers: false, columnLabels: [], - }); + }) - const yearAbacusDisplayWidth = WIDTH * 0.15; // Display size on page + const yearAbacusDisplayWidth = WIDTH * 0.15 // Display size on page const yearAbacusDisplayHeight = - (yearAbacusActualHeight / yearAbacusActualWidth) * yearAbacusDisplayWidth; + (yearAbacusActualHeight / yearAbacusActualWidth) * yearAbacusDisplayWidth // Header - sized to fit month name + year abacus - const MONTH_NAME_HEIGHT = 40; - const HEADER_HEIGHT = MONTH_NAME_HEIGHT + yearAbacusDisplayHeight + 20; // 20px spacing - const TITLE_Y = MARGIN + 35; - const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2; - const yearAbacusY = TITLE_Y + 10; + const MONTH_NAME_HEIGHT = 40 + const HEADER_HEIGHT = MONTH_NAME_HEIGHT + yearAbacusDisplayHeight + 20 // 20px spacing + const TITLE_Y = MARGIN + 35 + const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2 + const yearAbacusY = TITLE_Y + 10 // Calendar grid - const GRID_START_Y = MARGIN + HEADER_HEIGHT; - const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT; - const WEEKDAY_ROW_HEIGHT = 25; - const DAY_GRID_HEIGHT = GRID_HEIGHT - WEEKDAY_ROW_HEIGHT; + const GRID_START_Y = MARGIN + HEADER_HEIGHT + const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT + const WEEKDAY_ROW_HEIGHT = 25 + const DAY_GRID_HEIGHT = GRID_HEIGHT - WEEKDAY_ROW_HEIGHT // 7 columns, up to 6 rows (35 cells max = 5 empty + 30 days worst case) - const CELL_WIDTH = CONTENT_WIDTH / 7; - const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6; + const CELL_WIDTH = CONTENT_WIDTH / 7 + const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6 // Day abacus sizing - fit in cell with padding - const CELL_PADDING = 5; + const CELL_PADDING = 5 // Calculate max scale to fit in cell - const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / ABACUS_NATURAL_WIDTH; - const MAX_SCALE_Y = - (DAY_CELL_HEIGHT - CELL_PADDING * 2) / ABACUS_NATURAL_HEIGHT; - const ABACUS_SCALE = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.9; // 90% to leave breathing room + const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / ABACUS_NATURAL_WIDTH + const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / ABACUS_NATURAL_HEIGHT + const ABACUS_SCALE = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.9 // 90% to leave breathing room - const SCALED_ABACUS_WIDTH = ABACUS_NATURAL_WIDTH * ABACUS_SCALE; - const SCALED_ABACUS_HEIGHT = ABACUS_NATURAL_HEIGHT * ABACUS_SCALE; + const SCALED_ABACUS_WIDTH = ABACUS_NATURAL_WIDTH * ABACUS_SCALE + const SCALED_ABACUS_HEIGHT = ABACUS_NATURAL_HEIGHT * ABACUS_SCALE // Generate calendar grid - const calendarCells: (number | null)[] = []; + const calendarCells: (number | null)[] = [] for (let i = 0; i < firstDayOfWeek; i++) { - calendarCells.push(null); + calendarCells.push(null) } for (let day = 1; day <= daysInMonth; day++) { - calendarCells.push(day); + calendarCells.push(day) } // Render individual abacus SVGs as complete SVG elements - function renderAbacusSVG( - value: number, - columns: number, - scale: number, - ): string { + function renderAbacusSVG(value: number, columns: number, scale: number): string { return renderToString( , - ); + /> + ) } // Main composite SVG @@ -147,14 +137,12 @@ export function generateCalendarComposite( ${(() => { - const yearAbacusSVG = renderAbacusSVG(year, yearColumns, 1); - const yearAbacusContent = yearAbacusSVG - .replace(/]*>/, "") - .replace(/<\/svg>$/, ""); + const yearAbacusSVG = renderAbacusSVG(year, yearColumns, 1) + const yearAbacusContent = yearAbacusSVG.replace(/]*>/, '').replace(/<\/svg>$/, '') return ` ${yearAbacusContent} - `; + ` })()} @@ -163,8 +151,8 @@ export function generateCalendarComposite( ${day} - `, - ).join("")} + ` + ).join('')} ${calendarCells .map((day, index) => { - const row = Math.floor(index / 7); - const col = index % 7; - const cellX = MARGIN + col * CELL_WIDTH; - const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT; + const row = Math.floor(index / 7) + const col = index % 7 + const cellX = MARGIN + col * CELL_WIDTH + const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT return ` `; + fill="none" stroke="#333" stroke-width="2"/>` }) - .join("")} + .join('')} ${calendarCells .map((day, index) => { - if (day === null) return ""; + if (day === null) return '' - const row = Math.floor(index / 7); - const col = index % 7; - const cellX = MARGIN + col * CELL_WIDTH; - const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT; + const row = Math.floor(index / 7) + const col = index % 7 + const cellX = MARGIN + col * CELL_WIDTH + const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT // Render cropped abacus SVG - const abacusSVG = renderAbacusSVG(day, 2, 1); + const abacusSVG = renderAbacusSVG(day, 2, 1) // Extract viewBox and dimensions from the cropped SVG - const viewBoxMatch = abacusSVG.match(/viewBox="([^"]*)"/); - const widthMatch = abacusSVG.match(/width="?([0-9.]+)"?/); - const heightMatch = abacusSVG.match(/height="?([0-9.]+)"?/); + const viewBoxMatch = abacusSVG.match(/viewBox="([^"]*)"/) + const widthMatch = abacusSVG.match(/width="?([0-9.]+)"?/) + const heightMatch = abacusSVG.match(/height="?([0-9.]+)"?/) - const croppedViewBox = viewBoxMatch ? viewBoxMatch[1] : "0 0 120 230"; - const croppedWidth = widthMatch - ? parseFloat(widthMatch[1]) - : ABACUS_NATURAL_WIDTH; - const croppedHeight = heightMatch - ? parseFloat(heightMatch[1]) - : ABACUS_NATURAL_HEIGHT; + const croppedViewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 120 230' + const croppedWidth = widthMatch ? parseFloat(widthMatch[1]) : ABACUS_NATURAL_WIDTH + const croppedHeight = heightMatch ? parseFloat(heightMatch[1]) : ABACUS_NATURAL_HEIGHT // Calculate scale to fit cropped abacus in cell - const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / croppedWidth; - const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / croppedHeight; - const fitScale = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.95; // 95% to leave breathing room + const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / croppedWidth + const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / croppedHeight + const fitScale = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.95 // 95% to leave breathing room - const scaledWidth = croppedWidth * fitScale; - const scaledHeight = croppedHeight * fitScale; + const scaledWidth = croppedWidth * fitScale + const scaledHeight = croppedHeight * fitScale // Center abacus in cell - const abacusCenterX = cellX + CELL_WIDTH / 2; - const abacusCenterY = cellY + DAY_CELL_HEIGHT / 2; + const abacusCenterX = cellX + CELL_WIDTH / 2 + const abacusCenterY = cellY + DAY_CELL_HEIGHT / 2 // Offset to top-left corner of abacus - const abacusX = abacusCenterX - scaledWidth / 2; - const abacusY = abacusCenterY - scaledHeight / 2; + const abacusX = abacusCenterX - scaledWidth / 2 + const abacusY = abacusCenterY - scaledHeight / 2 // Extract SVG content (remove outer tags) - const svgContent = abacusSVG - .replace(/]*>/, "") - .replace(/<\/svg>$/, ""); + const svgContent = abacusSVG.replace(/]*>/, '').replace(/<\/svg>$/, '') return ` ${svgContent} - `; + ` }) - .join("")} -`; + .join('')} +` - return compositeSVG; + return compositeSVG } diff --git a/apps/web/src/utils/flashcards/generateFlashcardSvgs.tsx b/apps/web/src/utils/flashcards/generateFlashcardSvgs.tsx index 24d686a7..4fa8c0dd 100644 --- a/apps/web/src/utils/flashcards/generateFlashcardSvgs.tsx +++ b/apps/web/src/utils/flashcards/generateFlashcardSvgs.tsx @@ -3,18 +3,18 @@ * This replaces Python-based SVG generation for better performance */ -import type React from "react"; -import { AbacusStatic } from "@soroban/abacus-react/static"; +import type React from 'react' +import { AbacusStatic } from '@soroban/abacus-react/static' export interface FlashcardConfig { - beadShape?: "diamond" | "circle" | "square"; - colorScheme?: "monochrome" | "place-value" | "heaven-earth" | "alternating"; - colorPalette?: "default" | "pastel" | "vibrant" | "earth-tones"; - hideInactiveBeads?: boolean; - showEmptyColumns?: boolean; - columns?: number | "auto"; - scaleFactor?: number; - coloredNumerals?: boolean; + beadShape?: 'diamond' | 'circle' | 'square' + colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating' + colorPalette?: 'default' | 'pastel' | 'vibrant' | 'earth-tones' + hideInactiveBeads?: boolean + showEmptyColumns?: boolean + columns?: number | 'auto' + scaleFactor?: number + coloredNumerals?: boolean } /** @@ -22,17 +22,17 @@ export interface FlashcardConfig { */ export function generateFlashcardFront( value: number, - config: FlashcardConfig = {}, + config: FlashcardConfig = {} ): React.ReactElement { const { - beadShape = "diamond", - colorScheme = "place-value", - colorPalette = "default", + beadShape = 'diamond', + colorScheme = 'place-value', + colorPalette = 'default', hideInactiveBeads = false, showEmptyColumns = false, - columns = "auto", + columns = 'auto', scaleFactor = 0.9, - } = config; + } = config return ( - ); + ) } /** @@ -55,21 +55,19 @@ export function generateFlashcardFront( */ export function generateFlashcardBack( value: number, - config: FlashcardConfig = {}, + config: FlashcardConfig = {} ): React.ReactElement { - const { coloredNumerals = false, colorScheme = "place-value" } = config; + const { coloredNumerals = false, colorScheme = 'place-value' } = config // For back, we show just the numeral // Use a simple SVG with text - const fontSize = 120; - const width = 300; - const height = 200; + const fontSize = 120 + const width = 300 + const height = 200 // Get color based on place value if colored numerals enabled const textColor = - coloredNumerals && colorScheme === "place-value" - ? getPlaceValueColor(value) - : "#000000"; + coloredNumerals && colorScheme === 'place-value' ? getPlaceValueColor(value) : '#000000' return ( - ); + ) } function getPlaceValueColor(value: number): string { // Simple place value coloring for single digits const colors = [ - "#ef4444", // red - ones - "#f59e0b", // amber - tens - "#10b981", // emerald - hundreds - "#3b82f6", // blue - thousands - "#8b5cf6", // purple - ten thousands - ]; + '#ef4444', // red - ones + '#f59e0b', // amber - tens + '#10b981', // emerald - hundreds + '#3b82f6', // blue - thousands + '#8b5cf6', // purple - ten thousands + ] - const digits = value.toString().length; - return colors[(digits - 1) % colors.length]; + const digits = value.toString().length + return colors[(digits - 1) % colors.length] } diff --git a/apps/web/src/utils/playerNames.ts b/apps/web/src/utils/playerNames.ts index 42ac61cf..6174229e 100644 --- a/apps/web/src/utils/playerNames.ts +++ b/apps/web/src/utils/playerNames.ts @@ -6,171 +6,161 @@ * Falls back gracefully: emoji-specific → category → generic abacus theme */ -import { - EMOJI_SPECIFIC_WORDS, - EMOJI_TO_THEME, - THEMED_WORD_LISTS, -} from "./themedWords"; +import { EMOJI_SPECIFIC_WORDS, EMOJI_TO_THEME, THEMED_WORD_LISTS } from './themedWords' // Generic abacus-themed words (used as ultimate fallback) const ADJECTIVES = [ // Abacus-themed adjectives - "Ancient", - "Wooden", - "Sliding", - "Decimal", - "Binary", - "Counting", - "Soroban", - "Chinese", - "Japanese", - "Nimble", - "Clicking", - "Beaded", - "Columnar", - "Vertical", - "Horizontal", - "Upper", - "Lower", - "Heaven", - "Earth", - "Golden", - "Jade", - "Bamboo", - "Polished", - "Skilled", - "Master", + 'Ancient', + 'Wooden', + 'Sliding', + 'Decimal', + 'Binary', + 'Counting', + 'Soroban', + 'Chinese', + 'Japanese', + 'Nimble', + 'Clicking', + 'Beaded', + 'Columnar', + 'Vertical', + 'Horizontal', + 'Upper', + 'Lower', + 'Heaven', + 'Earth', + 'Golden', + 'Jade', + 'Bamboo', + 'Polished', + 'Skilled', + 'Master', // Arithmetic/calculation adjectives - "Adding", - "Subtracting", - "Multiplying", - "Dividing", - "Calculating", - "Computing", - "Estimating", - "Rounding", - "Summing", - "Tallying", - "Decimal", - "Fractional", - "Exponential", - "Algebraic", - "Geometric", - "Prime", - "Composite", - "Rational", - "Digital", - "Numeric", - "Precise", - "Accurate", - "Lightning", - "Rapid", - "Mental", -]; + 'Adding', + 'Subtracting', + 'Multiplying', + 'Dividing', + 'Calculating', + 'Computing', + 'Estimating', + 'Rounding', + 'Summing', + 'Tallying', + 'Decimal', + 'Fractional', + 'Exponential', + 'Algebraic', + 'Geometric', + 'Prime', + 'Composite', + 'Rational', + 'Digital', + 'Numeric', + 'Precise', + 'Accurate', + 'Lightning', + 'Rapid', + 'Mental', +] const NOUNS = [ // Abacus-themed nouns - "Counter", - "Abacist", - "Calculator", - "Bead", - "Rod", - "Frame", - "Slider", - "Merchant", - "Trader", - "Accountant", - "Bookkeeper", - "Clerk", - "Scribe", - "Master", - "Apprentice", - "Scholar", - "Student", - "Teacher", - "Sensei", - "Guru", - "Expert", - "Virtuoso", - "Prodigy", - "Wizard", - "Sage", + 'Counter', + 'Abacist', + 'Calculator', + 'Bead', + 'Rod', + 'Frame', + 'Slider', + 'Merchant', + 'Trader', + 'Accountant', + 'Bookkeeper', + 'Clerk', + 'Scribe', + 'Master', + 'Apprentice', + 'Scholar', + 'Student', + 'Teacher', + 'Sensei', + 'Guru', + 'Expert', + 'Virtuoso', + 'Prodigy', + 'Wizard', + 'Sage', // Arithmetic/calculation nouns - "Adder", - "Multiplier", - "Divider", - "Solver", - "Mathematician", - "Arithmetician", - "Analyst", - "Computer", - "Estimator", - "Logician", - "Statistician", - "Numerologist", - "Quantifier", - "Tallier", - "Sumner", - "Keeper", - "Reckoner", - "Cipher", - "Digit", - "Figure", - "Number", - "Brain", - "Thinker", - "Genius", - "Whiz", -]; + 'Adder', + 'Multiplier', + 'Divider', + 'Solver', + 'Mathematician', + 'Arithmetician', + 'Analyst', + 'Computer', + 'Estimator', + 'Logician', + 'Statistician', + 'Numerologist', + 'Quantifier', + 'Tallier', + 'Sumner', + 'Keeper', + 'Reckoner', + 'Cipher', + 'Digit', + 'Figure', + 'Number', + 'Brain', + 'Thinker', + 'Genius', + 'Whiz', +] /** * Select a word list tier using weighted random selection * Balanced mix: emoji-specific (50%), category (25%), global abacus (25%) */ -function selectWordListTier( - emoji: string, - wordType: "adjectives" | "nouns", -): string[] { +function selectWordListTier(emoji: string, wordType: 'adjectives' | 'nouns'): string[] { // Collect available tiers - const availableTiers: Array<{ weight: number; words: string[] }> = []; + const availableTiers: Array<{ weight: number; words: string[] }> = [] // Emoji-specific tier (50% preference) - const emojiSpecific = EMOJI_SPECIFIC_WORDS[emoji]; + const emojiSpecific = EMOJI_SPECIFIC_WORDS[emoji] if (emojiSpecific) { - availableTiers.push({ weight: 50, words: emojiSpecific[wordType] }); + availableTiers.push({ weight: 50, words: emojiSpecific[wordType] }) } // Category tier (25% preference) - const category = EMOJI_TO_THEME[emoji]; + const category = EMOJI_TO_THEME[emoji] if (category) { - const categoryTheme = THEMED_WORD_LISTS[category]; + const categoryTheme = THEMED_WORD_LISTS[category] if (categoryTheme) { - availableTiers.push({ weight: 25, words: categoryTheme[wordType] }); + availableTiers.push({ weight: 25, words: categoryTheme[wordType] }) } } // Global abacus tier (25% preference) availableTiers.push({ weight: 25, - words: wordType === "adjectives" ? ADJECTIVES : NOUNS, - }); + words: wordType === 'adjectives' ? ADJECTIVES : NOUNS, + }) // Weighted random selection - const totalWeight = availableTiers.reduce( - (sum, tier) => sum + tier.weight, - 0, - ); - let random = Math.random() * totalWeight; + const totalWeight = availableTiers.reduce((sum, tier) => sum + tier.weight, 0) + let random = Math.random() * totalWeight for (const tier of availableTiers) { - random -= tier.weight; + random -= tier.weight if (random <= 0) { - return tier.words; + return tier.words } } // Fallback (should never reach here) - return wordType === "adjectives" ? ADJECTIVES : NOUNS; + return wordType === 'adjectives' ? ADJECTIVES : NOUNS } /** @@ -184,21 +174,20 @@ function selectWordListTier( export function generatePlayerName(emoji?: string): string { if (!emoji) { // No emoji provided, use pure abacus theme - const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; - const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; - return `${adjective} ${noun}`; + const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)] + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)] + return `${adjective} ${noun}` } // Select tier independently for each word type // This creates natural mixing: adjective might be emoji-specific while noun is global - const adjectiveList = selectWordListTier(emoji, "adjectives"); - const nounList = selectWordListTier(emoji, "nouns"); + const adjectiveList = selectWordListTier(emoji, 'adjectives') + const nounList = selectWordListTier(emoji, 'nouns') - const adjective = - adjectiveList[Math.floor(Math.random() * adjectiveList.length)]; - const noun = nounList[Math.floor(Math.random() * nounList.length)]; + const adjective = adjectiveList[Math.floor(Math.random() * adjectiveList.length)] + const noun = nounList[Math.floor(Math.random() * nounList.length)] - return `${adjective} ${noun}`; + return `${adjective} ${noun}` } /** @@ -211,26 +200,24 @@ export function generatePlayerName(emoji?: string): string { export function generateUniquePlayerName( existingNames: string[], emoji?: string, - maxAttempts = 50, + maxAttempts = 50 ): string { - const existingNamesSet = new Set( - existingNames.map((name) => name.toLowerCase()), - ); + const existingNamesSet = new Set(existingNames.map((name) => name.toLowerCase())) for (let i = 0; i < maxAttempts; i++) { - const name = generatePlayerName(emoji); + const name = generatePlayerName(emoji) if (!existingNamesSet.has(name.toLowerCase())) { - return name; + return name } } // Fallback: if we can't find a unique name, append a number - const baseName = generatePlayerName(emoji); - let counter = 1; + const baseName = generatePlayerName(emoji) + let counter = 1 while (existingNamesSet.has(`${baseName} ${counter}`.toLowerCase())) { - counter++; + counter++ } - return `${baseName} ${counter}`; + return `${baseName} ${counter}` } /** @@ -239,14 +226,11 @@ export function generateUniquePlayerName( * @param emoji - Optional emoji avatar to theme the names around * @returns Array of unique player names */ -export function generateUniquePlayerNames( - count: number, - emoji?: string, -): string[] { - const names: string[] = []; +export function generateUniquePlayerNames(count: number, emoji?: string): string[] { + const names: string[] = [] for (let i = 0; i < count; i++) { - const name = generateUniquePlayerName(names, emoji); - names.push(name); + const name = generateUniquePlayerName(names, emoji) + names.push(name) } - return names; + return names } diff --git a/apps/web/src/utils/pluralization.ts b/apps/web/src/utils/pluralization.ts index d754c8f7..385f25b4 100644 --- a/apps/web/src/utils/pluralization.ts +++ b/apps/web/src/utils/pluralization.ts @@ -1,7 +1,7 @@ -import { en } from "make-plural"; +import { en } from 'make-plural' // Use English pluralization rules from make-plural -const plural = en; +const plural = en /** * Pluralize a word based on count using make-plural library @@ -10,16 +10,12 @@ const plural = en; * @param plural - The plural form of the word (optional, will add 's' by default) * @returns The properly pluralized word */ -export function pluralizeWord( - count: number, - singular: string, - pluralForm?: string, -): string { - const category = plural(count); - if (category === "one") { - return singular; +export function pluralizeWord(count: number, singular: string, pluralForm?: string): string { + const category = plural(count) + if (category === 'one') { + return singular } - return pluralForm || `${singular}s`; + return pluralForm || `${singular}s` } /** @@ -29,24 +25,20 @@ export function pluralizeWord( * @param plural - The plural form of the word (optional) * @returns Formatted string like "1 pair" or "3 pairs" */ -export function pluralizeCount( - count: number, - singular: string, - pluralForm?: string, -): string { - return `${count} ${pluralizeWord(count, singular, pluralForm)}`; +export function pluralizeCount(count: number, singular: string, pluralForm?: string): string { + return `${count} ${pluralizeWord(count, singular, pluralForm)}` } /** * Common game-specific pluralization helpers using make-plural */ export const gamePlurals = { - pair: (count: number) => pluralizeCount(count, "pair"), - pairs: (count: number) => pluralizeCount(count, "pair"), - move: (count: number) => pluralizeCount(count, "move"), - moves: (count: number) => pluralizeCount(count, "move"), - match: (count: number) => pluralizeCount(count, "match", "matches"), - matches: (count: number) => pluralizeCount(count, "match", "matches"), - player: (count: number) => pluralizeCount(count, "player"), - players: (count: number) => pluralizeCount(count, "player"), -} as const; + pair: (count: number) => pluralizeCount(count, 'pair'), + pairs: (count: number) => pluralizeCount(count, 'pair'), + move: (count: number) => pluralizeCount(count, 'move'), + moves: (count: number) => pluralizeCount(count, 'move'), + match: (count: number) => pluralizeCount(count, 'match', 'matches'), + matches: (count: number) => pluralizeCount(count, 'match', 'matches'), + player: (count: number) => pluralizeCount(count, 'player'), + players: (count: number) => pluralizeCount(count, 'player'), +} as const diff --git a/apps/web/src/utils/problemGenerator.ts b/apps/web/src/utils/problemGenerator.ts index dbbe8808..5bad288d 100644 --- a/apps/web/src/utils/problemGenerator.ts +++ b/apps/web/src/utils/problemGenerator.ts @@ -1,87 +1,67 @@ -import type { PracticeStep, SkillSet } from "../types/tutorial"; +import type { PracticeStep, SkillSet } from '../types/tutorial' export interface GeneratedProblem { - id: string; - terms: number[]; - answer: number; - requiredSkills: string[]; - difficulty: "easy" | "medium" | "hard"; - explanation?: string; + id: string + terms: number[] + answer: number + requiredSkills: string[] + difficulty: 'easy' | 'medium' | 'hard' + explanation?: string } export interface ProblemConstraints { - numberRange: { min: number; max: number }; - maxSum?: number; - minSum?: number; - maxTerms: number; - problemCount: number; + numberRange: { min: number; max: number } + maxSum?: number + minSum?: number + maxTerms: number + problemCount: number } /** * Analyzes which skills are required during the sequential addition process * This simulates adding each term one by one to the abacus */ -export function analyzeRequiredSkills( - terms: number[], - _finalSum: number, -): string[] { - const skills: string[] = []; - let currentValue = 0; +export function analyzeRequiredSkills(terms: number[], _finalSum: number): string[] { + const skills: string[] = [] + let currentValue = 0 // Simulate adding each term sequentially for (const term of terms) { - const newValue = currentValue + term; - const requiredSkillsForStep = analyzeStepSkills( - currentValue, - term, - newValue, - ); - skills.push(...requiredSkillsForStep); - currentValue = newValue; + const newValue = currentValue + term + const requiredSkillsForStep = analyzeStepSkills(currentValue, term, newValue) + skills.push(...requiredSkillsForStep) + currentValue = newValue } - return [...new Set(skills)]; // Remove duplicates + return [...new Set(skills)] // Remove duplicates } /** * Analyzes skills needed for a single addition step: currentValue + term = newValue */ -function analyzeStepSkills( - currentValue: number, - term: number, - newValue: number, -): string[] { - const skills: string[] = []; +function analyzeStepSkills(currentValue: number, term: number, newValue: number): string[] { + const skills: string[] = [] // Work column by column from right to left - const currentDigits = getDigits(currentValue); - const termDigits = getDigits(term); - const newDigits = getDigits(newValue); + const currentDigits = getDigits(currentValue) + const termDigits = getDigits(term) + const newDigits = getDigits(newValue) - const maxColumns = Math.max( - currentDigits.length, - termDigits.length, - newDigits.length, - ); + const maxColumns = Math.max(currentDigits.length, termDigits.length, newDigits.length) for (let column = 0; column < maxColumns; column++) { - const currentDigit = currentDigits[column] || 0; - const termDigit = termDigits[column] || 0; - const newDigit = newDigits[column] || 0; + const currentDigit = currentDigits[column] || 0 + const termDigit = termDigits[column] || 0 + const newDigit = newDigits[column] || 0 - if (termDigit === 0) continue; // No addition in this column + if (termDigit === 0) continue // No addition in this column // Analyze what happens in this column - const columnSkills = analyzeColumnAddition( - currentDigit, - termDigit, - newDigit, - column, - ); - skills.push(...columnSkills); + const columnSkills = analyzeColumnAddition(currentDigit, termDigit, newDigit, column) + skills.push(...columnSkills) } - return skills; + return skills } /** @@ -91,78 +71,78 @@ function analyzeColumnAddition( currentDigit: number, termDigit: number, _resultDigit: number, - _column: number, + _column: number ): string[] { - const skills: string[] = []; + const skills: string[] = [] // Direct addition (1-4) if (termDigit >= 1 && termDigit <= 4) { if (currentDigit + termDigit <= 4) { - skills.push("basic.directAddition"); + skills.push('basic.directAddition') } else if (currentDigit + termDigit === 5) { // Adding to make exactly 5 - could be direct or complement if (currentDigit === 0) { - skills.push("basic.heavenBead"); // Direct 5 + skills.push('basic.heavenBead') // Direct 5 } else { // Five complement: need to use 5 - complement - skills.push(`fiveComplements.${termDigit}=5-${5 - termDigit}`); - skills.push("basic.heavenBead"); + skills.push(`fiveComplements.${termDigit}=5-${5 - termDigit}`) + skills.push('basic.heavenBead') } } else if (currentDigit + termDigit > 5 && currentDigit + termDigit <= 9) { // Results in 6-9: use five complement + simple combination - skills.push(`fiveComplements.${termDigit}=5-${5 - termDigit}`); - skills.push("basic.heavenBead"); - skills.push("basic.simpleCombinations"); + skills.push(`fiveComplements.${termDigit}=5-${5 - termDigit}`) + skills.push('basic.heavenBead') + skills.push('basic.simpleCombinations') } else if (currentDigit + termDigit >= 10) { // Ten complement needed - const complement = 10 - termDigit; - skills.push(`tenComplements.${termDigit}=10-${complement}`); + const complement = 10 - termDigit + skills.push(`tenComplements.${termDigit}=10-${complement}`) } } // Direct heaven bead (5) else if (termDigit === 5) { if (currentDigit === 0) { - skills.push("basic.heavenBead"); + skills.push('basic.heavenBead') } else if (currentDigit + 5 <= 9) { - skills.push("basic.heavenBead"); - skills.push("basic.simpleCombinations"); + skills.push('basic.heavenBead') + skills.push('basic.simpleCombinations') } else { // Ten complement - skills.push(`tenComplements.5=10-5`); + skills.push(`tenComplements.5=10-5`) } } // Simple combinations (6-9) else if (termDigit >= 6 && termDigit <= 9) { if (currentDigit === 0) { - skills.push("basic.heavenBead"); - skills.push("basic.simpleCombinations"); + skills.push('basic.heavenBead') + skills.push('basic.simpleCombinations') } else if (currentDigit + termDigit <= 9) { - skills.push("basic.heavenBead"); - skills.push("basic.simpleCombinations"); + skills.push('basic.heavenBead') + skills.push('basic.simpleCombinations') } else { // Ten complement - const complement = 10 - termDigit; - skills.push(`tenComplements.${termDigit}=10-${complement}`); + const complement = 10 - termDigit + skills.push(`tenComplements.${termDigit}=10-${complement}`) } } - return skills; + return skills } /** * Converts a number to array of digits (ones, tens, hundreds, etc.) */ function getDigits(num: number): number[] { - if (num === 0) return [0]; + if (num === 0) return [0] - const digits: number[] = []; + const digits: number[] = [] while (num > 0) { - digits.push(num % 10); - num = Math.floor(num / 10); + digits.push(num % 10) + num = Math.floor(num / 10) } - return digits; + return digits } /** @@ -172,87 +152,66 @@ export function problemMatchesSkills( problem: GeneratedProblem, requiredSkills: SkillSet, targetSkills?: Partial, - forbiddenSkills?: Partial, + forbiddenSkills?: Partial ): boolean { // Check required skills - problem must use at least one enabled required skill const hasRequiredSkill = problem.requiredSkills.some((skillPath) => { - const [category, skill] = skillPath.split("."); - if (category === "basic") { - return requiredSkills.basic[skill as keyof typeof requiredSkills.basic]; - } else if (category === "fiveComplements") { - return requiredSkills.fiveComplements[ - skill as keyof typeof requiredSkills.fiveComplements - ]; - } else if (category === "tenComplements") { - return requiredSkills.tenComplements[ - skill as keyof typeof requiredSkills.tenComplements - ]; + const [category, skill] = skillPath.split('.') + if (category === 'basic') { + return requiredSkills.basic[skill as keyof typeof requiredSkills.basic] + } else if (category === 'fiveComplements') { + return requiredSkills.fiveComplements[skill as keyof typeof requiredSkills.fiveComplements] + } else if (category === 'tenComplements') { + return requiredSkills.tenComplements[skill as keyof typeof requiredSkills.tenComplements] } - return false; - }); + return false + }) - if (!hasRequiredSkill) return false; + if (!hasRequiredSkill) return false // Check forbidden skills - problem must not use any forbidden skills if (forbiddenSkills) { const usesForbiddenSkill = problem.requiredSkills.some((skillPath) => { - const [category, skill] = skillPath.split("."); - if (category === "basic" && forbiddenSkills.basic) { - return forbiddenSkills.basic[ - skill as keyof typeof forbiddenSkills.basic - ]; - } else if ( - category === "fiveComplements" && - forbiddenSkills.fiveComplements - ) { + const [category, skill] = skillPath.split('.') + if (category === 'basic' && forbiddenSkills.basic) { + return forbiddenSkills.basic[skill as keyof typeof forbiddenSkills.basic] + } else if (category === 'fiveComplements' && forbiddenSkills.fiveComplements) { return forbiddenSkills.fiveComplements[ skill as keyof typeof forbiddenSkills.fiveComplements - ]; - } else if ( - category === "tenComplements" && - forbiddenSkills.tenComplements - ) { - return forbiddenSkills.tenComplements[ - skill as keyof typeof forbiddenSkills.tenComplements - ]; + ] + } else if (category === 'tenComplements' && forbiddenSkills.tenComplements) { + return forbiddenSkills.tenComplements[skill as keyof typeof forbiddenSkills.tenComplements] } - return false; - }); + return false + }) - if (usesForbiddenSkill) return false; + if (usesForbiddenSkill) return false } // Check target skills - if specified, problem should use at least one target skill if (targetSkills) { const hasTargetSkill = problem.requiredSkills.some((skillPath) => { - const [category, skill] = skillPath.split("."); - if (category === "basic" && targetSkills.basic) { - return targetSkills.basic[skill as keyof typeof targetSkills.basic]; - } else if ( - category === "fiveComplements" && - targetSkills.fiveComplements - ) { - return targetSkills.fiveComplements[ - skill as keyof typeof targetSkills.fiveComplements - ]; - } else if (category === "tenComplements" && targetSkills.tenComplements) { - return targetSkills.tenComplements[ - skill as keyof typeof targetSkills.tenComplements - ]; + const [category, skill] = skillPath.split('.') + if (category === 'basic' && targetSkills.basic) { + return targetSkills.basic[skill as keyof typeof targetSkills.basic] + } else if (category === 'fiveComplements' && targetSkills.fiveComplements) { + return targetSkills.fiveComplements[skill as keyof typeof targetSkills.fiveComplements] + } else if (category === 'tenComplements' && targetSkills.tenComplements) { + return targetSkills.tenComplements[skill as keyof typeof targetSkills.tenComplements] } - return false; - }); + return false + }) // If target skills are specified but none match, reject const hasAnyTargetSkill = Object.values(targetSkills.basic || {}).some(Boolean) || Object.values(targetSkills.fiveComplements || {}).some(Boolean) || - Object.values(targetSkills.tenComplements || {}).some(Boolean); + Object.values(targetSkills.tenComplements || {}).some(Boolean) - if (hasAnyTargetSkill && !hasTargetSkill) return false; + if (hasAnyTargetSkill && !hasTargetSkill) return false } - return true; + return true } /** @@ -263,11 +222,11 @@ export function generateSingleProblem( requiredSkills: SkillSet, targetSkills?: Partial, forbiddenSkills?: Partial, - attempts: number = 100, + attempts: number = 100 ): GeneratedProblem | null { for (let attempt = 0; attempt < attempts; attempt++) { // Generate random number of terms (3 to 5 as specified) - const termCount = Math.floor(Math.random() * 3) + 3; // 3-5 terms + const termCount = Math.floor(Math.random() * 3) + 3 // 3-5 terms // Generate the sequence of numbers to add const terms = generateSequence( @@ -275,28 +234,26 @@ export function generateSingleProblem( termCount, requiredSkills, targetSkills, - forbiddenSkills, - ); + forbiddenSkills + ) - if (!terms) continue; // Failed to generate valid sequence + if (!terms) continue // Failed to generate valid sequence - const sum = terms.reduce((acc, term) => acc + term, 0); + const sum = terms.reduce((acc, term) => acc + term, 0) // Check sum constraints - if (constraints.maxSum && sum > constraints.maxSum) continue; - if (constraints.minSum && sum < constraints.minSum) continue; + if (constraints.maxSum && sum > constraints.maxSum) continue + if (constraints.minSum && sum < constraints.minSum) continue // Analyze what skills this sequential addition requires - const problemSkills = analyzeRequiredSkills(terms, sum); + const problemSkills = analyzeRequiredSkills(terms, sum) // Determine difficulty based on skills required - let difficulty: "easy" | "medium" | "hard" = "easy"; - if (problemSkills.some((skill) => skill.startsWith("tenComplements"))) { - difficulty = "hard"; - } else if ( - problemSkills.some((skill) => skill.startsWith("fiveComplements")) - ) { - difficulty = "medium"; + let difficulty: 'easy' | 'medium' | 'hard' = 'easy' + if (problemSkills.some((skill) => skill.startsWith('tenComplements'))) { + difficulty = 'hard' + } else if (problemSkills.some((skill) => skill.startsWith('fiveComplements'))) { + difficulty = 'medium' } const problem: GeneratedProblem = { @@ -306,22 +263,15 @@ export function generateSingleProblem( requiredSkills: problemSkills, difficulty, explanation: generateSequentialExplanation(terms, sum, problemSkills), - }; + } // Check if problem matches skill requirements - if ( - problemMatchesSkills( - problem, - requiredSkills, - targetSkills, - forbiddenSkills, - ) - ) { - return problem; + if (problemMatchesSkills(problem, requiredSkills, targetSkills, forbiddenSkills)) { + return problem } } - return null; // Failed to generate a suitable problem + return null // Failed to generate a suitable problem } /** @@ -332,10 +282,10 @@ function generateSequence( termCount: number, requiredSkills: SkillSet, targetSkills?: Partial, - forbiddenSkills?: Partial, + forbiddenSkills?: Partial ): number[] | null { - const terms: number[] = []; - let currentValue = 0; + const terms: number[] = [] + let currentValue = 0 for (let i = 0; i < termCount; i++) { // Try to find a valid next term @@ -345,16 +295,16 @@ function generateSequence( requiredSkills, targetSkills, forbiddenSkills, - i === termCount - 1, // isLastTerm - ); + i === termCount - 1 // isLastTerm + ) - if (validTerm === null) return null; // Couldn't find valid term + if (validTerm === null) return null // Couldn't find valid term - terms.push(validTerm); - currentValue += validTerm; + terms.push(validTerm) + currentValue += validTerm } - return terms; + return terms } /** @@ -366,177 +316,138 @@ function findValidNextTerm( requiredSkills: SkillSet, targetSkills?: Partial, forbiddenSkills?: Partial, - isLastTerm: boolean = false, + isLastTerm: boolean = false ): number | null { - const { min, max } = constraints.numberRange; - const candidates: number[] = []; + const { min, max } = constraints.numberRange + const candidates: number[] = [] // Try each possible term value for (let term = min; term <= max; term++) { - const newValue = currentValue + term; + const newValue = currentValue + term // Check if this addition step is valid - const stepSkills = analyzeStepSkills(currentValue, term, newValue); + const stepSkills = analyzeStepSkills(currentValue, term, newValue) // Check if the step uses only allowed skills const usesValidSkills = stepSkills.every((skillPath) => { - const [category, skill] = skillPath.split("."); + const [category, skill] = skillPath.split('.') // Must use only required skills - let hasSkill = false; - if (category === "basic") { + let hasSkill = false + if (category === 'basic') { + hasSkill = requiredSkills.basic[skill as keyof typeof requiredSkills.basic] + } else if (category === 'fiveComplements') { hasSkill = - requiredSkills.basic[skill as keyof typeof requiredSkills.basic]; - } else if (category === "fiveComplements") { + requiredSkills.fiveComplements[skill as keyof typeof requiredSkills.fiveComplements] + } else if (category === 'tenComplements') { hasSkill = - requiredSkills.fiveComplements[ - skill as keyof typeof requiredSkills.fiveComplements - ]; - } else if (category === "tenComplements") { - hasSkill = - requiredSkills.tenComplements[ - skill as keyof typeof requiredSkills.tenComplements - ]; + requiredSkills.tenComplements[skill as keyof typeof requiredSkills.tenComplements] } - if (!hasSkill) return false; + if (!hasSkill) return false // Must not use forbidden skills if (forbiddenSkills) { - let isForbidden = false; - if (category === "basic" && forbiddenSkills.basic) { - isForbidden = - forbiddenSkills.basic[ - skill as keyof typeof forbiddenSkills.basic - ] || false; - } else if ( - category === "fiveComplements" && - forbiddenSkills.fiveComplements - ) { + let isForbidden = false + if (category === 'basic' && forbiddenSkills.basic) { + isForbidden = forbiddenSkills.basic[skill as keyof typeof forbiddenSkills.basic] || false + } else if (category === 'fiveComplements' && forbiddenSkills.fiveComplements) { isForbidden = forbiddenSkills.fiveComplements[ skill as keyof typeof forbiddenSkills.fiveComplements - ] || false; - } else if ( - category === "tenComplements" && - forbiddenSkills.tenComplements - ) { + ] || false + } else if (category === 'tenComplements' && forbiddenSkills.tenComplements) { isForbidden = - forbiddenSkills.tenComplements[ - skill as keyof typeof forbiddenSkills.tenComplements - ] || false; + forbiddenSkills.tenComplements[skill as keyof typeof forbiddenSkills.tenComplements] || + false } - if (isForbidden) return false; + if (isForbidden) return false } - return true; - }); + return true + }) if (usesValidSkills) { - candidates.push(term); + candidates.push(term) } } - if (candidates.length === 0) return null; + if (candidates.length === 0) return null // If we have target skills and this is not the last term, try to pick a term that uses target skills if (targetSkills && !isLastTerm) { const targetCandidates = candidates.filter((term) => { - const newValue = currentValue + term; - const stepSkills = analyzeStepSkills(currentValue, term, newValue); + const newValue = currentValue + term + const stepSkills = analyzeStepSkills(currentValue, term, newValue) return stepSkills.some((skillPath) => { - const [category, skill] = skillPath.split("."); - if (category === "basic" && targetSkills.basic) { - return targetSkills.basic[skill as keyof typeof targetSkills.basic]; - } else if ( - category === "fiveComplements" && - targetSkills.fiveComplements - ) { - return targetSkills.fiveComplements[ - skill as keyof typeof targetSkills.fiveComplements - ]; - } else if ( - category === "tenComplements" && - targetSkills.tenComplements - ) { - return targetSkills.tenComplements[ - skill as keyof typeof targetSkills.tenComplements - ]; + const [category, skill] = skillPath.split('.') + if (category === 'basic' && targetSkills.basic) { + return targetSkills.basic[skill as keyof typeof targetSkills.basic] + } else if (category === 'fiveComplements' && targetSkills.fiveComplements) { + return targetSkills.fiveComplements[skill as keyof typeof targetSkills.fiveComplements] + } else if (category === 'tenComplements' && targetSkills.tenComplements) { + return targetSkills.tenComplements[skill as keyof typeof targetSkills.tenComplements] } - return false; - }); - }); + return false + }) + }) if (targetCandidates.length > 0) { - return targetCandidates[ - Math.floor(Math.random() * targetCandidates.length) - ]; + return targetCandidates[Math.floor(Math.random() * targetCandidates.length)] } } // Return random valid candidate - return candidates[Math.floor(Math.random() * candidates.length)]; + return candidates[Math.floor(Math.random() * candidates.length)] } /** * Generates an explanation for how to solve the sequential addition problem */ -function generateSequentialExplanation( - terms: number[], - sum: number, - skills: string[], -): string { - const explanations: string[] = []; +function generateSequentialExplanation(terms: number[], sum: number, skills: string[]): string { + const explanations: string[] = [] // Create vertical display format for explanation - const verticalDisplay = `${terms.map((term) => ` ${term}`).join("\n")}\n---\n ${sum}`; + const verticalDisplay = `${terms.map((term) => ` ${term}`).join('\n')}\n---\n ${sum}` - explanations.push( - `Calculate this problem by adding each number in sequence:\n${verticalDisplay}`, - ); + explanations.push(`Calculate this problem by adding each number in sequence:\n${verticalDisplay}`) // Skill-specific explanations - if (skills.includes("basic.directAddition")) { - explanations.push("Use direct addition for numbers 1-4."); + if (skills.includes('basic.directAddition')) { + explanations.push('Use direct addition for numbers 1-4.') } - if (skills.includes("basic.heavenBead")) { + if (skills.includes('basic.heavenBead')) { + explanations.push('Use the heaven bead when working with 5 or making totals involving 5.') + } + + if (skills.includes('basic.simpleCombinations')) { + explanations.push('Use combinations of heaven and earth beads for 6-9.') + } + + if (skills.some((skill) => skill.startsWith('fiveComplements'))) { + const complements = skills.filter((skill) => skill.startsWith('fiveComplements')) explanations.push( - "Use the heaven bead when working with 5 or making totals involving 5.", - ); + `Apply five complements: ${complements.map((s) => s.split('.')[1]).join(', ')}.` + ) } - if (skills.includes("basic.simpleCombinations")) { - explanations.push("Use combinations of heaven and earth beads for 6-9."); - } - - if (skills.some((skill) => skill.startsWith("fiveComplements"))) { - const complements = skills.filter((skill) => - skill.startsWith("fiveComplements"), - ); + if (skills.some((skill) => skill.startsWith('tenComplements'))) { + const complements = skills.filter((skill) => skill.startsWith('tenComplements')) explanations.push( - `Apply five complements: ${complements.map((s) => s.split(".")[1]).join(", ")}.`, - ); + `Apply ten complements: ${complements.map((s) => s.split('.')[1]).join(', ')}.` + ) } - if (skills.some((skill) => skill.startsWith("tenComplements"))) { - const complements = skills.filter((skill) => - skill.startsWith("tenComplements"), - ); - explanations.push( - `Apply ten complements: ${complements.map((s) => s.split(".")[1]).join(", ")}.`, - ); - } - - return explanations.join(" "); + return explanations.join(' ') } /** * Creates a unique signature for a problem to detect duplicates */ function getProblemSignature(terms: number[]): string { - return terms.join("-"); + return terms.join('-') } /** @@ -544,143 +455,119 @@ function getProblemSignature(terms: number[]): string { */ function isDuplicateProblem( problem: GeneratedProblem, - existingProblems: GeneratedProblem[], + existingProblems: GeneratedProblem[] ): boolean { - const signature = getProblemSignature(problem.terms); - return existingProblems.some( - (existing) => getProblemSignature(existing.terms) === signature, - ); + const signature = getProblemSignature(problem.terms) + return existingProblems.some((existing) => getProblemSignature(existing.terms) === signature) } /** * Generates multiple unique problems for a practice step */ -export function generateProblems( - practiceStep: PracticeStep, -): GeneratedProblem[] { +export function generateProblems(practiceStep: PracticeStep): GeneratedProblem[] { const constraints: ProblemConstraints = { numberRange: practiceStep.numberRange || { min: 1, max: 9 }, maxSum: practiceStep.sumConstraints?.maxSum, minSum: practiceStep.sumConstraints?.minSum, maxTerms: practiceStep.maxTerms, problemCount: practiceStep.problemCount, - }; + } - const problems: GeneratedProblem[] = []; - const problemSignatures = new Set(); - const maxAttempts = practiceStep.problemCount * 50; // Increased attempts for better uniqueness - let attempts = 0; - let consecutiveFailures = 0; + const problems: GeneratedProblem[] = [] + const problemSignatures = new Set() + const maxAttempts = practiceStep.problemCount * 50 // Increased attempts for better uniqueness + let attempts = 0 + let consecutiveFailures = 0 - while ( - problems.length < practiceStep.problemCount && - attempts < maxAttempts - ) { - attempts++; + while (problems.length < practiceStep.problemCount && attempts < maxAttempts) { + attempts++ const problem = generateSingleProblem( constraints, practiceStep.requiredSkills, practiceStep.targetSkills, practiceStep.forbiddenSkills, - 150, // More attempts per problem for uniqueness - ); + 150 // More attempts per problem for uniqueness + ) if (problem) { - const signature = getProblemSignature(problem.terms); + const signature = getProblemSignature(problem.terms) // Check for duplicates using both the signature set and existing problems - if ( - !problemSignatures.has(signature) && - !isDuplicateProblem(problem, problems) - ) { - problems.push(problem); - problemSignatures.add(signature); - consecutiveFailures = 0; + if (!problemSignatures.has(signature) && !isDuplicateProblem(problem, problems)) { + problems.push(problem) + problemSignatures.add(signature) + consecutiveFailures = 0 } else { - consecutiveFailures++; + consecutiveFailures++ // If we're getting too many duplicates, the constraints might be too restrictive if (consecutiveFailures > practiceStep.problemCount * 5) { - console.warn( - "Too many duplicate problems generated. Constraints may be too restrictive.", - ); - break; + console.warn('Too many duplicate problems generated. Constraints may be too restrictive.') + break } } } else { - consecutiveFailures++; + consecutiveFailures++ } } // If we couldn't generate enough unique problems, fill with fallback problems // but ensure even fallbacks are unique - let fallbackIndex = 0; + let fallbackIndex = 0 while (problems.length < practiceStep.problemCount) { - let fallbackProblem; - let fallbackAttempts = 0; + let fallbackProblem + let fallbackAttempts = 0 do { - fallbackProblem = generateFallbackProblem(constraints, fallbackIndex++); - fallbackAttempts++; - } while ( - fallbackAttempts < 20 && - isDuplicateProblem(fallbackProblem, problems) - ); + fallbackProblem = generateFallbackProblem(constraints, fallbackIndex++) + fallbackAttempts++ + } while (fallbackAttempts < 20 && isDuplicateProblem(fallbackProblem, problems)) // Only add if it's unique or we've exhausted attempts if (!isDuplicateProblem(fallbackProblem, problems)) { - problems.push(fallbackProblem); - problemSignatures.add(getProblemSignature(fallbackProblem.terms)); + problems.push(fallbackProblem) + problemSignatures.add(getProblemSignature(fallbackProblem.terms)) } else { // Last resort: modify the last term slightly to create uniqueness - const modifiedProblem = createModifiedUniqueProblem( - fallbackProblem, - problems, - constraints, - ); + const modifiedProblem = createModifiedUniqueProblem(fallbackProblem, problems, constraints) if (modifiedProblem) { - problems.push(modifiedProblem); - problemSignatures.add(getProblemSignature(modifiedProblem.terms)); + problems.push(modifiedProblem) + problemSignatures.add(getProblemSignature(modifiedProblem.terms)) } } } - return problems; + return problems } /** * Generates a simple fallback problem when constraints are too restrictive */ -function generateFallbackProblem( - constraints: ProblemConstraints, - index: number, -): GeneratedProblem { - const { min, max } = constraints.numberRange; - const termCount = 3; // Generate 3-term problems as fallback +function generateFallbackProblem(constraints: ProblemConstraints, index: number): GeneratedProblem { + const { min, max } = constraints.numberRange + const termCount = 3 // Generate 3-term problems as fallback // Use the seed index to create variation - const seed = index * 7 + 3; // Prime numbers for better distribution + const seed = index * 7 + 3 // Prime numbers for better distribution - const terms: number[] = []; + const terms: number[] = [] for (let i = 0; i < termCount; i++) { // Create pseudo-random but deterministic terms based on index - const term = ((seed + i * 5) % (max - min + 1)) + min; - terms.push(Math.max(min, Math.min(max, term))); + const term = ((seed + i * 5) % (max - min + 1)) + min + terms.push(Math.max(min, Math.min(max, term))) } - const sum = terms.reduce((acc, term) => acc + term, 0); + const sum = terms.reduce((acc, term) => acc + term, 0) return { id: `fallback_${index}_${Date.now()}_${Math.random().toString(36).substr(2, 4)}`, terms, answer: sum, - requiredSkills: ["basic.directAddition"], - difficulty: "easy", - explanation: generateSequentialExplanation(terms, sum, [ - "basic.directAddition", - ]), - }; + requiredSkills: ['basic.directAddition'], + difficulty: 'easy', + explanation: generateSequentialExplanation(terms, sum, ['basic.directAddition']), + } } /** @@ -689,21 +576,21 @@ function generateFallbackProblem( function createModifiedUniqueProblem( baseProblem: GeneratedProblem, existingProblems: GeneratedProblem[], - constraints: ProblemConstraints, + constraints: ProblemConstraints ): GeneratedProblem | null { - const { min, max } = constraints.numberRange; + const { min, max } = constraints.numberRange // Try modifying the last term to create uniqueness for (let modifier = 1; modifier <= 3; modifier++) { for (const direction of [1, -1]) { - const newTerms = [...baseProblem.terms]; - const lastIndex = newTerms.length - 1; - const newLastTerm = newTerms[lastIndex] + modifier * direction; + const newTerms = [...baseProblem.terms] + const lastIndex = newTerms.length - 1 + const newLastTerm = newTerms[lastIndex] + modifier * direction // Check if the new term is within constraints if (newLastTerm >= min && newLastTerm <= max) { - newTerms[lastIndex] = newLastTerm; - const newSum = newTerms.reduce((acc, term) => acc + term, 0); + newTerms[lastIndex] = newLastTerm + const newSum = newTerms.reduce((acc, term) => acc + term, 0) // Check sum constraints if ( @@ -719,89 +606,69 @@ function createModifiedUniqueProblem( explanation: generateSequentialExplanation( newTerms, newSum, - baseProblem.requiredSkills, + baseProblem.requiredSkills ), - }; + } // Check if this modification creates a unique problem if (!isDuplicateProblem(modifiedProblem, existingProblems)) { - return modifiedProblem; + return modifiedProblem } } } } } - return null; // Could not create a unique modification + return null // Could not create a unique modification } /** * Validates that a practice step configuration can generate problems */ export function validatePracticeStepConfiguration(practiceStep: PracticeStep): { - isValid: boolean; - warnings: string[]; - suggestions: string[]; + isValid: boolean + warnings: string[] + suggestions: string[] } { - const warnings: string[] = []; - const suggestions: string[] = []; + const warnings: string[] = [] + const suggestions: string[] = [] // Check if any required skills are enabled const hasAnyRequiredSkill = Object.values(practiceStep.requiredSkills.basic).some(Boolean) || Object.values(practiceStep.requiredSkills.fiveComplements).some(Boolean) || - Object.values(practiceStep.requiredSkills.tenComplements).some(Boolean); + Object.values(practiceStep.requiredSkills.tenComplements).some(Boolean) if (!hasAnyRequiredSkill) { - warnings.push( - "No required skills are enabled. Problems may be very basic.", - ); - suggestions.push( - 'Enable at least one skill in the "Required Skills" section.', - ); + warnings.push('No required skills are enabled. Problems may be very basic.') + suggestions.push('Enable at least one skill in the "Required Skills" section.') } // Check number range vs sum constraints const maxPossibleSum = practiceStep.numberRange?.max ? practiceStep.numberRange.max * practiceStep.maxTerms - : 9 * practiceStep.maxTerms; + : 9 * practiceStep.maxTerms - if ( - practiceStep.sumConstraints?.maxSum && - practiceStep.sumConstraints.maxSum > maxPossibleSum - ) { - warnings.push( - "Maximum sum constraint is higher than what the number range allows.", - ); - suggestions.push( - "Either increase the number range maximum or decrease the sum constraint.", - ); + if (practiceStep.sumConstraints?.maxSum && practiceStep.sumConstraints.maxSum > maxPossibleSum) { + warnings.push('Maximum sum constraint is higher than what the number range allows.') + suggestions.push('Either increase the number range maximum or decrease the sum constraint.') } // Check if constraints are too restrictive - if ( - practiceStep.sumConstraints?.maxSum && - practiceStep.sumConstraints.maxSum < 5 - ) { - warnings.push("Very low sum constraint may limit problem variety."); - suggestions.push( - "Consider increasing the maximum sum to allow more diverse problems.", - ); + if (practiceStep.sumConstraints?.maxSum && practiceStep.sumConstraints.maxSum < 5) { + warnings.push('Very low sum constraint may limit problem variety.') + suggestions.push('Consider increasing the maximum sum to allow more diverse problems.') } // Check problem count if (practiceStep.problemCount > 20) { - warnings.push( - "High problem count may take a long time to generate and complete.", - ); - suggestions.push( - "Consider reducing the problem count for better user experience.", - ); + warnings.push('High problem count may take a long time to generate and complete.') + suggestions.push('Consider reducing the problem count for better user experience.') } return { isValid: warnings.length === 0, warnings, suggestions, - }; + } } diff --git a/apps/web/src/utils/room-display.ts b/apps/web/src/utils/room-display.ts index 78a00e27..8c989125 100644 --- a/apps/web/src/utils/room-display.ts +++ b/apps/web/src/utils/room-display.ts @@ -6,15 +6,15 @@ export interface RoomDisplayData { /** * The room's custom name if provided */ - name: string | null; + name: string | null /** * The room's unique code (e.g., "ABC123") */ - code: string; + code: string /** * The game type (optional, for emoji selection) */ - gameName?: string; + gameName?: string } export interface RoomDisplay { @@ -22,36 +22,36 @@ export interface RoomDisplay { * Plain text representation - ALWAYS available * Use this for: document titles, logs, notifications, plaintext contexts */ - plaintext: string; + plaintext: string /** * Primary display text (without emoji) */ - primary: string; + primary: string /** * Secondary/subtitle text (optional) */ - secondary?: string; + secondary?: string /** * Emoji/icon for the room (optional) */ - emoji?: string; + emoji?: string /** * Whether the name was auto-generated (vs. custom) */ - isGenerated: boolean; + isGenerated: boolean } const GAME_EMOJIS: Record = { - matching: "🃏", - "memory-quiz": "🧠", - "complement-race": "⚡", -}; + matching: '🃏', + 'memory-quiz': '🧠', + 'complement-race': '⚡', +} -const DEFAULT_EMOJI = "🎮"; +const DEFAULT_EMOJI = '🎮' /** * Get structured room display information @@ -75,11 +75,11 @@ export function getRoomDisplay(room: RoomDisplayData): RoomDisplay { secondary: room.code, emoji: undefined, isGenerated: false, - }; + } } // Auto-generate display - const emoji = GAME_EMOJIS[room.gameName || ""] || DEFAULT_EMOJI; + const emoji = GAME_EMOJIS[room.gameName || ''] || DEFAULT_EMOJI return { plaintext: `Room ${room.code}`, // Always plaintext fallback @@ -87,7 +87,7 @@ export function getRoomDisplay(room: RoomDisplayData): RoomDisplay { secondary: undefined, emoji, isGenerated: true, - }; + } } /** @@ -103,7 +103,7 @@ export function getRoomDisplay(room: RoomDisplayData): RoomDisplay { * // => "Room ABC123" */ export function getRoomDisplayName(room: RoomDisplayData): string { - return getRoomDisplay(room).plaintext; + return getRoomDisplay(room).plaintext } /** @@ -118,9 +118,9 @@ export function getRoomDisplayName(room: RoomDisplayData): string { * // => "🃏 ABC123" */ export function getRoomDisplayWithEmoji(room: RoomDisplayData): string { - const display = getRoomDisplay(room); + const display = getRoomDisplay(room) if (display.emoji) { - return `${display.emoji} ${display.primary}`; + return `${display.emoji} ${display.primary}` } - return display.primary; + return display.primary } diff --git a/apps/web/src/utils/skillConfiguration.ts b/apps/web/src/utils/skillConfiguration.ts index 348adc4f..4188f2ba 100644 --- a/apps/web/src/utils/skillConfiguration.ts +++ b/apps/web/src/utils/skillConfiguration.ts @@ -1,92 +1,92 @@ -import type { SkillSet } from "../types/tutorial"; +import type { SkillSet } from '../types/tutorial' -export type SkillMode = "off" | "allowed" | "target" | "forbidden"; +export type SkillMode = 'off' | 'allowed' | 'target' | 'forbidden' export interface SkillConfiguration { basic: { - directAddition: SkillMode; - heavenBead: SkillMode; - simpleCombinations: SkillMode; - }; + directAddition: SkillMode + heavenBead: SkillMode + simpleCombinations: SkillMode + } fiveComplements: { - "4=5-1": SkillMode; - "3=5-2": SkillMode; - "2=5-3": SkillMode; - "1=5-4": SkillMode; - }; + '4=5-1': SkillMode + '3=5-2': SkillMode + '2=5-3': SkillMode + '1=5-4': SkillMode + } tenComplements: { - "9=10-1": SkillMode; - "8=10-2": SkillMode; - "7=10-3": SkillMode; - "6=10-4": SkillMode; - "5=10-5": SkillMode; - "4=10-6": SkillMode; - "3=10-7": SkillMode; - "2=10-8": SkillMode; - "1=10-9": SkillMode; - }; + '9=10-1': SkillMode + '8=10-2': SkillMode + '7=10-3': SkillMode + '6=10-4': SkillMode + '5=10-5': SkillMode + '4=10-6': SkillMode + '3=10-7': SkillMode + '2=10-8': SkillMode + '1=10-9': SkillMode + } } // Helper functions for new skill configuration export function createDefaultSkillConfiguration(): SkillConfiguration { return { basic: { - directAddition: "allowed", - heavenBead: "off", - simpleCombinations: "off", + directAddition: 'allowed', + heavenBead: 'off', + simpleCombinations: 'off', }, fiveComplements: { - "4=5-1": "off", - "3=5-2": "off", - "2=5-3": "off", - "1=5-4": "off", + '4=5-1': 'off', + '3=5-2': 'off', + '2=5-3': 'off', + '1=5-4': 'off', }, tenComplements: { - "9=10-1": "off", - "8=10-2": "off", - "7=10-3": "off", - "6=10-4": "off", - "5=10-5": "off", - "4=10-6": "off", - "3=10-7": "off", - "2=10-8": "off", - "1=10-9": "off", + '9=10-1': 'off', + '8=10-2': 'off', + '7=10-3': 'off', + '6=10-4': 'off', + '5=10-5': 'off', + '4=10-6': 'off', + '3=10-7': 'off', + '2=10-8': 'off', + '1=10-9': 'off', }, - }; + } } export function createBasicAllowedConfiguration(): SkillConfiguration { return { basic: { - directAddition: "allowed", - heavenBead: "allowed", - simpleCombinations: "off", + directAddition: 'allowed', + heavenBead: 'allowed', + simpleCombinations: 'off', }, fiveComplements: { - "4=5-1": "off", - "3=5-2": "off", - "2=5-3": "off", - "1=5-4": "off", + '4=5-1': 'off', + '3=5-2': 'off', + '2=5-3': 'off', + '1=5-4': 'off', }, tenComplements: { - "9=10-1": "off", - "8=10-2": "off", - "7=10-3": "off", - "6=10-4": "off", - "5=10-5": "off", - "4=10-6": "off", - "3=10-7": "off", - "2=10-8": "off", - "1=10-9": "off", + '9=10-1': 'off', + '8=10-2': 'off', + '7=10-3': 'off', + '6=10-4': 'off', + '5=10-5': 'off', + '4=10-6': 'off', + '3=10-7': 'off', + '2=10-8': 'off', + '1=10-9': 'off', }, - }; + } } // Convert between old and new formats export function skillConfigurationToSkillSets(config: SkillConfiguration): { - required: SkillSet; - target: Partial; - forbidden: Partial; + required: SkillSet + target: Partial + forbidden: Partial } { const required: SkillSet = { basic: { @@ -95,129 +95,122 @@ export function skillConfigurationToSkillSets(config: SkillConfiguration): { simpleCombinations: false, }, fiveComplements: { - "4=5-1": false, - "3=5-2": false, - "2=5-3": false, - "1=5-4": false, + '4=5-1': false, + '3=5-2': false, + '2=5-3': false, + '1=5-4': false, }, tenComplements: { - "9=10-1": false, - "8=10-2": false, - "7=10-3": false, - "6=10-4": false, - "5=10-5": false, - "4=10-6": false, - "3=10-7": false, - "2=10-8": false, - "1=10-9": false, + '9=10-1': false, + '8=10-2': false, + '7=10-3': false, + '6=10-4': false, + '5=10-5': false, + '4=10-6': false, + '3=10-7': false, + '2=10-8': false, + '1=10-9': false, }, - }; + } - const target: Partial = {}; - const forbidden: Partial = {}; + const target: Partial = {} + const forbidden: Partial = {} // Basic skills Object.entries(config.basic).forEach(([skill, mode]) => { - if (mode === "allowed" || mode === "target") { - required.basic[skill as keyof typeof required.basic] = true; + if (mode === 'allowed' || mode === 'target') { + required.basic[skill as keyof typeof required.basic] = true } - if (mode === "target") { - if (!target.basic) target.basic = {} as any; - target.basic![skill as keyof typeof required.basic] = true; + if (mode === 'target') { + if (!target.basic) target.basic = {} as any + target.basic![skill as keyof typeof required.basic] = true } - if (mode === "forbidden") { - if (!forbidden.basic) forbidden.basic = {} as any; - forbidden.basic![skill as keyof typeof required.basic] = true; + if (mode === 'forbidden') { + if (!forbidden.basic) forbidden.basic = {} as any + forbidden.basic![skill as keyof typeof required.basic] = true } - }); + }) // Five complements Object.entries(config.fiveComplements).forEach(([skill, mode]) => { - if (mode === "allowed" || mode === "target") { - required.fiveComplements[skill as keyof typeof required.fiveComplements] = - true; + if (mode === 'allowed' || mode === 'target') { + required.fiveComplements[skill as keyof typeof required.fiveComplements] = true } - if (mode === "target") { - if (!target.fiveComplements) target.fiveComplements = {} as any; - target.fiveComplements![skill as keyof typeof required.fiveComplements] = - true; + if (mode === 'target') { + if (!target.fiveComplements) target.fiveComplements = {} as any + target.fiveComplements![skill as keyof typeof required.fiveComplements] = true } - if (mode === "forbidden") { - if (!forbidden.fiveComplements) forbidden.fiveComplements = {} as any; - forbidden.fiveComplements![ - skill as keyof typeof required.fiveComplements - ] = true; + if (mode === 'forbidden') { + if (!forbidden.fiveComplements) forbidden.fiveComplements = {} as any + forbidden.fiveComplements![skill as keyof typeof required.fiveComplements] = true } - }); + }) // Ten complements Object.entries(config.tenComplements).forEach(([skill, mode]) => { - if (mode === "allowed" || mode === "target") { - required.tenComplements[skill as keyof typeof required.tenComplements] = - true; + if (mode === 'allowed' || mode === 'target') { + required.tenComplements[skill as keyof typeof required.tenComplements] = true } - if (mode === "target") { - if (!target.tenComplements) target.tenComplements = {} as any; - target.tenComplements![skill as keyof typeof required.tenComplements] = - true; + if (mode === 'target') { + if (!target.tenComplements) target.tenComplements = {} as any + target.tenComplements![skill as keyof typeof required.tenComplements] = true } - if (mode === "forbidden") { - if (!forbidden.tenComplements) forbidden.tenComplements = {} as any; - forbidden.tenComplements![skill as keyof typeof required.tenComplements] = - true; + if (mode === 'forbidden') { + if (!forbidden.tenComplements) forbidden.tenComplements = {} as any + forbidden.tenComplements![skill as keyof typeof required.tenComplements] = true } - }); + }) - return { required, target, forbidden }; + return { required, target, forbidden } } // Convert from old format to new format export function skillSetsToConfiguration( required: SkillSet, target?: Partial, - forbidden?: Partial, + forbidden?: Partial ): SkillConfiguration { - const config = createDefaultSkillConfiguration(); + const config = createDefaultSkillConfiguration() // Process each skill category Object.entries(required.basic).forEach(([skill, isRequired]) => { - const skillKey = skill as keyof typeof config.basic; + const skillKey = skill as keyof typeof config.basic if (forbidden?.basic?.[skillKey]) { - config.basic[skillKey] = "forbidden"; + config.basic[skillKey] = 'forbidden' } else if (target?.basic?.[skillKey]) { - config.basic[skillKey] = "target"; + config.basic[skillKey] = 'target' } else if (isRequired) { - config.basic[skillKey] = "allowed"; + config.basic[skillKey] = 'allowed' } else { - config.basic[skillKey] = "off"; + config.basic[skillKey] = 'off' } - }); + }) Object.entries(required.fiveComplements).forEach(([skill, isRequired]) => { - const skillKey = skill as keyof typeof config.fiveComplements; + const skillKey = skill as keyof typeof config.fiveComplements if (forbidden?.fiveComplements?.[skillKey]) { - config.fiveComplements[skillKey] = "forbidden"; + config.fiveComplements[skillKey] = 'forbidden' } else if (target?.fiveComplements?.[skillKey]) { - config.fiveComplements[skillKey] = "target"; + config.fiveComplements[skillKey] = 'target' } else if (isRequired) { - config.fiveComplements[skillKey] = "allowed"; + config.fiveComplements[skillKey] = 'allowed' } else { - config.fiveComplements[skillKey] = "off"; + config.fiveComplements[skillKey] = 'off' } - }); + }) Object.entries(required.tenComplements).forEach(([skill, isRequired]) => { - const skillKey = skill as keyof typeof config.tenComplements; + const skillKey = skill as keyof typeof config.tenComplements if (forbidden?.tenComplements?.[skillKey]) { - config.tenComplements[skillKey] = "forbidden"; + config.tenComplements[skillKey] = 'forbidden' } else if (target?.tenComplements?.[skillKey]) { - config.tenComplements[skillKey] = "target"; + config.tenComplements[skillKey] = 'target' } else if (isRequired) { - config.tenComplements[skillKey] = "allowed"; + config.tenComplements[skillKey] = 'allowed' } else { - config.tenComplements[skillKey] = "off"; + config.tenComplements[skillKey] = 'off' } - }); + }) - return config; + return config } diff --git a/apps/web/src/utils/test/beadDiff.test.ts b/apps/web/src/utils/test/beadDiff.test.ts index d2f3218b..fd33d91e 100644 --- a/apps/web/src/utils/test/beadDiff.test.ts +++ b/apps/web/src/utils/test/beadDiff.test.ts @@ -1,235 +1,233 @@ -import { describe, expect, it } from "vitest"; -import { numberToAbacusState } from "../abacusInstructionGenerator"; +import { describe, expect, it } from 'vitest' +import { numberToAbacusState } from '../abacusInstructionGenerator' import { areStatesEqual, calculateBeadDiff, calculateBeadDiffFromValues, calculateMultiStepBeadDiffs, validateBeadDiff, -} from "../beadDiff"; +} from '../beadDiff' -describe("Bead Diff Algorithm", () => { - describe("Basic State Transitions", () => { - it("should calculate diff for simple addition: 0 + 1", () => { - const diff = calculateBeadDiffFromValues(0, 1); +describe('Bead Diff Algorithm', () => { + describe('Basic State Transitions', () => { + it('should calculate diff for simple addition: 0 + 1', () => { + const diff = calculateBeadDiffFromValues(0, 1) - expect(diff.hasChanges).toBe(true); - expect(diff.changes).toHaveLength(1); + expect(diff.hasChanges).toBe(true) + expect(diff.changes).toHaveLength(1) expect(diff.changes[0]).toEqual({ placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 0, - direction: "activate", + direction: 'activate', order: 0, - }); - expect(diff.summary).toBe("add 1 earth bead in ones column"); - }); + }) + expect(diff.summary).toBe('add 1 earth bead in ones column') + }) - it("should calculate diff for heaven bead: 0 + 5", () => { - const diff = calculateBeadDiffFromValues(0, 5); + it('should calculate diff for heaven bead: 0 + 5', () => { + const diff = calculateBeadDiffFromValues(0, 5) - expect(diff.hasChanges).toBe(true); - expect(diff.changes).toHaveLength(1); + expect(diff.hasChanges).toBe(true) + expect(diff.changes).toHaveLength(1) expect(diff.changes[0]).toEqual({ placeValue: 0, - beadType: "heaven", - direction: "activate", + beadType: 'heaven', + direction: 'activate', order: 0, - }); - expect(diff.summary).toBe("add heaven bead in ones column"); - }); + }) + expect(diff.summary).toBe('add heaven bead in ones column') + }) - it("should calculate diff for complement operation: 3 + 4 = 7", () => { - const diff = calculateBeadDiffFromValues(3, 7); + it('should calculate diff for complement operation: 3 + 4 = 7', () => { + const diff = calculateBeadDiffFromValues(3, 7) - expect(diff.hasChanges).toBe(true); - expect(diff.changes).toHaveLength(2); // Remove 1 earth, add heaven + expect(diff.hasChanges).toBe(true) + expect(diff.changes).toHaveLength(2) // Remove 1 earth, add heaven // Should remove 1 earth bead first (pedagogical order) - const removals = diff.changes.filter((c) => c.direction === "deactivate"); - const additions = diff.changes.filter((c) => c.direction === "activate"); + const removals = diff.changes.filter((c) => c.direction === 'deactivate') + const additions = diff.changes.filter((c) => c.direction === 'activate') - expect(removals).toHaveLength(1); // Remove 1 earth bead (position 2) - expect(additions).toHaveLength(1); // Add heaven bead + expect(removals).toHaveLength(1) // Remove 1 earth bead (position 2) + expect(additions).toHaveLength(1) // Add heaven bead // Removals should come first in order - expect(removals[0].order).toBeLessThan(additions[0].order); + expect(removals[0].order).toBeLessThan(additions[0].order) - expect(diff.summary).toContain("remove 1 earth bead"); - expect(diff.summary).toContain("add heaven bead"); - }); + expect(diff.summary).toContain('remove 1 earth bead') + expect(diff.summary).toContain('add heaven bead') + }) - it("should calculate diff for ten transition: 9 + 1 = 10", () => { - const diff = calculateBeadDiffFromValues(9, 10); + it('should calculate diff for ten transition: 9 + 1 = 10', () => { + const diff = calculateBeadDiffFromValues(9, 10) - expect(diff.hasChanges).toBe(true); + expect(diff.hasChanges).toBe(true) // Should remove heaven + 4 earth in ones, add 1 earth in tens - const onesChanges = diff.changes.filter((c) => c.placeValue === 0); - const tensChanges = diff.changes.filter((c) => c.placeValue === 1); + const onesChanges = diff.changes.filter((c) => c.placeValue === 0) + const tensChanges = diff.changes.filter((c) => c.placeValue === 1) - expect(onesChanges).toHaveLength(5); // Remove heaven + 4 earth - expect(tensChanges).toHaveLength(1); // Add 1 earth in tens + expect(onesChanges).toHaveLength(5) // Remove heaven + 4 earth + expect(tensChanges).toHaveLength(1) // Add 1 earth in tens - expect(diff.summary).toContain("tens column"); - expect(diff.summary).toContain("ones column"); - }); - }); + expect(diff.summary).toContain('tens column') + expect(diff.summary).toContain('ones column') + }) + }) - describe("Multi-Step Operations", () => { - it("should calculate multi-step diff for 3 + 14 = 17", () => { + describe('Multi-Step Operations', () => { + it('should calculate multi-step diff for 3 + 14 = 17', () => { const steps = [ - { expectedValue: 13, instruction: "Add 10" }, - { expectedValue: 17, instruction: "Add 4 using complement" }, - ]; + { expectedValue: 13, instruction: 'Add 10' }, + { expectedValue: 17, instruction: 'Add 4 using complement' }, + ] - const multiStepDiffs = calculateMultiStepBeadDiffs(3, steps); + const multiStepDiffs = calculateMultiStepBeadDiffs(3, steps) - expect(multiStepDiffs).toHaveLength(2); + expect(multiStepDiffs).toHaveLength(2) // Step 1: 3 → 13 (add 1 earth bead in tens) - const step1 = multiStepDiffs[0]; - expect(step1.fromValue).toBe(3); - expect(step1.toValue).toBe(13); - expect(step1.diff.changes).toHaveLength(1); - expect(step1.diff.changes[0].placeValue).toBe(1); // tens - expect(step1.diff.changes[0].beadType).toBe("earth"); - expect(step1.diff.changes[0].direction).toBe("activate"); + const step1 = multiStepDiffs[0] + expect(step1.fromValue).toBe(3) + expect(step1.toValue).toBe(13) + expect(step1.diff.changes).toHaveLength(1) + expect(step1.diff.changes[0].placeValue).toBe(1) // tens + expect(step1.diff.changes[0].beadType).toBe('earth') + expect(step1.diff.changes[0].direction).toBe('activate') // Step 2: 13 → 17 (complement operation in ones) - const step2 = multiStepDiffs[1]; - expect(step2.fromValue).toBe(13); - expect(step2.toValue).toBe(17); - expect(step2.diff.changes.length).toBeGreaterThan(1); // Multiple bead movements - }); - }); + const step2 = multiStepDiffs[1] + expect(step2.fromValue).toBe(13) + expect(step2.toValue).toBe(17) + expect(step2.diff.changes.length).toBeGreaterThan(1) // Multiple bead movements + }) + }) - describe("Edge Cases and Validation", () => { - it("should return no changes for identical states", () => { - const diff = calculateBeadDiffFromValues(5, 5); + describe('Edge Cases and Validation', () => { + it('should return no changes for identical states', () => { + const diff = calculateBeadDiffFromValues(5, 5) - expect(diff.hasChanges).toBe(false); - expect(diff.changes).toHaveLength(0); - expect(diff.summary).toBe("No changes needed"); - }); + expect(diff.hasChanges).toBe(false) + expect(diff.changes).toHaveLength(0) + expect(diff.summary).toBe('No changes needed') + }) - it("should handle large numbers correctly", () => { - const diff = calculateBeadDiffFromValues(0, 999); + it('should handle large numbers correctly', () => { + const diff = calculateBeadDiffFromValues(0, 999) - expect(diff.hasChanges).toBe(true); - expect(diff.changes.length).toBeGreaterThan(0); + expect(diff.hasChanges).toBe(true) + expect(diff.changes.length).toBeGreaterThan(0) // Should have changes in hundreds, tens, and ones places - const places = new Set(diff.changes.map((c) => c.placeValue)); - expect(places).toContain(0); // ones - expect(places).toContain(1); // tens - expect(places).toContain(2); // hundreds - }); + const places = new Set(diff.changes.map((c) => c.placeValue)) + expect(places).toContain(0) // ones + expect(places).toContain(1) // tens + expect(places).toContain(2) // hundreds + }) - it("should validate impossible bead states", () => { + it('should validate impossible bead states', () => { // Create a diff that would result in more than 4 earth beads - const fromState = numberToAbacusState(0); - const toState = numberToAbacusState(0); - toState[0] = { heavenActive: false, earthActive: 5 }; // Invalid: too many earth beads + const fromState = numberToAbacusState(0) + const toState = numberToAbacusState(0) + toState[0] = { heavenActive: false, earthActive: 5 } // Invalid: too many earth beads - const diff = calculateBeadDiff(fromState, toState); - const validation = validateBeadDiff(diff); + const diff = calculateBeadDiff(fromState, toState) + const validation = validateBeadDiff(diff) - expect(validation.isValid).toBe(false); - expect(validation.errors.length).toBeGreaterThan(0); - expect(validation.errors[0]).toContain( - "Cannot have more than 4 earth beads", - ); - }); + expect(validation.isValid).toBe(false) + expect(validation.errors.length).toBeGreaterThan(0) + expect(validation.errors[0]).toContain('Cannot have more than 4 earth beads') + }) - it("should correctly identify equal states", () => { - const state1 = numberToAbacusState(42); - const state2 = numberToAbacusState(42); - const state3 = numberToAbacusState(43); + it('should correctly identify equal states', () => { + const state1 = numberToAbacusState(42) + const state2 = numberToAbacusState(42) + const state3 = numberToAbacusState(43) - expect(areStatesEqual(state1, state2)).toBe(true); - expect(areStatesEqual(state1, state3)).toBe(false); - }); - }); + expect(areStatesEqual(state1, state2)).toBe(true) + expect(areStatesEqual(state1, state3)).toBe(false) + }) + }) - describe("Pedagogical Ordering", () => { - it("should process removals before additions", () => { + describe('Pedagogical Ordering', () => { + it('should process removals before additions', () => { // Test a case that requires both removing and adding beads - const diff = calculateBeadDiffFromValues(7, 2); // 7 → 2: remove heaven, remove 2 earth, add 2 earth + const diff = calculateBeadDiffFromValues(7, 2) // 7 → 2: remove heaven, remove 2 earth, add 2 earth - const removals = diff.changes.filter((c) => c.direction === "deactivate"); - const additions = diff.changes.filter((c) => c.direction === "activate"); + const removals = diff.changes.filter((c) => c.direction === 'deactivate') + const additions = diff.changes.filter((c) => c.direction === 'activate') if (removals.length > 0 && additions.length > 0) { // All removals should have lower order numbers than additions - const maxRemovalOrder = Math.max(...removals.map((r) => r.order)); - const minAdditionOrder = Math.min(...additions.map((a) => a.order)); + const maxRemovalOrder = Math.max(...removals.map((r) => r.order)) + const minAdditionOrder = Math.min(...additions.map((a) => a.order)) - expect(maxRemovalOrder).toBeLessThan(minAdditionOrder); + expect(maxRemovalOrder).toBeLessThan(minAdditionOrder) } - }); + }) - it("should maintain consistent ordering for animation", () => { - const diff = calculateBeadDiffFromValues(0, 23); // Complex operation + it('should maintain consistent ordering for animation', () => { + const diff = calculateBeadDiffFromValues(0, 23) // Complex operation // Orders should be consecutive starting from 0 - const orders = diff.changes.map((c) => c.order).sort((a, b) => a - b); + const orders = diff.changes.map((c) => c.order).sort((a, b) => a - b) for (let i = 0; i < orders.length; i++) { - expect(orders[i]).toBe(i); + expect(orders[i]).toBe(i) } - }); - }); + }) + }) - describe("Real Tutorial Examples", () => { + describe('Real Tutorial Examples', () => { it('should handle the classic "3 + 14 = 17" example', () => { - console.log("=== Testing 3 + 14 = 17 ==="); + console.log('=== Testing 3 + 14 = 17 ===') - const diff = calculateBeadDiffFromValues(3, 17); + const diff = calculateBeadDiffFromValues(3, 17) - console.log("Changes:", diff.changes); - console.log("Summary:", diff.summary); + console.log('Changes:', diff.changes) + console.log('Summary:', diff.summary) - expect(diff.hasChanges).toBe(true); - expect(diff.summary).toBeDefined(); + expect(diff.hasChanges).toBe(true) + expect(diff.summary).toBeDefined() // Should involve both tens and ones places - const places = new Set(diff.changes.map((c) => c.placeValue)); - expect(places).toContain(0); // ones - expect(places).toContain(1); // tens - }); + const places = new Set(diff.changes.map((c) => c.placeValue)) + expect(places).toContain(0) // ones + expect(places).toContain(1) // tens + }) it('should handle "7 + 4 = 11" ten complement', () => { - console.log("=== Testing 7 + 4 = 11 ==="); + console.log('=== Testing 7 + 4 = 11 ===') - const diff = calculateBeadDiffFromValues(7, 11); + const diff = calculateBeadDiffFromValues(7, 11) - console.log("Changes:", diff.changes); - console.log("Summary:", diff.summary); + console.log('Changes:', diff.changes) + console.log('Summary:', diff.summary) - expect(diff.hasChanges).toBe(true); + expect(diff.hasChanges).toBe(true) // Should involve both tens and ones places - const places = new Set(diff.changes.map((c) => c.placeValue)); - expect(places).toContain(0); // ones - expect(places).toContain(1); // tens - }); + const places = new Set(diff.changes.map((c) => c.placeValue)) + expect(places).toContain(0) // ones + expect(places).toContain(1) // tens + }) it('should handle "99 + 1 = 100" boundary crossing', () => { - console.log("=== Testing 99 + 1 = 100 ==="); + console.log('=== Testing 99 + 1 = 100 ===') - const diff = calculateBeadDiffFromValues(99, 100); + const diff = calculateBeadDiffFromValues(99, 100) - console.log("Changes:", diff.changes); - console.log("Summary:", diff.summary); + console.log('Changes:', diff.changes) + console.log('Summary:', diff.summary) - expect(diff.hasChanges).toBe(true); + expect(diff.hasChanges).toBe(true) // Should involve ones, tens, and hundreds places - const places = new Set(diff.changes.map((c) => c.placeValue)); - expect(places).toContain(0); // ones - expect(places).toContain(1); // tens - expect(places).toContain(2); // hundreds - }); - }); -}); + const places = new Set(diff.changes.map((c) => c.placeValue)) + expect(places).toContain(0) // ones + expect(places).toContain(1) // tens + expect(places).toContain(2) // hundreds + }) + }) +}) diff --git a/apps/web/src/utils/test/instructionGenerator.test.ts b/apps/web/src/utils/test/instructionGenerator.test.ts index 94ab0425..a2d1c719 100644 --- a/apps/web/src/utils/test/instructionGenerator.test.ts +++ b/apps/web/src/utils/test/instructionGenerator.test.ts @@ -1,21 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest' import { detectComplementOperation, generateAbacusInstructions, numberToAbacusState, validateInstruction, -} from "../abacusInstructionGenerator"; +} from '../abacusInstructionGenerator' -describe("Automatic Abacus Instruction Generator", () => { - describe("numberToAbacusState", () => { - it("should convert numbers to correct abacus states", () => { +describe('Automatic Abacus Instruction Generator', () => { + describe('numberToAbacusState', () => { + it('should convert numbers to correct abacus states', () => { expect(numberToAbacusState(0)).toEqual({ 0: { heavenActive: false, earthActive: 0 }, 1: { heavenActive: false, earthActive: 0 }, 2: { heavenActive: false, earthActive: 0 }, 3: { heavenActive: false, earthActive: 0 }, 4: { heavenActive: false, earthActive: 0 }, - }); + }) expect(numberToAbacusState(5)).toEqual({ 0: { heavenActive: true, earthActive: 0 }, @@ -23,7 +23,7 @@ describe("Automatic Abacus Instruction Generator", () => { 2: { heavenActive: false, earthActive: 0 }, 3: { heavenActive: false, earthActive: 0 }, 4: { heavenActive: false, earthActive: 0 }, - }); + }) expect(numberToAbacusState(7)).toEqual({ 0: { heavenActive: true, earthActive: 2 }, @@ -31,7 +31,7 @@ describe("Automatic Abacus Instruction Generator", () => { 2: { heavenActive: false, earthActive: 0 }, 3: { heavenActive: false, earthActive: 0 }, 4: { heavenActive: false, earthActive: 0 }, - }); + }) expect(numberToAbacusState(23)).toEqual({ 0: { heavenActive: false, earthActive: 3 }, @@ -39,277 +39,257 @@ describe("Automatic Abacus Instruction Generator", () => { 2: { heavenActive: false, earthActive: 0 }, 3: { heavenActive: false, earthActive: 0 }, 4: { heavenActive: false, earthActive: 0 }, - }); - }); - }); + }) + }) + }) - describe("detectComplementOperation", () => { - it("should detect five complement operations", () => { + describe('detectComplementOperation', () => { + it('should detect five complement operations', () => { // 3 + 4 = 7 (need complement because only 1 earth space available) - const result = detectComplementOperation(3, 7, 0); - expect(result.needsComplement).toBe(true); - expect(result.complementType).toBe("five"); - expect(result.complementDetails?.addValue).toBe(5); - expect(result.complementDetails?.subtractValue).toBe(1); - }); + const result = detectComplementOperation(3, 7, 0) + expect(result.needsComplement).toBe(true) + expect(result.complementType).toBe('five') + expect(result.complementDetails?.addValue).toBe(5) + expect(result.complementDetails?.subtractValue).toBe(1) + }) - it("should detect ten complement operations", () => { + it('should detect ten complement operations', () => { // 7 + 4 = 11 (need to carry to tens place) - const result = detectComplementOperation(7, 11, 0); - expect(result.needsComplement).toBe(true); - expect(result.complementType).toBe("ten"); - }); + const result = detectComplementOperation(7, 11, 0) + expect(result.needsComplement).toBe(true) + expect(result.complementType).toBe('ten') + }) - it("should not detect complement for direct operations", () => { + it('should not detect complement for direct operations', () => { // 1 + 1 = 2 (direct addition) - const result = detectComplementOperation(1, 2, 0); - expect(result.needsComplement).toBe(false); - expect(result.complementType).toBe("none"); - }); - }); + const result = detectComplementOperation(1, 2, 0) + expect(result.needsComplement).toBe(false) + expect(result.complementType).toBe('none') + }) + }) - describe("generateAbacusInstructions", () => { - it("should generate correct instructions for basic addition", () => { - const instruction = generateAbacusInstructions(0, 1); + describe('generateAbacusInstructions', () => { + it('should generate correct instructions for basic addition', () => { + const instruction = generateAbacusInstructions(0, 1) - expect(instruction.highlightBeads).toHaveLength(1); + expect(instruction.highlightBeads).toHaveLength(1) expect(instruction.highlightBeads[0]).toEqual({ placeValue: 0, - beadType: "earth", + beadType: 'earth', position: 0, - }); - expect(instruction.expectedAction).toBe("add"); - expect(instruction.actionDescription).toContain("earth bead"); - }); + }) + expect(instruction.expectedAction).toBe('add') + expect(instruction.actionDescription).toContain('earth bead') + }) - it("should generate correct instructions for heaven bead", () => { - const instruction = generateAbacusInstructions(0, 5); + it('should generate correct instructions for heaven bead', () => { + const instruction = generateAbacusInstructions(0, 5) - expect(instruction.highlightBeads).toHaveLength(1); + expect(instruction.highlightBeads).toHaveLength(1) expect(instruction.highlightBeads[0]).toEqual({ placeValue: 0, - beadType: "heaven", - }); - expect(instruction.expectedAction).toBe("add"); - expect(instruction.actionDescription).toContain("heaven bead"); - }); + beadType: 'heaven', + }) + expect(instruction.expectedAction).toBe('add') + expect(instruction.actionDescription).toContain('heaven bead') + }) - it("should generate correct instructions for five complement", () => { - const instruction = generateAbacusInstructions(3, 7); // 3 + 4 + it('should generate correct instructions for five complement', () => { + const instruction = generateAbacusInstructions(3, 7) // 3 + 4 - expect(instruction.highlightBeads).toHaveLength(2); - expect(instruction.expectedAction).toBe("multi-step"); - expect(instruction.actionDescription).toContain("3 + 4 = 3 + (5 - 1)"); - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.multiStepInstructions).toHaveLength(2); + expect(instruction.highlightBeads).toHaveLength(2) + expect(instruction.expectedAction).toBe('multi-step') + expect(instruction.actionDescription).toContain('3 + 4 = 3 + (5 - 1)') + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.multiStepInstructions).toHaveLength(2) // Should highlight heaven bead to add - const heavenBead = instruction.highlightBeads.find( - (b) => b.beadType === "heaven", - ); - expect(heavenBead).toBeDefined(); + const heavenBead = instruction.highlightBeads.find((b) => b.beadType === 'heaven') + expect(heavenBead).toBeDefined() // Should highlight earth bead to remove (the last one in the sequence) - const earthBead = instruction.highlightBeads.find( - (b) => b.beadType === "earth", - ); - expect(earthBead).toBeDefined(); - expect(earthBead?.position).toBe(2); // Position 2 (third earth bead) needs to be removed - }); + const earthBead = instruction.highlightBeads.find((b) => b.beadType === 'earth') + expect(earthBead).toBeDefined() + expect(earthBead?.position).toBe(2) // Position 2 (third earth bead) needs to be removed + }) - it("should generate correct instructions for ten complement", () => { - const instruction = generateAbacusInstructions(7, 11); // 7 + 4 + it('should generate correct instructions for ten complement', () => { + const instruction = generateAbacusInstructions(7, 11) // 7 + 4 - expect(instruction.highlightBeads).toHaveLength(3); // tens earth + ones heaven + 1 ones earth - expect(instruction.expectedAction).toBe("multi-step"); - expect(instruction.actionDescription).toContain("7 + 4 = 7 + (5 - 1)"); + expect(instruction.highlightBeads).toHaveLength(3) // tens earth + ones heaven + 1 ones earth + expect(instruction.expectedAction).toBe('multi-step') + expect(instruction.actionDescription).toContain('7 + 4 = 7 + (5 - 1)') // Should highlight tens place earth bead (to add 1 in tens place) const tensEarth = instruction.highlightBeads.find( - (b) => b.placeValue === 1 && b.beadType === "earth", - ); - expect(tensEarth).toBeDefined(); + (b) => b.placeValue === 1 && b.beadType === 'earth' + ) + expect(tensEarth).toBeDefined() // Should highlight ones place beads to change - const onesBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 0, - ); - expect(onesBeads).toHaveLength(2); // ones heaven + 1 ones earth to remove - }); + const onesBeads = instruction.highlightBeads.filter((b) => b.placeValue === 0) + expect(onesBeads).toHaveLength(2) // ones heaven + 1 ones earth to remove + }) - it("should generate correct instructions for direct multi-bead addition", () => { - const instruction = generateAbacusInstructions(6, 8); // 6 + 2 + it('should generate correct instructions for direct multi-bead addition', () => { + const instruction = generateAbacusInstructions(6, 8) // 6 + 2 - expect(instruction.highlightBeads).toHaveLength(2); - expect(instruction.expectedAction).toBe("multi-step"); + expect(instruction.highlightBeads).toHaveLength(2) + expect(instruction.expectedAction).toBe('multi-step') // Should highlight earth beads at positions 1 and 2 instruction.highlightBeads.forEach((bead) => { - expect(bead.beadType).toBe("earth"); - expect(bead.placeValue).toBe(0); - expect([1, 2]).toContain(bead.position); - }); - }); + expect(bead.beadType).toBe('earth') + expect(bead.placeValue).toBe(0) + expect([1, 2]).toContain(bead.position) + }) + }) - it("should generate correct instructions for multi-place operations", () => { - const instruction = generateAbacusInstructions(15, 23); // 15 + 8 + it('should generate correct instructions for multi-place operations', () => { + const instruction = generateAbacusInstructions(15, 23) // 15 + 8 // Should involve both ones and tens places - const onesBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 0, - ); - const tensBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 1, - ); + const onesBeads = instruction.highlightBeads.filter((b) => b.placeValue === 0) + const tensBeads = instruction.highlightBeads.filter((b) => b.placeValue === 1) - expect(onesBeads.length + tensBeads.length).toBe( - instruction.highlightBeads.length, - ); - expect(instruction.expectedAction).toBe("multi-step"); - }); - }); + expect(onesBeads.length + tensBeads.length).toBe(instruction.highlightBeads.length) + expect(instruction.expectedAction).toBe('multi-step') + }) + }) - describe("validateInstruction", () => { - it("should validate correct instructions", () => { - const instruction = generateAbacusInstructions(0, 1); - const validation = validateInstruction(instruction, 0, 1); + describe('validateInstruction', () => { + it('should validate correct instructions', () => { + const instruction = generateAbacusInstructions(0, 1) + const validation = validateInstruction(instruction, 0, 1) - expect(validation.isValid).toBe(true); - expect(validation.issues).toHaveLength(0); - }); + expect(validation.isValid).toBe(true) + expect(validation.issues).toHaveLength(0) + }) - it("should catch invalid place values", () => { - const instruction = generateAbacusInstructions(0, 1); + it('should catch invalid place values', () => { + const instruction = generateAbacusInstructions(0, 1) // Manually corrupt the instruction - instruction.highlightBeads[0].placeValue = 5 as any; + instruction.highlightBeads[0].placeValue = 5 as any - const validation = validateInstruction(instruction, 0, 1); - expect(validation.isValid).toBe(false); - expect(validation.issues).toContain("Invalid place value: 5"); - }); + const validation = validateInstruction(instruction, 0, 1) + expect(validation.isValid).toBe(false) + expect(validation.issues).toContain('Invalid place value: 5') + }) - it("should catch missing multi-step instructions", () => { - const instruction = generateAbacusInstructions(3, 7); + it('should catch missing multi-step instructions', () => { + const instruction = generateAbacusInstructions(3, 7) // Manually corrupt the instruction - instruction.multiStepInstructions = undefined; + instruction.multiStepInstructions = undefined - const validation = validateInstruction(instruction, 3, 7); - expect(validation.isValid).toBe(false); - expect(validation.issues).toContain( - "Multi-step action without step instructions", - ); - }); - }); + const validation = validateInstruction(instruction, 3, 7) + expect(validation.isValid).toBe(false) + expect(validation.issues).toContain('Multi-step action without step instructions') + }) + }) - describe("Real-world tutorial examples", () => { + describe('Real-world tutorial examples', () => { const examples = [ - { start: 0, target: 1, name: "Basic: 0 + 1" }, - { start: 1, target: 2, name: "Basic: 1 + 1" }, - { start: 2, target: 3, name: "Basic: 2 + 1" }, - { start: 3, target: 4, name: "Basic: 3 + 1" }, - { start: 0, target: 5, name: "Heaven: 0 + 5" }, - { start: 5, target: 6, name: "Heaven + Earth: 5 + 1" }, - { start: 3, target: 7, name: "Five complement: 3 + 4" }, - { start: 2, target: 5, name: "Five complement: 2 + 3" }, - { start: 6, target: 8, name: "Direct: 6 + 2" }, - { start: 7, target: 11, name: "Ten complement: 7 + 4" }, - ]; + { start: 0, target: 1, name: 'Basic: 0 + 1' }, + { start: 1, target: 2, name: 'Basic: 1 + 1' }, + { start: 2, target: 3, name: 'Basic: 2 + 1' }, + { start: 3, target: 4, name: 'Basic: 3 + 1' }, + { start: 0, target: 5, name: 'Heaven: 0 + 5' }, + { start: 5, target: 6, name: 'Heaven + Earth: 5 + 1' }, + { start: 3, target: 7, name: 'Five complement: 3 + 4' }, + { start: 2, target: 5, name: 'Five complement: 2 + 3' }, + { start: 6, target: 8, name: 'Direct: 6 + 2' }, + { start: 7, target: 11, name: 'Ten complement: 7 + 4' }, + ] examples.forEach(({ start, target, name }) => { it(`should generate valid instructions for ${name}`, () => { - const instruction = generateAbacusInstructions(start, target); - const validation = validateInstruction(instruction, start, target); + const instruction = generateAbacusInstructions(start, target) + const validation = validateInstruction(instruction, start, target) - expect(validation.isValid).toBe(true); - expect(instruction.highlightBeads.length).toBeGreaterThan(0); - expect(instruction.actionDescription).toBeTruthy(); - expect(instruction.tooltip.content).toBeTruthy(); - expect(instruction.errorMessages.wrongBead).toBeTruthy(); - }); - }); - }); + expect(validation.isValid).toBe(true) + expect(instruction.highlightBeads.length).toBeGreaterThan(0) + expect(instruction.actionDescription).toBeTruthy() + expect(instruction.tooltip.content).toBeTruthy() + expect(instruction.errorMessages.wrongBead).toBeTruthy() + }) + }) + }) - describe("Edge cases and boundary conditions", () => { - it("should handle subtraction operations", () => { - const instruction = generateAbacusInstructions(5, 3); // 5 - 2 - expect(instruction.expectedAction).toBe("multi-step"); - expect(instruction.actionDescription).toContain("subtract"); + describe('Edge cases and boundary conditions', () => { + it('should handle subtraction operations', () => { + const instruction = generateAbacusInstructions(5, 3) // 5 - 2 + expect(instruction.expectedAction).toBe('multi-step') + expect(instruction.actionDescription).toContain('subtract') - const validation = validateInstruction(instruction, 5, 3); - expect(validation.isValid).toBe(true); - }); + const validation = validateInstruction(instruction, 5, 3) + expect(validation.isValid).toBe(true) + }) - it("should handle zero difference (same start and target)", () => { - const instruction = generateAbacusInstructions(7, 7); // 7 - 0 - expect(instruction.highlightBeads).toHaveLength(0); - expect(instruction.expectedAction).toBe("add"); - expect(instruction.actionDescription).toContain("No change needed"); + it('should handle zero difference (same start and target)', () => { + const instruction = generateAbacusInstructions(7, 7) // 7 - 0 + expect(instruction.highlightBeads).toHaveLength(0) + expect(instruction.expectedAction).toBe('add') + expect(instruction.actionDescription).toContain('No change needed') - const validation = validateInstruction(instruction, 7, 7); - expect(validation.isValid).toBe(true); - }); + const validation = validateInstruction(instruction, 7, 7) + expect(validation.isValid).toBe(true) + }) - it("should handle maximum single-digit values", () => { - const instruction = generateAbacusInstructions(0, 9); // 0 + 9 - expect(instruction.highlightBeads.length).toBeGreaterThan(0); + it('should handle maximum single-digit values', () => { + const instruction = generateAbacusInstructions(0, 9) // 0 + 9 + expect(instruction.highlightBeads.length).toBeGreaterThan(0) - const validation = validateInstruction(instruction, 0, 9); - expect(validation.isValid).toBe(true); - }); + const validation = validateInstruction(instruction, 0, 9) + expect(validation.isValid).toBe(true) + }) - it("should handle maximum two-digit values", () => { - const instruction = generateAbacusInstructions(0, 99); // 0 + 99 - expect(instruction.highlightBeads.length).toBeGreaterThan(0); + it('should handle maximum two-digit values', () => { + const instruction = generateAbacusInstructions(0, 99) // 0 + 99 + expect(instruction.highlightBeads.length).toBeGreaterThan(0) // Should involve both ones and tens places - const onesBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 0, - ); - const tensBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 1, - ); - expect(onesBeads.length + tensBeads.length).toBe( - instruction.highlightBeads.length, - ); + const onesBeads = instruction.highlightBeads.filter((b) => b.placeValue === 0) + const tensBeads = instruction.highlightBeads.filter((b) => b.placeValue === 1) + expect(onesBeads.length + tensBeads.length).toBe(instruction.highlightBeads.length) - const validation = validateInstruction(instruction, 0, 99); - expect(validation.isValid).toBe(true); - }); + const validation = validateInstruction(instruction, 0, 99) + expect(validation.isValid).toBe(true) + }) - it("should handle complex complement operations across place values", () => { - const instruction = generateAbacusInstructions(89, 95); // 89 + 6 - expect(instruction.expectedAction).toBe("multi-step"); + it('should handle complex complement operations across place values', () => { + const instruction = generateAbacusInstructions(89, 95) // 89 + 6 + expect(instruction.expectedAction).toBe('multi-step') - const validation = validateInstruction(instruction, 89, 95); - expect(validation.isValid).toBe(true); - }); + const validation = validateInstruction(instruction, 89, 95) + expect(validation.isValid).toBe(true) + }) - it("should handle large subtraction with borrowing", () => { - const instruction = generateAbacusInstructions(50, 7); // 50 - 43 - expect(instruction.expectedAction).toBe("multi-step"); - expect(instruction.actionDescription).toContain("subtract"); + it('should handle large subtraction with borrowing', () => { + const instruction = generateAbacusInstructions(50, 7) // 50 - 43 + expect(instruction.expectedAction).toBe('multi-step') + expect(instruction.actionDescription).toContain('subtract') - const validation = validateInstruction(instruction, 50, 7); - expect(validation.isValid).toBe(true); - }); + const validation = validateInstruction(instruction, 50, 7) + expect(validation.isValid).toBe(true) + }) - it("should handle all possible five complement scenarios", () => { + it('should handle all possible five complement scenarios', () => { const fiveComplementCases = [ { start: 1, target: 4 }, // 1 + 3 = 4 (no complement needed) { start: 1, target: 5 }, // 1 + 4 = 5 (complement needed) { start: 2, target: 5 }, // 2 + 3 = 5 (complement needed) { start: 3, target: 7 }, // 3 + 4 = 7 (complement needed) { start: 4, target: 8 }, // 4 + 4 = 8 (complement needed) - ]; + ] fiveComplementCases.forEach(({ start, target }) => { - const instruction = generateAbacusInstructions(start, target); - const validation = validateInstruction(instruction, start, target); - expect(validation.isValid).toBe(true); - }); - }); + const instruction = generateAbacusInstructions(start, target) + const validation = validateInstruction(instruction, start, target) + expect(validation.isValid).toBe(true) + }) + }) - it("should handle all possible ten complement scenarios", () => { + it('should handle all possible ten complement scenarios', () => { const tenComplementCases = [ { start: 6, target: 10 }, // 6 + 4 = 10 { start: 7, target: 11 }, // 7 + 4 = 11 @@ -317,520 +297,510 @@ describe("Automatic Abacus Instruction Generator", () => { { start: 9, target: 13 }, // 9 + 4 = 13 { start: 19, target: 23 }, // 19 + 4 = 23 (tens place) { start: 29, target: 33 }, // 29 + 4 = 33 (tens place) - ]; + ] tenComplementCases.forEach(({ start, target }) => { - const instruction = generateAbacusInstructions(start, target); - const validation = validateInstruction(instruction, start, target); - expect(validation.isValid).toBe(true); - }); - }); - }); + const instruction = generateAbacusInstructions(start, target) + const validation = validateInstruction(instruction, start, target) + expect(validation.isValid).toBe(true) + }) + }) + }) - describe("Stress testing with random operations", () => { - it("should handle 100 random addition operations", () => { + describe('Stress testing with random operations', () => { + it('should handle 100 random addition operations', () => { const failedCases: Array<{ - start: number; - target: number; - error: string; - }> = []; + start: number + target: number + error: string + }> = [] for (let i = 0; i < 100; i++) { - const start = Math.floor(Math.random() * 90); // 0-89 - const additionAmount = Math.floor(Math.random() * 10) + 1; // 1-10 - const target = start + additionAmount; + const start = Math.floor(Math.random() * 90) // 0-89 + const additionAmount = Math.floor(Math.random() * 10) + 1 // 1-10 + const target = start + additionAmount // Skip if target exceeds our max value - if (target > 99) continue; + if (target > 99) continue try { - const instruction = generateAbacusInstructions(start, target); - const validation = validateInstruction(instruction, start, target); + const instruction = generateAbacusInstructions(start, target) + const validation = validateInstruction(instruction, start, target) if (!validation.isValid) { failedCases.push({ start, target, - error: `Validation failed: ${validation.issues.join(", ")}`, - }); + error: `Validation failed: ${validation.issues.join(', ')}`, + }) } } catch (error) { failedCases.push({ start, target, error: `Exception: ${error instanceof Error ? error.message : String(error)}`, - }); + }) } } if (failedCases.length > 0) { - console.error("Failed stress test cases:", failedCases); + console.error('Failed stress test cases:', failedCases) } - expect(failedCases).toHaveLength(0); - }); + expect(failedCases).toHaveLength(0) + }) - it("should handle 50 random subtraction operations", () => { + it('should handle 50 random subtraction operations', () => { const failedCases: Array<{ - start: number; - target: number; - error: string; - }> = []; + start: number + target: number + error: string + }> = [] for (let i = 0; i < 50; i++) { - const start = Math.floor(Math.random() * 89) + 10; // 10-98 - const subtractionAmount = - Math.floor(Math.random() * Math.min(start, 10)) + 1; // 1 to min(start, 10) - const target = start - subtractionAmount; + const start = Math.floor(Math.random() * 89) + 10 // 10-98 + const subtractionAmount = Math.floor(Math.random() * Math.min(start, 10)) + 1 // 1 to min(start, 10) + const target = start - subtractionAmount try { - const instruction = generateAbacusInstructions(start, target); - const validation = validateInstruction(instruction, start, target); + const instruction = generateAbacusInstructions(start, target) + const validation = validateInstruction(instruction, start, target) if (!validation.isValid) { failedCases.push({ start, target, - error: `Validation failed: ${validation.issues.join(", ")}`, - }); + error: `Validation failed: ${validation.issues.join(', ')}`, + }) } } catch (error) { failedCases.push({ start, target, error: `Exception: ${error instanceof Error ? error.message : String(error)}`, - }); + }) } } if (failedCases.length > 0) { - console.error("Failed subtraction stress test cases:", failedCases); + console.error('Failed subtraction stress test cases:', failedCases) } - expect(failedCases).toHaveLength(0); - }); + expect(failedCases).toHaveLength(0) + }) - it("should handle all systematic single-digit operations", () => { + it('should handle all systematic single-digit operations', () => { const failedCases: Array<{ - start: number; - target: number; - error: string; - }> = []; + start: number + target: number + error: string + }> = [] // Test every possible single-digit to single-digit operation for (let start = 0; start <= 9; start++) { for (let target = 0; target <= 9; target++) { - if (start === target) continue; // Skip no-change operations + if (start === target) continue // Skip no-change operations try { - const instruction = generateAbacusInstructions(start, target); - const validation = validateInstruction(instruction, start, target); + const instruction = generateAbacusInstructions(start, target) + const validation = validateInstruction(instruction, start, target) if (!validation.isValid) { failedCases.push({ start, target, - error: `Validation failed: ${validation.issues.join(", ")}`, - }); + error: `Validation failed: ${validation.issues.join(', ')}`, + }) } } catch (error) { failedCases.push({ start, target, error: `Exception: ${error instanceof Error ? error.message : String(error)}`, - }); + }) } } } if (failedCases.length > 0) { - console.error( - "Failed systematic single-digit test cases:", - failedCases, - ); + console.error('Failed systematic single-digit test cases:', failedCases) } - expect(failedCases).toHaveLength(0); - }); - }); + expect(failedCases).toHaveLength(0) + }) + }) - describe("Performance benchmarks", () => { - it("should generate instructions quickly for simple operations", () => { - const start = performance.now(); + describe('Performance benchmarks', () => { + it('should generate instructions quickly for simple operations', () => { + const start = performance.now() for (let i = 0; i < 1000; i++) { - generateAbacusInstructions(3, 7); // Five complement operation + generateAbacusInstructions(3, 7) // Five complement operation } - const end = performance.now(); - const timePerOperation = (end - start) / 1000; + const end = performance.now() + const timePerOperation = (end - start) / 1000 // Should take less than 1ms per operation on average - expect(timePerOperation).toBeLessThan(1); - }); + expect(timePerOperation).toBeLessThan(1) + }) - it("should generate instructions quickly for complex operations", () => { - const start = performance.now(); + it('should generate instructions quickly for complex operations', () => { + const start = performance.now() for (let i = 0; i < 1000; i++) { - generateAbacusInstructions(89, 95); // Complex multi-place operation + generateAbacusInstructions(89, 95) // Complex multi-place operation } - const end = performance.now(); - const timePerOperation = (end - start) / 1000; + const end = performance.now() + const timePerOperation = (end - start) / 1000 // Should take less than 2ms per operation on average - expect(timePerOperation).toBeLessThan(2); - }); - }); + expect(timePerOperation).toBeLessThan(2) + }) + }) - describe("Input validation and error handling", () => { - it("should handle negative start values gracefully", () => { - expect(() => generateAbacusInstructions(-1, 5)).not.toThrow(); - }); + describe('Input validation and error handling', () => { + it('should handle negative start values gracefully', () => { + expect(() => generateAbacusInstructions(-1, 5)).not.toThrow() + }) - it("should handle negative target values gracefully", () => { - expect(() => generateAbacusInstructions(5, -1)).not.toThrow(); - }); + it('should handle negative target values gracefully', () => { + expect(() => generateAbacusInstructions(5, -1)).not.toThrow() + }) - it("should handle values exceeding normal abacus range", () => { - expect(() => generateAbacusInstructions(0, 12345)).not.toThrow(); - }); + it('should handle values exceeding normal abacus range', () => { + expect(() => generateAbacusInstructions(0, 12345)).not.toThrow() + }) - it("should handle very large differences", () => { - expect(() => generateAbacusInstructions(1, 999)).not.toThrow(); - }); - }); + it('should handle very large differences', () => { + expect(() => generateAbacusInstructions(1, 999)).not.toThrow() + }) + }) - describe("Bug fixes", () => { - it("should show correct operation in hint message when old operation is passed", () => { + describe('Bug fixes', () => { + it('should show correct operation in hint message when old operation is passed', () => { // Bug: when start=4, target=12, and old operation="0 + 1" is passed, // the hint message shows "0 + 1 = 12" instead of "4 + 8 = 12" - const instruction = generateAbacusInstructions(4, 12, "0 + 1"); + const instruction = generateAbacusInstructions(4, 12, '0 + 1') // The hint message should show the correct operation based on start/target values // not the passed operation string - expect(instruction.errorMessages.hint).toContain("4 + 8 = 12"); - expect(instruction.errorMessages.hint).not.toContain("0 + 1 = 12"); - }); - }); + expect(instruction.errorMessages.hint).toContain('4 + 8 = 12') + expect(instruction.errorMessages.hint).not.toContain('0 + 1 = 12') + }) + }) - describe("Traditional abacus complement descriptions", () => { - it("should use proper mathematical breakdown for five complement", () => { + describe('Traditional abacus complement descriptions', () => { + it('should use proper mathematical breakdown for five complement', () => { // Test five complement: 3 + 4 = 7 - const instruction = generateAbacusInstructions(3, 7); - expect(instruction.actionDescription).toContain("3 + 4 = 3 + (5 - 1)"); - }); + const instruction = generateAbacusInstructions(3, 7) + expect(instruction.actionDescription).toContain('3 + 4 = 3 + (5 - 1)') + }) - it("should use proper mathematical breakdown for ten complement", () => { + it('should use proper mathematical breakdown for ten complement', () => { // Test ten complement: 7 + 4 = 11 - const instruction = generateAbacusInstructions(7, 11); - expect(instruction.actionDescription).toContain("7 + 4 = 7 + (5 - 1)"); - }); + const instruction = generateAbacusInstructions(7, 11) + expect(instruction.actionDescription).toContain('7 + 4 = 7 + (5 - 1)') + }) - it("should handle large ten complement correctly", () => { + it('should handle large ten complement correctly', () => { // Test large ten complement: 3 + 98 = 101 // Now uses recursive complement explanation - const instruction = generateAbacusInstructions(3, 101); + const instruction = generateAbacusInstructions(3, 101) - console.log("Multi-place operation (3 + 98 = 101):"); - console.log(" Action:", instruction.actionDescription); - console.log(" Highlighted beads:", instruction.highlightBeads.length); + console.log('Multi-place operation (3 + 98 = 101):') + console.log(' Action:', instruction.actionDescription) + console.log(' Highlighted beads:', instruction.highlightBeads.length) instruction.highlightBeads.forEach((bead, i) => { console.log( - ` ${i + 1}. Place ${bead.placeValue} ${bead.beadType} ${bead.position !== undefined ? `position ${bead.position}` : ""}`, - ); - }); + ` ${i + 1}. Place ${bead.placeValue} ${bead.beadType} ${bead.position !== undefined ? `position ${bead.position}` : ''}` + ) + }) if (instruction.multiStepInstructions) { - console.log(" Multi-step instructions:"); + console.log(' Multi-step instructions:') instruction.multiStepInstructions.forEach((step, i) => { - console.log(` ${i + 1}. ${step}`); - }); + console.log(` ${i + 1}. ${step}`) + }) } - console.log(" Hint:", instruction.errorMessages.hint); + console.log(' Hint:', instruction.errorMessages.hint) // Should show the compact math format for complement - expect(instruction.actionDescription).toContain("3 + 98 = 3 + (100 - 2)"); - expect(instruction.errorMessages.hint).toContain( - "3 + 98 = 101, using if 98 = 100 - 2", - ); - }); + expect(instruction.actionDescription).toContain('3 + 98 = 3 + (100 - 2)') + expect(instruction.errorMessages.hint).toContain('3 + 98 = 101, using if 98 = 100 - 2') + }) - it("should provide proper complement breakdown with compact math and simple movements", () => { + it('should provide proper complement breakdown with compact math and simple movements', () => { // Test case: 3 + 98 = 101 // Correct breakdown: 3 + 98 = 3 + (100 - 2) // This decomposes into simple movements: add 100, subtract 2 - const instruction = generateAbacusInstructions(3, 101); + const instruction = generateAbacusInstructions(3, 101) - console.log("Proper complement breakdown (3 + 98 = 101):"); - console.log(" Action:", instruction.actionDescription); - console.log(" Multi-step instructions:"); + console.log('Proper complement breakdown (3 + 98 = 101):') + console.log(' Action:', instruction.actionDescription) + console.log(' Multi-step instructions:') instruction.multiStepInstructions?.forEach((step, i) => { - console.log(` ${i + 1}. ${step}`); - }); + console.log(` ${i + 1}. ${step}`) + }) // Should provide compact math sentence: 3 + 98 = 3 + (100 - 2) - expect(instruction.actionDescription).toContain("3 + 98 = 3 + (100 - 2)"); + expect(instruction.actionDescription).toContain('3 + 98 = 3 + (100 - 2)') // Multi-step instructions should explain the simple movements - expect(instruction.multiStepInstructions).toBeDefined(); + expect(instruction.multiStepInstructions).toBeDefined() expect( instruction.multiStepInstructions!.some( (step) => - step.includes("add 100") || - step.includes("Add 1 to hundreds") || - step.includes("earth bead 1 in the hundreds column to add"), - ), - ).toBe(true); + step.includes('add 100') || + step.includes('Add 1 to hundreds') || + step.includes('earth bead 1 in the hundreds column to add') + ) + ).toBe(true) expect( instruction.multiStepInstructions!.some( - (step) => - step.includes("subtract 2") || step.includes("Remove 2 from ones"), - ), - ).toBe(true); - }); + (step) => step.includes('subtract 2') || step.includes('Remove 2 from ones') + ) + ).toBe(true) + }) - it("should handle five complement with proper breakdown", () => { + it('should handle five complement with proper breakdown', () => { // Test case: 3 + 4 = 7 // Breakdown: 3 + 4 = 3 + (5 - 1) - const instruction = generateAbacusInstructions(3, 7); + const instruction = generateAbacusInstructions(3, 7) - console.log("Five complement breakdown (3 + 4 = 7):"); - console.log(" Action:", instruction.actionDescription); + console.log('Five complement breakdown (3 + 4 = 7):') + console.log(' Action:', instruction.actionDescription) // Should provide compact math sentence - expect(instruction.actionDescription).toContain("3 + 4 = 3 + (5 - 1)"); - }); - }); + expect(instruction.actionDescription).toContain('3 + 4 = 3 + (5 - 1)') + }) + }) - describe("Comprehensive complement breakdown coverage", () => { - describe("Known five complement situations that require complements", () => { + describe('Comprehensive complement breakdown coverage', () => { + describe('Known five complement situations that require complements', () => { // Test cases where we know five complement is actually needed const actualFiveComplementCases = [ { start: 3, target: 7, - description: "3 + 4 where 4 requires five complement", + description: '3 + 4 where 4 requires five complement', }, { start: 2, target: 7, - description: "2 + 5 where the 1 part of 5 goes beyond capacity", + description: '2 + 5 where the 1 part of 5 goes beyond capacity', }, { start: 1, target: 7, - description: "1 + 6 where 6 requires five complement", + description: '1 + 6 where 6 requires five complement', }, { start: 0, target: 6, - description: "0 + 6 where 6 requires five complement", + description: '0 + 6 where 6 requires five complement', }, { start: 4, target: 8, - description: "4 + 4 where 4 requires five complement", + description: '4 + 4 where 4 requires five complement', }, { start: 13, target: 17, - description: "13 + 4 where 4 requires five complement in ones place", + description: '13 + 4 where 4 requires five complement in ones place', }, { start: 23, target: 27, - description: "23 + 4 where 4 requires five complement in ones place", + description: '23 + 4 where 4 requires five complement in ones place', }, - ]; + ] actualFiveComplementCases.forEach(({ start, target, description }) => { it(`should handle five complement: ${description}`, () => { - const instruction = generateAbacusInstructions(start, target); + const instruction = generateAbacusInstructions(start, target) // Check that it generates the proper complement breakdown - if (instruction.actionDescription.includes("(5 - ")) { - expect(instruction.expectedAction).toBe("multi-step"); - expect(instruction.actionDescription).toContain("(5 - "); - expect(instruction.highlightBeads.length).toBeGreaterThan(1); + if (instruction.actionDescription.includes('(5 - ')) { + expect(instruction.expectedAction).toBe('multi-step') + expect(instruction.actionDescription).toContain('(5 - ') + expect(instruction.highlightBeads.length).toBeGreaterThan(1) } else { // Some operations might not need complement - just verify they work - expect(instruction).toBeDefined(); - expect(instruction.highlightBeads.length).toBeGreaterThan(0); + expect(instruction).toBeDefined() + expect(instruction.highlightBeads.length).toBeGreaterThan(0) } - }); - }); - }); + }) + }) + }) - describe("Known ten complement situations that require complements", () => { + describe('Known ten complement situations that require complements', () => { // Test cases where we know ten complement is actually needed const actualTenComplementCases = [ { start: 7, target: 11, - description: - "7 + 4 where 4 requires five complement which triggers ten complement", + description: '7 + 4 where 4 requires five complement which triggers ten complement', }, { start: 6, target: 13, - description: "6 + 7 where 7 requires complement", + description: '6 + 7 where 7 requires complement', }, { start: 8, target: 15, - description: "8 + 7 where 7 requires complement", + description: '8 + 7 where 7 requires complement', }, { start: 9, target: 16, - description: "9 + 7 where 7 requires complement", + description: '9 + 7 where 7 requires complement', }, { start: 17, target: 24, - description: "17 + 7 where 7 requires complement in ones place", + description: '17 + 7 where 7 requires complement in ones place', }, { start: 25, target: 32, - description: "25 + 7 where 7 requires complement in ones place", + description: '25 + 7 where 7 requires complement in ones place', }, - ]; + ] actualTenComplementCases.forEach(({ start, target, description }) => { it(`should handle ten complement: ${description}`, () => { - const instruction = generateAbacusInstructions(start, target); + const instruction = generateAbacusInstructions(start, target) // Check that it generates the proper complement breakdown if (instruction.actionDescription.match(/\((?:5|10) - /)) { - expect(instruction.expectedAction).toBe("multi-step"); - expect(instruction.actionDescription).toMatch(/\((?:5|10) - /); - expect(instruction.highlightBeads.length).toBeGreaterThan(1); + expect(instruction.expectedAction).toBe('multi-step') + expect(instruction.actionDescription).toMatch(/\((?:5|10) - /) + expect(instruction.highlightBeads.length).toBeGreaterThan(1) } else { // Some operations might not need complement - just verify they work - expect(instruction).toBeDefined(); - expect(instruction.highlightBeads.length).toBeGreaterThan(0); + expect(instruction).toBeDefined() + expect(instruction.highlightBeads.length).toBeGreaterThan(0) } - }); - }); - }); + }) + }) + }) - describe("Known hundred complement situations", () => { + describe('Known hundred complement situations', () => { // Test cases where we know hundred complement is actually needed const actualHundredComplementCases = [ { start: 3, target: 101, - description: "3 + 98 where 98 requires hundred complement", + description: '3 + 98 where 98 requires hundred complement', }, { start: 5, target: 103, - description: "5 + 98 where 98 requires hundred complement", + description: '5 + 98 where 98 requires hundred complement', }, { start: 10, target: 108, - description: "10 + 98 where 98 requires hundred complement", + description: '10 + 98 where 98 requires hundred complement', }, { start: 15, target: 113, - description: "15 + 98 where 98 requires hundred complement", + description: '15 + 98 where 98 requires hundred complement', }, { start: 20, target: 118, - description: "20 + 98 where 98 requires hundred complement", + description: '20 + 98 where 98 requires hundred complement', }, - ]; + ] actualHundredComplementCases.forEach(({ start, target, description }) => { it(`should handle hundred complement: ${description}`, () => { - const instruction = generateAbacusInstructions(start, target); + const instruction = generateAbacusInstructions(start, target) // Check that it uses complement methodology - expect(instruction.expectedAction).toBe("multi-step"); - expect(instruction.actionDescription).toContain("(100 - "); - expect(instruction.highlightBeads.length).toBeGreaterThan(1); - }); - }); - }); + expect(instruction.expectedAction).toBe('multi-step') + expect(instruction.actionDescription).toContain('(100 - ') + expect(instruction.highlightBeads.length).toBeGreaterThan(1) + }) + }) + }) - describe("Direct operations that should NOT use complements", () => { + describe('Direct operations that should NOT use complements', () => { const directOperationCases = [ - { start: 0, target: 1, description: "0 + 1 direct earth bead" }, - { start: 0, target: 4, description: "0 + 4 direct earth beads" }, - { start: 0, target: 5, description: "0 + 5 direct heaven bead" }, - { start: 1, target: 2, description: "1 + 1 direct earth bead" }, - { start: 5, target: 9, description: "5 + 4 direct earth beads" }, - { start: 1, target: 3, description: "1 + 2 direct earth beads" }, - ]; + { start: 0, target: 1, description: '0 + 1 direct earth bead' }, + { start: 0, target: 4, description: '0 + 4 direct earth beads' }, + { start: 0, target: 5, description: '0 + 5 direct heaven bead' }, + { start: 1, target: 2, description: '1 + 1 direct earth bead' }, + { start: 5, target: 9, description: '5 + 4 direct earth beads' }, + { start: 1, target: 3, description: '1 + 2 direct earth beads' }, + ] directOperationCases.forEach(({ start, target, description }) => { it(`should handle direct operation: ${description}`, () => { - const instruction = generateAbacusInstructions(start, target); + const instruction = generateAbacusInstructions(start, target) // Accept any action type that doesn't use complement notation - expect(instruction.actionDescription).not.toContain("("); - expect(instruction.actionDescription).not.toContain(" - "); - expect(instruction.highlightBeads.length).toBeGreaterThan(0); - }); - }); - }); + expect(instruction.actionDescription).not.toContain('(') + expect(instruction.actionDescription).not.toContain(' - ') + expect(instruction.highlightBeads.length).toBeGreaterThan(0) + }) + }) + }) - describe("Edge cases and boundary conditions", () => { - it("should handle maximum single place operations", () => { - const instruction = generateAbacusInstructions(0, 9); - expect(instruction).toBeDefined(); - expect(instruction.highlightBeads.length).toBeGreaterThan(0); - }); + describe('Edge cases and boundary conditions', () => { + it('should handle maximum single place operations', () => { + const instruction = generateAbacusInstructions(0, 9) + expect(instruction).toBeDefined() + expect(instruction.highlightBeads.length).toBeGreaterThan(0) + }) - it("should handle operations crossing place boundaries", () => { - const instruction = generateAbacusInstructions(9, 10); - expect(instruction).toBeDefined(); - expect(instruction.highlightBeads.length).toBeGreaterThan(0); - }); + it('should handle operations crossing place boundaries', () => { + const instruction = generateAbacusInstructions(9, 10) + expect(instruction).toBeDefined() + expect(instruction.highlightBeads.length).toBeGreaterThan(0) + }) - it("should handle large complement operations", () => { - const instruction = generateAbacusInstructions(1, 199); - expect(instruction).toBeDefined(); - expect(instruction.highlightBeads.length).toBeGreaterThan(0); - }); - }); + it('should handle large complement operations', () => { + const instruction = generateAbacusInstructions(1, 199) + expect(instruction).toBeDefined() + expect(instruction.highlightBeads.length).toBeGreaterThan(0) + }) + }) - describe("Step-by-step instruction quality", () => { - it("should provide clear step explanations for five complement", () => { - const instruction = generateAbacusInstructions(3, 7); - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1); + describe('Step-by-step instruction quality', () => { + it('should provide clear step explanations for five complement', () => { + const instruction = generateAbacusInstructions(3, 7) + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1) expect( instruction.multiStepInstructions!.some( - (step) => step.includes("Add") || step.includes("Remove"), - ), - ).toBe(true); - }); + (step) => step.includes('Add') || step.includes('Remove') + ) + ).toBe(true) + }) - it("should provide clear step explanations for hundred complement", () => { - const instruction = generateAbacusInstructions(3, 101); - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1); + it('should provide clear step explanations for hundred complement', () => { + const instruction = generateAbacusInstructions(3, 101) + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1) expect( instruction.multiStepInstructions!.some( (step) => - (step.includes("Add") && step.includes("hundreds")) || - (step.includes("Click") && - step.includes("hundreds") && - step.includes("add")), - ), - ).toBe(true); + (step.includes('Add') && step.includes('hundreds')) || + (step.includes('Click') && step.includes('hundreds') && step.includes('add')) + ) + ).toBe(true) expect( instruction.multiStepInstructions!.some( - (step) => step.includes("Remove") && step.includes("ones"), - ), - ).toBe(true); - }); - }); + (step) => step.includes('Remove') && step.includes('ones') + ) + ).toBe(true) + }) + }) - describe("Validation and error handling", () => { - it("should validate all generated instructions correctly", () => { + describe('Validation and error handling', () => { + it('should validate all generated instructions correctly', () => { const testCases = [ { start: 3, target: 7 }, // Five complement { start: 7, target: 11 }, // Ten complement (via five) @@ -838,433 +808,402 @@ describe("Automatic Abacus Instruction Generator", () => { { start: 0, target: 1 }, // Direct { start: 0, target: 10 }, // Direct tens { start: 0, target: 5 }, // Direct heaven - ]; + ] testCases.forEach(({ start, target }) => { - const instruction = generateAbacusInstructions(start, target); - const validation = validateInstruction(instruction, start, target); - expect(validation.isValid).toBe(true); - expect(validation.issues).toHaveLength(0); - }); - }); + const instruction = generateAbacusInstructions(start, target) + const validation = validateInstruction(instruction, start, target) + expect(validation.isValid).toBe(true) + expect(validation.issues).toHaveLength(0) + }) + }) - it("should handle edge case inputs gracefully", () => { + it('should handle edge case inputs gracefully', () => { // Test same start and target - const instruction1 = generateAbacusInstructions(5, 5); - expect(instruction1).toBeDefined(); + const instruction1 = generateAbacusInstructions(5, 5) + expect(instruction1).toBeDefined() // Test reverse operation (subtraction) - const instruction2 = generateAbacusInstructions(10, 5); - expect(instruction2).toBeDefined(); + const instruction2 = generateAbacusInstructions(10, 5) + expect(instruction2).toBeDefined() // Test very large numbers - const instruction3 = generateAbacusInstructions(0, 999); - expect(instruction3).toBeDefined(); - }); - }); + const instruction3 = generateAbacusInstructions(0, 999) + expect(instruction3).toBeDefined() + }) + }) - describe("Complement format consistency", () => { - it("should consistently use compact math format for complements", () => { + describe('Complement format consistency', () => { + it('should consistently use compact math format for complements', () => { const complementCases = [ { start: 3, target: 7 }, // 3 + 4 = 3 + (5 - 1) { start: 3, target: 101 }, // 3 + 98 = 3 + (100 - 2) { start: 7, target: 11 }, // 7 + 4 = 7 + (5 - 1) - ]; + ] complementCases.forEach(({ start, target }) => { - const instruction = generateAbacusInstructions(start, target); - if (instruction.expectedAction === "multi-step") { + const instruction = generateAbacusInstructions(start, target) + if (instruction.expectedAction === 'multi-step') { // Should show the breakdown format without redundant arithmetic - expect(instruction.actionDescription).toMatch( - /\d+ \+ \d+ = \d+ \+ \(\d+ - \d+\)/, - ); + expect(instruction.actionDescription).toMatch(/\d+ \+ \d+ = \d+ \+ \(\d+ - \d+\)/) // Should NOT show the final arithmetic chain - expect(instruction.actionDescription).not.toMatch( - /= \d+ - \d+ = \d+$/, - ); + expect(instruction.actionDescription).not.toMatch(/= \d+ - \d+ = \d+$/) } - }); - }); + }) + }) - it("should handle recursive complement breakdown for 99 + 1 = 100", () => { + it('should handle recursive complement breakdown for 99 + 1 = 100', () => { // This is a critical test case where simple complement explanation fails // 99 + 1 requires adding to a column that's already at capacity - const instruction = generateAbacusInstructions(99, 100); + const instruction = generateAbacusInstructions(99, 100) - console.log("Complex recursive breakdown (99 + 1 = 100):"); - console.log(" Action:", instruction.actionDescription); - console.log(" Hint:", instruction.errorMessages.hint); - console.log(" Multi-step instructions:"); + console.log('Complex recursive breakdown (99 + 1 = 100):') + console.log(' Action:', instruction.actionDescription) + console.log(' Hint:', instruction.errorMessages.hint) + console.log(' Multi-step instructions:') instruction.multiStepInstructions?.forEach((step, i) => - console.log(` ${i + 1}. ${step}`), - ); + console.log(` ${i + 1}. ${step}`) + ) // Both action description and hint should be consistent // And should break down into actual performable operations - expect(instruction.actionDescription).toContain("99 + 1"); - expect(instruction.errorMessages.hint).toContain("99 + 1 = 100"); + expect(instruction.actionDescription).toContain('99 + 1') + expect(instruction.errorMessages.hint).toContain('99 + 1 = 100') // Should break down the impossible "add 10 to 9" into "add 100, subtract 90" const hasRecursiveBreakdown = - instruction.actionDescription.includes("((100 - 90) - 9)") || - instruction.errorMessages.hint.includes("100 - 90 - 9"); + instruction.actionDescription.includes('((100 - 90) - 9)') || + instruction.errorMessages.hint.includes('100 - 90 - 9') - expect(hasRecursiveBreakdown).toBe(true); + expect(hasRecursiveBreakdown).toBe(true) // The hint and action description should not contradict each other - if (instruction.actionDescription.includes("(5 - 4)")) { - expect(instruction.errorMessages.hint).not.toContain("(10 - 9)"); + if (instruction.actionDescription.includes('(5 - 4)')) { + expect(instruction.errorMessages.hint).not.toContain('(10 - 9)') } - if (instruction.errorMessages.hint.includes("(10 - 9)")) { - expect(instruction.actionDescription).not.toContain("(5 - 4)"); + if (instruction.errorMessages.hint.includes('(10 - 9)')) { + expect(instruction.actionDescription).not.toContain('(5 - 4)') } - }); - }); - }); + }) + }) + }) - describe("Progressive Step-Bead Mapping", () => { - it("should generate correct step-bead mapping for 99 + 1 = 100 recursive case", () => { - const instruction = generateAbacusInstructions(99, 100); + describe('Progressive Step-Bead Mapping', () => { + it('should generate correct step-bead mapping for 99 + 1 = 100 recursive case', () => { + const instruction = generateAbacusInstructions(99, 100) - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBe(3); + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBe(3) - const stepBeads = instruction.stepBeadHighlights!; + const stepBeads = instruction.stepBeadHighlights! // Step 0: Add 1 to hundreds column (highest place value first) - const step0Beads = stepBeads.filter((b) => b.stepIndex === 0); - expect(step0Beads).toHaveLength(1); + const step0Beads = stepBeads.filter((b) => b.stepIndex === 0) + expect(step0Beads).toHaveLength(1) expect(step0Beads[0]).toEqual({ placeValue: 2, // hundreds place - beadType: "earth", + beadType: 'earth', position: 0, stepIndex: 0, - direction: "activate", + direction: 'activate', order: 0, - }); + }) // Step 1: Remove 90 from tens column (1 heaven + 4 earth beads) - const step1Beads = stepBeads.filter((b) => b.stepIndex === 1); - expect(step1Beads).toHaveLength(5); + const step1Beads = stepBeads.filter((b) => b.stepIndex === 1) + expect(step1Beads).toHaveLength(5) // Should have 1 heaven bead and 4 earth beads, all with 'deactivate' direction - const step1Heaven = step1Beads.filter((b) => b.beadType === "heaven"); - const step1Earth = step1Beads.filter((b) => b.beadType === "earth"); - expect(step1Heaven).toHaveLength(1); - expect(step1Earth).toHaveLength(4); - expect(step1Beads.every((b) => b.direction === "deactivate")).toBe(true); - expect(step1Beads.every((b) => b.placeValue === 1)).toBe(true); // tens column + const step1Heaven = step1Beads.filter((b) => b.beadType === 'heaven') + const step1Earth = step1Beads.filter((b) => b.beadType === 'earth') + expect(step1Heaven).toHaveLength(1) + expect(step1Earth).toHaveLength(4) + expect(step1Beads.every((b) => b.direction === 'deactivate')).toBe(true) + expect(step1Beads.every((b) => b.placeValue === 1)).toBe(true) // tens column // Step 2: Remove 9 from ones column (1 heaven + 4 earth beads) - const step2Beads = stepBeads.filter((b) => b.stepIndex === 2); - expect(step2Beads).toHaveLength(5); + const step2Beads = stepBeads.filter((b) => b.stepIndex === 2) + expect(step2Beads).toHaveLength(5) // Should have 1 heaven bead and 4 earth beads, all with 'deactivate' direction - const step2Heaven = step2Beads.filter((b) => b.beadType === "heaven"); - const step2Earth = step2Beads.filter((b) => b.beadType === "earth"); - expect(step2Heaven).toHaveLength(1); - expect(step2Earth).toHaveLength(4); - expect(step2Beads.every((b) => b.direction === "deactivate")).toBe(true); - expect(step2Beads.every((b) => b.placeValue === 0)).toBe(true); // ones column + const step2Heaven = step2Beads.filter((b) => b.beadType === 'heaven') + const step2Earth = step2Beads.filter((b) => b.beadType === 'earth') + expect(step2Heaven).toHaveLength(1) + expect(step2Earth).toHaveLength(4) + expect(step2Beads.every((b) => b.direction === 'deactivate')).toBe(true) + expect(step2Beads.every((b) => b.placeValue === 0)).toBe(true) // ones column // Verify pedagogical ordering: highest place value first, then next highest - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.multiStepInstructions!).toHaveLength(3); - }); + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.multiStepInstructions!).toHaveLength(3) + }) - it("should generate correct step-bead mapping for 3 + 98 = 101 non-recursive case", () => { - const instruction = generateAbacusInstructions(3, 101); + it('should generate correct step-bead mapping for 3 + 98 = 101 non-recursive case', () => { + const instruction = generateAbacusInstructions(3, 101) - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBe(2); + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBe(2) - const stepBeads = instruction.stepBeadHighlights!; + const stepBeads = instruction.stepBeadHighlights! // Step 0: Add 1 to hundreds column - const step0Beads = stepBeads.filter((b) => b.stepIndex === 0); - expect(step0Beads).toHaveLength(1); + const step0Beads = stepBeads.filter((b) => b.stepIndex === 0) + expect(step0Beads).toHaveLength(1) expect(step0Beads[0]).toEqual({ placeValue: 2, - beadType: "earth", + beadType: 'earth', position: 0, stepIndex: 0, - direction: "activate", + direction: 'activate', order: 0, - }); + }) // Step 1: Remove 2 from ones column (2 earth beads) - const step1Beads = stepBeads.filter((b) => b.stepIndex === 1); - expect(step1Beads).toHaveLength(2); - expect(step1Beads.every((b) => b.beadType === "earth")).toBe(true); - expect(step1Beads.every((b) => b.direction === "deactivate")).toBe(true); - expect(step1Beads.every((b) => b.placeValue === 0)).toBe(true); // ones column - }); + const step1Beads = stepBeads.filter((b) => b.stepIndex === 1) + expect(step1Beads).toHaveLength(2) + expect(step1Beads.every((b) => b.beadType === 'earth')).toBe(true) + expect(step1Beads.every((b) => b.direction === 'deactivate')).toBe(true) + expect(step1Beads.every((b) => b.placeValue === 0)).toBe(true) // ones column + }) - it("should handle single-step operations gracefully", () => { - const instruction = generateAbacusInstructions(0, 1); + it('should handle single-step operations gracefully', () => { + const instruction = generateAbacusInstructions(0, 1) // Single step operations might not have stepBeadHighlights or have them all in step 0 if (instruction.stepBeadHighlights) { - const stepBeads = instruction.stepBeadHighlights; - expect(stepBeads.every((b) => b.stepIndex === 0)).toBe(true); + const stepBeads = instruction.stepBeadHighlights + expect(stepBeads.every((b) => b.stepIndex === 0)).toBe(true) } - }); + }) // CRITICAL: Test the 3 + 14 = 17 case that was the focus of our fix - it("should generate correct pedagogical step ordering for 3 + 14 = 17 using generic algorithm", () => { - const instruction = generateAbacusInstructions(3, 17); + it('should generate correct pedagogical step ordering for 3 + 14 = 17 using generic algorithm', () => { + const instruction = generateAbacusInstructions(3, 17) - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBe(3); + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBe(3) - const stepBeads = instruction.stepBeadHighlights!; + const stepBeads = instruction.stepBeadHighlights! // Step 0: Add 1 earth bead to tens place (highest place value first) - const step0Beads = stepBeads.filter((b) => b.stepIndex === 0); - expect(step0Beads).toHaveLength(1); + const step0Beads = stepBeads.filter((b) => b.stepIndex === 0) + expect(step0Beads).toHaveLength(1) expect(step0Beads[0]).toEqual({ placeValue: 1, // tens place - beadType: "earth", + beadType: 'earth', position: 0, stepIndex: 0, - direction: "activate", + direction: 'activate', order: 0, - }); + }) // Step 1: Add heaven bead to ones place (addition for ones place) - const step1Beads = stepBeads.filter((b) => b.stepIndex === 1); - expect(step1Beads).toHaveLength(1); + const step1Beads = stepBeads.filter((b) => b.stepIndex === 1) + expect(step1Beads).toHaveLength(1) expect(step1Beads[0]).toEqual({ placeValue: 0, // ones place - beadType: "heaven", + beadType: 'heaven', stepIndex: 1, - direction: "activate", + direction: 'activate', order: 1, - }); + }) // Step 2: Remove 1 earth bead from ones place (subtraction for ones place) - const step2Beads = stepBeads.filter((b) => b.stepIndex === 2); - expect(step2Beads).toHaveLength(1); + const step2Beads = stepBeads.filter((b) => b.stepIndex === 2) + expect(step2Beads).toHaveLength(1) expect(step2Beads[0]).toEqual({ placeValue: 0, // ones place - beadType: "earth", + beadType: 'earth', position: 2, // position 2 (3rd earth bead) stepIndex: 2, - direction: "deactivate", + direction: 'deactivate', order: 2, - }); + }) // Verify pedagogical ordering: highest place value additions first, then subtractions - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.multiStepInstructions!).toHaveLength(3); + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.multiStepInstructions!).toHaveLength(3) // FIXED: multiStepInstructions now use PEDAGOGICAL ORDER (highest place first) // AND are consolidated to match step groupings // For 3+14=17 using 3+(20-6) decomposition: tens first (add 10), then ones (add 5, remove 1) - expect(instruction.multiStepInstructions![0]).toContain( - "earth bead 1 in the tens column", - ); - expect(instruction.multiStepInstructions![1]).toContain( - "heaven bead in the ones column", - ); + expect(instruction.multiStepInstructions![0]).toContain('earth bead 1 in the tens column') + expect(instruction.multiStepInstructions![1]).toContain('heaven bead in the ones column') // FIXED: Now consolidated - shows "earth bead 1" instead of individual bead references expect(instruction.multiStepInstructions![2]).toContain( - "earth bead 1 in the ones column to remove", - ); - }); + 'earth bead 1 in the ones column to remove' + ) + }) // Test pedagogical ordering algorithm with various complex cases - describe("Pedagogical step ordering algorithm", () => { - it("should use generic algorithm for 27 + 36 = 63 (multi-place with complement)", () => { - const instruction = generateAbacusInstructions(27, 63); + describe('Pedagogical step ordering algorithm', () => { + it('should use generic algorithm for 27 + 36 = 63 (multi-place with complement)', () => { + const instruction = generateAbacusInstructions(27, 63) - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(1); + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(1) // Verify steps are ordered from highest to lowest place value - const stepBeads = instruction.stepBeadHighlights!; - const additionSteps = stepBeads.filter( - (b) => b.direction === "activate", - ); - const subtractionSteps = stepBeads.filter( - (b) => b.direction === "deactivate", - ); + const stepBeads = instruction.stepBeadHighlights! + const additionSteps = stepBeads.filter((b) => b.direction === 'activate') + const subtractionSteps = stepBeads.filter((b) => b.direction === 'deactivate') // All additions should come before subtractions within the same place value if (additionSteps.length > 0 && subtractionSteps.length > 0) { - const lastAdditionStep = Math.max( - ...additionSteps.map((b) => b.stepIndex), - ); - const firstSubtractionStep = Math.min( - ...subtractionSteps.map((b) => b.stepIndex), - ); - expect(lastAdditionStep).toBeLessThanOrEqual(firstSubtractionStep); + const lastAdditionStep = Math.max(...additionSteps.map((b) => b.stepIndex)) + const firstSubtractionStep = Math.min(...subtractionSteps.map((b) => b.stepIndex)) + expect(lastAdditionStep).toBeLessThanOrEqual(firstSubtractionStep) } - }); + }) - it("should use generic algorithm for 87 + 26 = 113 (crossing hundreds)", () => { - const instruction = generateAbacusInstructions(87, 113); + it('should use generic algorithm for 87 + 26 = 113 (crossing hundreds)', () => { + const instruction = generateAbacusInstructions(87, 113) - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(1); + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(1) - const stepBeads = instruction.stepBeadHighlights!; + const stepBeads = instruction.stepBeadHighlights! // Should have steps for hundreds, tens, and ones places - const hundredsBeads = stepBeads.filter((b) => b.placeValue === 2); - const tensBeads = stepBeads.filter((b) => b.placeValue === 1); - const onesBeads = stepBeads.filter((b) => b.placeValue === 0); + const hundredsBeads = stepBeads.filter((b) => b.placeValue === 2) + const tensBeads = stepBeads.filter((b) => b.placeValue === 1) + const onesBeads = stepBeads.filter((b) => b.placeValue === 0) - expect(hundredsBeads.length).toBeGreaterThan(0); - expect(tensBeads.length + onesBeads.length).toBeGreaterThan(0); + expect(hundredsBeads.length).toBeGreaterThan(0) + expect(tensBeads.length + onesBeads.length).toBeGreaterThan(0) // Hundreds place operations should come first - if ( - hundredsBeads.length > 0 && - (tensBeads.length > 0 || onesBeads.length > 0) - ) { - const maxHundredsStep = Math.max( - ...hundredsBeads.map((b) => b.stepIndex), - ); + if (hundredsBeads.length > 0 && (tensBeads.length > 0 || onesBeads.length > 0)) { + const maxHundredsStep = Math.max(...hundredsBeads.map((b) => b.stepIndex)) const minLowerPlaceStep = Math.min( - ...[...tensBeads, ...onesBeads].map((b) => b.stepIndex), - ); - expect(maxHundredsStep).toBeLessThan(minLowerPlaceStep); + ...[...tensBeads, ...onesBeads].map((b) => b.stepIndex) + ) + expect(maxHundredsStep).toBeLessThan(minLowerPlaceStep) } - }); + }) - it("should use generic algorithm for 45 + 67 = 112 (complex multi-step)", () => { - const instruction = generateAbacusInstructions(45, 112); + it('should use generic algorithm for 45 + 67 = 112 (complex multi-step)', () => { + const instruction = generateAbacusInstructions(45, 112) - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(1); + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(1) // Verify the instruction is generated without hard-coded special cases - expect(instruction.actionDescription).toContain("45 + 67"); - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1); - }); + expect(instruction.actionDescription).toContain('45 + 67') + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1) + }) // Test that no hard-coded special cases exist by testing variations of the original problem - it("should handle 4 + 14 = 18 without hard-coded logic", () => { - const instruction = generateAbacusInstructions(4, 18); - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(1); - }); + it('should handle 4 + 14 = 18 without hard-coded logic', () => { + const instruction = generateAbacusInstructions(4, 18) + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(1) + }) - it("should handle 2 + 14 = 16 without hard-coded logic", () => { - const instruction = generateAbacusInstructions(2, 16); - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(1); - }); + it('should handle 2 + 14 = 16 without hard-coded logic', () => { + const instruction = generateAbacusInstructions(2, 16) + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(1) + }) - it("should handle 5 + 14 = 19 without hard-coded logic", () => { - const instruction = generateAbacusInstructions(5, 19); - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(1); - }); + it('should handle 5 + 14 = 19 without hard-coded logic', () => { + const instruction = generateAbacusInstructions(5, 19) + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(1) + }) - it("should handle 13 + 4 = 17 (reverse of original) without hard-coded logic", () => { - const instruction = generateAbacusInstructions(13, 17); - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(0); - }); + it('should handle 13 + 4 = 17 (reverse of original) without hard-coded logic', () => { + const instruction = generateAbacusInstructions(13, 17) + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(0) + }) // Test systematic variations to ensure generic algorithm it('should handle all "X + 14" patterns systematically', () => { const failedCases: Array<{ - start: number; - target: number; - error: string; - }> = []; + start: number + target: number + error: string + }> = [] for (let start = 0; start <= 9; start++) { - const target = start + 14; - if (target > 99) continue; + const target = start + 14 + if (target > 99) continue try { - const instruction = generateAbacusInstructions(start, target); - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(0); + const instruction = generateAbacusInstructions(start, target) + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(0) // Verify pedagogical ordering if multi-step if (instruction.totalSteps && instruction.totalSteps > 1) { - const stepBeads = instruction.stepBeadHighlights!; - const placeValues = [ - ...new Set(stepBeads.map((b) => b.placeValue)), - ].sort((a, b) => b - a); + const stepBeads = instruction.stepBeadHighlights! + const placeValues = [...new Set(stepBeads.map((b) => b.placeValue))].sort( + (a, b) => b - a + ) // For each place value, additions should come before subtractions placeValues.forEach((place) => { - const placeBeads = stepBeads.filter( - (b) => b.placeValue === place, - ); - const additions = placeBeads.filter( - (b) => b.direction === "activate", - ); - const subtractions = placeBeads.filter( - (b) => b.direction === "deactivate", - ); + const placeBeads = stepBeads.filter((b) => b.placeValue === place) + const additions = placeBeads.filter((b) => b.direction === 'activate') + const subtractions = placeBeads.filter((b) => b.direction === 'deactivate') if (additions.length > 0 && subtractions.length > 0) { - const lastAddition = Math.max( - ...additions.map((b) => b.stepIndex), - ); - const firstSubtraction = Math.min( - ...subtractions.map((b) => b.stepIndex), - ); - expect(lastAddition).toBeLessThanOrEqual(firstSubtraction); + const lastAddition = Math.max(...additions.map((b) => b.stepIndex)) + const firstSubtraction = Math.min(...subtractions.map((b) => b.stepIndex)) + expect(lastAddition).toBeLessThanOrEqual(firstSubtraction) } - }); + }) } } catch (error) { failedCases.push({ start, target, error: `Exception: ${error instanceof Error ? error.message : String(error)}`, - }); + }) } } if (failedCases.length > 0) { - console.error('Failed "X + 14" pattern cases:', failedCases); + console.error('Failed "X + 14" pattern cases:', failedCases) } - expect(failedCases).toHaveLength(0); - }); + expect(failedCases).toHaveLength(0) + }) it('should handle all "3 + Y" patterns systematically', () => { const failedCases: Array<{ - start: number; - target: number; - error: string; - }> = []; + start: number + target: number + error: string + }> = [] for (let addAmount = 1; addAmount <= 20; addAmount++) { - const start = 3; - const target = start + addAmount; - if (target > 99) continue; + const start = 3 + const target = start + addAmount + if (target > 99) continue try { - const instruction = generateAbacusInstructions(start, target); - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.totalSteps).toBeGreaterThan(0); + const instruction = generateAbacusInstructions(start, target) + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.totalSteps).toBeGreaterThan(0) } catch (error) { failedCases.push({ start, target, error: `Exception: ${error instanceof Error ? error.message : String(error)}`, - }); + }) } } if (failedCases.length > 0) { - console.error('Failed "3 + Y" pattern cases:', failedCases); + console.error('Failed "3 + Y" pattern cases:', failedCases) } - expect(failedCases).toHaveLength(0); - }); - }); + expect(failedCases).toHaveLength(0) + }) + }) // Anti-cheat tests: ensure no hard-coded special cases - describe("Anti-cheat verification", () => { - it("should not contain hard-coded special cases for specific problems", () => { + describe('Anti-cheat verification', () => { + it('should not contain hard-coded special cases for specific problems', () => { // Read the source code to verify no hard-coded special cases // This test would fail if someone added "if (start === 3 && target === 17)" logic @@ -1274,24 +1213,20 @@ describe("Automatic Abacus Instruction Generator", () => { { start: 2, target: 16 }, { start: 5, target: 19 }, { start: 13, target: 17 }, - ]; + ] testCases.forEach(({ start, target }) => { - const instruction1 = generateAbacusInstructions(start, target); - const instruction2 = generateAbacusInstructions(start, target); + const instruction1 = generateAbacusInstructions(start, target) + const instruction2 = generateAbacusInstructions(start, target) // Results should be consistent (no randomness or hard-coded switching) - expect(instruction1.stepBeadHighlights).toEqual( - instruction2.stepBeadHighlights, - ); - expect(instruction1.totalSteps).toBe(instruction2.totalSteps); - expect(instruction1.multiStepInstructions).toEqual( - instruction2.multiStepInstructions, - ); - }); - }); + expect(instruction1.stepBeadHighlights).toEqual(instruction2.stepBeadHighlights) + expect(instruction1.totalSteps).toBe(instruction2.totalSteps) + expect(instruction1.multiStepInstructions).toEqual(instruction2.multiStepInstructions) + }) + }) - it("should generate identical results for equivalent problems", () => { + it('should generate identical results for equivalent problems', () => { // Problems with the same structure should have same step count const equivalentPairs = [ [ @@ -1306,236 +1241,204 @@ describe("Automatic Abacus Instruction Generator", () => { { start: 4, target: 18 }, { start: 14, target: 28 }, ], // Both "4 + 14" in different decades - ]; + ] equivalentPairs.forEach(([case1, case2]) => { - const instruction1 = generateAbacusInstructions( - case1.start, - case1.target, - ); - const instruction2 = generateAbacusInstructions( - case2.start, - case2.target, - ); + const instruction1 = generateAbacusInstructions(case1.start, case1.target) + const instruction2 = generateAbacusInstructions(case2.start, case2.target) // Should have same number of steps (structure should be identical) - expect(instruction1.totalSteps).toBe(instruction2.totalSteps); + expect(instruction1.totalSteps).toBe(instruction2.totalSteps) // Should have same number of step bead highlights expect(instruction1.stepBeadHighlights?.length).toBe( - instruction2.stepBeadHighlights?.length, - ); - }); - }); - }); - }); + instruction2.stepBeadHighlights?.length + ) + }) + }) + }) + }) - describe("Expected States Calculation", () => { - it("should correctly calculate expected states for each multi-step instruction", () => { - const instruction = generateAbacusInstructions(3, 17); + describe('Expected States Calculation', () => { + it('should correctly calculate expected states for each multi-step instruction', () => { + const instruction = generateAbacusInstructions(3, 17) // Calculate expected states using the same logic as tutorial editor - const expectedStates: number[] = []; + const expectedStates: number[] = [] if (instruction.stepBeadHighlights && instruction.multiStepInstructions) { const stepIndices = [ - ...new Set( - instruction.stepBeadHighlights.map((bead) => bead.stepIndex), - ), - ].sort(); - let currentValue = 3; + ...new Set(instruction.stepBeadHighlights.map((bead) => bead.stepIndex)), + ].sort() + let currentValue = 3 stepIndices.forEach((stepIndex, _i) => { const stepBeads = instruction.stepBeadHighlights!.filter( - (bead) => bead.stepIndex === stepIndex, - ); - let valueChange = 0; + (bead) => bead.stepIndex === stepIndex + ) + let valueChange = 0 stepBeads.forEach((bead) => { - const placeMultiplier = 10 ** bead.placeValue; - if (bead.beadType === "heaven") { + const placeMultiplier = 10 ** bead.placeValue + if (bead.beadType === 'heaven') { valueChange += - bead.direction === "activate" - ? 5 * placeMultiplier - : -(5 * placeMultiplier); + bead.direction === 'activate' ? 5 * placeMultiplier : -(5 * placeMultiplier) } else { - valueChange += - bead.direction === "activate" - ? placeMultiplier - : -placeMultiplier; + valueChange += bead.direction === 'activate' ? placeMultiplier : -placeMultiplier } - }); + }) - currentValue += valueChange; - expectedStates.push(currentValue); - }); + currentValue += valueChange + expectedStates.push(currentValue) + }) } // Verify we have the correct number of expected states - expect(expectedStates.length).toBe(instruction.totalSteps); - expect(expectedStates.length).toBe( - instruction.multiStepInstructions?.length || 0, - ); + expect(expectedStates.length).toBe(instruction.totalSteps) + expect(expectedStates.length).toBe(instruction.multiStepInstructions?.length || 0) // Verify the final state matches the target - expect(expectedStates[expectedStates.length - 1]).toBe(17); + expect(expectedStates[expectedStates.length - 1]).toBe(17) // Verify all states are progressive (increasing or equal) - expect(expectedStates[0]).toBeGreaterThanOrEqual(3); // First step should be >= start value + expect(expectedStates[0]).toBeGreaterThanOrEqual(3) // First step should be >= start value for (let i = 1; i < expectedStates.length; i++) { // Each step should be different from the previous (actual progression) - expect(expectedStates[i]).not.toBe(expectedStates[i - 1]); + expect(expectedStates[i]).not.toBe(expectedStates[i - 1]) } - }); + }) - it("should calculate expected states for complex case 99+1=100", () => { - const instruction = generateAbacusInstructions(99, 100); + it('should calculate expected states for complex case 99+1=100', () => { + const instruction = generateAbacusInstructions(99, 100) // Calculate expected states - const expectedStates: number[] = []; + const expectedStates: number[] = [] if (instruction.stepBeadHighlights && instruction.multiStepInstructions) { const stepIndices = [ - ...new Set( - instruction.stepBeadHighlights.map((bead) => bead.stepIndex), - ), - ].sort(); - let currentValue = 99; + ...new Set(instruction.stepBeadHighlights.map((bead) => bead.stepIndex)), + ].sort() + let currentValue = 99 stepIndices.forEach((stepIndex, _i) => { const stepBeads = instruction.stepBeadHighlights!.filter( - (bead) => bead.stepIndex === stepIndex, - ); - let valueChange = 0; + (bead) => bead.stepIndex === stepIndex + ) + let valueChange = 0 stepBeads.forEach((bead) => { - const placeMultiplier = 10 ** bead.placeValue; - if (bead.beadType === "heaven") { + const placeMultiplier = 10 ** bead.placeValue + if (bead.beadType === 'heaven') { valueChange += - bead.direction === "activate" - ? 5 * placeMultiplier - : -(5 * placeMultiplier); + bead.direction === 'activate' ? 5 * placeMultiplier : -(5 * placeMultiplier) } else { - valueChange += - bead.direction === "activate" - ? placeMultiplier - : -placeMultiplier; + valueChange += bead.direction === 'activate' ? placeMultiplier : -placeMultiplier } - }); + }) - currentValue += valueChange; - expectedStates.push(currentValue); - }); + currentValue += valueChange + expectedStates.push(currentValue) + }) } // Verify final state is correct - expect(expectedStates[expectedStates.length - 1]).toBe(100); + expect(expectedStates[expectedStates.length - 1]).toBe(100) // Verify we have a reasonable number of steps (should be multi-step for this complex case) - expect(expectedStates.length).toBeGreaterThan(1); - }); + expect(expectedStates.length).toBeGreaterThan(1) + }) - it("should handle edge case where start equals target", () => { - const instruction = generateAbacusInstructions(42, 42); + it('should handle edge case where start equals target', () => { + const instruction = generateAbacusInstructions(42, 42) // Should have no steps since no change is needed - expect(instruction.totalSteps || 0).toBe(0); - expect(instruction.multiStepInstructions?.length || 0).toBe(0); - }); + expect(instruction.totalSteps || 0).toBe(0) + expect(instruction.multiStepInstructions?.length || 0).toBe(0) + }) - it("should calculate expected states that match tutorial progression", () => { + it('should calculate expected states that match tutorial progression', () => { // Test case from tutorial editor: 27 -> 65 - const instruction = generateAbacusInstructions(27, 65); + const instruction = generateAbacusInstructions(27, 65) - const expectedStates: number[] = []; + const expectedStates: number[] = [] if (instruction.stepBeadHighlights && instruction.multiStepInstructions) { const stepIndices = [ - ...new Set( - instruction.stepBeadHighlights.map((bead) => bead.stepIndex), - ), - ].sort(); - let currentValue = 27; + ...new Set(instruction.stepBeadHighlights.map((bead) => bead.stepIndex)), + ].sort() + let currentValue = 27 stepIndices.forEach((stepIndex, _i) => { const stepBeads = instruction.stepBeadHighlights!.filter( - (bead) => bead.stepIndex === stepIndex, - ); - let valueChange = 0; + (bead) => bead.stepIndex === stepIndex + ) + let valueChange = 0 stepBeads.forEach((bead) => { - const placeMultiplier = 10 ** bead.placeValue; - if (bead.beadType === "heaven") { + const placeMultiplier = 10 ** bead.placeValue + if (bead.beadType === 'heaven') { valueChange += - bead.direction === "activate" - ? 5 * placeMultiplier - : -(5 * placeMultiplier); + bead.direction === 'activate' ? 5 * placeMultiplier : -(5 * placeMultiplier) } else { - valueChange += - bead.direction === "activate" - ? placeMultiplier - : -placeMultiplier; + valueChange += bead.direction === 'activate' ? placeMultiplier : -placeMultiplier } - }); + }) - currentValue += valueChange; - expectedStates.push(currentValue); - }); + currentValue += valueChange + expectedStates.push(currentValue) + }) } // Verify tutorial editor functionality: each state should be reasonable // Note: Due to pedagogical ordering, intermediate states may temporarily exceed target expectedStates.forEach((state, index) => { - expect(state).toBeGreaterThanOrEqual(0); // Must be non-negative - expect(state).toBeLessThanOrEqual(200); // Reasonable upper bound + expect(state).toBeGreaterThanOrEqual(0) // Must be non-negative + expect(state).toBeLessThanOrEqual(200) // Reasonable upper bound // Should have meaningful step descriptions - expect(instruction.multiStepInstructions?.[index]).toBeDefined(); - expect(instruction.multiStepInstructions?.[index]).not.toBe(""); - }); + expect(instruction.multiStepInstructions?.[index]).toBeDefined() + expect(instruction.multiStepInstructions?.[index]).not.toBe('') + }) // Final verification - expect(expectedStates[expectedStates.length - 1]).toBe(65); - }); + expect(expectedStates[expectedStates.length - 1]).toBe(65) + }) - it("should correctly consolidate multi-bead instructions for 56 → 104", () => { + it('should correctly consolidate multi-bead instructions for 56 → 104', () => { // Verifies the fix for consolidated instructions matching expected states - const instruction = generateAbacusInstructions(56, 104); + const instruction = generateAbacusInstructions(56, 104) // Verify instruction consolidation - expect(instruction.multiStepInstructions).toBeDefined(); + expect(instruction.multiStepInstructions).toBeDefined() expect(instruction.multiStepInstructions![1]).toContain( - "Add 3 to ones column (3 earth beads)", - ); + 'Add 3 to ones column (3 earth beads)' + ) // Calculate expected states step by step like tutorial editor does - const expectedStates: number[] = []; + const expectedStates: number[] = [] if (instruction.stepBeadHighlights && instruction.multiStepInstructions) { const stepIndices = [ - ...new Set( - instruction.stepBeadHighlights.map((bead) => bead.stepIndex), - ), - ].sort(); - let currentValue = 56; + ...new Set(instruction.stepBeadHighlights.map((bead) => bead.stepIndex)), + ].sort() + let currentValue = 56 stepIndices.forEach((stepIndex, _i) => { const stepBeads = instruction.stepBeadHighlights!.filter( - (bead) => bead.stepIndex === stepIndex, - ); - let valueChange = 0; + (bead) => bead.stepIndex === stepIndex + ) + let valueChange = 0 stepBeads.forEach((bead) => { - const multiplier = 10 ** bead.placeValue; - const value = - bead.beadType === "heaven" ? 5 * multiplier : multiplier; - const change = bead.direction === "activate" ? value : -value; - valueChange += change; - }); + const multiplier = 10 ** bead.placeValue + const value = bead.beadType === 'heaven' ? 5 * multiplier : multiplier + const change = bead.direction === 'activate' ? value : -value + valueChange += change + }) - currentValue += valueChange; - expectedStates.push(currentValue); - }); + currentValue += valueChange + expectedStates.push(currentValue) + }) } // FIXED: step 1 should be 156 + 3 = 159 (3 earth beads consolidated) - expect(expectedStates[1]).toBe(159); // Instruction and expected state now match - }); - }); -}); + expect(expectedStates[1]).toBe(159) // Instruction and expected state now match + }) + }) +}) diff --git a/apps/web/src/utils/test/pedagogicalDecomposition.test.ts b/apps/web/src/utils/test/pedagogicalDecomposition.test.ts index a40fd0dd..aed5c2c3 100644 --- a/apps/web/src/utils/test/pedagogicalDecomposition.test.ts +++ b/apps/web/src/utils/test/pedagogicalDecomposition.test.ts @@ -1,314 +1,281 @@ -import { describe, expect, it } from "vitest"; -import { generateAbacusInstructions } from "../abacusInstructionGenerator"; +import { describe, expect, it } from 'vitest' +import { generateAbacusInstructions } from '../abacusInstructionGenerator' -describe("Pedagogical Decomposition", () => { - describe("Core Principle: 1:1 Mapping to Bead Movements", () => { - it("should map each decomposition term to a specific bead movement", () => { +describe('Pedagogical Decomposition', () => { + describe('Core Principle: 1:1 Mapping to Bead Movements', () => { + it('should map each decomposition term to a specific bead movement', () => { // Test case: 3 + 14 = 17 - const instruction = generateAbacusInstructions(3, 17); + const instruction = generateAbacusInstructions(3, 17) // Should show decomposition like "3 + 10 + (5 - 1)" where: // - "10" maps to adding 1 earth bead in tens place // - "(5 - 1)" maps to adding heaven bead and removing 1 earth bead in ones - expect(instruction.actionDescription).toContain("3 + 14 = 3 + 10"); - expect(instruction.actionDescription).toContain("(5 - 1)"); + expect(instruction.actionDescription).toContain('3 + 14 = 3 + 10') + expect(instruction.actionDescription).toContain('(5 - 1)') // Verify bead movements match decomposition terms - const tensBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 1, - ); - const onesBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 0, - ); + const tensBeads = instruction.highlightBeads.filter((b) => b.placeValue === 1) + const onesBeads = instruction.highlightBeads.filter((b) => b.placeValue === 0) - expect(tensBeads).toHaveLength(1); // One term "10" = 1 tens bead - expect(onesBeads).toHaveLength(2); // Term "(5 - 1)" = heaven + 1 earth bead - }); + expect(tensBeads).toHaveLength(1) // One term "10" = 1 tens bead + expect(onesBeads).toHaveLength(2) // Term "(5 - 1)" = heaven + 1 earth bead + }) - it("should handle ten complement with proper breakdown: 7 + 4 = 11", () => { - const instruction = generateAbacusInstructions(7, 11); + it('should handle ten complement with proper breakdown: 7 + 4 = 11', () => { + const instruction = generateAbacusInstructions(7, 11) // Should break down as: 7 + 4 = 7 + 10 - 6 // - "+10" maps to adding 1 earth bead in tens // - "-6" maps to removing heaven bead + 1 earth bead in ones - expect(instruction.actionDescription).toContain("7 + 4 = 7 + 10 - 6"); + expect(instruction.actionDescription).toContain('7 + 4 = 7 + 10 - 6') // Verify bead movements: 1 tens addition + 2 ones removals - expect(instruction.highlightBeads).toHaveLength(3); + expect(instruction.highlightBeads).toHaveLength(3) - const tensAdditions = instruction.highlightBeads.filter( - (b) => b.placeValue === 1, - ); - const onesBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 0, - ); + const tensAdditions = instruction.highlightBeads.filter((b) => b.placeValue === 1) + const onesBeads = instruction.highlightBeads.filter((b) => b.placeValue === 0) - expect(tensAdditions).toHaveLength(1); // "+10" term - expect(onesBeads).toHaveLength(2); // "-6" = heaven + earth removal - }); + expect(tensAdditions).toHaveLength(1) // "+10" term + expect(onesBeads).toHaveLength(2) // "-6" = heaven + earth removal + }) - it("should handle five complement: 2 + 3 = 5", () => { - const instruction = generateAbacusInstructions(2, 5); + it('should handle five complement: 2 + 3 = 5', () => { + const instruction = generateAbacusInstructions(2, 5) // Should show: 2 + 3 = 2 + (5 - 2) where: // - "+5" maps to adding heaven bead // - "-2" maps to removing 2 earth beads - expect(instruction.actionDescription).toContain("2 + 3 = 2 + (5 - 2)"); + expect(instruction.actionDescription).toContain('2 + 3 = 2 + (5 - 2)') // Verify bead movements match - const onesBeads = instruction.highlightBeads.filter( - (b) => b.placeValue === 0, - ); - expect(onesBeads).toHaveLength(3); // heaven + 2 earth removals - }); - }); + const onesBeads = instruction.highlightBeads.filter((b) => b.placeValue === 0) + expect(onesBeads).toHaveLength(3) // heaven + 2 earth removals + }) + }) - describe("Complex Multi-Place Operations", () => { - it("should decompose 3 + 98 = 101 by place value", () => { - const instruction = generateAbacusInstructions(3, 98); + describe('Complex Multi-Place Operations', () => { + it('should decompose 3 + 98 = 101 by place value', () => { + const instruction = generateAbacusInstructions(3, 98) // Should break down like: 3 + 95 = 3 + 90 + 5 // Each term should map to place-specific bead movements - expect(instruction.actionDescription).toMatch(/3 \+ 95 = 3 \+ \d+/); + expect(instruction.actionDescription).toMatch(/3 \+ 95 = 3 \+ \d+/) // Should have beads in multiple places - const places = new Set( - instruction.highlightBeads.map((b) => b.placeValue), - ); - expect(places.size).toBeGreaterThan(1); - }); + const places = new Set(instruction.highlightBeads.map((b) => b.placeValue)) + expect(places.size).toBeGreaterThan(1) + }) - it("should handle hundred boundary crossing: 99 + 1 = 100", () => { - const instruction = generateAbacusInstructions(99, 100); + it('should handle hundred boundary crossing: 99 + 1 = 100', () => { + const instruction = generateAbacusInstructions(99, 100) // Should show recursive breakdown that maps to actual bead movements - expect(instruction.actionDescription).toContain( - "99 + 1 = 99 + (100 - 90) - 9", - ); + expect(instruction.actionDescription).toContain('99 + 1 = 99 + (100 - 90) - 9') // Verify step-by-step bead mapping exists - expect(instruction.stepBeadHighlights).toBeDefined(); - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1); - }); + expect(instruction.stepBeadHighlights).toBeDefined() + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1) + }) - it("should decompose 56 → 104 correctly", () => { - const instruction = generateAbacusInstructions(56, 104); + it('should decompose 56 → 104 correctly', () => { + const instruction = generateAbacusInstructions(56, 104) // Should break down 48 into place-value components - expect(instruction.actionDescription).toMatch(/56 \+ 48/); + expect(instruction.actionDescription).toMatch(/56 \+ 48/) // Verify multi-step instructions are properly consolidated - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1); - }); - }); + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.multiStepInstructions!.length).toBeGreaterThan(1) + }) + }) - describe("Edge Cases and Boundary Conditions", () => { - it("should handle single digit additions", () => { + describe('Edge Cases and Boundary Conditions', () => { + it('should handle single digit additions', () => { const testCases = [ { start: 1, target: 2, expectedBeads: 1 }, // Add 1 earth bead { start: 0, target: 1, expectedBeads: 1 }, // Add 1 earth bead { start: 4, target: 5, expectedBeads: 5 }, // Use complement operation (heaven + removals) - ]; + ] testCases.forEach(({ start, target, expectedBeads }) => { - const instruction = generateAbacusInstructions(start, target); + const instruction = generateAbacusInstructions(start, target) // Simple operations show action descriptions rather than mathematical breakdown - expect(instruction.actionDescription).toBeDefined(); - expect(instruction.highlightBeads).toHaveLength(expectedBeads); - }); - }); + expect(instruction.actionDescription).toBeDefined() + expect(instruction.highlightBeads).toHaveLength(expectedBeads) + }) + }) - it("should handle five complement edge cases", () => { + it('should handle five complement edge cases', () => { const fiveComplementCases = [ { start: 1, target: 4 }, // 1 + 3 { start: 2, target: 6 }, // 2 + 4 { start: 3, target: 7 }, // 3 + 4 - ]; + ] fiveComplementCases.forEach(({ start, target }) => { - const instruction = generateAbacusInstructions(start, target); + const instruction = generateAbacusInstructions(start, target) // Should have mathematical breakdown or action description - expect(instruction.actionDescription).toBeDefined(); + expect(instruction.actionDescription).toBeDefined() // Should involve bead movements (could be complement or direct) - expect(instruction.highlightBeads.length).toBeGreaterThan(0); - }); - }); + expect(instruction.highlightBeads.length).toBeGreaterThan(0) + }) + }) - it("should handle ten complement edge cases", () => { + it('should handle ten complement edge cases', () => { const tenComplementCases = [ - { start: 6, target: 12, description: "6 + 6 crossing tens" }, - { start: 8, target: 15, description: "8 + 7 crossing tens" }, - { start: 9, target: 18, description: "9 + 9 crossing tens" }, - ]; + { start: 6, target: 12, description: '6 + 6 crossing tens' }, + { start: 8, target: 15, description: '8 + 7 crossing tens' }, + { start: 9, target: 18, description: '9 + 9 crossing tens' }, + ] tenComplementCases.forEach(({ start, target, description }) => { - const instruction = generateAbacusInstructions(start, target); + const instruction = generateAbacusInstructions(start, target) // Should have mathematical breakdown for complex operations - expect(instruction.actionDescription).toContain( - `${start} + ${target - start}`, - ); + expect(instruction.actionDescription).toContain(`${start} + ${target - start}`) // Should have beads in both ones and tens places - const places = new Set( - instruction.highlightBeads.map((b) => b.placeValue), - ); - expect(places).toContain(0); // ones place - expect(places).toContain(1); // tens place - }); - }); + const places = new Set(instruction.highlightBeads.map((b) => b.placeValue)) + expect(places).toContain(0) // ones place + expect(places).toContain(1) // tens place + }) + }) - it("should handle large numbers with multiple complements", () => { - const instruction = generateAbacusInstructions(87, 134); + it('should handle large numbers with multiple complements', () => { + const instruction = generateAbacusInstructions(87, 134) // 87 + 47 should break down by place value - expect(instruction.actionDescription).toContain("87 + 47"); + expect(instruction.actionDescription).toContain('87 + 47') // Should have proper multi-step breakdown - expect(instruction.multiStepInstructions).toBeDefined(); - expect(instruction.stepBeadHighlights).toBeDefined(); - }); - }); + expect(instruction.multiStepInstructions).toBeDefined() + expect(instruction.stepBeadHighlights).toBeDefined() + }) + }) - describe("Regression Tests for Known Issues", () => { - it("should NOT generate unhelpful decompositions like (100 - 86)", () => { - const instruction = generateAbacusInstructions(3, 17); + describe('Regression Tests for Known Issues', () => { + it('should NOT generate unhelpful decompositions like (100 - 86)', () => { + const instruction = generateAbacusInstructions(3, 17) // Should NOT contain large complement subtractions - expect(instruction.actionDescription).not.toContain("(100 - 86)"); - expect(instruction.actionDescription).not.toMatch(/\(100 - \d{2}\)/); + expect(instruction.actionDescription).not.toContain('(100 - 86)') + expect(instruction.actionDescription).not.toMatch(/\(100 - \d{2}\)/) // Should contain proper place-value breakdown - expect(instruction.actionDescription).toContain("10"); - expect(instruction.actionDescription).toContain("(5 - 1)"); - }); + expect(instruction.actionDescription).toContain('10') + expect(instruction.actionDescription).toContain('(5 - 1)') + }) - it("should ensure instruction text matches expected state calculations", () => { - const instruction = generateAbacusInstructions(56, 104); + it('should ensure instruction text matches expected state calculations', () => { + const instruction = generateAbacusInstructions(56, 104) if (instruction.multiStepInstructions && instruction.stepBeadHighlights) { // Verify that each instruction corresponds to correct expected state // This prevents the "impossible" state bug we fixed - expect(instruction.multiStepInstructions.length).toBeGreaterThan(0); + expect(instruction.multiStepInstructions.length).toBeGreaterThan(0) // Each step should have corresponding bead highlights - const stepIndices = new Set( - instruction.stepBeadHighlights.map((b) => b.stepIndex), - ); - expect(stepIndices.size).toBe(instruction.multiStepInstructions.length); + const stepIndices = new Set(instruction.stepBeadHighlights.map((b) => b.stepIndex)) + expect(stepIndices.size).toBe(instruction.multiStepInstructions.length) } - }); + }) - it("should maintain pedagogical ordering (highest place first)", () => { - const instruction = generateAbacusInstructions(99, 100); + it('should maintain pedagogical ordering (highest place first)', () => { + const instruction = generateAbacusInstructions(99, 100) if (instruction.multiStepInstructions) { // First instruction should involve hundreds place - expect(instruction.multiStepInstructions[0]).toMatch(/hundreds?/); + expect(instruction.multiStepInstructions[0]).toMatch(/hundreds?/) // Later instructions should involve lower places - const hasOnesInstruction = instruction.multiStepInstructions.some( - (inst) => inst.includes("ones"), - ); - expect(hasOnesInstruction).toBe(true); + const hasOnesInstruction = instruction.multiStepInstructions.some((inst) => + inst.includes('ones') + ) + expect(hasOnesInstruction).toBe(true) } - }); + }) - it("should consolidate multiple beads in same step", () => { - const instruction = generateAbacusInstructions(56, 104); + it('should consolidate multiple beads in same step', () => { + const instruction = generateAbacusInstructions(56, 104) if (instruction.multiStepInstructions) { // Instructions should be consolidated (not individual bead references) - const hasConsolidatedInstruction = - instruction.multiStepInstructions.some( - (inst) => - inst.includes("earth beads") || inst.includes("heaven bead"), - ); - expect(hasConsolidatedInstruction).toBe(true); + const hasConsolidatedInstruction = instruction.multiStepInstructions.some( + (inst) => inst.includes('earth beads') || inst.includes('heaven bead') + ) + expect(hasConsolidatedInstruction).toBe(true) // Should not have multiple separate instructions for same step - expect(instruction.multiStepInstructions.length).toBeLessThan(10); + expect(instruction.multiStepInstructions.length).toBeLessThan(10) } - }); - }); + }) + }) - describe("Decomposition Term Validation", () => { - it("should ensure all terms are mathematically valid", () => { + describe('Decomposition Term Validation', () => { + it('should ensure all terms are mathematically valid', () => { const testCases = [ { start: 3, target: 17 }, { start: 7, target: 11 }, { start: 56, target: 104 }, { start: 99, target: 100 }, - ]; + ] testCases.forEach(({ start, target }) => { - const instruction = generateAbacusInstructions(start, target); - const difference = target - start; + const instruction = generateAbacusInstructions(start, target) + const difference = target - start // Action description should include the correct operation - expect(instruction.actionDescription).toContain( - `${start} + ${difference}`, - ); - expect(instruction.actionDescription).toContain(`= ${target}`); - }); - }); + expect(instruction.actionDescription).toContain(`${start} + ${difference}`) + expect(instruction.actionDescription).toContain(`= ${target}`) + }) + }) - it("should ensure bead count matches decomposition complexity", () => { + it('should ensure bead count matches decomposition complexity', () => { // Simple operations should have fewer beads - const simple = generateAbacusInstructions(1, 2); - expect(simple.highlightBeads.length).toBeLessThanOrEqual(2); + const simple = generateAbacusInstructions(1, 2) + expect(simple.highlightBeads.length).toBeLessThanOrEqual(2) // Complex operations should have more beads - const complex = generateAbacusInstructions(87, 134); - expect(complex.highlightBeads.length).toBeGreaterThan(2); - }); + const complex = generateAbacusInstructions(87, 134) + expect(complex.highlightBeads.length).toBeGreaterThan(2) + }) - it("should validate step bead mapping consistency", () => { - const instruction = generateAbacusInstructions(3, 17); + it('should validate step bead mapping consistency', () => { + const instruction = generateAbacusInstructions(3, 17) if (instruction.stepBeadHighlights && instruction.multiStepInstructions) { // Total step bead highlights should match total highlights - expect(instruction.stepBeadHighlights.length).toBe( - instruction.highlightBeads.length, - ); + expect(instruction.stepBeadHighlights.length).toBe(instruction.highlightBeads.length) // Each step index should be valid instruction.stepBeadHighlights.forEach((bead) => { - expect(bead.stepIndex).toBeGreaterThanOrEqual(0); - expect(bead.stepIndex).toBeLessThan( - instruction.multiStepInstructions!.length, - ); - }); + expect(bead.stepIndex).toBeGreaterThanOrEqual(0) + expect(bead.stepIndex).toBeLessThan(instruction.multiStepInstructions!.length) + }) } - }); - }); + }) + }) - describe("Performance and Consistency", () => { - it("should generate consistent results for equivalent problems", () => { + describe('Performance and Consistency', () => { + it('should generate consistent results for equivalent problems', () => { // Same structure in different decades should have same pattern - const pattern3Plus14 = generateAbacusInstructions(3, 17); - const pattern13Plus14 = generateAbacusInstructions(13, 27); + const pattern3Plus14 = generateAbacusInstructions(3, 17) + const pattern13Plus14 = generateAbacusInstructions(13, 27) // Both should use similar decomposition patterns - expect(pattern3Plus14.expectedAction).toBe( - pattern13Plus14.expectedAction, - ); + expect(pattern3Plus14.expectedAction).toBe(pattern13Plus14.expectedAction) - if ( - pattern3Plus14.multiStepInstructions && - pattern13Plus14.multiStepInstructions - ) { + if (pattern3Plus14.multiStepInstructions && pattern13Plus14.multiStepInstructions) { expect(pattern3Plus14.multiStepInstructions.length).toBe( - pattern13Plus14.multiStepInstructions.length, - ); + pattern13Plus14.multiStepInstructions.length + ) } - }); + }) - it("should handle all numbers 0-999 without errors", () => { + it('should handle all numbers 0-999 without errors', () => { // Spot check various ranges const testCases = [ [0, 9], @@ -318,19 +285,19 @@ describe("Pedagogical Decomposition", () => { [100, 109], [500, 509], [990, 999], - ]; + ] testCases.forEach(([start, end]) => { for (let i = start; i <= Math.min(end, start + 3); i++) { - const target = Math.min(i + Math.floor(Math.random() * 10) + 1, 999); + const target = Math.min(i + Math.floor(Math.random() * 10) + 1, 999) expect(() => { - const instruction = generateAbacusInstructions(i, target); - expect(instruction.actionDescription).toBeDefined(); - expect(instruction.highlightBeads).toBeDefined(); - }).not.toThrow(); + const instruction = generateAbacusInstructions(i, target) + expect(instruction.actionDescription).toBeDefined() + expect(instruction.highlightBeads).toBeDefined() + }).not.toThrow() } - }); - }); - }); -}); + }) + }) + }) +}) diff --git a/apps/web/src/utils/test/stepIndexRegression.test.ts b/apps/web/src/utils/test/stepIndexRegression.test.ts index 3c149f92..9667a23e 100644 --- a/apps/web/src/utils/test/stepIndexRegression.test.ts +++ b/apps/web/src/utils/test/stepIndexRegression.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { calculateBeadDiffFromValues } from "../beadDiff"; +import { describe, expect, it } from 'vitest' +import { calculateBeadDiffFromValues } from '../beadDiff' /** * REGRESSION TEST: Prevent stepIndex mismatch in multi-step sequences @@ -12,22 +12,22 @@ import { calculateBeadDiffFromValues } from "../beadDiff"; * Key requirement: When generating StepBeadHighlight objects from bead diffs, * the stepIndex must match the currentMultiStep to ensure arrows appear. */ -describe("StepIndex Regression Prevention", () => { - it("should prevent stepIndex mismatch in multi-step arrow display", () => { +describe('StepIndex Regression Prevention', () => { + it('should prevent stepIndex mismatch in multi-step arrow display', () => { // This test simulates the exact scenario that caused the bug: // Multi-step sequence where currentMultiStep > 0 - const fromValue = 199; // After completing first step (3 + 196 = 199) - const toValue = 109; // Second step target (199 - 90 = 109) - const currentMultiStep = 1; // We're on the second step (index 1) + const fromValue = 199 // After completing first step (3 + 196 = 199) + const toValue = 109 // Second step target (199 - 90 = 109) + const currentMultiStep = 1 // We're on the second step (index 1) // Calculate the bead diff (this part was working correctly) - const beadDiff = calculateBeadDiffFromValues(fromValue, toValue); + const beadDiff = calculateBeadDiffFromValues(fromValue, toValue) // Verify the calculation produces the expected changes - expect(beadDiff.hasChanges).toBe(true); - expect(beadDiff.changes.length).toBeGreaterThan(0); - expect(beadDiff.summary).toContain("remove"); // Should be removing beads for 199 → 109 + expect(beadDiff.hasChanges).toBe(true) + expect(beadDiff.changes.length).toBeGreaterThan(0) + expect(beadDiff.summary).toContain('remove') // Should be removing beads for 199 → 109 // This is the critical test: Simulate how TutorialPlayer.tsx converts // bead diff to StepBeadHighlight format @@ -38,41 +38,41 @@ describe("StepIndex Regression Prevention", () => { direction: change.direction, stepIndex: currentMultiStep, // ✅ MUST be currentMultiStep, not hardcoded 0 order: change.order, - })); + })) // Verify that ALL generated highlights have the correct stepIndex stepBeadHighlights.forEach((highlight) => { - expect(highlight.stepIndex).toBe(currentMultiStep); - expect(highlight.stepIndex).not.toBe(0); // Prevent hardcoding to 0 - }); + expect(highlight.stepIndex).toBe(currentMultiStep) + expect(highlight.stepIndex).not.toBe(0) // Prevent hardcoding to 0 + }) // Simulate AbacusReact's getBeadStepHighlight filtering logic // This is what was failing before the fix - const currentStep = currentMultiStep; // AbacusReact receives currentStep={currentMultiStep} + const currentStep = currentMultiStep // AbacusReact receives currentStep={currentMultiStep} stepBeadHighlights.forEach((highlight) => { // This is the exact logic from AbacusReact.tsx:675 - const isCurrentStep = highlight.stepIndex === currentStep; - const shouldShowArrow = isCurrentStep; // Only show arrows for current step + const isCurrentStep = highlight.stepIndex === currentStep + const shouldShowArrow = isCurrentStep // Only show arrows for current step // Before the fix, this would be false for all highlights when currentMultiStep > 0 - expect(shouldShowArrow).toBe(true); + expect(shouldShowArrow).toBe(true) // Verify direction is preserved for current step if (isCurrentStep) { - expect(highlight.direction).toBeDefined(); - expect(["activate", "deactivate"]).toContain(highlight.direction); + expect(highlight.direction).toBeDefined() + expect(['activate', 'deactivate']).toContain(highlight.direction) } - }); - }); + }) + }) - it("should work correctly for first step (currentMultiStep = 0)", () => { + it('should work correctly for first step (currentMultiStep = 0)', () => { // Verify the fix doesn't break the first step - const fromValue = 3; - const toValue = 199; - const currentMultiStep = 0; + const fromValue = 3 + const toValue = 199 + const currentMultiStep = 0 - const beadDiff = calculateBeadDiffFromValues(fromValue, toValue); + const beadDiff = calculateBeadDiffFromValues(fromValue, toValue) const stepBeadHighlights = beadDiff.changes.map((change) => ({ placeValue: change.placeValue, @@ -81,29 +81,29 @@ describe("StepIndex Regression Prevention", () => { direction: change.direction, stepIndex: currentMultiStep, order: change.order, - })); + })) stepBeadHighlights.forEach((highlight) => { - expect(highlight.stepIndex).toBe(0); // Should be 0 for first step + expect(highlight.stepIndex).toBe(0) // Should be 0 for first step // Verify arrows show for first step - const currentStep = currentMultiStep; - const isCurrentStep = highlight.stepIndex === currentStep; - expect(isCurrentStep).toBe(true); - }); - }); + const currentStep = currentMultiStep + const isCurrentStep = highlight.stepIndex === currentStep + expect(isCurrentStep).toBe(true) + }) + }) - it("should work correctly for any arbitrary step index", () => { + it('should work correctly for any arbitrary step index', () => { // Test with various step indices to ensure the pattern holds const testCases = [ { currentMultiStep: 0, fromValue: 0, toValue: 5 }, { currentMultiStep: 1, fromValue: 5, toValue: 15 }, { currentMultiStep: 2, fromValue: 15, toValue: 20 }, { currentMultiStep: 3, fromValue: 20, toValue: 25 }, - ]; + ] testCases.forEach(({ currentMultiStep, fromValue, toValue }) => { - const beadDiff = calculateBeadDiffFromValues(fromValue, toValue); + const beadDiff = calculateBeadDiffFromValues(fromValue, toValue) if (beadDiff.hasChanges) { const stepBeadHighlights = beadDiff.changes.map((change) => ({ @@ -113,63 +113,63 @@ describe("StepIndex Regression Prevention", () => { direction: change.direction, stepIndex: currentMultiStep, order: change.order, - })); + })) stepBeadHighlights.forEach((highlight) => { - expect(highlight.stepIndex).toBe(currentMultiStep); + expect(highlight.stepIndex).toBe(currentMultiStep) // Simulate AbacusReact filtering - const currentStep = currentMultiStep; - const isCurrentStep = highlight.stepIndex === currentStep; - expect(isCurrentStep).toBe(true); - }); + const currentStep = currentMultiStep + const isCurrentStep = highlight.stepIndex === currentStep + expect(isCurrentStep).toBe(true) + }) } - }); - }); + }) + }) - it("should document the exact getBeadStepHighlight logic from AbacusReact", () => { + it('should document the exact getBeadStepHighlight logic from AbacusReact', () => { // This test documents the filtering logic from AbacusReact.tsx // to make the dependency clear and prevent future mismatches const mockStepBeadHighlights = [ { stepIndex: 0, - direction: "activate", + direction: 'activate', placeValue: 0, - beadType: "earth" as const, + beadType: 'earth' as const, }, { stepIndex: 1, - direction: "deactivate", + direction: 'deactivate', placeValue: 1, - beadType: "heaven" as const, + beadType: 'heaven' as const, }, { stepIndex: 2, - direction: "activate", + direction: 'activate', placeValue: 2, - beadType: "earth" as const, + beadType: 'earth' as const, }, - ]; + ] // Test each step index - const stepIndices = [0, 1, 2]; + const stepIndices = [0, 1, 2] stepIndices.forEach((currentStep) => { mockStepBeadHighlights.forEach((highlight) => { // This is the exact logic from getBeadStepHighlight in AbacusReact.tsx:675 - const isCurrentStep = highlight.stepIndex === currentStep; - const isCompleted = highlight.stepIndex < currentStep; - const _isHighlighted = isCurrentStep || isCompleted; - const direction = isCurrentStep ? highlight.direction : undefined; + const isCurrentStep = highlight.stepIndex === currentStep + const isCompleted = highlight.stepIndex < currentStep + const _isHighlighted = isCurrentStep || isCompleted + const direction = isCurrentStep ? highlight.direction : undefined if (highlight.stepIndex === currentStep) { - expect(isCurrentStep).toBe(true); - expect(direction).toBe(highlight.direction); // Arrows only show for current step + expect(isCurrentStep).toBe(true) + expect(direction).toBe(highlight.direction) // Arrows only show for current step } else { - expect(isCurrentStep).toBe(false); - expect(direction).toBeUndefined(); // No arrows for other steps + expect(isCurrentStep).toBe(false) + expect(direction).toBeUndefined() // No arrows for other steps } - }); - }); - }); -}); + }) + }) + }) +}) diff --git a/apps/web/src/utils/test/unifiedStepGenerator.test.ts b/apps/web/src/utils/test/unifiedStepGenerator.test.ts index 6aec10ad..edd841dc 100644 --- a/apps/web/src/utils/test/unifiedStepGenerator.test.ts +++ b/apps/web/src/utils/test/unifiedStepGenerator.test.ts @@ -1,196 +1,191 @@ -import { describe, expect, it } from "vitest"; -import { generateUnifiedInstructionSequence } from "../unifiedStepGenerator"; +import { describe, expect, it } from 'vitest' +import { generateUnifiedInstructionSequence } from '../unifiedStepGenerator' -describe("Unified Step Generator", () => { - it("should generate consistent step data for 3 + 14 = 17", () => { - const sequence = generateUnifiedInstructionSequence(3, 17); +describe('Unified Step Generator', () => { + it('should generate consistent step data for 3 + 14 = 17', () => { + const sequence = generateUnifiedInstructionSequence(3, 17) // Check overall decomposition - expect(sequence.fullDecomposition).toContain("3 + 14 = 3 +"); - expect(sequence.fullDecomposition).toContain("= 17"); - expect(sequence.startValue).toBe(3); - expect(sequence.targetValue).toBe(17); + expect(sequence.fullDecomposition).toContain('3 + 14 = 3 +') + expect(sequence.fullDecomposition).toContain('= 17') + expect(sequence.startValue).toBe(3) + expect(sequence.targetValue).toBe(17) // Should have multiple steps - expect(sequence.steps.length).toBeGreaterThan(1); + expect(sequence.steps.length).toBeGreaterThan(1) // Each step should be valid sequence.steps.forEach((step, index) => { - expect(step.isValid).toBe(true); - expect(step.stepIndex).toBe(index); - expect(step.mathematicalTerm).toBeDefined(); - expect(step.englishInstruction).toBeDefined(); - expect(step.expectedValue).toBeGreaterThan(3); - expect(step.expectedValue).toBeLessThanOrEqual(17); - expect(step.beadMovements.length).toBeGreaterThan(0); - }); + expect(step.isValid).toBe(true) + expect(step.stepIndex).toBe(index) + expect(step.mathematicalTerm).toBeDefined() + expect(step.englishInstruction).toBeDefined() + expect(step.expectedValue).toBeGreaterThan(3) + expect(step.expectedValue).toBeLessThanOrEqual(17) + expect(step.beadMovements.length).toBeGreaterThan(0) + }) // Steps should progress logically - expect(sequence.steps[0].expectedValue).toBeGreaterThan(3); - expect(sequence.steps[sequence.steps.length - 1].expectedValue).toBe(17); + expect(sequence.steps[0].expectedValue).toBeGreaterThan(3) + expect(sequence.steps[sequence.steps.length - 1].expectedValue).toBe(17) - console.log("3 + 14 = 17 Unified Sequence:"); - console.log("Full decomposition:", sequence.fullDecomposition); + console.log('3 + 14 = 17 Unified Sequence:') + console.log('Full decomposition:', sequence.fullDecomposition) sequence.steps.forEach((step, i) => { - console.log(`Step ${i + 1}:`); - console.log(` Math term: ${step.mathematicalTerm}`); - console.log(` Instruction: ${step.englishInstruction}`); - console.log(` Expected value: ${step.expectedValue}`); - console.log(` Bead movements: ${step.beadMovements.length}`); - }); - }); + console.log(`Step ${i + 1}:`) + console.log(` Math term: ${step.mathematicalTerm}`) + console.log(` Instruction: ${step.englishInstruction}`) + console.log(` Expected value: ${step.expectedValue}`) + console.log(` Bead movements: ${step.beadMovements.length}`) + }) + }) - it("should generate consistent step data for 7 + 4 = 11 (ten complement)", () => { - const sequence = generateUnifiedInstructionSequence(7, 11); + it('should generate consistent step data for 7 + 4 = 11 (ten complement)', () => { + const sequence = generateUnifiedInstructionSequence(7, 11) - expect(sequence.fullDecomposition).toContain("7 + 4 = 7 +"); - expect(sequence.fullDecomposition).toContain("= 11"); + expect(sequence.fullDecomposition).toContain('7 + 4 = 7 +') + expect(sequence.fullDecomposition).toContain('= 11') // Should have steps involving both tens and ones places - const allMovements = sequence.steps.flatMap((step) => step.beadMovements); - const places = new Set(allMovements.map((movement) => movement.placeValue)); - expect(places).toContain(0); // ones place - expect(places).toContain(1); // tens place + const allMovements = sequence.steps.flatMap((step) => step.beadMovements) + const places = new Set(allMovements.map((movement) => movement.placeValue)) + expect(places).toContain(0) // ones place + expect(places).toContain(1) // tens place // Each step should be valid and consistent sequence.steps.forEach((step) => { - expect(step.isValid).toBe(true); - expect(step.validationIssues).toEqual([]); - }); + expect(step.isValid).toBe(true) + expect(step.validationIssues).toEqual([]) + }) - console.log("7 + 4 = 11 Unified Sequence:"); - console.log("Full decomposition:", sequence.fullDecomposition); + console.log('7 + 4 = 11 Unified Sequence:') + console.log('Full decomposition:', sequence.fullDecomposition) sequence.steps.forEach((step, i) => { - console.log(`Step ${i + 1}:`); - console.log(` Math term: ${step.mathematicalTerm}`); - console.log(` Instruction: ${step.englishInstruction}`); - console.log(` Expected value: ${step.expectedValue}`); + console.log(`Step ${i + 1}:`) + console.log(` Math term: ${step.mathematicalTerm}`) + console.log(` Instruction: ${step.englishInstruction}`) + console.log(` Expected value: ${step.expectedValue}`) console.log( - ` Movements: ${step.beadMovements.map((m) => `${m.placeValue}:${m.beadType}:${m.direction}`).join(", ")}`, - ); - }); - }); + ` Movements: ${step.beadMovements.map((m) => `${m.placeValue}:${m.beadType}:${m.direction}`).join(', ')}` + ) + }) + }) - it("should ensure perfect consistency between all aspects", () => { + it('should ensure perfect consistency between all aspects', () => { const testCases = [ { start: 3, target: 17 }, { start: 7, target: 11 }, { start: 2, target: 5 }, { start: 56, target: 104 }, - ]; + ] testCases.forEach(({ start, target }) => { - const sequence = generateUnifiedInstructionSequence(start, target); + const sequence = generateUnifiedInstructionSequence(start, target) // All steps must be valid (no inconsistencies) sequence.steps.forEach((step, index) => { - expect(step.isValid).toBe(true); + expect(step.isValid).toBe(true) if (!step.isValid) { console.error( `Step ${index} validation failed for ${start} + ${target - start}:`, - step.validationIssues, - ); + step.validationIssues + ) } - }); + }) // Final step should reach target value - const lastStep = sequence.steps[sequence.steps.length - 1]; - expect(lastStep.expectedValue).toBe(target); + const lastStep = sequence.steps[sequence.steps.length - 1] + expect(lastStep.expectedValue).toBe(target) // No step should have impossible expected values sequence.steps.forEach((step) => { - expect(step.expectedValue).toBeGreaterThanOrEqual(0); - expect(step.expectedValue).toBeLessThan(10000); // reasonable bounds - }); - }); - }); + expect(step.expectedValue).toBeGreaterThanOrEqual(0) + expect(step.expectedValue).toBeLessThan(10000) // reasonable bounds + }) + }) + }) - it("should maintain pedagogical ordering", () => { - const sequence = generateUnifiedInstructionSequence(99, 100); + it('should maintain pedagogical ordering', () => { + const sequence = generateUnifiedInstructionSequence(99, 100) // Should process highest place values first - const firstStepMovements = sequence.steps[0].beadMovements; - const firstStepPlaces = firstStepMovements.map((m) => m.placeValue); + const firstStepMovements = sequence.steps[0].beadMovements + const firstStepPlaces = firstStepMovements.map((m) => m.placeValue) // First step should involve hundreds place for 99 + 1 - expect(firstStepPlaces).toContain(2); // hundreds place + expect(firstStepPlaces).toContain(2) // hundreds place - console.log("99 + 1 = 100 Unified Sequence:"); - console.log("Full decomposition:", sequence.fullDecomposition); + console.log('99 + 1 = 100 Unified Sequence:') + console.log('Full decomposition:', sequence.fullDecomposition) sequence.steps.forEach((step, i) => { - console.log(`Step ${i + 1}:`); - console.log(` Math term: ${step.mathematicalTerm}`); - console.log(` Instruction: ${step.englishInstruction}`); - console.log(` Expected value: ${step.expectedValue}`); - }); - }); + console.log(`Step ${i + 1}:`) + console.log(` Math term: ${step.mathematicalTerm}`) + console.log(` Instruction: ${step.englishInstruction}`) + console.log(` Expected value: ${step.expectedValue}`) + }) + }) - it("should handle edge cases without errors", () => { + it('should handle edge cases without errors', () => { const edgeCases = [ { start: 0, target: 1 }, { start: 4, target: 5 }, { start: 9, target: 10 }, { start: 99, target: 100 }, - ]; + ] edgeCases.forEach(({ start, target }) => { expect(() => { - const sequence = generateUnifiedInstructionSequence(start, target); - expect(sequence.steps.length).toBeGreaterThan(0); - expect(sequence.steps.every((step) => step.isValid)).toBe(true); - }).not.toThrow(); - }); - }); + const sequence = generateUnifiedInstructionSequence(start, target) + expect(sequence.steps.length).toBeGreaterThan(0) + expect(sequence.steps.every((step) => step.isValid)).toBe(true) + }).not.toThrow() + }) + }) - it("should generate correct term positions for precise highlighting", () => { - const sequence = generateUnifiedInstructionSequence(3, 17); + it('should generate correct term positions for precise highlighting', () => { + const sequence = generateUnifiedInstructionSequence(3, 17) // Full decomposition should be "3 + 14 = 3 + 10 + (5 - 1) = 17" - expect(sequence.fullDecomposition).toBe("3 + 14 = 3 + 10 + (5 - 1) = 17"); + expect(sequence.fullDecomposition).toBe('3 + 14 = 3 + 10 + (5 - 1) = 17') // Each step should have valid position information sequence.steps.forEach((step, index) => { - expect(step.termPosition).toBeDefined(); - expect(step.termPosition.startIndex).toBeGreaterThanOrEqual(0); - expect(step.termPosition.endIndex).toBeGreaterThan( - step.termPosition.startIndex, - ); + expect(step.termPosition).toBeDefined() + expect(step.termPosition.startIndex).toBeGreaterThanOrEqual(0) + expect(step.termPosition.endIndex).toBeGreaterThan(step.termPosition.startIndex) // Extract the term using the position and verify it matches - const { startIndex, endIndex } = step.termPosition; - const extractedTerm = sequence.fullDecomposition.substring( - startIndex, - endIndex, - ); - expect(extractedTerm).toBe(step.mathematicalTerm); + const { startIndex, endIndex } = step.termPosition + const extractedTerm = sequence.fullDecomposition.substring(startIndex, endIndex) + expect(extractedTerm).toBe(step.mathematicalTerm) console.log( - `Step ${index + 1}: "${step.mathematicalTerm}" at position ${startIndex}-${endIndex} = "${extractedTerm}"`, - ); - }); + `Step ${index + 1}: "${step.mathematicalTerm}" at position ${startIndex}-${endIndex} = "${extractedTerm}"` + ) + }) // Verify specific positions for known case "3 + 14 = 3 + 10 + (5 - 1) = 17" // 0123456789012345678901234567890123456 // 0 1 2 3 // First step should be "10" at position around 13-15 - const firstStep = sequence.steps[0]; - expect(firstStep.mathematicalTerm).toBe("10"); + const firstStep = sequence.steps[0] + expect(firstStep.mathematicalTerm).toBe('10') expect( sequence.fullDecomposition.substring( firstStep.termPosition.startIndex, - firstStep.termPosition.endIndex, - ), - ).toBe("10"); + firstStep.termPosition.endIndex + ) + ).toBe('10') // Second step should be "(5 - 1)" at position around 18-25 - const secondStep = sequence.steps[1]; - expect(secondStep.mathematicalTerm).toBe("(5 - 1)"); + const secondStep = sequence.steps[1] + expect(secondStep.mathematicalTerm).toBe('(5 - 1)') expect( sequence.fullDecomposition.substring( secondStep.termPosition.startIndex, - secondStep.termPosition.endIndex, - ), - ).toBe("(5 - 1)"); - }); -}); + secondStep.termPosition.endIndex + ) + ).toBe('(5 - 1)') + }) +}) diff --git a/apps/web/src/utils/themedWords.ts b/apps/web/src/utils/themedWords.ts index e46c857a..5cca47d9 100644 --- a/apps/web/src/utils/themedWords.ts +++ b/apps/web/src/utils/themedWords.ts @@ -6,13 +6,13 @@ */ export interface ThemedWordList { - adjectives: string[]; - nouns: string[]; + adjectives: string[] + nouns: string[] } export interface AvatarTheme { - category: string; - words: ThemedWordList; + category: string + words: ThemedWordList } /** @@ -23,2703 +23,2703 @@ export const THEMED_WORD_LISTS: Record = { // === ABACUS === abacus: { adjectives: [ - "Ancient", - "Wooden", - "Sliding", - "Decimal", - "Binary", - "Counting", - "Soroban", - "Chinese", - "Japanese", - "Nimble", - "Clicking", - "Beaded", - "Columnar", - "Vertical", - "Horizontal", - "Upper", - "Lower", - "Heaven", - "Earth", - "Golden", - "Jade", - "Bamboo", - "Polished", - "Skilled", - "Master", - "Adding", - "Subtracting", - "Multiplying", - "Dividing", - "Calculating", - "Computing", - "Precise", - "Accurate", - "Lightning", - "Rapid", + 'Ancient', + 'Wooden', + 'Sliding', + 'Decimal', + 'Binary', + 'Counting', + 'Soroban', + 'Chinese', + 'Japanese', + 'Nimble', + 'Clicking', + 'Beaded', + 'Columnar', + 'Vertical', + 'Horizontal', + 'Upper', + 'Lower', + 'Heaven', + 'Earth', + 'Golden', + 'Jade', + 'Bamboo', + 'Polished', + 'Skilled', + 'Master', + 'Adding', + 'Subtracting', + 'Multiplying', + 'Dividing', + 'Calculating', + 'Computing', + 'Precise', + 'Accurate', + 'Lightning', + 'Rapid', ], nouns: [ - "Counter", - "Abacist", - "Calculator", - "Bead", - "Rod", - "Frame", - "Slider", - "Merchant", - "Trader", - "Accountant", - "Bookkeeper", - "Clerk", - "Scribe", - "Master", - "Apprentice", - "Scholar", - "Student", - "Teacher", - "Sensei", - "Guru", - "Expert", - "Virtuoso", - "Prodigy", - "Wizard", - "Sage", - "Adder", - "Multiplier", - "Divider", - "Solver", - "Mathematician", - "Arithmetician", - "Analyst", - "Computer", - "Reckoner", + 'Counter', + 'Abacist', + 'Calculator', + 'Bead', + 'Rod', + 'Frame', + 'Slider', + 'Merchant', + 'Trader', + 'Accountant', + 'Bookkeeper', + 'Clerk', + 'Scribe', + 'Master', + 'Apprentice', + 'Scholar', + 'Student', + 'Teacher', + 'Sensei', + 'Guru', + 'Expert', + 'Virtuoso', + 'Prodigy', + 'Wizard', + 'Sage', + 'Adder', + 'Multiplier', + 'Divider', + 'Solver', + 'Mathematician', + 'Arithmetician', + 'Analyst', + 'Computer', + 'Reckoner', ], }, // === HAPPY FACES (😀😃😄😁😆😅🤣😂🙂😉😊😇) === happyFaces: { adjectives: [ - "Cheerful", - "Joyful", - "Gleeful", - "Merry", - "Bright", - "Sunny", - "Radiant", - "Beaming", - "Grinning", - "Smiling", - "Laughing", - "Giggling", - "Chuckling", - "Jolly", - "Bubbly", - "Peppy", - "Perky", - "Chipper", - "Upbeat", - "Lively", - "Spirited", - "Vibrant", - "Exuberant", - "Jubilant", - "Ecstatic", + 'Cheerful', + 'Joyful', + 'Gleeful', + 'Merry', + 'Bright', + 'Sunny', + 'Radiant', + 'Beaming', + 'Grinning', + 'Smiling', + 'Laughing', + 'Giggling', + 'Chuckling', + 'Jolly', + 'Bubbly', + 'Peppy', + 'Perky', + 'Chipper', + 'Upbeat', + 'Lively', + 'Spirited', + 'Vibrant', + 'Exuberant', + 'Jubilant', + 'Ecstatic', ], nouns: [ - "Optimist", - "Smiler", - "Grinner", - "Laugher", - "Giggler", - "Jokester", - "Comedian", - "Entertainer", - "Cheerleader", - "Motivator", - "Inspirer", - "Brightener", - "Sunshine", - "Rainbow", - "Spark", - "Beacon", - "Light", - "Champ", - "Winner", - "Star", - "Buddy", - "Friend", - "Pal", - "Companion", + 'Optimist', + 'Smiler', + 'Grinner', + 'Laugher', + 'Giggler', + 'Jokester', + 'Comedian', + 'Entertainer', + 'Cheerleader', + 'Motivator', + 'Inspirer', + 'Brightener', + 'Sunshine', + 'Rainbow', + 'Spark', + 'Beacon', + 'Light', + 'Champ', + 'Winner', + 'Star', + 'Buddy', + 'Friend', + 'Pal', + 'Companion', ], }, // === LOVE & AFFECTION (🥰😍🤩😘😗😚) === loveAndAffection: { adjectives: [ - "Loving", - "Caring", - "Tender", - "Sweet", - "Affectionate", - "Warm", - "Gentle", - "Kind", - "Compassionate", - "Devoted", - "Adoring", - "Cherishing", - "Doting", - "Romantic", - "Passionate", - "Heart-eyed", - "Starry-eyed", - "Smitten", - "Charming", - "Endearing", - "Delightful", - "Adorable", - "Precious", + 'Loving', + 'Caring', + 'Tender', + 'Sweet', + 'Affectionate', + 'Warm', + 'Gentle', + 'Kind', + 'Compassionate', + 'Devoted', + 'Adoring', + 'Cherishing', + 'Doting', + 'Romantic', + 'Passionate', + 'Heart-eyed', + 'Starry-eyed', + 'Smitten', + 'Charming', + 'Endearing', + 'Delightful', + 'Adorable', + 'Precious', ], nouns: [ - "Sweetheart", - "Darling", - "Beloved", - "Admirer", - "Romantic", - "Lover", - "Charmer", - "Cupid", - "Heart", - "Angel", - "Treasure", - "Gem", - "Pearl", - "Dreamboat", - "Heartthrob", - "Cutie", - "Honey", - "Sugarplum", - "Buttercup", - "Valentine", - "Soulmate", - "Companion", - "Partner", + 'Sweetheart', + 'Darling', + 'Beloved', + 'Admirer', + 'Romantic', + 'Lover', + 'Charmer', + 'Cupid', + 'Heart', + 'Angel', + 'Treasure', + 'Gem', + 'Pearl', + 'Dreamboat', + 'Heartthrob', + 'Cutie', + 'Honey', + 'Sugarplum', + 'Buttercup', + 'Valentine', + 'Soulmate', + 'Companion', + 'Partner', ], }, // === PLAYFUL (😋😛😝😜🤪) === playful: { adjectives: [ - "Playful", - "Silly", - "Goofy", - "Wacky", - "Zany", - "Quirky", - "Whimsical", - "Mischievous", - "Cheeky", - "Sassy", - "Impish", - "Teasing", - "Joking", - "Fun-loving", - "Carefree", - "Wild", - "Crazy", - "Nutty", - "Loopy", - "Bonkers", - "Ridiculous", - "Absurd", - "Hilarious", - "Amusing", + 'Playful', + 'Silly', + 'Goofy', + 'Wacky', + 'Zany', + 'Quirky', + 'Whimsical', + 'Mischievous', + 'Cheeky', + 'Sassy', + 'Impish', + 'Teasing', + 'Joking', + 'Fun-loving', + 'Carefree', + 'Wild', + 'Crazy', + 'Nutty', + 'Loopy', + 'Bonkers', + 'Ridiculous', + 'Absurd', + 'Hilarious', + 'Amusing', ], nouns: [ - "Jester", - "Clown", - "Prankster", - "Trickster", - "Imp", - "Rascal", - "Scamp", - "Goofball", - "Nutcase", - "Wildcard", - "Joker", - "Goofster", - "Silly-billy", - "Cutup", - "Character", - "Card", - "Comedian", - "Entertainer", - "Performer", - "Funster", - "Merrymaker", - "Reveler", - "Buffoon", + 'Jester', + 'Clown', + 'Prankster', + 'Trickster', + 'Imp', + 'Rascal', + 'Scamp', + 'Goofball', + 'Nutcase', + 'Wildcard', + 'Joker', + 'Goofster', + 'Silly-billy', + 'Cutup', + 'Character', + 'Card', + 'Comedian', + 'Entertainer', + 'Performer', + 'Funster', + 'Merrymaker', + 'Reveler', + 'Buffoon', ], }, // === CLEVER (🤨🧐🤓) === clever: { adjectives: [ - "Clever", - "Smart", - "Intelligent", - "Bright", - "Brilliant", - "Astute", - "Sharp", - "Keen", - "Quick", - "Witty", - "Shrewd", - "Savvy", - "Wise", - "Sage", - "Learned", - "Scholarly", - "Academic", - "Studious", - "Intellectual", - "Cerebral", - "Analytical", - "Logical", - "Rational", - "Thoughtful", - "Insightful", + 'Clever', + 'Smart', + 'Intelligent', + 'Bright', + 'Brilliant', + 'Astute', + 'Sharp', + 'Keen', + 'Quick', + 'Witty', + 'Shrewd', + 'Savvy', + 'Wise', + 'Sage', + 'Learned', + 'Scholarly', + 'Academic', + 'Studious', + 'Intellectual', + 'Cerebral', + 'Analytical', + 'Logical', + 'Rational', + 'Thoughtful', + 'Insightful', ], nouns: [ - "Scholar", - "Professor", - "Genius", - "Intellectual", - "Thinker", - "Philosopher", - "Researcher", - "Analyst", - "Scientist", - "Expert", - "Specialist", - "Authority", - "Savant", - "Prodigy", - "Mastermind", - "Brain", - "Whiz", - "Ace", - "Maven", - "Pundit", - "Sage", - "Oracle", - "Mentor", - "Teacher", + 'Scholar', + 'Professor', + 'Genius', + 'Intellectual', + 'Thinker', + 'Philosopher', + 'Researcher', + 'Analyst', + 'Scientist', + 'Expert', + 'Specialist', + 'Authority', + 'Savant', + 'Prodigy', + 'Mastermind', + 'Brain', + 'Whiz', + 'Ace', + 'Maven', + 'Pundit', + 'Sage', + 'Oracle', + 'Mentor', + 'Teacher', ], }, // === COOL (😎🥸) === cool: { adjectives: [ - "Cool", - "Slick", - "Smooth", - "Suave", - "Stylish", - "Chic", - "Hip", - "Trendy", - "Fresh", - "Fly", - "Dapper", - "Sharp", - "Snazzy", - "Classy", - "Sleek", - "Rad", - "Awesome", - "Epic", - "Stellar", - "Prime", - "Boss", - "Supreme", - "Confident", - "Bold", - "Daring", + 'Cool', + 'Slick', + 'Smooth', + 'Suave', + 'Stylish', + 'Chic', + 'Hip', + 'Trendy', + 'Fresh', + 'Fly', + 'Dapper', + 'Sharp', + 'Snazzy', + 'Classy', + 'Sleek', + 'Rad', + 'Awesome', + 'Epic', + 'Stellar', + 'Prime', + 'Boss', + 'Supreme', + 'Confident', + 'Bold', + 'Daring', ], nouns: [ - "Dude", - "Cat", - "Player", - "Hotshot", - "Rockstar", - "Maverick", - "Rebel", - "Icon", - "Legend", - "Champion", - "Ace", - "Pro", - "Boss", - "Chief", - "Superstar", - "VIP", - "Celebrity", - "Trendsetter", - "Influencer", - "Phenomenon", - "Sensation", - "Trailblazer", - "Pioneer", + 'Dude', + 'Cat', + 'Player', + 'Hotshot', + 'Rockstar', + 'Maverick', + 'Rebel', + 'Icon', + 'Legend', + 'Champion', + 'Ace', + 'Pro', + 'Boss', + 'Chief', + 'Superstar', + 'VIP', + 'Celebrity', + 'Trendsetter', + 'Influencer', + 'Phenomenon', + 'Sensation', + 'Trailblazer', + 'Pioneer', ], }, // === PARTY (🥳) === party: { adjectives: [ - "Festive", - "Celebratory", - "Jubilant", - "Lively", - "Energetic", - "Exciting", - "Thrilling", - "Electric", - "Pumped", - "Hyped", - "Enthusiastic", - "Spirited", - "Vivacious", - "Buoyant", - "Effervescent", - "Sparkling", - "Dazzling", - "Spectacular", - "Magnificent", - "Fabulous", - "Glitzy", - "Glamorous", + 'Festive', + 'Celebratory', + 'Jubilant', + 'Lively', + 'Energetic', + 'Exciting', + 'Thrilling', + 'Electric', + 'Pumped', + 'Hyped', + 'Enthusiastic', + 'Spirited', + 'Vivacious', + 'Buoyant', + 'Effervescent', + 'Sparkling', + 'Dazzling', + 'Spectacular', + 'Magnificent', + 'Fabulous', + 'Glitzy', + 'Glamorous', ], nouns: [ - "Partier", - "Celebrator", - "Reveler", - "Merrymaker", - "Socialite", - "Host", - "Entertainer", - "Showstopper", - "Star", - "Sensation", - "Headliner", - "Performer", - "DJ", - "MC", - "Maestro", - "Conductor", - "Director", - "Producer", - "Coordinator", - "Organizer", - "Planner", + 'Partier', + 'Celebrator', + 'Reveler', + 'Merrymaker', + 'Socialite', + 'Host', + 'Entertainer', + 'Showstopper', + 'Star', + 'Sensation', + 'Headliner', + 'Performer', + 'DJ', + 'MC', + 'Maestro', + 'Conductor', + 'Director', + 'Producer', + 'Coordinator', + 'Organizer', + 'Planner', ], }, // === MOODY (😏😒😞😔😟😕) === moody: { adjectives: [ - "Moody", - "Brooding", - "Pensive", - "Thoughtful", - "Contemplative", - "Reflective", - "Introspective", - "Deep", - "Mysterious", - "Enigmatic", - "Complex", - "Layered", - "Nuanced", - "Subtle", - "Reserved", - "Quiet", - "Serious", - "Somber", - "Melancholic", - "Wistful", - "Nostalgic", - "Sentimental", - "Emotional", + 'Moody', + 'Brooding', + 'Pensive', + 'Thoughtful', + 'Contemplative', + 'Reflective', + 'Introspective', + 'Deep', + 'Mysterious', + 'Enigmatic', + 'Complex', + 'Layered', + 'Nuanced', + 'Subtle', + 'Reserved', + 'Quiet', + 'Serious', + 'Somber', + 'Melancholic', + 'Wistful', + 'Nostalgic', + 'Sentimental', + 'Emotional', ], nouns: [ - "Thinker", - "Philosopher", - "Poet", - "Artist", - "Dreamer", - "Visionary", - "Rebel", - "Maverick", - "Outsider", - "Loner", - "Wanderer", - "Drifter", - "Seeker", - "Explorer", - "Questioner", - "Skeptic", - "Critic", - "Observer", - "Watcher", - "Witness", - "Soul", - "Spirit", - "Heart", + 'Thinker', + 'Philosopher', + 'Poet', + 'Artist', + 'Dreamer', + 'Visionary', + 'Rebel', + 'Maverick', + 'Outsider', + 'Loner', + 'Wanderer', + 'Drifter', + 'Seeker', + 'Explorer', + 'Questioner', + 'Skeptic', + 'Critic', + 'Observer', + 'Watcher', + 'Witness', + 'Soul', + 'Spirit', + 'Heart', ], }, // === WILD WEST (🤠) === wildWest: { adjectives: [ - "Wild", - "Rugged", - "Dusty", - "Frontier", - "Outlaw", - "Lawless", - "Rough", - "Tough", - "Hardy", - "Gritty", - "Weathered", - "Leather", - "Denim", - "Bronzed", - "Sun-baked", - "Prairie", - "Desert", - "Mountain", - "Canyon", - "Trail-blazing", - "Pioneer", - "Homestead", - "Ranch", - "Rodeo", - "Saddle-worn", + 'Wild', + 'Rugged', + 'Dusty', + 'Frontier', + 'Outlaw', + 'Lawless', + 'Rough', + 'Tough', + 'Hardy', + 'Gritty', + 'Weathered', + 'Leather', + 'Denim', + 'Bronzed', + 'Sun-baked', + 'Prairie', + 'Desert', + 'Mountain', + 'Canyon', + 'Trail-blazing', + 'Pioneer', + 'Homestead', + 'Ranch', + 'Rodeo', + 'Saddle-worn', ], nouns: [ - "Cowboy", - "Cowgirl", - "Rancher", - "Wrangler", - "Rustler", - "Gunslinger", - "Outlaw", - "Sheriff", - "Marshal", - "Deputy", - "Ranger", - "Scout", - "Trapper", - "Prospector", - "Miner", - "Pioneer", - "Settler", - "Homesteader", - "Buckaroo", - "Vaquero", - "Bronco-buster", - "Lasso", - "Spur", - "Boot", + 'Cowboy', + 'Cowgirl', + 'Rancher', + 'Wrangler', + 'Rustler', + 'Gunslinger', + 'Outlaw', + 'Sheriff', + 'Marshal', + 'Deputy', + 'Ranger', + 'Scout', + 'Trapper', + 'Prospector', + 'Miner', + 'Pioneer', + 'Settler', + 'Homesteader', + 'Buckaroo', + 'Vaquero', + 'Bronco-buster', + 'Lasso', + 'Spur', + 'Boot', ], }, // === NINJA (🥷) === ninja: { adjectives: [ - "Stealthy", - "Silent", - "Shadow", - "Swift", - "Agile", - "Quick", - "Lightning", - "Deadly", - "Precise", - "Accurate", - "Focused", - "Disciplined", - "Trained", - "Skilled", - "Expert", - "Master", - "Ancient", - "Mysterious", - "Secretive", - "Hidden", - "Invisible", - "Phantom", - "Ghostly", - "Elusive", - "Cunning", + 'Stealthy', + 'Silent', + 'Shadow', + 'Swift', + 'Agile', + 'Quick', + 'Lightning', + 'Deadly', + 'Precise', + 'Accurate', + 'Focused', + 'Disciplined', + 'Trained', + 'Skilled', + 'Expert', + 'Master', + 'Ancient', + 'Mysterious', + 'Secretive', + 'Hidden', + 'Invisible', + 'Phantom', + 'Ghostly', + 'Elusive', + 'Cunning', ], nouns: [ - "Ninja", - "Shinobi", - "Assassin", - "Warrior", - "Fighter", - "Striker", - "Shadow", - "Phantom", - "Ghost", - "Wraith", - "Specter", - "Spirit", - "Blade", - "Katana", - "Shuriken", - "Kunai", - "Master", - "Sensei", - "Apprentice", - "Student", - "Disciple", - "Acolyte", - "Initiate", + 'Ninja', + 'Shinobi', + 'Assassin', + 'Warrior', + 'Fighter', + 'Striker', + 'Shadow', + 'Phantom', + 'Ghost', + 'Wraith', + 'Specter', + 'Spirit', + 'Blade', + 'Katana', + 'Shuriken', + 'Kunai', + 'Master', + 'Sensei', + 'Apprentice', + 'Student', + 'Disciple', + 'Acolyte', + 'Initiate', ], }, // === ROYAL (👑) === royal: { adjectives: [ - "Royal", - "Regal", - "Majestic", - "Noble", - "Aristocratic", - "Imperial", - "Sovereign", - "Crowned", - "Enthroned", - "Supreme", - "Grand", - "Magnificent", - "Elegant", - "Refined", - "Distinguished", - "Prestigious", - "Esteemed", - "Honored", - "Dignified", - "Stately", - "Splendid", - "Glorious", - "Resplendent", + 'Royal', + 'Regal', + 'Majestic', + 'Noble', + 'Aristocratic', + 'Imperial', + 'Sovereign', + 'Crowned', + 'Enthroned', + 'Supreme', + 'Grand', + 'Magnificent', + 'Elegant', + 'Refined', + 'Distinguished', + 'Prestigious', + 'Esteemed', + 'Honored', + 'Dignified', + 'Stately', + 'Splendid', + 'Glorious', + 'Resplendent', ], nouns: [ - "King", - "Queen", - "Prince", - "Princess", - "Emperor", - "Empress", - "Monarch", - "Sovereign", - "Ruler", - "Lord", - "Lady", - "Duke", - "Duchess", - "Count", - "Countess", - "Baron", - "Baroness", - "Noble", - "Aristocrat", - "Regent", - "Throne", - "Crown", - "Scepter", - "Majesty", + 'King', + 'Queen', + 'Prince', + 'Princess', + 'Emperor', + 'Empress', + 'Monarch', + 'Sovereign', + 'Ruler', + 'Lord', + 'Lady', + 'Duke', + 'Duchess', + 'Count', + 'Countess', + 'Baron', + 'Baroness', + 'Noble', + 'Aristocrat', + 'Regent', + 'Throne', + 'Crown', + 'Scepter', + 'Majesty', ], }, // === THEATER (🎭) === theater: { adjectives: [ - "Dramatic", - "Theatrical", - "Expressive", - "Passionate", - "Emotive", - "演Technical", - "Comedic", - "Tragic", - "Epic", - "Classical", - "Modern", - "Broadway", - "Shakespearean", - "Method", - "Improvised", - "Scripted", - "Rehearsed", - "Polished", - "Raw", - "Powerful", - "Moving", - "Captivating", - "Mesmerizing", - "Spellbinding", - "Riveting", + 'Dramatic', + 'Theatrical', + 'Expressive', + 'Passionate', + 'Emotive', + '演Technical', + 'Comedic', + 'Tragic', + 'Epic', + 'Classical', + 'Modern', + 'Broadway', + 'Shakespearean', + 'Method', + 'Improvised', + 'Scripted', + 'Rehearsed', + 'Polished', + 'Raw', + 'Powerful', + 'Moving', + 'Captivating', + 'Mesmerizing', + 'Spellbinding', + 'Riveting', ], nouns: [ - "Actor", - "Actress", - "Performer", - "Thespian", - "Player", - "Star", - "Lead", - "Supporting", - "Understudy", - "Director", - "Producer", - "Playwright", - "Dramatist", - "Screenwriter", - "Character", - "Role", - "Part", - "Scene", - "Act", - "Stage", - "Spotlight", - "Curtain", - "Encore", - "Ovation", + 'Actor', + 'Actress', + 'Performer', + 'Thespian', + 'Player', + 'Star', + 'Lead', + 'Supporting', + 'Understudy', + 'Director', + 'Producer', + 'Playwright', + 'Dramatist', + 'Screenwriter', + 'Character', + 'Role', + 'Part', + 'Scene', + 'Act', + 'Stage', + 'Spotlight', + 'Curtain', + 'Encore', + 'Ovation', ], }, // === ROBOT (🤖) === robot: { adjectives: [ - "Robotic", - "Mechanical", - "Automated", - "Digital", - "Electronic", - "Cyber", - "Synthetic", - "Artificial", - "Programmed", - "Computed", - "Binary", - "Circuit", - "Metal", - "Steel", - "Chrome", - "Titanium", - "Nano", - "Micro", - "Tech", - "Advanced", - "Futuristic", - "Modern", - "Sleek", - "Efficient", - "Optimal", + 'Robotic', + 'Mechanical', + 'Automated', + 'Digital', + 'Electronic', + 'Cyber', + 'Synthetic', + 'Artificial', + 'Programmed', + 'Computed', + 'Binary', + 'Circuit', + 'Metal', + 'Steel', + 'Chrome', + 'Titanium', + 'Nano', + 'Micro', + 'Tech', + 'Advanced', + 'Futuristic', + 'Modern', + 'Sleek', + 'Efficient', + 'Optimal', ], nouns: [ - "Robot", - "Android", - "Cyborg", - "Bot", - "Droid", - "Automaton", - "Machine", - "Computer", - "Processor", - "CPU", - "Algorithm", - "Protocol", - "System", - "Network", - "Interface", - "Terminal", - "Console", - "Module", - "Unit", - "Component", - "Circuit", - "Chip", - "Byte", - "Core", + 'Robot', + 'Android', + 'Cyborg', + 'Bot', + 'Droid', + 'Automaton', + 'Machine', + 'Computer', + 'Processor', + 'CPU', + 'Algorithm', + 'Protocol', + 'System', + 'Network', + 'Interface', + 'Terminal', + 'Console', + 'Module', + 'Unit', + 'Component', + 'Circuit', + 'Chip', + 'Byte', + 'Core', ], }, // === SPOOKY (👻💀) === spooky: { adjectives: [ - "Spooky", - "Ghostly", - "Haunted", - "Eerie", - "Creepy", - "Scary", - "Frightening", - "Chilling", - "Bone-chilling", - "Spine-tingling", - "Hair-raising", - "Supernatural", - "Paranormal", - "Spectral", - "Phantom", - "Ethereal", - "Otherworldly", - "Mysterious", - "Uncanny", - "Sinister", - "Ominous", - "Foreboding", - "Dark", + 'Spooky', + 'Ghostly', + 'Haunted', + 'Eerie', + 'Creepy', + 'Scary', + 'Frightening', + 'Chilling', + 'Bone-chilling', + 'Spine-tingling', + 'Hair-raising', + 'Supernatural', + 'Paranormal', + 'Spectral', + 'Phantom', + 'Ethereal', + 'Otherworldly', + 'Mysterious', + 'Uncanny', + 'Sinister', + 'Ominous', + 'Foreboding', + 'Dark', ], nouns: [ - "Ghost", - "Specter", - "Phantom", - "Spirit", - "Wraith", - "Apparition", - "Poltergeist", - "Haunt", - "Skeleton", - "Bones", - "Skull", - "Reaper", - "Shadow", - "Shade", - "Banshee", - "Ghoul", - "Zombie", - "Revenant", - "Spook", - "Phantasm", - "Ectoplasm", - "Entity", - "Presence", + 'Ghost', + 'Specter', + 'Phantom', + 'Spirit', + 'Wraith', + 'Apparition', + 'Poltergeist', + 'Haunt', + 'Skeleton', + 'Bones', + 'Skull', + 'Reaper', + 'Shadow', + 'Shade', + 'Banshee', + 'Ghoul', + 'Zombie', + 'Revenant', + 'Spook', + 'Phantasm', + 'Ectoplasm', + 'Entity', + 'Presence', ], }, // === ALIEN (👽) === alien: { adjectives: [ - "Alien", - "Extraterrestrial", - "Cosmic", - "Galactic", - "Interstellar", - "Universal", - "Celestial", - "Astral", - "Stellar", - "Lunar", - "Solar", - "Planetary", - "Nebular", - "Orbital", - "Space-faring", - "Otherworldly", - "Unearthly", - "Foreign", - "Strange", - "Unknown", - "Mysterious", - "Enigmatic", - "Advanced", + 'Alien', + 'Extraterrestrial', + 'Cosmic', + 'Galactic', + 'Interstellar', + 'Universal', + 'Celestial', + 'Astral', + 'Stellar', + 'Lunar', + 'Solar', + 'Planetary', + 'Nebular', + 'Orbital', + 'Space-faring', + 'Otherworldly', + 'Unearthly', + 'Foreign', + 'Strange', + 'Unknown', + 'Mysterious', + 'Enigmatic', + 'Advanced', ], nouns: [ - "Alien", - "Extraterrestrial", - "Visitor", - "Traveler", - "Explorer", - "Scout", - "Ambassador", - "Diplomat", - "Envoy", - "Emissary", - "Being", - "Entity", - "Creature", - "Lifeform", - "Species", - "Civilization", - "Culture", - "Society", - "UFO", - "Saucer", - "Craft", - "Vessel", - "Ship", - "Probe", + 'Alien', + 'Extraterrestrial', + 'Visitor', + 'Traveler', + 'Explorer', + 'Scout', + 'Ambassador', + 'Diplomat', + 'Envoy', + 'Emissary', + 'Being', + 'Entity', + 'Creature', + 'Lifeform', + 'Species', + 'Civilization', + 'Culture', + 'Society', + 'UFO', + 'Saucer', + 'Craft', + 'Vessel', + 'Ship', + 'Probe', ], }, // === CIRCUS (🤡) === circus: { adjectives: [ - "Circus", - "Clownish", - "Comical", - "Amusing", - "Entertaining", - "Spectacular", - "Dazzling", - "Amazing", - "Astonishing", - "Breathtaking", - "Thrilling", - "Exciting", - "Colorful", - "Vibrant", - "Bright", - "Flashy", - "Showy", - "Acrobatic", - "Juggling", - "Balancing", - "High-flying", - "Death-defying", + 'Circus', + 'Clownish', + 'Comical', + 'Amusing', + 'Entertaining', + 'Spectacular', + 'Dazzling', + 'Amazing', + 'Astonishing', + 'Breathtaking', + 'Thrilling', + 'Exciting', + 'Colorful', + 'Vibrant', + 'Bright', + 'Flashy', + 'Showy', + 'Acrobatic', + 'Juggling', + 'Balancing', + 'High-flying', + 'Death-defying', ], nouns: [ - "Clown", - "Jester", - "Performer", - "Acrobat", - "Juggler", - "Tightrope-walker", - "Trapeze-artist", - "Ringmaster", - "Showman", - "Entertainer", - "Magician", - "Illusionist", - "Prestidigitator", - "Fire-eater", - "Sword-swallower", - "Strongman", - "Contortionist", - "Mime", - "Harlequin", - "Pierrot", - "Buffoon", + 'Clown', + 'Jester', + 'Performer', + 'Acrobat', + 'Juggler', + 'Tightrope-walker', + 'Trapeze-artist', + 'Ringmaster', + 'Showman', + 'Entertainer', + 'Magician', + 'Illusionist', + 'Prestidigitator', + 'Fire-eater', + 'Sword-swallower', + 'Strongman', + 'Contortionist', + 'Mime', + 'Harlequin', + 'Pierrot', + 'Buffoon', ], }, // === WIZARDS (🧙‍♂️🧙‍♀️) === wizards: { adjectives: [ - "Magical", - "Mystical", - "Enchanted", - "Spellbinding", - "Sorcerous", - "Arcane", - "Occult", - "Esoteric", - "Mysterious", - "Ancient", - "Wise", - "Powerful", - "Potent", - "Mighty", - "Supreme", - "Grand", - "High", - "Elder", - "Venerable", - "Bearded", - "Robed", - "Staffed", - "Runic", - "Incantatory", - "Alchemical", + 'Magical', + 'Mystical', + 'Enchanted', + 'Spellbinding', + 'Sorcerous', + 'Arcane', + 'Occult', + 'Esoteric', + 'Mysterious', + 'Ancient', + 'Wise', + 'Powerful', + 'Potent', + 'Mighty', + 'Supreme', + 'Grand', + 'High', + 'Elder', + 'Venerable', + 'Bearded', + 'Robed', + 'Staffed', + 'Runic', + 'Incantatory', + 'Alchemical', ], nouns: [ - "Wizard", - "Sorcerer", - "Mage", - "Magician", - "Warlock", - "Enchanter", - "Spellcaster", - "Conjurer", - "Summoner", - "Diviner", - "Oracle", - "Seer", - "Prophet", - "Sage", - "Mystic", - "Adept", - "Arcanist", - "Thaumaturge", - "Alchemist", - "Elementalist", - "Necromancer", - "Illusionist", - "Staff", - "Wand", + 'Wizard', + 'Sorcerer', + 'Mage', + 'Magician', + 'Warlock', + 'Enchanter', + 'Spellcaster', + 'Conjurer', + 'Summoner', + 'Diviner', + 'Oracle', + 'Seer', + 'Prophet', + 'Sage', + 'Mystic', + 'Adept', + 'Arcanist', + 'Thaumaturge', + 'Alchemist', + 'Elementalist', + 'Necromancer', + 'Illusionist', + 'Staff', + 'Wand', ], }, // === FAIRIES (🧚‍♂️🧚‍♀️) === fairies: { adjectives: [ - "Fairy", - "Fae", - "Enchanted", - "Magical", - "Whimsical", - "Delicate", - "Dainty", - "Graceful", - "Elegant", - "Beautiful", - "Lovely", - "Pretty", - "Charming", - "Ethereal", - "Gossamer", - "Shimmering", - "Glittering", - "Sparkling", - "Twinkling", - "Glowing", - "Luminous", - "Radiant", - "Iridescent", - "Winged", + 'Fairy', + 'Fae', + 'Enchanted', + 'Magical', + 'Whimsical', + 'Delicate', + 'Dainty', + 'Graceful', + 'Elegant', + 'Beautiful', + 'Lovely', + 'Pretty', + 'Charming', + 'Ethereal', + 'Gossamer', + 'Shimmering', + 'Glittering', + 'Sparkling', + 'Twinkling', + 'Glowing', + 'Luminous', + 'Radiant', + 'Iridescent', + 'Winged', ], nouns: [ - "Fairy", - "Sprite", - "Pixie", - "Nymph", - "Sylph", - "Dryad", - "Naiad", - "Fae", - "Faerie", - "Changeling", - "Sidhe", - "Enchantress", - "Spirit", - "Being", - "Creature", - "Guardian", - "Protector", - "Keeper", - "Warden", - "Wing", - "Dewdrop", - "Moonbeam", - "Starlight", - "Blossom", + 'Fairy', + 'Sprite', + 'Pixie', + 'Nymph', + 'Sylph', + 'Dryad', + 'Naiad', + 'Fae', + 'Faerie', + 'Changeling', + 'Sidhe', + 'Enchantress', + 'Spirit', + 'Being', + 'Creature', + 'Guardian', + 'Protector', + 'Keeper', + 'Warden', + 'Wing', + 'Dewdrop', + 'Moonbeam', + 'Starlight', + 'Blossom', ], }, // === VAMPIRES (🧛‍♂️🧛‍♀️) === vampires: { adjectives: [ - "Vampiric", - "Nocturnal", - "Gothic", - "Dark", - "Shadowy", - "Mysterious", - "Immortal", - "Eternal", - "Undead", - "Blood-drinking", - "Fanged", - "Pale", - "Aristocratic", - "Noble", - "Elegant", - "Sophisticated", - "Cultured", - "Ancient", - "Old", - "Centuries-old", - "Timeless", - "Ageless", - "Supernatural", + 'Vampiric', + 'Nocturnal', + 'Gothic', + 'Dark', + 'Shadowy', + 'Mysterious', + 'Immortal', + 'Eternal', + 'Undead', + 'Blood-drinking', + 'Fanged', + 'Pale', + 'Aristocratic', + 'Noble', + 'Elegant', + 'Sophisticated', + 'Cultured', + 'Ancient', + 'Old', + 'Centuries-old', + 'Timeless', + 'Ageless', + 'Supernatural', ], nouns: [ - "Vampire", - "Vampyre", - "Nosferatu", - "Count", - "Countess", - "Lord", - "Lady", - "Master", - "Mistress", - "Prince", - "Princess", - "Noble", - "Aristocrat", - "Bloodsucker", - "Nightcrawler", - "Creature", - "Immortal", - "Undead", - "Fang", - "Bite", - "Shadow", - "Night", - "Darkness", - "Eclipse", + 'Vampire', + 'Vampyre', + 'Nosferatu', + 'Count', + 'Countess', + 'Lord', + 'Lady', + 'Master', + 'Mistress', + 'Prince', + 'Princess', + 'Noble', + 'Aristocrat', + 'Bloodsucker', + 'Nightcrawler', + 'Creature', + 'Immortal', + 'Undead', + 'Fang', + 'Bite', + 'Shadow', + 'Night', + 'Darkness', + 'Eclipse', ], }, // === MERFOLK (🧜‍♂️🧜‍♀️) === merfolk: { adjectives: [ - "Aquatic", - "Oceanic", - "Marine", - "Nautical", - "Seafaring", - "Deep-sea", - "Underwater", - "Submerged", - "Diving", - "Swimming", - "Floating", - "Drifting", - "Tidal", - "Watery", - "Liquid", - "Flowing", - "Rippling", - "Shimmering", - "Pearl", - "Coral", - "Seashell", - "Tropical", - "Temperate", - "Arctic", + 'Aquatic', + 'Oceanic', + 'Marine', + 'Nautical', + 'Seafaring', + 'Deep-sea', + 'Underwater', + 'Submerged', + 'Diving', + 'Swimming', + 'Floating', + 'Drifting', + 'Tidal', + 'Watery', + 'Liquid', + 'Flowing', + 'Rippling', + 'Shimmering', + 'Pearl', + 'Coral', + 'Seashell', + 'Tropical', + 'Temperate', + 'Arctic', ], nouns: [ - "Mermaid", - "Merman", - "Merfolk", - "Siren", - "Selkie", - "Nereid", - "Triton", - "Sea-dweller", - "Ocean-dweller", - "Deep-sea-dweller", - "Swimmer", - "Diver", - "Sailor", - "Navigator", - "Explorer", - "Guardian", - "Protector", - "Keeper", - "Wave", - "Tide", - "Current", - "Coral", - "Pearl", - "Shell", + 'Mermaid', + 'Merman', + 'Merfolk', + 'Siren', + 'Selkie', + 'Nereid', + 'Triton', + 'Sea-dweller', + 'Ocean-dweller', + 'Deep-sea-dweller', + 'Swimmer', + 'Diver', + 'Sailor', + 'Navigator', + 'Explorer', + 'Guardian', + 'Protector', + 'Keeper', + 'Wave', + 'Tide', + 'Current', + 'Coral', + 'Pearl', + 'Shell', ], }, // === ELVES (🧝‍♂️🧝‍♀️) === elves: { adjectives: [ - "Elven", - "Elvish", - "Forest", - "Woodland", - "Sylvan", - "Arboreal", - "Green", - "Natural", - "Wild", - "Ancient", - "Ageless", - "Immortal", - "Graceful", - "Elegant", - "Nimble", - "Swift", - "Quick", - "Sharp", - "Keen", - "Perceptive", - "Observant", - "Wise", - "Learned", - "Skilled", - "Expert", + 'Elven', + 'Elvish', + 'Forest', + 'Woodland', + 'Sylvan', + 'Arboreal', + 'Green', + 'Natural', + 'Wild', + 'Ancient', + 'Ageless', + 'Immortal', + 'Graceful', + 'Elegant', + 'Nimble', + 'Swift', + 'Quick', + 'Sharp', + 'Keen', + 'Perceptive', + 'Observant', + 'Wise', + 'Learned', + 'Skilled', + 'Expert', ], nouns: [ - "Elf", - "Ranger", - "Archer", - "Scout", - "Hunter", - "Tracker", - "Pathfinder", - "Wayfarer", - "Wanderer", - "Guardian", - "Protector", - "Keeper", - "Warden", - "Sentinel", - "Bow", - "Arrow", - "Quiver", - "Leaf", - "Branch", - "Tree", - "Forest", - "Woods", - "Grove", - "Glade", - "Vale", + 'Elf', + 'Ranger', + 'Archer', + 'Scout', + 'Hunter', + 'Tracker', + 'Pathfinder', + 'Wayfarer', + 'Wanderer', + 'Guardian', + 'Protector', + 'Keeper', + 'Warden', + 'Sentinel', + 'Bow', + 'Arrow', + 'Quiver', + 'Leaf', + 'Branch', + 'Tree', + 'Forest', + 'Woods', + 'Grove', + 'Glade', + 'Vale', ], }, // === HEROES (🦸‍♂️🦸‍♀️) === heroes: { adjectives: [ - "Heroic", - "Brave", - "Courageous", - "Valiant", - "Gallant", - "Fearless", - "Daring", - "Bold", - "Intrepid", - "Audacious", - "Mighty", - "Powerful", - "Strong", - "Robust", - "Invincible", - "Unbeatable", - "Unstoppable", - "Super", - "Ultra", - "Mega", - "Hyper", - "Supreme", - "Ultimate", - "Prime", + 'Heroic', + 'Brave', + 'Courageous', + 'Valiant', + 'Gallant', + 'Fearless', + 'Daring', + 'Bold', + 'Intrepid', + 'Audacious', + 'Mighty', + 'Powerful', + 'Strong', + 'Robust', + 'Invincible', + 'Unbeatable', + 'Unstoppable', + 'Super', + 'Ultra', + 'Mega', + 'Hyper', + 'Supreme', + 'Ultimate', + 'Prime', ], nouns: [ - "Hero", - "Champion", - "Defender", - "Protector", - "Guardian", - "Savior", - "Rescuer", - "Liberator", - "Crusader", - "Warrior", - "Fighter", - "Soldier", - "Knight", - "Paladin", - "Avenger", - "Justice", - "Sentinel", - "Shield", - "Sword", - "Cape", - "Mask", - "Power", - "Force", - "Might", + 'Hero', + 'Champion', + 'Defender', + 'Protector', + 'Guardian', + 'Savior', + 'Rescuer', + 'Liberator', + 'Crusader', + 'Warrior', + 'Fighter', + 'Soldier', + 'Knight', + 'Paladin', + 'Avenger', + 'Justice', + 'Sentinel', + 'Shield', + 'Sword', + 'Cape', + 'Mask', + 'Power', + 'Force', + 'Might', ], }, // === VILLAINS (🦹‍♂️) === villains: { adjectives: [ - "Villainous", - "Evil", - "Wicked", - "Sinister", - "Malevolent", - "Malicious", - "Nefarious", - "Diabolical", - "Devious", - "Cunning", - "Scheming", - "Plotting", - "Dastardly", - "Fiendish", - "Dark", - "Shadow", - "Twisted", - "Corrupt", - "Rogue", - "Rebellious", - "Defiant", - "Anarchic", - "Chaotic", - "Ruthless", + 'Villainous', + 'Evil', + 'Wicked', + 'Sinister', + 'Malevolent', + 'Malicious', + 'Nefarious', + 'Diabolical', + 'Devious', + 'Cunning', + 'Scheming', + 'Plotting', + 'Dastardly', + 'Fiendish', + 'Dark', + 'Shadow', + 'Twisted', + 'Corrupt', + 'Rogue', + 'Rebellious', + 'Defiant', + 'Anarchic', + 'Chaotic', + 'Ruthless', ], nouns: [ - "Villain", - "Evildoer", - "Wrongdoer", - "Malefactor", - "Scoundrel", - "Rogue", - "Rascal", - "Knave", - "Fiend", - "Demon", - "Devil", - "Monster", - "Beast", - "Nemesis", - "Adversary", - "Antagonist", - "Rival", - "Foe", - "Enemy", - "Mastermind", - "Schemer", - "Plotter", - "Conspirator", - "Overlord", + 'Villain', + 'Evildoer', + 'Wrongdoer', + 'Malefactor', + 'Scoundrel', + 'Rogue', + 'Rascal', + 'Knave', + 'Fiend', + 'Demon', + 'Devil', + 'Monster', + 'Beast', + 'Nemesis', + 'Adversary', + 'Antagonist', + 'Rival', + 'Foe', + 'Enemy', + 'Mastermind', + 'Schemer', + 'Plotter', + 'Conspirator', + 'Overlord', ], }, // === CANINES (🐶🐺) === canines: { adjectives: [ - "Loyal", - "Faithful", - "Devoted", - "Trusty", - "Friendly", - "Playful", - "Energetic", - "Active", - "Lively", - "Spirited", - "Happy", - "Joyful", - "Enthusiastic", - "Eager", - "Alert", - "Watchful", - "Protective", - "Guardian", - "Wild", - "Fierce", - "Feral", - "Howling", - "Pack", - "Alpha", + 'Loyal', + 'Faithful', + 'Devoted', + 'Trusty', + 'Friendly', + 'Playful', + 'Energetic', + 'Active', + 'Lively', + 'Spirited', + 'Happy', + 'Joyful', + 'Enthusiastic', + 'Eager', + 'Alert', + 'Watchful', + 'Protective', + 'Guardian', + 'Wild', + 'Fierce', + 'Feral', + 'Howling', + 'Pack', + 'Alpha', ], nouns: [ - "Dog", - "Pup", - "Puppy", - "Hound", - "Canine", - "Pooch", - "Mutt", - "Doggo", - "Pupper", - "Wolf", - "Wolfhound", - "Shepherd", - "Retriever", - "Terrier", - "Spaniel", - "Beagle", - "Husky", - "Malamute", - "Corgi", - "Dachshund", - "Poodle", - "Bulldog", - "Boxer", - "Collie", - "Alpha", + 'Dog', + 'Pup', + 'Puppy', + 'Hound', + 'Canine', + 'Pooch', + 'Mutt', + 'Doggo', + 'Pupper', + 'Wolf', + 'Wolfhound', + 'Shepherd', + 'Retriever', + 'Terrier', + 'Spaniel', + 'Beagle', + 'Husky', + 'Malamute', + 'Corgi', + 'Dachshund', + 'Poodle', + 'Bulldog', + 'Boxer', + 'Collie', + 'Alpha', ], }, // === FELINES (🐱🐯🦁) === felines: { adjectives: [ - "Feline", - "Catlike", - "Sleek", - "Graceful", - "Agile", - "Nimble", - "Quick", - "Stealthy", - "Silent", - "Sneaky", - "Curious", - "Independent", - "Aloof", - "Majestic", - "Regal", - "Noble", - "Wild", - "Fierce", - "Ferocious", - "Striped", - "Spotted", - "Furry", - "Soft", - "Purring", - "Prowling", + 'Feline', + 'Catlike', + 'Sleek', + 'Graceful', + 'Agile', + 'Nimble', + 'Quick', + 'Stealthy', + 'Silent', + 'Sneaky', + 'Curious', + 'Independent', + 'Aloof', + 'Majestic', + 'Regal', + 'Noble', + 'Wild', + 'Fierce', + 'Ferocious', + 'Striped', + 'Spotted', + 'Furry', + 'Soft', + 'Purring', + 'Prowling', ], nouns: [ - "Cat", - "Kitty", - "Kitten", - "Feline", - "Tabby", - "Tomcat", - "Mouser", - "Tiger", - "Tigress", - "Lion", - "Lioness", - "Panther", - "Leopard", - "Cheetah", - "Jaguar", - "Cougar", - "Puma", - "Lynx", - "Bobcat", - "Pride", - "Claw", - "Paw", - "Whisker", - "Tail", - "Mane", + 'Cat', + 'Kitty', + 'Kitten', + 'Feline', + 'Tabby', + 'Tomcat', + 'Mouser', + 'Tiger', + 'Tigress', + 'Lion', + 'Lioness', + 'Panther', + 'Leopard', + 'Cheetah', + 'Jaguar', + 'Cougar', + 'Puma', + 'Lynx', + 'Bobcat', + 'Pride', + 'Claw', + 'Paw', + 'Whisker', + 'Tail', + 'Mane', ], }, // === RODENTS (🐭🐹) === rodents: { adjectives: [ - "Tiny", - "Small", - "Little", - "Miniature", - "Petite", - "Wee", - "Itty-bitty", - "Quick", - "Speedy", - "Swift", - "Fast", - "Nimble", - "Agile", - "Scurrying", - "Busy", - "Active", - "Energetic", - "Curious", - "Inquisitive", - "Clever", - "Smart", - "Resourceful", - "Adaptable", - "Cute", - "Adorable", + 'Tiny', + 'Small', + 'Little', + 'Miniature', + 'Petite', + 'Wee', + 'Itty-bitty', + 'Quick', + 'Speedy', + 'Swift', + 'Fast', + 'Nimble', + 'Agile', + 'Scurrying', + 'Busy', + 'Active', + 'Energetic', + 'Curious', + 'Inquisitive', + 'Clever', + 'Smart', + 'Resourceful', + 'Adaptable', + 'Cute', + 'Adorable', ], nouns: [ - "Mouse", - "Mice", - "Mousie", - "Squeaker", - "Hamster", - "Hammie", - "Gerbil", - "Rat", - "Ratty", - "Rodent", - "Critter", - "Creature", - "Pet", - "Pocket-pet", - "Nibbler", - "Gnawer", - "Whisker", - "Squeaks", - "Cheese-lover", - "Burrow", - "Den", - "Nest", - "Hole", - "Warren", - "Runner", + 'Mouse', + 'Mice', + 'Mousie', + 'Squeaker', + 'Hamster', + 'Hammie', + 'Gerbil', + 'Rat', + 'Ratty', + 'Rodent', + 'Critter', + 'Creature', + 'Pet', + 'Pocket-pet', + 'Nibbler', + 'Gnawer', + 'Whisker', + 'Squeaks', + 'Cheese-lover', + 'Burrow', + 'Den', + 'Nest', + 'Hole', + 'Warren', + 'Runner', ], }, // === RABBITS (🐰) === rabbits: { adjectives: [ - "Fluffy", - "Soft", - "Fuzzy", - "Cottony", - "Downy", - "Cuddly", - "Cute", - "Adorable", - "Sweet", - "Gentle", - "Timid", - "Shy", - "Hopping", - "Bouncing", - "Jumping", - "Leaping", - "Quick", - "Swift", - "Fast", - "Speedy", - "Nimble", - "Long-eared", - "Twitchy", - "Alert", - "Watchful", + 'Fluffy', + 'Soft', + 'Fuzzy', + 'Cottony', + 'Downy', + 'Cuddly', + 'Cute', + 'Adorable', + 'Sweet', + 'Gentle', + 'Timid', + 'Shy', + 'Hopping', + 'Bouncing', + 'Jumping', + 'Leaping', + 'Quick', + 'Swift', + 'Fast', + 'Speedy', + 'Nimble', + 'Long-eared', + 'Twitchy', + 'Alert', + 'Watchful', ], nouns: [ - "Rabbit", - "Bunny", - "Cottontail", - "Hare", - "Jackrabbit", - "Lop", - "Rex", - "Flemish", - "Dutch", - "Angora", - "Kit", - "Leveret", - "Hopper", - "Thumper", - "Cotton", - "Fluff", - "Whiskers", - "Ears", - "Tail", - "Paw", - "Warren", - "Burrow", - "Hutch", - "Carrot", - "Clover", + 'Rabbit', + 'Bunny', + 'Cottontail', + 'Hare', + 'Jackrabbit', + 'Lop', + 'Rex', + 'Flemish', + 'Dutch', + 'Angora', + 'Kit', + 'Leveret', + 'Hopper', + 'Thumper', + 'Cotton', + 'Fluff', + 'Whiskers', + 'Ears', + 'Tail', + 'Paw', + 'Warren', + 'Burrow', + 'Hutch', + 'Carrot', + 'Clover', ], }, // === FOXES (🦊) === foxes: { adjectives: [ - "Foxy", - "Clever", - "Cunning", - "Sly", - "Wily", - "Crafty", - "Sneaky", - "Stealthy", - "Quick", - "Swift", - "Nimble", - "Agile", - "Alert", - "Sharp", - "Keen", - "Perceptive", - "Observant", - "Red", - "Orange", - "Russet", - "Tawny", - "Bushy-tailed", - "Woodland", - "Forest", - "Wild", + 'Foxy', + 'Clever', + 'Cunning', + 'Sly', + 'Wily', + 'Crafty', + 'Sneaky', + 'Stealthy', + 'Quick', + 'Swift', + 'Nimble', + 'Agile', + 'Alert', + 'Sharp', + 'Keen', + 'Perceptive', + 'Observant', + 'Red', + 'Orange', + 'Russet', + 'Tawny', + 'Bushy-tailed', + 'Woodland', + 'Forest', + 'Wild', ], nouns: [ - "Fox", - "Vixen", - "Kit", - "Reynard", - "Tod", - "Red-fox", - "Arctic-fox", - "Silver-fox", - "Swift-fox", - "Gray-fox", - "Fennec", - "Corsac", - "Kit-fox", - "Tail", - "Brush", - "Muzzle", - "Snout", - "Den", - "Burrow", - "Earth", - "Skulk", - "Leash", - "Hunter", - "Prowler", - "Shadow", + 'Fox', + 'Vixen', + 'Kit', + 'Reynard', + 'Tod', + 'Red-fox', + 'Arctic-fox', + 'Silver-fox', + 'Swift-fox', + 'Gray-fox', + 'Fennec', + 'Corsac', + 'Kit-fox', + 'Tail', + 'Brush', + 'Muzzle', + 'Snout', + 'Den', + 'Burrow', + 'Earth', + 'Skulk', + 'Leash', + 'Hunter', + 'Prowler', + 'Shadow', ], }, // === BEARS (🐻🐼🐻‍❄️) === bears: { adjectives: [ - "Bear-like", - "Strong", - "Mighty", - "Powerful", - "Robust", - "Sturdy", - "Hefty", - "Burly", - "Brawny", - "Husky", - "Cuddly", - "Fuzzy", - "Fluffy", - "Soft", - "Gentle", - "Fierce", - "Ferocious", - "Wild", - "Grizzly", - "Polar", - "Black", - "Brown", - "White", - "Panda", - "Hibernating", + 'Bear-like', + 'Strong', + 'Mighty', + 'Powerful', + 'Robust', + 'Sturdy', + 'Hefty', + 'Burly', + 'Brawny', + 'Husky', + 'Cuddly', + 'Fuzzy', + 'Fluffy', + 'Soft', + 'Gentle', + 'Fierce', + 'Ferocious', + 'Wild', + 'Grizzly', + 'Polar', + 'Black', + 'Brown', + 'White', + 'Panda', + 'Hibernating', ], nouns: [ - "Bear", - "Cub", - "Grizzly", - "Brown-bear", - "Black-bear", - "Polar-bear", - "Panda", - "Sun-bear", - "Moon-bear", - "Sloth-bear", - "Kodiak", - "Ursine", - "Bruin", - "Teddy", - "Mama-bear", - "Papa-bear", - "Baby-bear", - "Den", - "Cave", - "Forest", - "Mountain", - "Wilderness", - "Salmon", - "Honey", - "Claw", + 'Bear', + 'Cub', + 'Grizzly', + 'Brown-bear', + 'Black-bear', + 'Polar-bear', + 'Panda', + 'Sun-bear', + 'Moon-bear', + 'Sloth-bear', + 'Kodiak', + 'Ursine', + 'Bruin', + 'Teddy', + 'Mama-bear', + 'Papa-bear', + 'Baby-bear', + 'Den', + 'Cave', + 'Forest', + 'Mountain', + 'Wilderness', + 'Salmon', + 'Honey', + 'Claw', ], }, // === KOALAS (🐨) === koalas: { adjectives: [ - "Sleepy", - "Drowsy", - "Dozy", - "Snoozy", - "Relaxed", - "Calm", - "Peaceful", - "Tranquil", - "Serene", - "Laid-back", - "Easygoing", - "Mellow", - "Chill", - "Cuddly", - "Huggable", - "Fuzzy", - "Fluffy", - "Soft", - "Cute", - "Adorable", - "Australian", - "Eucalyptus", - "Tree-dwelling", - "Marsupial", - "Gray", + 'Sleepy', + 'Drowsy', + 'Dozy', + 'Snoozy', + 'Relaxed', + 'Calm', + 'Peaceful', + 'Tranquil', + 'Serene', + 'Laid-back', + 'Easygoing', + 'Mellow', + 'Chill', + 'Cuddly', + 'Huggable', + 'Fuzzy', + 'Fluffy', + 'Soft', + 'Cute', + 'Adorable', + 'Australian', + 'Eucalyptus', + 'Tree-dwelling', + 'Marsupial', + 'Gray', ], nouns: [ - "Koala", - "Bear", - "Joey", - "Marsupial", - "Treehugger", - "Climber", - "Sleeper", - "Napper", - "Snoozer", - "Cuddler", - "Eucalyptus", - "Gumtree", - "Bush", - "Outback", - "Down-under", - "Aussie", - "Mate", - "Pouch", - "Paw", - "Claw", - "Fur", - "Branch", - "Leaf", - "Tree", - "Forest", + 'Koala', + 'Bear', + 'Joey', + 'Marsupial', + 'Treehugger', + 'Climber', + 'Sleeper', + 'Napper', + 'Snoozer', + 'Cuddler', + 'Eucalyptus', + 'Gumtree', + 'Bush', + 'Outback', + 'Down-under', + 'Aussie', + 'Mate', + 'Pouch', + 'Paw', + 'Claw', + 'Fur', + 'Branch', + 'Leaf', + 'Tree', + 'Forest', ], }, // === BOVINE (🐮) === bovine: { adjectives: [ - "Bovine", - "Pastoral", - "Farm", - "Country", - "Rural", - "Agricultural", - "Dairy", - "Milk", - "Gentle", - "Docile", - "Calm", - "Peaceful", - "Patient", - "Grazing", - "Munching", - "Chewing", - "Mooing", - "Spotted", - "Black-and-white", - "Brown", - "Holstein", - "Jersey", - "Angus", - "Hereford", + 'Bovine', + 'Pastoral', + 'Farm', + 'Country', + 'Rural', + 'Agricultural', + 'Dairy', + 'Milk', + 'Gentle', + 'Docile', + 'Calm', + 'Peaceful', + 'Patient', + 'Grazing', + 'Munching', + 'Chewing', + 'Mooing', + 'Spotted', + 'Black-and-white', + 'Brown', + 'Holstein', + 'Jersey', + 'Angus', + 'Hereford', ], nouns: [ - "Cow", - "Bull", - "Calf", - "Cattle", - "Bovine", - "Heifer", - "Steer", - "Ox", - "Oxen", - "Dairy", - "Milker", - "Moo-cow", - "Bessie", - "Daisy", - "Buttercup", - "Farm", - "Barn", - "Pasture", - "Meadow", - "Field", - "Grassland", - "Herd", - "Udder", - "Bell", - "Hay", - "Grass", + 'Cow', + 'Bull', + 'Calf', + 'Cattle', + 'Bovine', + 'Heifer', + 'Steer', + 'Ox', + 'Oxen', + 'Dairy', + 'Milker', + 'Moo-cow', + 'Bessie', + 'Daisy', + 'Buttercup', + 'Farm', + 'Barn', + 'Pasture', + 'Meadow', + 'Field', + 'Grassland', + 'Herd', + 'Udder', + 'Bell', + 'Hay', + 'Grass', ], }, // === PIGS (🐷) === pigs: { adjectives: [ - "Porcine", - "Piggy", - "Farm", - "Barnyard", - "Pink", - "Rosy", - "Plump", - "Round", - "Chubby", - "Rotund", - "Cute", - "Adorable", - "Happy", - "Cheerful", - "Snorting", - "Oinking", - "Snuffling", - "Rooting", - "Muddy", - "Dirty", - "Clean", - "Smart", - "Intelligent", - "Clever", - "Resourceful", + 'Porcine', + 'Piggy', + 'Farm', + 'Barnyard', + 'Pink', + 'Rosy', + 'Plump', + 'Round', + 'Chubby', + 'Rotund', + 'Cute', + 'Adorable', + 'Happy', + 'Cheerful', + 'Snorting', + 'Oinking', + 'Snuffling', + 'Rooting', + 'Muddy', + 'Dirty', + 'Clean', + 'Smart', + 'Intelligent', + 'Clever', + 'Resourceful', ], nouns: [ - "Pig", - "Piglet", - "Hog", - "Swine", - "Porker", - "Boar", - "Sow", - "Piggy", - "Oinker", - "Snorter", - "Hamlet", - "Bacon", - "Pork", - "Farm", - "Barnyard", - "Sty", - "Pen", - "Mud", - "Slop", - "Trough", - "Snout", - "Tail", - "Curly-tail", - "Truffle", - "Hunter", + 'Pig', + 'Piglet', + 'Hog', + 'Swine', + 'Porker', + 'Boar', + 'Sow', + 'Piggy', + 'Oinker', + 'Snorter', + 'Hamlet', + 'Bacon', + 'Pork', + 'Farm', + 'Barnyard', + 'Sty', + 'Pen', + 'Mud', + 'Slop', + 'Trough', + 'Snout', + 'Tail', + 'Curly-tail', + 'Truffle', + 'Hunter', ], }, // === FROGS (🐸) === frogs: { adjectives: [ - "Amphibian", - "Hopping", - "Jumping", - "Leaping", - "Bouncing", - "Croaking", - "Ribbiting", - "Green", - "Slimy", - "Wet", - "Moist", - "Damp", - "Aquatic", - "Semi-aquatic", - "Pond", - "Swamp", - "Marsh", - "Bog", - "Lily-pad", - "Spotted", - "Speckled", - "Smooth", - "Bumpy", - "Warty", - "Webbed", + 'Amphibian', + 'Hopping', + 'Jumping', + 'Leaping', + 'Bouncing', + 'Croaking', + 'Ribbiting', + 'Green', + 'Slimy', + 'Wet', + 'Moist', + 'Damp', + 'Aquatic', + 'Semi-aquatic', + 'Pond', + 'Swamp', + 'Marsh', + 'Bog', + 'Lily-pad', + 'Spotted', + 'Speckled', + 'Smooth', + 'Bumpy', + 'Warty', + 'Webbed', ], nouns: [ - "Frog", - "Toad", - "Tadpole", - "Pollywog", - "Bullfrog", - "Tree-frog", - "Spring-peeper", - "Croaker", - "Ribbiter", - "Hopper", - "Jumper", - "Leaper", - "Amphibian", - "Pond", - "Lily-pad", - "Lotus", - "Bog", - "Swamp", - "Marsh", - "Creek", - "Stream", - "Water", - "Tongue", - "Leg", - "Webfoot", + 'Frog', + 'Toad', + 'Tadpole', + 'Pollywog', + 'Bullfrog', + 'Tree-frog', + 'Spring-peeper', + 'Croaker', + 'Ribbiter', + 'Hopper', + 'Jumper', + 'Leaper', + 'Amphibian', + 'Pond', + 'Lily-pad', + 'Lotus', + 'Bog', + 'Swamp', + 'Marsh', + 'Creek', + 'Stream', + 'Water', + 'Tongue', + 'Leg', + 'Webfoot', ], }, // === MONKEYS (🐵🙈🙉🙊🐒) === monkeys: { adjectives: [ - "Monkey", - "Simian", - "Primate", - "Mischievous", - "Playful", - "Silly", - "Goofy", - "Funny", - "Amusing", - "Entertaining", - "Clever", - "Smart", - "Intelligent", - "Quick", - "Agile", - "Nimble", - "Acrobatic", - "Swinging", - "Climbing", - "Tree-dwelling", - "Jungle", - "Tropical", - "Curious", - "Sneaky", + 'Monkey', + 'Simian', + 'Primate', + 'Mischievous', + 'Playful', + 'Silly', + 'Goofy', + 'Funny', + 'Amusing', + 'Entertaining', + 'Clever', + 'Smart', + 'Intelligent', + 'Quick', + 'Agile', + 'Nimble', + 'Acrobatic', + 'Swinging', + 'Climbing', + 'Tree-dwelling', + 'Jungle', + 'Tropical', + 'Curious', + 'Sneaky', ], nouns: [ - "Monkey", - "Ape", - "Chimp", - "Chimpanzee", - "Gorilla", - "Orangutan", - "Baboon", - "Macaque", - "Capuchin", - "Tamarin", - "Marmoset", - "Howler", - "Spider-monkey", - "Squirrel-monkey", - "Proboscis", - "Primate", - "Simian", - "Tree", - "Branch", - "Vine", - "Jungle", - "Canopy", - "Tail", - "Banana", - "Coconut", + 'Monkey', + 'Ape', + 'Chimp', + 'Chimpanzee', + 'Gorilla', + 'Orangutan', + 'Baboon', + 'Macaque', + 'Capuchin', + 'Tamarin', + 'Marmoset', + 'Howler', + 'Spider-monkey', + 'Squirrel-monkey', + 'Proboscis', + 'Primate', + 'Simian', + 'Tree', + 'Branch', + 'Vine', + 'Jungle', + 'Canopy', + 'Tail', + 'Banana', + 'Coconut', ], }, // === BIRDS (🦆🐧🐦🐤🐣🐥🦅🦉) === birds: { adjectives: [ - "Avian", - "Feathered", - "Winged", - "Flying", - "Soaring", - "Gliding", - "Fluttering", - "Chirping", - "Singing", - "Tweeting", - "Warbling", - "Whistling", - "Early-bird", - "Migratory", - "Nesting", - "Perched", - "Roosting", - "Swift", - "Fast", - "Graceful", - "Majestic", - "Noble", - "Wild", - "Free", - "Sky-bound", + 'Avian', + 'Feathered', + 'Winged', + 'Flying', + 'Soaring', + 'Gliding', + 'Fluttering', + 'Chirping', + 'Singing', + 'Tweeting', + 'Warbling', + 'Whistling', + 'Early-bird', + 'Migratory', + 'Nesting', + 'Perched', + 'Roosting', + 'Swift', + 'Fast', + 'Graceful', + 'Majestic', + 'Noble', + 'Wild', + 'Free', + 'Sky-bound', ], nouns: [ - "Bird", - "Birdie", - "Feather", - "Wing", - "Beak", - "Robin", - "Sparrow", - "Finch", - "Warbler", - "Thrush", - "Cardinal", - "Jay", - "Crow", - "Raven", - "Hawk", - "Eagle", - "Falcon", - "Owl", - "Hoot", - "Penguin", - "Duck", - "Duckling", - "Chick", - "Nestling", - "Fledgling", - "Nest", - "Egg", - "Sky", - "Cloud", + 'Bird', + 'Birdie', + 'Feather', + 'Wing', + 'Beak', + 'Robin', + 'Sparrow', + 'Finch', + 'Warbler', + 'Thrush', + 'Cardinal', + 'Jay', + 'Crow', + 'Raven', + 'Hawk', + 'Eagle', + 'Falcon', + 'Owl', + 'Hoot', + 'Penguin', + 'Duck', + 'Duckling', + 'Chick', + 'Nestling', + 'Fledgling', + 'Nest', + 'Egg', + 'Sky', + 'Cloud', ], }, // === BATS (🦇) === bats: { adjectives: [ - "Nocturnal", - "Night-flying", - "Dark", - "Shadow", - "Cave-dwelling", - "Echolocating", - "Sonar", - "Ultrasonic", - "Winged", - "Flying", - "Hanging", - "Upside-down", - "Mysterious", - "Spooky", - "Gothic", - "Vampiric", - "Fruit", - "Insect-eating", - "Small", - "Tiny", - "Furry", - "Leathery", - "Membranous", - "Acrobatic", + 'Nocturnal', + 'Night-flying', + 'Dark', + 'Shadow', + 'Cave-dwelling', + 'Echolocating', + 'Sonar', + 'Ultrasonic', + 'Winged', + 'Flying', + 'Hanging', + 'Upside-down', + 'Mysterious', + 'Spooky', + 'Gothic', + 'Vampiric', + 'Fruit', + 'Insect-eating', + 'Small', + 'Tiny', + 'Furry', + 'Leathery', + 'Membranous', + 'Acrobatic', ], nouns: [ - "Bat", - "Chiroptera", - "Microbat", - "Megabat", - "Fruit-bat", - "Flying-fox", - "Vampire-bat", - "Horseshoe-bat", - "Pipistrelle", - "Echo", - "Sonar", - "Wing", - "Membrane", - "Cave", - "Belfry", - "Roost", - "Colony", - "Night", - "Dusk", - "Dawn", - "Twilight", - "Moon", - "Star", - "Shadow", - "Hunter", + 'Bat', + 'Chiroptera', + 'Microbat', + 'Megabat', + 'Fruit-bat', + 'Flying-fox', + 'Vampire-bat', + 'Horseshoe-bat', + 'Pipistrelle', + 'Echo', + 'Sonar', + 'Wing', + 'Membrane', + 'Cave', + 'Belfry', + 'Roost', + 'Colony', + 'Night', + 'Dusk', + 'Dawn', + 'Twilight', + 'Moon', + 'Star', + 'Shadow', + 'Hunter', ], }, // === BOARS (🐗) === boars: { adjectives: [ - "Wild", - "Feral", - "Fierce", - "Ferocious", - "Tough", - "Rugged", - "Bristly", - "Hairy", - "Tusked", - "Charging", - "Rushing", - "Rooting", - "Foraging", - "Forest", - "Woodland", - "Mountain", - "Strong", - "Powerful", - "Sturdy", - "Hardy", - "Resilient", - "Untamed", - "Savage", - "Aggressive", + 'Wild', + 'Feral', + 'Fierce', + 'Ferocious', + 'Tough', + 'Rugged', + 'Bristly', + 'Hairy', + 'Tusked', + 'Charging', + 'Rushing', + 'Rooting', + 'Foraging', + 'Forest', + 'Woodland', + 'Mountain', + 'Strong', + 'Powerful', + 'Sturdy', + 'Hardy', + 'Resilient', + 'Untamed', + 'Savage', + 'Aggressive', ], nouns: [ - "Boar", - "Wild-boar", - "Warthog", - "Peccary", - "Tusker", - "Bristle-back", - "Hog", - "Swine", - "Pig", - "Hunter", - "Forager", - "Rooter", - "Beast", - "Creature", - "Forest", - "Woods", - "Thicket", - "Undergrowth", - "Tusk", - "Snout", - "Hide", - "Bristle", - "Charge", - "Rush", - "Thunder", + 'Boar', + 'Wild-boar', + 'Warthog', + 'Peccary', + 'Tusker', + 'Bristle-back', + 'Hog', + 'Swine', + 'Pig', + 'Hunter', + 'Forager', + 'Rooter', + 'Beast', + 'Creature', + 'Forest', + 'Woods', + 'Thicket', + 'Undergrowth', + 'Tusk', + 'Snout', + 'Hide', + 'Bristle', + 'Charge', + 'Rush', + 'Thunder', ], }, // === HORSES (🐴🦄) === horses: { adjectives: [ - "Equine", - "Majestic", - "Noble", - "Graceful", - "Elegant", - "Swift", - "Fast", - "Running", - "Galloping", - "Trotting", - "Cantering", - "Prancing", - "Wild", - "Free", - "Spirited", - "Untamed", - "Strong", - "Powerful", - "Mighty", - "Muscular", - "Magical", - "Mystical", - "Enchanted", - "Unicorn", - "Pegasus", + 'Equine', + 'Majestic', + 'Noble', + 'Graceful', + 'Elegant', + 'Swift', + 'Fast', + 'Running', + 'Galloping', + 'Trotting', + 'Cantering', + 'Prancing', + 'Wild', + 'Free', + 'Spirited', + 'Untamed', + 'Strong', + 'Powerful', + 'Mighty', + 'Muscular', + 'Magical', + 'Mystical', + 'Enchanted', + 'Unicorn', + 'Pegasus', ], nouns: [ - "Horse", - "Mare", - "Stallion", - "Gelding", - "Foal", - "Colt", - "Filly", - "Pony", - "Steed", - "Charger", - "Courser", - "Mustang", - "Bronco", - "Pacer", - "Trotter", - "Racer", - "Unicorn", - "Pegasus", - "Alicorn", - "Mane", - "Tail", - "Hoof", - "Gallop", - "Thunder", - "Wind", + 'Horse', + 'Mare', + 'Stallion', + 'Gelding', + 'Foal', + 'Colt', + 'Filly', + 'Pony', + 'Steed', + 'Charger', + 'Courser', + 'Mustang', + 'Bronco', + 'Pacer', + 'Trotter', + 'Racer', + 'Unicorn', + 'Pegasus', + 'Alicorn', + 'Mane', + 'Tail', + 'Hoof', + 'Gallop', + 'Thunder', + 'Wind', ], }, // === INSECTS (🐝🐛🦋) === insects: { adjectives: [ - "Tiny", - "Small", - "Miniature", - "Micro", - "Buzzing", - "Humming", - "Flying", - "Fluttering", - "Hovering", - "Busy", - "Industrious", - "Hard-working", - "Diligent", - "Social", - "Colony", - "Hive", - "Swarm", - "Colorful", - "Bright", - "Beautiful", - "Delicate", - "Fragile", - "Metamorphic", - "Transforming", - "Emerging", + 'Tiny', + 'Small', + 'Miniature', + 'Micro', + 'Buzzing', + 'Humming', + 'Flying', + 'Fluttering', + 'Hovering', + 'Busy', + 'Industrious', + 'Hard-working', + 'Diligent', + 'Social', + 'Colony', + 'Hive', + 'Swarm', + 'Colorful', + 'Bright', + 'Beautiful', + 'Delicate', + 'Fragile', + 'Metamorphic', + 'Transforming', + 'Emerging', ], nouns: [ - "Bee", - "Honeybee", - "Bumblebee", - "Worker", - "Queen", - "Drone", - "Hive", - "Colony", - "Swarm", - "Buzz", - "Caterpillar", - "Larva", - "Chrysalis", - "Cocoon", - "Butterfly", - "Monarch", - "Swallowtail", - "Morpho", - "Wing", - "Flutter", - "Nectar", - "Pollen", - "Flower", - "Garden", - "Meadow", + 'Bee', + 'Honeybee', + 'Bumblebee', + 'Worker', + 'Queen', + 'Drone', + 'Hive', + 'Colony', + 'Swarm', + 'Buzz', + 'Caterpillar', + 'Larva', + 'Chrysalis', + 'Cocoon', + 'Butterfly', + 'Monarch', + 'Swallowtail', + 'Morpho', + 'Wing', + 'Flutter', + 'Nectar', + 'Pollen', + 'Flower', + 'Garden', + 'Meadow', ], }, // === STARS (⭐🌟💫✨) === stars: { adjectives: [ - "Stellar", - "Starry", - "Celestial", - "Cosmic", - "Astral", - "Heavenly", - "Divine", - "Radiant", - "Shining", - "Glowing", - "Glittering", - "Sparkling", - "Twinkling", - "Shimmering", - "Luminous", - "Brilliant", - "Dazzling", - "Blazing", - "Incandescent", - "Iridescent", - "Opalescent", - "Prismatic", - "Rainbow", - "Bright", + 'Stellar', + 'Starry', + 'Celestial', + 'Cosmic', + 'Astral', + 'Heavenly', + 'Divine', + 'Radiant', + 'Shining', + 'Glowing', + 'Glittering', + 'Sparkling', + 'Twinkling', + 'Shimmering', + 'Luminous', + 'Brilliant', + 'Dazzling', + 'Blazing', + 'Incandescent', + 'Iridescent', + 'Opalescent', + 'Prismatic', + 'Rainbow', + 'Bright', ], nouns: [ - "Star", - "Starlight", - "Supernova", - "Nova", - "Pulsar", - "Quasar", - "Nebula", - "Galaxy", - "Constellation", - "Comet", - "Meteor", - "Shooting-star", - "Asteroid", - "Celestial", - "Cosmos", - "Universe", - "Heaven", - "Sky", - "Night-sky", - "Twinkle", - "Shimmer", - "Sparkle", - "Glitter", - "Shine", - "Gleam", + 'Star', + 'Starlight', + 'Supernova', + 'Nova', + 'Pulsar', + 'Quasar', + 'Nebula', + 'Galaxy', + 'Constellation', + 'Comet', + 'Meteor', + 'Shooting-star', + 'Asteroid', + 'Celestial', + 'Cosmos', + 'Universe', + 'Heaven', + 'Sky', + 'Night-sky', + 'Twinkle', + 'Shimmer', + 'Sparkle', + 'Glitter', + 'Shine', + 'Gleam', ], }, // === POWER (⚡🔥) === power: { adjectives: [ - "Powerful", - "Mighty", - "Strong", - "Potent", - "Intense", - "Fierce", - "Ferocious", - "Electric", - "Shocking", - "Stunning", - "Electrifying", - "Charged", - "Voltage", - "Blazing", - "Burning", - "Scorching", - "Searing", - "Flaming", - "Fiery", - "Hot", - "Infernal", - "Volcanic", - "Explosive", - "Dynamic", - "Energetic", + 'Powerful', + 'Mighty', + 'Strong', + 'Potent', + 'Intense', + 'Fierce', + 'Ferocious', + 'Electric', + 'Shocking', + 'Stunning', + 'Electrifying', + 'Charged', + 'Voltage', + 'Blazing', + 'Burning', + 'Scorching', + 'Searing', + 'Flaming', + 'Fiery', + 'Hot', + 'Infernal', + 'Volcanic', + 'Explosive', + 'Dynamic', + 'Energetic', ], nouns: [ - "Lightning", - "Thunder", - "Bolt", - "Strike", - "Shock", - "Voltage", - "Current", - "Charge", - "Spark", - "Arc", - "Flash", - "Fire", - "Flame", - "Blaze", - "Inferno", - "Pyre", - "Ember", - "Cinder", - "Ash", - "Phoenix", - "Dragon", - "Salamander", - "Power", - "Energy", - "Force", - "Might", - "Strength", + 'Lightning', + 'Thunder', + 'Bolt', + 'Strike', + 'Shock', + 'Voltage', + 'Current', + 'Charge', + 'Spark', + 'Arc', + 'Flash', + 'Fire', + 'Flame', + 'Blaze', + 'Inferno', + 'Pyre', + 'Ember', + 'Cinder', + 'Ash', + 'Phoenix', + 'Dragon', + 'Salamander', + 'Power', + 'Energy', + 'Force', + 'Might', + 'Strength', ], }, // === RAINBOW (🌈) === rainbow: { adjectives: [ - "Rainbow", - "Colorful", - "Multicolored", - "Prismatic", - "Spectrum", - "Chromatic", - "Vibrant", - "Vivid", - "Bright", - "Brilliant", - "Radiant", - "Luminous", - "Glowing", - "Shimmering", - "Iridescent", - "Opalescent", - "Pearlescent", - "Lustrous", - "Happy", - "Joyful", - "Cheerful", - "Optimistic", - "Hopeful", - "Positive", - "Sunny", + 'Rainbow', + 'Colorful', + 'Multicolored', + 'Prismatic', + 'Spectrum', + 'Chromatic', + 'Vibrant', + 'Vivid', + 'Bright', + 'Brilliant', + 'Radiant', + 'Luminous', + 'Glowing', + 'Shimmering', + 'Iridescent', + 'Opalescent', + 'Pearlescent', + 'Lustrous', + 'Happy', + 'Joyful', + 'Cheerful', + 'Optimistic', + 'Hopeful', + 'Positive', + 'Sunny', ], nouns: [ - "Rainbow", - "Arc", - "Prism", - "Spectrum", - "Color", - "Hue", - "Tint", - "Shade", - "Red", - "Orange", - "Yellow", - "Green", - "Blue", - "Indigo", - "Violet", - "Purple", - "ROYGBIV", - "Light", - "Refraction", - "Ray", - "Beam", - "Promise", - "Hope", - "Dream", - "Magic", - "Wonder", + 'Rainbow', + 'Arc', + 'Prism', + 'Spectrum', + 'Color', + 'Hue', + 'Tint', + 'Shade', + 'Red', + 'Orange', + 'Yellow', + 'Green', + 'Blue', + 'Indigo', + 'Violet', + 'Purple', + 'ROYGBIV', + 'Light', + 'Refraction', + 'Ray', + 'Beam', + 'Promise', + 'Hope', + 'Dream', + 'Magic', + 'Wonder', ], }, // === ENTERTAINMENT (🎪🎨🎯🎲🎮🕹️) === entertainment: { adjectives: [ - "Entertaining", - "Fun", - "Playful", - "Amusing", - "Enjoyable", - "Exciting", - "Thrilling", - "Spectacular", - "Amazing", - "Awesome", - "Epic", - "Legendary", - "Gaming", - "Playing", - "Competing", - "Winning", - "Artistic", - "Creative", - "Imaginative", - "Colorful", - "Vibrant", - "Expressive", - "Skillful", - "Expert", + 'Entertaining', + 'Fun', + 'Playful', + 'Amusing', + 'Enjoyable', + 'Exciting', + 'Thrilling', + 'Spectacular', + 'Amazing', + 'Awesome', + 'Epic', + 'Legendary', + 'Gaming', + 'Playing', + 'Competing', + 'Winning', + 'Artistic', + 'Creative', + 'Imaginative', + 'Colorful', + 'Vibrant', + 'Expressive', + 'Skillful', + 'Expert', ], nouns: [ - "Player", - "Gamer", - "Competitor", - "Champion", - "Winner", - "Victor", - "Ace", - "Pro", - "Master", - "Expert", - "Artist", - "Painter", - "Creator", - "Maker", - "Builder", - "Designer", - "Archer", - "Bullseye", - "Target", - "Dice", - "Die", - "Cube", - "Roller", - "Console", - "Joystick", - "Controller", - "Game", + 'Player', + 'Gamer', + 'Competitor', + 'Champion', + 'Winner', + 'Victor', + 'Ace', + 'Pro', + 'Master', + 'Expert', + 'Artist', + 'Painter', + 'Creator', + 'Maker', + 'Builder', + 'Designer', + 'Archer', + 'Bullseye', + 'Target', + 'Dice', + 'Die', + 'Cube', + 'Roller', + 'Console', + 'Joystick', + 'Controller', + 'Game', ], }, // === MUSIC (🎸🎺🎷🥁🎻🎤🎧) === music: { adjectives: [ - "Musical", - "Melodic", - "Harmonic", - "Rhythmic", - "Tuneful", - "Lyrical", - "Symphonic", - "Orchestral", - "Jazzy", - "Bluesy", - "Rockin", - "Groovin", - "Funky", - "Soulful", - "Classical", - "Modern", - "Electric", - "Acoustic", - "Amplified", - "Loud", - "Soft", - "Gentle", - "Powerful", - "Passionate", - "Expressive", + 'Musical', + 'Melodic', + 'Harmonic', + 'Rhythmic', + 'Tuneful', + 'Lyrical', + 'Symphonic', + 'Orchestral', + 'Jazzy', + 'Bluesy', + 'Rockin', + 'Groovin', + 'Funky', + 'Soulful', + 'Classical', + 'Modern', + 'Electric', + 'Acoustic', + 'Amplified', + 'Loud', + 'Soft', + 'Gentle', + 'Powerful', + 'Passionate', + 'Expressive', ], nouns: [ - "Musician", - "Player", - "Performer", - "Artist", - "Maestro", - "Virtuoso", - "Guitarist", - "Bassist", - "Drummer", - "Percussionist", - "Pianist", - "Violinist", - "Trumpeter", - "Saxophonist", - "Vocalist", - "Singer", - "Crooner", - "DJ", - "Producer", - "Composer", - "Songwriter", - "Melody", - "Harmony", - "Rhythm", - "Beat", - "Note", - "Chord", - "Song", - "Tune", + 'Musician', + 'Player', + 'Performer', + 'Artist', + 'Maestro', + 'Virtuoso', + 'Guitarist', + 'Bassist', + 'Drummer', + 'Percussionist', + 'Pianist', + 'Violinist', + 'Trumpeter', + 'Saxophonist', + 'Vocalist', + 'Singer', + 'Crooner', + 'DJ', + 'Producer', + 'Composer', + 'Songwriter', + 'Melody', + 'Harmony', + 'Rhythm', + 'Beat', + 'Note', + 'Chord', + 'Song', + 'Tune', ], }, // === FILM (🎬🎥) === film: { adjectives: [ - "Cinematic", - "Theatrical", - "Dramatic", - "Epic", - "Blockbuster", - "Award-winning", - "Starring", - "Featured", - "Leading", - "Supporting", - "Directing", - "Producing", - "Filming", - "Shooting", - "Recording", - "Documenting", - "Action", - "Comedy", - "Drama", - "Thriller", - "Suspense", - "Mystery", - "Romance", - "Sci-fi", - "Fantasy", + 'Cinematic', + 'Theatrical', + 'Dramatic', + 'Epic', + 'Blockbuster', + 'Award-winning', + 'Starring', + 'Featured', + 'Leading', + 'Supporting', + 'Directing', + 'Producing', + 'Filming', + 'Shooting', + 'Recording', + 'Documenting', + 'Action', + 'Comedy', + 'Drama', + 'Thriller', + 'Suspense', + 'Mystery', + 'Romance', + 'Sci-fi', + 'Fantasy', ], nouns: [ - "Director", - "Producer", - "Filmmaker", - "Cinematographer", - "Camera-operator", - "Actor", - "Actress", - "Star", - "Lead", - "Supporting", - "Extra", - "Stunt-double", - "Editor", - "Cutter", - "Splicer", - "Auteur", - "Visionary", - "Artist", - "Creator", - "Scene", - "Shot", - "Take", - "Cut", - "Action", - "Wrap", - "Premiere", - "Oscar", + 'Director', + 'Producer', + 'Filmmaker', + 'Cinematographer', + 'Camera-operator', + 'Actor', + 'Actress', + 'Star', + 'Lead', + 'Supporting', + 'Extra', + 'Stunt-double', + 'Editor', + 'Cutter', + 'Splicer', + 'Auteur', + 'Visionary', + 'Artist', + 'Creator', + 'Scene', + 'Shot', + 'Take', + 'Cut', + 'Action', + 'Wrap', + 'Premiere', + 'Oscar', ], }, // === FRUITS (🍎🍊🍌🍇🍓🥝🍑🥭🍍🥥) === fruits: { adjectives: [ - "Fresh", - "Ripe", - "Juicy", - "Sweet", - "Tangy", - "Tart", - "Zesty", - "Citrus", - "Tropical", - "Exotic", - "Luscious", - "Succulent", - "Delicious", - "Tasty", - "Yummy", - "Scrumptious", - "Mouthwatering", - "Appetizing", - "Organic", - "Natural", - "Healthy", - "Nutritious", - "Vitamin-rich", - "Colorful", - "Vibrant", + 'Fresh', + 'Ripe', + 'Juicy', + 'Sweet', + 'Tangy', + 'Tart', + 'Zesty', + 'Citrus', + 'Tropical', + 'Exotic', + 'Luscious', + 'Succulent', + 'Delicious', + 'Tasty', + 'Yummy', + 'Scrumptious', + 'Mouthwatering', + 'Appetizing', + 'Organic', + 'Natural', + 'Healthy', + 'Nutritious', + 'Vitamin-rich', + 'Colorful', + 'Vibrant', ], nouns: [ - "Apple", - "Orange", - "Banana", - "Grape", - "Berry", - "Strawberry", - "Blueberry", - "Raspberry", - "Blackberry", - "Kiwi", - "Peach", - "Mango", - "Pineapple", - "Coconut", - "Cherry", - "Plum", - "Pear", - "Watermelon", - "Cantaloupe", - "Honeydew", - "Papaya", - "Guava", - "Passionfruit", - "Lychee", - "Dragonfruit", - "Smoothie", + 'Apple', + 'Orange', + 'Banana', + 'Grape', + 'Berry', + 'Strawberry', + 'Blueberry', + 'Raspberry', + 'Blackberry', + 'Kiwi', + 'Peach', + 'Mango', + 'Pineapple', + 'Coconut', + 'Cherry', + 'Plum', + 'Pear', + 'Watermelon', + 'Cantaloupe', + 'Honeydew', + 'Papaya', + 'Guava', + 'Passionfruit', + 'Lychee', + 'Dragonfruit', + 'Smoothie', ], }, // === VEGETABLES (🥑🍆🥕🌽🌶️🫑🥒🥬🥦🧄🧅🍄🥜🌰) === vegetables: { adjectives: [ - "Fresh", - "Crisp", - "Crunchy", - "Green", - "Leafy", - "Healthy", - "Nutritious", - "Organic", - "Natural", - "Garden-fresh", - "Farm-fresh", - "Homegrown", - "Local", - "Seasonal", - "Raw", - "Cooked", - "Roasted", - "Steamed", - "Grilled", - "Sautéed", - "Colorful", - "Vibrant", - "Vitamin-rich", - "Fiber-rich", - "Low-calorie", + 'Fresh', + 'Crisp', + 'Crunchy', + 'Green', + 'Leafy', + 'Healthy', + 'Nutritious', + 'Organic', + 'Natural', + 'Garden-fresh', + 'Farm-fresh', + 'Homegrown', + 'Local', + 'Seasonal', + 'Raw', + 'Cooked', + 'Roasted', + 'Steamed', + 'Grilled', + 'Sautéed', + 'Colorful', + 'Vibrant', + 'Vitamin-rich', + 'Fiber-rich', + 'Low-calorie', ], nouns: [ - "Veggie", - "Vegetable", - "Avocado", - "Eggplant", - "Carrot", - "Corn", - "Pepper", - "Bell-pepper", - "Cucumber", - "Lettuce", - "Spinach", - "Kale", - "Broccoli", - "Cauliflower", - "Cabbage", - "Garlic", - "Onion", - "Mushroom", - "Peanut", - "Chestnut", - "Tomato", - "Potato", - "Root", - "Stem", - "Leaf", - "Garden", + 'Veggie', + 'Vegetable', + 'Avocado', + 'Eggplant', + 'Carrot', + 'Corn', + 'Pepper', + 'Bell-pepper', + 'Cucumber', + 'Lettuce', + 'Spinach', + 'Kale', + 'Broccoli', + 'Cauliflower', + 'Cabbage', + 'Garlic', + 'Onion', + 'Mushroom', + 'Peanut', + 'Chestnut', + 'Tomato', + 'Potato', + 'Root', + 'Stem', + 'Leaf', + 'Garden', ], }, -}; +} /** * Emoji-specific word lists for ultra-personalized names @@ -2728,3711 +2728,3700 @@ export const THEMED_WORD_LISTS: Record = { */ export const EMOJI_SPECIFIC_WORDS: Record = { // === ABACUS === - "🧮": { + '🧮': { adjectives: [ - "Calculating", - "Sliding", - "Beaded", - "Counting", - "Ancient", - "Skilled", - "Master", - "Lightning-fast", - "Precise", - "Traditional", + 'Calculating', + 'Sliding', + 'Beaded', + 'Counting', + 'Ancient', + 'Skilled', + 'Master', + 'Lightning-fast', + 'Precise', + 'Traditional', ], nouns: [ - "Abacist", - "Counter", - "Calculator", - "Mathematician", - "Abacus", - "Reckoner", - "Comptroller", - "Tallier", - "Computer", - "Prodigy", + 'Abacist', + 'Counter', + 'Calculator', + 'Mathematician', + 'Abacus', + 'Reckoner', + 'Comptroller', + 'Tallier', + 'Computer', + 'Prodigy', ], }, // === HAPPY FACES === - "😀": { + '😀': { adjectives: [ - "Grinning", - "Beaming", - "Ear-to-ear", - "Big-smiled", - "Toothy", - "Wide-smiling", - "Radiant", - "Bright-faced", - "Happy-go-lucky", - "Exuberant", + 'Grinning', + 'Beaming', + 'Ear-to-ear', + 'Big-smiled', + 'Toothy', + 'Wide-smiling', + 'Radiant', + 'Bright-faced', + 'Happy-go-lucky', + 'Exuberant', ], nouns: [ - "Grinner", - "Beamer", - "Smiler", - "Happy-face", - "Sunshine", - "Joy", - "Grin", - "Beam", - "Brightener", - "Cheermeister", + 'Grinner', + 'Beamer', + 'Smiler', + 'Happy-face', + 'Sunshine', + 'Joy', + 'Grin', + 'Beam', + 'Brightener', + 'Cheermeister', ], }, - "😃": { + '😃': { adjectives: [ - "Smiling", - "Open-mouthed", - "Enthusiastic", - "Excited", - "Gleeful", - "Thrilled", - "Delighted", - "Elated", - "Overjoyed", - "Ecstatic", + 'Smiling', + 'Open-mouthed', + 'Enthusiastic', + 'Excited', + 'Gleeful', + 'Thrilled', + 'Delighted', + 'Elated', + 'Overjoyed', + 'Ecstatic', ], nouns: [ - "Smiler", - "Enthusiast", - "Excitement", - "Thrill", - "Delight", - "Joy-spreader", - "Happy-camper", - "Cheer", - "Spark", - "Energizer", + 'Smiler', + 'Enthusiast', + 'Excitement', + 'Thrill', + 'Delight', + 'Joy-spreader', + 'Happy-camper', + 'Cheer', + 'Spark', + 'Energizer', ], }, - "😄": { + '😄': { adjectives: [ - "Squinty-eyed", - "Crinkle-eyed", - "Eye-smiling", - "Genuine", - "Warm", - "Heartfelt", - "True", - "Real", - "Authentic", - "Sincere", + 'Squinty-eyed', + 'Crinkle-eyed', + 'Eye-smiling', + 'Genuine', + 'Warm', + 'Heartfelt', + 'True', + 'Real', + 'Authentic', + 'Sincere', ], nouns: [ - "Eye-smiler", - "Warmth", - "Heart", - "Truth", - "Genuineness", - "Sincerity", - "Crinkler", - "Twinkler", - "Sparkler", - "Glower", + 'Eye-smiler', + 'Warmth', + 'Heart', + 'Truth', + 'Genuineness', + 'Sincerity', + 'Crinkler', + 'Twinkler', + 'Sparkler', + 'Glower', ], }, - "😁": { + '😁': { adjectives: [ - "Beaming", - "Squinting", - "Bright", - "Radiant", - "Luminous", - "Glowing", - "Dazzling", - "Brilliant", - "Resplendent", - "Shining", + 'Beaming', + 'Squinting', + 'Bright', + 'Radiant', + 'Luminous', + 'Glowing', + 'Dazzling', + 'Brilliant', + 'Resplendent', + 'Shining', ], nouns: [ - "Beamer", - "Squinter", - "Brightness", - "Radiance", - "Luminosity", - "Glow", - "Dazzle", - "Brilliance", - "Light", - "Shine", + 'Beamer', + 'Squinter', + 'Brightness', + 'Radiance', + 'Luminosity', + 'Glow', + 'Dazzle', + 'Brilliance', + 'Light', + 'Shine', ], }, - "😆": { + '😆': { adjectives: [ - "Laughing", - "Cackling", - "Chuckling", - "Giggling", - "Chortling", - "Snickering", - "Guffawing", - "Howling", - "Roaring", - "Belly-laughing", + 'Laughing', + 'Cackling', + 'Chuckling', + 'Giggling', + 'Chortling', + 'Snickering', + 'Guffawing', + 'Howling', + 'Roaring', + 'Belly-laughing', ], nouns: [ - "Laugher", - "Cackler", - "Chuckler", - "Giggler", - "Chortler", - "Snickerer", - "Guffawer", - "Howler", - "Roarer", - "Jokester", + 'Laugher', + 'Cackler', + 'Chuckler', + 'Giggler', + 'Chortler', + 'Snickerer', + 'Guffawer', + 'Howler', + 'Roarer', + 'Jokester', ], }, - "😅": { + '😅': { adjectives: [ - "Nervous", - "Sweaty", - "Awkward", - "Sheepish", - "Embarrassed", - "Self-conscious", - "Flustered", - "Uncomfortable", - "Relieved", - "Phew", + 'Nervous', + 'Sweaty', + 'Awkward', + 'Sheepish', + 'Embarrassed', + 'Self-conscious', + 'Flustered', + 'Uncomfortable', + 'Relieved', + 'Phew', ], nouns: [ - "Sweater", - "Phew-er", - "Relief", - "Awkwardness", - "Embarrassment", - "Sheepishness", - "Fluster", - "Nervousness", - "Dropper", - "Wiper", + 'Sweater', + 'Phew-er', + 'Relief', + 'Awkwardness', + 'Embarrassment', + 'Sheepishness', + 'Fluster', + 'Nervousness', + 'Dropper', + 'Wiper', ], }, - "🤣": { + '🤣': { adjectives: [ - "Rolling", - "ROFL", - "Floor-rolling", - "Hysterical", - "Side-splitting", - "Tear-jerking", - "Hilarious", - "Uproarious", - "Riotous", - "Knee-slapping", + 'Rolling', + 'ROFL', + 'Floor-rolling', + 'Hysterical', + 'Side-splitting', + 'Tear-jerking', + 'Hilarious', + 'Uproarious', + 'Riotous', + 'Knee-slapping', ], nouns: [ - "Roller", - "ROFL-er", - "Hysterics", - "Comedian", - "Joker", - "Prankster", - "Laugher", - "Cackler", - "Whooper", - "Howler", + 'Roller', + 'ROFL-er', + 'Hysterics', + 'Comedian', + 'Joker', + 'Prankster', + 'Laugher', + 'Cackler', + 'Whooper', + 'Howler', ], }, - "😂": { + '😂': { adjectives: [ - "Crying-laughing", - "Tear-streaming", - "Joy-crying", - "Weeping-happy", - "Tear-filled", - "Sobbing-happy", - "Blubbering-joy", - "Bawling-happy", - "Wailing-joy", - "Sniffling-happy", + 'Crying-laughing', + 'Tear-streaming', + 'Joy-crying', + 'Weeping-happy', + 'Tear-filled', + 'Sobbing-happy', + 'Blubbering-joy', + 'Bawling-happy', + 'Wailing-joy', + 'Sniffling-happy', ], nouns: [ - "Joy-crier", - "Happy-weeper", - "Tear-streamer", - "Laugher", - "Sobber", - "Blubberer", - "Bawler", - "Wailer", - "Tear", - "Joy", + 'Joy-crier', + 'Happy-weeper', + 'Tear-streamer', + 'Laugher', + 'Sobber', + 'Blubberer', + 'Bawler', + 'Wailer', + 'Tear', + 'Joy', ], }, - "🙂": { + '🙂': { adjectives: [ - "Slightly-smiling", - "Content", - "Satisfied", - "Pleasant", - "Agreeable", - "Nice", - "Kind", - "Gentle", - "Mild", - "Soft", + 'Slightly-smiling', + 'Content', + 'Satisfied', + 'Pleasant', + 'Agreeable', + 'Nice', + 'Kind', + 'Gentle', + 'Mild', + 'Soft', ], nouns: [ - "Contenter", - "Satisfier", - "Pleasantness", - "Agreeability", - "Niceness", - "Kindness", - "Gentleness", - "Mildness", - "Softness", - "Ease", + 'Contenter', + 'Satisfier', + 'Pleasantness', + 'Agreeability', + 'Niceness', + 'Kindness', + 'Gentleness', + 'Mildness', + 'Softness', + 'Ease', ], }, - "😉": { + '😉': { adjectives: [ - "Winking", - "Sly", - "Knowing", - "Conspiratorial", - "In-the-know", - "Secretive", - "Mysterious", - "Playful", - "Flirty", - "Cheeky", + 'Winking', + 'Sly', + 'Knowing', + 'Conspiratorial', + 'In-the-know', + 'Secretive', + 'Mysterious', + 'Playful', + 'Flirty', + 'Cheeky', ], nouns: [ - "Winker", - "Slyster", - "Know-it-all", - "Conspirator", - "Secret-keeper", - "Mystery", - "Flirt", - "Cheek", - "Tease", - "Charmer", + 'Winker', + 'Slyster', + 'Know-it-all', + 'Conspirator', + 'Secret-keeper', + 'Mystery', + 'Flirt', + 'Cheek', + 'Tease', + 'Charmer', ], }, - "😊": { + '😊': { adjectives: [ - "Blushing", - "Warm", - "Rosy-cheeked", - "Flushed", - "Pink", - "Glowing", - "Bashful", - "Modest", - "Humble", - "Sweet", + 'Blushing', + 'Warm', + 'Rosy-cheeked', + 'Flushed', + 'Pink', + 'Glowing', + 'Bashful', + 'Modest', + 'Humble', + 'Sweet', ], nouns: [ - "Blusher", - "Warmer", - "Rosiness", - "Flush", - "Pink", - "Glow", - "Bashfulness", - "Modesty", - "Humility", - "Sweetness", + 'Blusher', + 'Warmer', + 'Rosiness', + 'Flush', + 'Pink', + 'Glow', + 'Bashfulness', + 'Modesty', + 'Humility', + 'Sweetness', ], }, - "😇": { + '😇': { adjectives: [ - "Angelic", - "Innocent", - "Pure", - "Holy", - "Divine", - "Blessed", - "Saintly", - "Virtuous", - "Righteous", - "Good", + 'Angelic', + 'Innocent', + 'Pure', + 'Holy', + 'Divine', + 'Blessed', + 'Saintly', + 'Virtuous', + 'Righteous', + 'Good', ], nouns: [ - "Angel", - "Innocent", - "Pure-heart", - "Saint", - "Virtue", - "Righteousness", - "Goodness", - "Halo", - "Wing", - "Heaven", + 'Angel', + 'Innocent', + 'Pure-heart', + 'Saint', + 'Virtue', + 'Righteousness', + 'Goodness', + 'Halo', + 'Wing', + 'Heaven', ], }, // === LOVE & AFFECTION === - "🥰": { + '🥰': { adjectives: [ - "Heart-eyed", - "Loving", - "Adoring", - "Smitten", - "Enamored", - "Infatuated", - "Besotted", - "Head-over-heels", - "Lovesick", - "Doting", + 'Heart-eyed', + 'Loving', + 'Adoring', + 'Smitten', + 'Enamored', + 'Infatuated', + 'Besotted', + 'Head-over-heels', + 'Lovesick', + 'Doting', ], nouns: [ - "Lover", - "Adorer", - "Sweetheart", - "Heart-eyes", - "Cupid", - "Love", - "Hearts", - "Affection", - "Devotion", - "Romance", + 'Lover', + 'Adorer', + 'Sweetheart', + 'Heart-eyes', + 'Cupid', + 'Love', + 'Hearts', + 'Affection', + 'Devotion', + 'Romance', ], }, - "😍": { + '😍': { adjectives: [ - "Starry-eyed", - "Awestruck", - "Amazed", - "Dazzled", - "Captivated", - "Mesmerized", - "Enchanted", - "Spellbound", - "Enthralled", - "Bedazzled", + 'Starry-eyed', + 'Awestruck', + 'Amazed', + 'Dazzled', + 'Captivated', + 'Mesmerized', + 'Enchanted', + 'Spellbound', + 'Enthralled', + 'Bedazzled', ], nouns: [ - "Star-eyes", - "Wonder", - "Awe", - "Dazzle", - "Captivation", - "Mesmerizer", - "Enchantment", - "Spell", - "Thrall", - "Magic", + 'Star-eyes', + 'Wonder', + 'Awe', + 'Dazzle', + 'Captivation', + 'Mesmerizer', + 'Enchantment', + 'Spell', + 'Thrall', + 'Magic', ], }, - "🤩": { + '🤩': { adjectives: [ - "Starstruck", - "Excited", - "Thrilled", - "Pumped", - "Hyped", - "Amped", - "Psyched", - "Stoked", - "Electrified", - "Energized", + 'Starstruck', + 'Excited', + 'Thrilled', + 'Pumped', + 'Hyped', + 'Amped', + 'Psyched', + 'Stoked', + 'Electrified', + 'Energized', ], nouns: [ - "Superstar", - "Star", - "Celebrity", - "VIP", - "Excitement", - "Thrill", - "Hype", - "Amp", - "Energy", - "Electricity", + 'Superstar', + 'Star', + 'Celebrity', + 'VIP', + 'Excitement', + 'Thrill', + 'Hype', + 'Amp', + 'Energy', + 'Electricity', ], }, - "😘": { + '😘': { adjectives: [ - "Kiss-blowing", - "Smooching", - "Affectionate", - "Tender", - "Loving", - "Sweet", - "Caring", - "Gentle", - "Romantic", - "Amorous", + 'Kiss-blowing', + 'Smooching', + 'Affectionate', + 'Tender', + 'Loving', + 'Sweet', + 'Caring', + 'Gentle', + 'Romantic', + 'Amorous', ], nouns: [ - "Kisser", - "Smoocher", - "Kiss", - "Smooch", - "Peck", - "Affection", - "Tenderness", - "Love", - "Romance", - "Heart", + 'Kisser', + 'Smoocher', + 'Kiss', + 'Smooch', + 'Peck', + 'Affection', + 'Tenderness', + 'Love', + 'Romance', + 'Heart', ], }, - "😗": { + '😗': { adjectives: [ - "Puckering", - "Kissing", - "Smoochy", - "Lip-pursing", - "Duck-facing", - "Pouting", - "Pout-lipped", - "Bee-stung", - "Full-lipped", - "Plump-lipped", + 'Puckering', + 'Kissing', + 'Smoochy', + 'Lip-pursing', + 'Duck-facing', + 'Pouting', + 'Pout-lipped', + 'Bee-stung', + 'Full-lipped', + 'Plump-lipped', ], nouns: [ - "Puckerer", - "Kissy-face", - "Smooch", - "Pucker", - "Lips", - "Pout", - "Duck-face", - "Bee-sting", - "Kiss", - "Muah", + 'Puckerer', + 'Kissy-face', + 'Smooch', + 'Pucker', + 'Lips', + 'Pout', + 'Duck-face', + 'Bee-sting', + 'Kiss', + 'Muah', ], }, - "😚": { + '😚': { adjectives: [ - "Blushing-kiss", - "Shy-kiss", - "Sweet-kiss", - "Tender-kiss", - "Gentle-kiss", - "Soft-kiss", - "Warm-kiss", - "Loving-kiss", - "Caring-kiss", - "Affectionate-kiss", + 'Blushing-kiss', + 'Shy-kiss', + 'Sweet-kiss', + 'Tender-kiss', + 'Gentle-kiss', + 'Soft-kiss', + 'Warm-kiss', + 'Loving-kiss', + 'Caring-kiss', + 'Affectionate-kiss', ], nouns: [ - "Shy-kisser", - "Sweet-kiss", - "Tender-kiss", - "Blush", - "Gentleness", - "Softness", - "Warmth", - "Care", - "Affection", - "Love", + 'Shy-kisser', + 'Sweet-kiss', + 'Tender-kiss', + 'Blush', + 'Gentleness', + 'Softness', + 'Warmth', + 'Care', + 'Affection', + 'Love', ], }, // === PLAYFUL === - "😋": { + '😋': { adjectives: [ - "Yummy", - "Delicious", - "Tasty", - "Savory", - "Scrumptious", - "Delectable", - "Mouthwatering", - "Appetizing", - "Lip-smacking", - "Tongue-out", + 'Yummy', + 'Delicious', + 'Tasty', + 'Savory', + 'Scrumptious', + 'Delectable', + 'Mouthwatering', + 'Appetizing', + 'Lip-smacking', + 'Tongue-out', ], nouns: [ - "Foodie", - "Taste-tester", - "Yum", - "Deliciousness", - "Flavor", - "Taste", - "Tongue", - "Lick", - "Savorer", - "Epicure", + 'Foodie', + 'Taste-tester', + 'Yum', + 'Deliciousness', + 'Flavor', + 'Taste', + 'Tongue', + 'Lick', + 'Savorer', + 'Epicure', ], }, - "😛": { + '😛': { adjectives: [ - "Tongue-out", - "Teasing", - "Playful", - "Cheeky", - "Sassy", - "Impish", - "Mischievous", - "Bratty", - "Naughty", - "Rascally", + 'Tongue-out', + 'Teasing', + 'Playful', + 'Cheeky', + 'Sassy', + 'Impish', + 'Mischievous', + 'Bratty', + 'Naughty', + 'Rascally', ], nouns: [ - "Teaser", - "Cheeky-monkey", - "Sassy-pants", - "Imp", - "Mischief", - "Brat", - "Rascal", - "Tongue", - "Raspberry", - "Blep", + 'Teaser', + 'Cheeky-monkey', + 'Sassy-pants', + 'Imp', + 'Mischief', + 'Brat', + 'Rascal', + 'Tongue', + 'Raspberry', + 'Blep', ], }, - "😝": { + '😝': { adjectives: [ - "Squinty-tongue", - "Silly", - "Goofy", - "Wacky", - "Zany", - "Absurd", - "Ridiculous", - "Ludicrous", - "Preposterous", - "Wild", + 'Squinty-tongue', + 'Silly', + 'Goofy', + 'Wacky', + 'Zany', + 'Absurd', + 'Ridiculous', + 'Ludicrous', + 'Preposterous', + 'Wild', ], nouns: [ - "Goofball", - "Silly-billy", - "Wacko", - "Zany", - "Nut", - "Kook", - "Clown", - "Jester", - "Fool", - "Joker", + 'Goofball', + 'Silly-billy', + 'Wacko', + 'Zany', + 'Nut', + 'Kook', + 'Clown', + 'Jester', + 'Fool', + 'Joker', ], }, - "😜": { + '😜': { adjectives: [ - "Winking-tongue", - "Playful", - "Flirty", - "Saucy", - "Pert", - "Sassy", - "Bold", - "Daring", - "Audacious", - "Brazen", + 'Winking-tongue', + 'Playful', + 'Flirty', + 'Saucy', + 'Pert', + 'Sassy', + 'Bold', + 'Daring', + 'Audacious', + 'Brazen', ], nouns: [ - "Flirt", - "Winker", - "Sassy-pants", - "Saucepot", - "Tease", - "Minx", - "Vixen", - "Charmer", - "Coquette", - "Boldness", + 'Flirt', + 'Winker', + 'Sassy-pants', + 'Saucepot', + 'Tease', + 'Minx', + 'Vixen', + 'Charmer', + 'Coquette', + 'Boldness', ], }, - "🤪": { + '🤪': { adjectives: [ - "Crazy", - "Wild", - "Bonkers", - "Bananas", - "Nuts", - "Loopy", - "Wacky", - "Zany", - "Madcap", - "Unhinged", + 'Crazy', + 'Wild', + 'Bonkers', + 'Bananas', + 'Nuts', + 'Loopy', + 'Wacky', + 'Zany', + 'Madcap', + 'Unhinged', ], nouns: [ - "Wildcard", - "Maniac", - "Lunatic", - "Madness", - "Chaos", - "Mayhem", - "Havoc", - "Pandemonium", - "Bedlam", - "Nutcase", + 'Wildcard', + 'Maniac', + 'Lunatic', + 'Madness', + 'Chaos', + 'Mayhem', + 'Havoc', + 'Pandemonium', + 'Bedlam', + 'Nutcase', ], }, // === CLEVER === - "🤨": { + '🤨': { adjectives: [ - "Skeptical", - "Doubtful", - "Questioning", - "Suspicious", - "Dubious", - "Wary", - "Distrustful", - "Uncertain", - "Raised-eyebrow", - "Quizzical", + 'Skeptical', + 'Doubtful', + 'Questioning', + 'Suspicious', + 'Dubious', + 'Wary', + 'Distrustful', + 'Uncertain', + 'Raised-eyebrow', + 'Quizzical', ], nouns: [ - "Skeptic", - "Doubter", - "Questioner", - "Eyebrow", - "Suspicion", - "Doubt", - "Question", - "Query", - "Inquiry", - "Probe", + 'Skeptic', + 'Doubter', + 'Questioner', + 'Eyebrow', + 'Suspicion', + 'Doubt', + 'Question', + 'Query', + 'Inquiry', + 'Probe', ], }, - "🧐": { + '🧐': { adjectives: [ - "Monocled", - "Sophisticated", - "Refined", - "Cultured", - "Educated", - "Learned", - "Well-read", - "Scholarly", - "Academic", - "Intellectual", + 'Monocled', + 'Sophisticated', + 'Refined', + 'Cultured', + 'Educated', + 'Learned', + 'Well-read', + 'Scholarly', + 'Academic', + 'Intellectual', ], nouns: [ - "Aristocrat", - "Sophisticate", - "Intellectual", - "Scholar", - "Professor", - "Monocle", - "Gentleman", - "Lady", - "Connoisseur", - "Savant", + 'Aristocrat', + 'Sophisticate', + 'Intellectual', + 'Scholar', + 'Professor', + 'Monocle', + 'Gentleman', + 'Lady', + 'Connoisseur', + 'Savant', ], }, - "🤓": { + '🤓': { adjectives: [ - "Nerdy", - "Geeky", - "Bookish", - "Studious", - "Academic", - "Brainy", - "Smart", - "Intelligent", - "Clever", - "Genius", + 'Nerdy', + 'Geeky', + 'Bookish', + 'Studious', + 'Academic', + 'Brainy', + 'Smart', + 'Intelligent', + 'Clever', + 'Genius', ], nouns: [ - "Nerd", - "Geek", - "Bookworm", - "Brain", - "Brainiac", - "Egghead", - "Smarty-pants", - "Know-it-all", - "Genius", - "Whiz", + 'Nerd', + 'Geek', + 'Bookworm', + 'Brain', + 'Brainiac', + 'Egghead', + 'Smarty-pants', + 'Know-it-all', + 'Genius', + 'Whiz', ], }, // === COOL === - "😎": { + '😎': { adjectives: [ - "Cool", - "Sunglassed", - "Shaded", - "Hip", - "Rad", - "Fly", - "Fresh", - "Dope", - "Lit", - "Fire", + 'Cool', + 'Sunglassed', + 'Shaded', + 'Hip', + 'Rad', + 'Fly', + 'Fresh', + 'Dope', + 'Lit', + 'Fire', ], nouns: [ - "Cool-cat", - "Dude", - "Shades", - "Sunglasses", - "Aviators", - "Ray-Bans", - "Coolness", - "Swag", - "Style", - "Vibe", + 'Cool-cat', + 'Dude', + 'Shades', + 'Sunglasses', + 'Aviators', + 'Ray-Bans', + 'Coolness', + 'Swag', + 'Style', + 'Vibe', ], }, - "🥸": { + '🥸': { adjectives: [ - "Disguised", - "Incognito", - "Undercover", - "Secret", - "Hidden", - "Masked", - "Concealed", - "Camouflaged", - "Stealthy", - "Covert", + 'Disguised', + 'Incognito', + 'Undercover', + 'Secret', + 'Hidden', + 'Masked', + 'Concealed', + 'Camouflaged', + 'Stealthy', + 'Covert', ], nouns: [ - "Disguise", - "Incognito", - "Spy", - "Agent", - "Detective", - "Sleuth", - "Investigator", - "Nose", - "Glasses", - "Mustache", + 'Disguise', + 'Incognito', + 'Spy', + 'Agent', + 'Detective', + 'Sleuth', + 'Investigator', + 'Nose', + 'Glasses', + 'Mustache', ], }, // === PARTY === - "🥳": { + '🥳': { adjectives: [ - "Party", - "Celebrating", - "Festive", - "Birthday", - "Anniversary", - "Jubilant", - "Revelrous", - "Merrymaking", - "Confetti", - "Balloon", + 'Party', + 'Celebrating', + 'Festive', + 'Birthday', + 'Anniversary', + 'Jubilant', + 'Revelrous', + 'Merrymaking', + 'Confetti', + 'Balloon', ], nouns: [ - "Partier", - "Celebrator", - "Birthday-person", - "Guest-of-honor", - "VIP", - "Confetti", - "Balloon", - "Streamer", - "Cake", - "Celebration", + 'Partier', + 'Celebrator', + 'Birthday-person', + 'Guest-of-honor', + 'VIP', + 'Confetti', + 'Balloon', + 'Streamer', + 'Cake', + 'Celebration', ], }, // === MOODY === - "😏": { + '😏': { adjectives: [ - "Smirking", - "Sly", - "Devious", - "Cunning", - "Scheming", - "Plotting", - "Wily", - "Crafty", - "Sneaky", - "Underhanded", + 'Smirking', + 'Sly', + 'Devious', + 'Cunning', + 'Scheming', + 'Plotting', + 'Wily', + 'Crafty', + 'Sneaky', + 'Underhanded', ], nouns: [ - "Smirker", - "Slyster", - "Schemer", - "Plotter", - "Trickster", - "Deceiver", - "Manipulator", - "Smirk", - "Scheme", - "Plot", + 'Smirker', + 'Slyster', + 'Schemer', + 'Plotter', + 'Trickster', + 'Deceiver', + 'Manipulator', + 'Smirk', + 'Scheme', + 'Plot', ], }, - "😒": { + '😒': { adjectives: [ - "Unamused", - "Unimpressed", - "Bored", - "Annoyed", - "Irritated", - "Miffed", - "Peeved", - "Displeased", - "Side-eyeing", - "Done", + 'Unamused', + 'Unimpressed', + 'Bored', + 'Annoyed', + 'Irritated', + 'Miffed', + 'Peeved', + 'Displeased', + 'Side-eyeing', + 'Done', ], nouns: [ - "Side-eye", - "Boredom", - "Annoyance", - "Irritation", - "Displeasure", - "Miff", - "Peeve", - "Eye-roll", - "Sigh", - "Whatever", + 'Side-eye', + 'Boredom', + 'Annoyance', + 'Irritation', + 'Displeasure', + 'Miff', + 'Peeve', + 'Eye-roll', + 'Sigh', + 'Whatever', ], }, - "😞": { + '😞': { adjectives: [ - "Disappointed", - "Let-down", - "Disheartened", - "Discouraged", - "Deflated", - "Crestfallen", - "Downcast", - "Dejected", - "Dispirited", - "Sad", + 'Disappointed', + 'Let-down', + 'Disheartened', + 'Discouraged', + 'Deflated', + 'Crestfallen', + 'Downcast', + 'Dejected', + 'Dispirited', + 'Sad', ], nouns: [ - "Disappointment", - "Let-down", - "Sadness", - "Sorrow", - "Blues", - "Melancholy", - "Gloom", - "Dejection", - "Discouragement", - "Frown", + 'Disappointment', + 'Let-down', + 'Sadness', + 'Sorrow', + 'Blues', + 'Melancholy', + 'Gloom', + 'Dejection', + 'Discouragement', + 'Frown', ], }, - "😔": { + '😔': { adjectives: [ - "Pensive", - "Thoughtful", - "Contemplative", - "Reflective", - "Meditative", - "Introspective", - "Brooding", - "Pondering", - "Musing", - "Ruminating", + 'Pensive', + 'Thoughtful', + 'Contemplative', + 'Reflective', + 'Meditative', + 'Introspective', + 'Brooding', + 'Pondering', + 'Musing', + 'Ruminating', ], nouns: [ - "Thinker", - "Ponderer", - "Muser", - "Contemplator", - "Reflector", - "Philosopher", - "Sage", - "Thought", - "Reflection", - "Meditation", + 'Thinker', + 'Ponderer', + 'Muser', + 'Contemplator', + 'Reflector', + 'Philosopher', + 'Sage', + 'Thought', + 'Reflection', + 'Meditation', ], }, - "😟": { + '😟': { adjectives: [ - "Worried", - "Concerned", - "Anxious", - "Nervous", - "Uneasy", - "Troubled", - "Distressed", - "Fretful", - "Apprehensive", - "Tense", + 'Worried', + 'Concerned', + 'Anxious', + 'Nervous', + 'Uneasy', + 'Troubled', + 'Distressed', + 'Fretful', + 'Apprehensive', + 'Tense', ], nouns: [ - "Worrier", - "Fretter", - "Concern", - "Worry", - "Anxiety", - "Nervousness", - "Unease", - "Distress", - "Apprehension", - "Tension", + 'Worrier', + 'Fretter', + 'Concern', + 'Worry', + 'Anxiety', + 'Nervousness', + 'Unease', + 'Distress', + 'Apprehension', + 'Tension', ], }, - "😕": { + '😕': { adjectives: [ - "Confused", - "Puzzled", - "Perplexed", - "Baffled", - "Bewildered", - "Mystified", - "Befuddled", - "Flummoxed", - "Stumped", - "Lost", + 'Confused', + 'Puzzled', + 'Perplexed', + 'Baffled', + 'Bewildered', + 'Mystified', + 'Befuddled', + 'Flummoxed', + 'Stumped', + 'Lost', ], nouns: [ - "Confusion", - "Puzzle", - "Perplexity", - "Bafflement", - "Bewilderment", - "Mystery", - "Befuddlement", - "Question-mark", - "Enigma", - "Riddle", + 'Confusion', + 'Puzzle', + 'Perplexity', + 'Bafflement', + 'Bewilderment', + 'Mystery', + 'Befuddlement', + 'Question-mark', + 'Enigma', + 'Riddle', ], }, // === FANTASY & FUN === - "🤠": { + '🤠': { adjectives: [ - "Cowboy", - "Western", - "Frontier", - "Wild-west", - "Rootin-tootin", - "Yeehaw", - "Rodeo", - "Ranch", - "Trail", - "Desert", + 'Cowboy', + 'Western', + 'Frontier', + 'Wild-west', + 'Rootin-tootin', + 'Yeehaw', + 'Rodeo', + 'Ranch', + 'Trail', + 'Desert', ], nouns: [ - "Cowboy", - "Cowgirl", - "Rancher", - "Wrangler", - "Sheriff", - "Deputy", - "Outlaw", - "Gunslinger", - "Buckaroo", - "Pardner", + 'Cowboy', + 'Cowgirl', + 'Rancher', + 'Wrangler', + 'Sheriff', + 'Deputy', + 'Outlaw', + 'Gunslinger', + 'Buckaroo', + 'Pardner', ], }, - "🥷": { + '🥷': { adjectives: [ - "Ninja", - "Stealthy", - "Shadow", - "Silent", - "Deadly", - "Swift", - "Agile", - "Trained", - "Skilled", - "Martial", + 'Ninja', + 'Stealthy', + 'Shadow', + 'Silent', + 'Deadly', + 'Swift', + 'Agile', + 'Trained', + 'Skilled', + 'Martial', ], nouns: [ - "Ninja", - "Shinobi", - "Assassin", - "Shadow-warrior", - "Blade", - "Shuriken", - "Katana", - "Sensei", - "Master", - "Disciple", + 'Ninja', + 'Shinobi', + 'Assassin', + 'Shadow-warrior', + 'Blade', + 'Shuriken', + 'Katana', + 'Sensei', + 'Master', + 'Disciple', ], }, - "👑": { + '👑': { adjectives: [ - "Royal", - "Regal", - "Majestic", - "Noble", - "Crowned", - "Sovereign", - "Imperial", - "Kingly", - "Queenly", - "Princely", + 'Royal', + 'Regal', + 'Majestic', + 'Noble', + 'Crowned', + 'Sovereign', + 'Imperial', + 'Kingly', + 'Queenly', + 'Princely', ], nouns: [ - "King", - "Queen", - "Monarch", - "Sovereign", - "Ruler", - "Crown", - "Throne", - "Majesty", - "Highness", - "Royalty", + 'King', + 'Queen', + 'Monarch', + 'Sovereign', + 'Ruler', + 'Crown', + 'Throne', + 'Majesty', + 'Highness', + 'Royalty', ], }, - "🎭": { + '🎭': { adjectives: [ - "Theatrical", - "Dramatic", - "Thespian", - "Acting", - "Performing", - "Stage", - "Broadway", - "Shakespearean", - "Melodramatic", - "Over-the-top", + 'Theatrical', + 'Dramatic', + 'Thespian', + 'Acting', + 'Performing', + 'Stage', + 'Broadway', + 'Shakespearean', + 'Melodramatic', + 'Over-the-top', ], nouns: [ - "Actor", - "Actress", - "Thespian", - "Performer", - "Star", - "Drama", - "Comedy", - "Tragedy", - "Mask", - "Stage", + 'Actor', + 'Actress', + 'Thespian', + 'Performer', + 'Star', + 'Drama', + 'Comedy', + 'Tragedy', + 'Mask', + 'Stage', ], }, - "🤖": { + '🤖': { adjectives: [ - "Robotic", - "Mechanical", - "Automated", - "Cyber", - "Digital", - "Electronic", - "Programmed", - "Binary", - "Tech", - "AI", + 'Robotic', + 'Mechanical', + 'Automated', + 'Cyber', + 'Digital', + 'Electronic', + 'Programmed', + 'Binary', + 'Tech', + 'AI', ], nouns: [ - "Robot", - "Bot", - "Droid", - "Android", - "Cyborg", - "AI", - "Machine", - "Computer", - "Circuit", - "Processor", + 'Robot', + 'Bot', + 'Droid', + 'Android', + 'Cyborg', + 'AI', + 'Machine', + 'Computer', + 'Circuit', + 'Processor', ], }, - "👻": { + '👻': { adjectives: [ - "Ghostly", - "Spectral", - "Phantom", - "Ethereal", - "Transparent", - "Floating", - "Haunting", - "Spooky", - "Eerie", - "Boo", + 'Ghostly', + 'Spectral', + 'Phantom', + 'Ethereal', + 'Transparent', + 'Floating', + 'Haunting', + 'Spooky', + 'Eerie', + 'Boo', ], nouns: [ - "Ghost", - "Specter", - "Phantom", - "Spirit", - "Apparition", - "Boo", - "Haunt", - "Poltergeist", - "Wraith", - "Shade", + 'Ghost', + 'Specter', + 'Phantom', + 'Spirit', + 'Apparition', + 'Boo', + 'Haunt', + 'Poltergeist', + 'Wraith', + 'Shade', ], }, - "💀": { + '💀': { adjectives: [ - "Skeletal", - "Bony", - "Skull", - "Death", - "Grim", - "Macabre", - "Gothic", - "Dark", - "Spooky", - "Hardcore", + 'Skeletal', + 'Bony', + 'Skull', + 'Death', + 'Grim', + 'Macabre', + 'Gothic', + 'Dark', + 'Spooky', + 'Hardcore', ], nouns: [ - "Skeleton", - "Skull", - "Bones", - "Death", - "Reaper", - "Grim", - "Bone-head", - "Skull-face", - "Ossuary", - "Crypt", + 'Skeleton', + 'Skull', + 'Bones', + 'Death', + 'Reaper', + 'Grim', + 'Bone-head', + 'Skull-face', + 'Ossuary', + 'Crypt', ], }, - "👽": { + '👽': { adjectives: [ - "Alien", - "Extraterrestrial", - "UFO", - "Martian", - "Space", - "Cosmic", - "Galactic", - "Otherworldly", - "Unearthly", - "Sci-fi", + 'Alien', + 'Extraterrestrial', + 'UFO', + 'Martian', + 'Space', + 'Cosmic', + 'Galactic', + 'Otherworldly', + 'Unearthly', + 'Sci-fi', ], nouns: [ - "Alien", - "ET", - "Extraterrestrial", - "Martian", - "Spaceman", - "Space-being", - "Visitor", - "UFO", - "Saucer", - "Probe", + 'Alien', + 'ET', + 'Extraterrestrial', + 'Martian', + 'Spaceman', + 'Space-being', + 'Visitor', + 'UFO', + 'Saucer', + 'Probe', ], }, - "🤡": { + '🤡': { adjectives: [ - "Clownish", - "Circus", - "Red-nosed", - "Face-painted", - "Wig-wearing", - "Balloon-animal", - "Honking", - "Juggling", - "Pie-throwing", - "Slapstick", + 'Clownish', + 'Circus', + 'Red-nosed', + 'Face-painted', + 'Wig-wearing', + 'Balloon-animal', + 'Honking', + 'Juggling', + 'Pie-throwing', + 'Slapstick', ], nouns: [ - "Clown", - "Jester", - "Bozo", - "Pierrot", - "Harlequin", - "Circus", - "Big-top", - "Nose", - "Wig", - "Balloon", + 'Clown', + 'Jester', + 'Bozo', + 'Pierrot', + 'Harlequin', + 'Circus', + 'Big-top', + 'Nose', + 'Wig', + 'Balloon', ], }, // === WIZARDS === - "🧙‍♂️": { + '🧙‍♂️': { adjectives: [ - "Wizardly", - "Magical", - "Bearded", - "Robed", - "Staffed", - "Spell-casting", - "Potion-brewing", - "Wand-waving", - "Ancient", - "Wise", + 'Wizardly', + 'Magical', + 'Bearded', + 'Robed', + 'Staffed', + 'Spell-casting', + 'Potion-brewing', + 'Wand-waving', + 'Ancient', + 'Wise', ], nouns: [ - "Wizard", - "Warlock", - "Mage", - "Sorcerer", - "Merlin", - "Gandalf", - "Staff", - "Wand", - "Spell", - "Magic", + 'Wizard', + 'Warlock', + 'Mage', + 'Sorcerer', + 'Merlin', + 'Gandalf', + 'Staff', + 'Wand', + 'Spell', + 'Magic', ], }, - "🧙‍♀️": { + '🧙‍♀️': { adjectives: [ - "Witch", - "Enchanting", - "Spell-weaving", - "Cauldron-stirring", - "Broom-riding", - "Hat-wearing", - "Moon-powered", - "Star-gazing", - "Mystical", - "Arcane", + 'Witch', + 'Enchanting', + 'Spell-weaving', + 'Cauldron-stirring', + 'Broom-riding', + 'Hat-wearing', + 'Moon-powered', + 'Star-gazing', + 'Mystical', + 'Arcane', ], nouns: [ - "Witch", - "Sorceress", - "Enchantress", - "Spellcaster", - "Hermione", - "Cauldron", - "Broom", - "Hat", - "Spell", - "Charm", + 'Witch', + 'Sorceress', + 'Enchantress', + 'Spellcaster', + 'Hermione', + 'Cauldron', + 'Broom', + 'Hat', + 'Spell', + 'Charm', ], }, // === FAIRIES === - "🧚‍♂️": { + '🧚‍♂️': { adjectives: [ - "Fairy", - "Fae", - "Winged", - "Flying", - "Fluttering", - "Magical", - "Tiny", - "Delicate", - "Sprightly", - "Nimble", + 'Fairy', + 'Fae', + 'Winged', + 'Flying', + 'Fluttering', + 'Magical', + 'Tiny', + 'Delicate', + 'Sprightly', + 'Nimble', ], nouns: [ - "Fairy", - "Sprite", - "Pixie", - "Fae", - "Peter-Pan", - "Tinker", - "Wing", - "Flight", - "Magic", - "Dust", + 'Fairy', + 'Sprite', + 'Pixie', + 'Fae', + 'Peter-Pan', + 'Tinker', + 'Wing', + 'Flight', + 'Magic', + 'Dust', ], }, - "🧚‍♀️": { + '🧚‍♀️': { adjectives: [ - "Fairy", - "Fae", - "Gossamer", - "Delicate", - "Graceful", - "Ethereal", - "Twinkling", - "Shimmering", - "Glittering", - "Sparkling", + 'Fairy', + 'Fae', + 'Gossamer', + 'Delicate', + 'Graceful', + 'Ethereal', + 'Twinkling', + 'Shimmering', + 'Glittering', + 'Sparkling', ], nouns: [ - "Fairy", - "Sprite", - "Pixie", - "Tinkerbell", - "Gossamer", - "Shimmer", - "Glitter", - "Sparkle", - "Twinkle", - "Wing", + 'Fairy', + 'Sprite', + 'Pixie', + 'Tinkerbell', + 'Gossamer', + 'Shimmer', + 'Glitter', + 'Sparkle', + 'Twinkle', + 'Wing', ], }, // === VAMPIRES === - "🧛‍♂️": { + '🧛‍♂️': { adjectives: [ - "Vampiric", - "Fanged", - "Blood-drinking", - "Nocturnal", - "Pale", - "Immortal", - "Undead", - "Gothic", - "Aristocratic", - "Cape-wearing", + 'Vampiric', + 'Fanged', + 'Blood-drinking', + 'Nocturnal', + 'Pale', + 'Immortal', + 'Undead', + 'Gothic', + 'Aristocratic', + 'Cape-wearing', ], nouns: [ - "Vampire", - "Count", - "Dracula", - "Nosferatu", - "Bloodsucker", - "Fang", - "Cape", - "Night", - "Bite", - "Immortal", + 'Vampire', + 'Count', + 'Dracula', + 'Nosferatu', + 'Bloodsucker', + 'Fang', + 'Cape', + 'Night', + 'Bite', + 'Immortal', ], }, - "🧛‍♀️": { + '🧛‍♀️': { adjectives: [ - "Vampiress", - "Fanged", - "Nocturnal", - "Immortal", - "Elegant", - "Dark", - "Mysterious", - "Alluring", - "Enchanting", - "Bewitching", + 'Vampiress', + 'Fanged', + 'Nocturnal', + 'Immortal', + 'Elegant', + 'Dark', + 'Mysterious', + 'Alluring', + 'Enchanting', + 'Bewitching', ], nouns: [ - "Vampiress", - "Countess", - "Lady", - "Night-queen", - "Bloodsucker", - "Fang", - "Enchantress", - "Night", - "Bite", - "Immortal", + 'Vampiress', + 'Countess', + 'Lady', + 'Night-queen', + 'Bloodsucker', + 'Fang', + 'Enchantress', + 'Night', + 'Bite', + 'Immortal', ], }, // === MERFOLK === - "🧜‍♂️": { + '🧜‍♂️': { adjectives: [ - "Merman", - "Aquatic", - "Ocean", - "Sea", - "Underwater", - "Swimming", - "Diving", - "Tailed", - "Scaled", - "Triton", + 'Merman', + 'Aquatic', + 'Ocean', + 'Sea', + 'Underwater', + 'Swimming', + 'Diving', + 'Tailed', + 'Scaled', + 'Triton', ], nouns: [ - "Merman", - "Triton", - "Poseidon", - "Sea-king", - "Ocean-dweller", - "Swimmer", - "Diver", - "Tail", - "Scale", - "Trident", + 'Merman', + 'Triton', + 'Poseidon', + 'Sea-king', + 'Ocean-dweller', + 'Swimmer', + 'Diver', + 'Tail', + 'Scale', + 'Trident', ], }, - "🧜‍♀️": { + '🧜‍♀️': { adjectives: [ - "Mermaid", - "Ocean", - "Sea", - "Coral", - "Pearl", - "Shell", - "Wave", - "Tidal", - "Aquatic", - "Siren", + 'Mermaid', + 'Ocean', + 'Sea', + 'Coral', + 'Pearl', + 'Shell', + 'Wave', + 'Tidal', + 'Aquatic', + 'Siren', ], nouns: [ - "Mermaid", - "Siren", - "Ariel", - "Sea-princess", - "Ocean-maiden", - "Wave", - "Tide", - "Coral", - "Pearl", - "Shell", + 'Mermaid', + 'Siren', + 'Ariel', + 'Sea-princess', + 'Ocean-maiden', + 'Wave', + 'Tide', + 'Coral', + 'Pearl', + 'Shell', ], }, // === ELVES === - "🧝‍♂️": { + '🧝‍♂️': { adjectives: [ - "Elven", - "Forest", - "Woodland", - "Bow-wielding", - "Arrow-shooting", - "Sharp-eared", - "Keen-eyed", - "Swift", - "Agile", - "Ancient", + 'Elven', + 'Forest', + 'Woodland', + 'Bow-wielding', + 'Arrow-shooting', + 'Sharp-eared', + 'Keen-eyed', + 'Swift', + 'Agile', + 'Ancient', ], nouns: [ - "Elf", - "Ranger", - "Archer", - "Legolas", - "Scout", - "Hunter", - "Bow", - "Arrow", - "Forest", - "Woods", + 'Elf', + 'Ranger', + 'Archer', + 'Legolas', + 'Scout', + 'Hunter', + 'Bow', + 'Arrow', + 'Forest', + 'Woods', ], }, - "🧝‍♀️": { + '🧝‍♀️': { adjectives: [ - "Elven", - "Graceful", - "Elegant", - "Forest", - "Woodland", - "Sylvan", - "Ageless", - "Immortal", - "Beautiful", - "Ethereal", + 'Elven', + 'Graceful', + 'Elegant', + 'Forest', + 'Woodland', + 'Sylvan', + 'Ageless', + 'Immortal', + 'Beautiful', + 'Ethereal', ], nouns: [ - "Elf", - "Ranger", - "Archer", - "Galadriel", - "Lady", - "Princess", - "Forest", - "Woods", - "Bow", - "Grace", + 'Elf', + 'Ranger', + 'Archer', + 'Galadriel', + 'Lady', + 'Princess', + 'Forest', + 'Woods', + 'Bow', + 'Grace', ], }, // === HEROES === - "🦸‍♂️": { + '🦸‍♂️': { adjectives: [ - "Heroic", - "Super", - "Mighty", - "Powerful", - "Strong", - "Brave", - "Courageous", - "Caped", - "Masked", - "Justice", + 'Heroic', + 'Super', + 'Mighty', + 'Powerful', + 'Strong', + 'Brave', + 'Courageous', + 'Caped', + 'Masked', + 'Justice', ], nouns: [ - "Hero", - "Superman", - "Superhero", - "Champion", - "Defender", - "Protector", - "Cape", - "Mask", - "Justice", - "Power", + 'Hero', + 'Superman', + 'Superhero', + 'Champion', + 'Defender', + 'Protector', + 'Cape', + 'Mask', + 'Justice', + 'Power', ], }, - "🦸‍♀️": { + '🦸‍♀️': { adjectives: [ - "Heroic", - "Super", - "Wonder", - "Mighty", - "Powerful", - "Strong", - "Brave", - "Fierce", - "Unstoppable", - "Amazing", + 'Heroic', + 'Super', + 'Wonder', + 'Mighty', + 'Powerful', + 'Strong', + 'Brave', + 'Fierce', + 'Unstoppable', + 'Amazing', ], nouns: [ - "Heroine", - "Wonder-woman", - "Superhero", - "Champion", - "Defender", - "Protector", - "Power", - "Force", - "Justice", - "Might", + 'Heroine', + 'Wonder-woman', + 'Superhero', + 'Champion', + 'Defender', + 'Protector', + 'Power', + 'Force', + 'Justice', + 'Might', ], }, // === VILLAINS === - "🦹‍♂️": { + '🦹‍♂️': { adjectives: [ - "Villainous", - "Evil", - "Wicked", - "Sinister", - "Malevolent", - "Dastardly", - "Nefarious", - "Devious", - "Cunning", - "Dark", + 'Villainous', + 'Evil', + 'Wicked', + 'Sinister', + 'Malevolent', + 'Dastardly', + 'Nefarious', + 'Devious', + 'Cunning', + 'Dark', ], nouns: [ - "Villain", - "Evildoer", - "Baddie", - "Nemesis", - "Adversary", - "Foe", - "Mastermind", - "Overlord", - "Dark-lord", - "Scoundrel", + 'Villain', + 'Evildoer', + 'Baddie', + 'Nemesis', + 'Adversary', + 'Foe', + 'Mastermind', + 'Overlord', + 'Dark-lord', + 'Scoundrel', ], }, // === ANIMALS - CANINES === - "🐶": { + '🐶': { adjectives: [ - "Doggy", - "Puppy", - "Playful", - "Loyal", - "Friendly", - "Happy", - "Tail-wagging", - "Ear-floppy", - "Panting", - "Barking", - ], - nouns: [ - "Dog", - "Doggo", - "Pupper", - "Pup", - "Puppy", - "Pooch", - "Hound", - "Canine", - "Woofer", - "Bork", + 'Doggy', + 'Puppy', + 'Playful', + 'Loyal', + 'Friendly', + 'Happy', + 'Tail-wagging', + 'Ear-floppy', + 'Panting', + 'Barking', ], + nouns: ['Dog', 'Doggo', 'Pupper', 'Pup', 'Puppy', 'Pooch', 'Hound', 'Canine', 'Woofer', 'Bork'], }, - "🐺": { + '🐺': { adjectives: [ - "Wolf", - "Wild", - "Pack", - "Howling", - "Alpha", - "Lone", - "Moon-howling", - "Fierce", - "Feral", - "Untamed", + 'Wolf', + 'Wild', + 'Pack', + 'Howling', + 'Alpha', + 'Lone', + 'Moon-howling', + 'Fierce', + 'Feral', + 'Untamed', ], nouns: [ - "Wolf", - "Alpha", - "Pack-leader", - "Lone-wolf", - "Howler", - "Moon-howler", - "Hunter", - "Predator", - "Wild-one", - "Fang", + 'Wolf', + 'Alpha', + 'Pack-leader', + 'Lone-wolf', + 'Howler', + 'Moon-howler', + 'Hunter', + 'Predator', + 'Wild-one', + 'Fang', ], }, // === ANIMALS - FELINES === - "🐱": { + '🐱': { adjectives: [ - "Kitty", - "Catty", - "Feline", - "Purring", - "Meowing", - "Whiskers", - "Fuzzy", - "Soft", - "Cuddly", - "Independent", + 'Kitty', + 'Catty', + 'Feline', + 'Purring', + 'Meowing', + 'Whiskers', + 'Fuzzy', + 'Soft', + 'Cuddly', + 'Independent', ], nouns: [ - "Cat", - "Kitty", - "Kitten", - "Feline", - "Meower", - "Purrer", - "Whiskers", - "Paws", - "Fur-ball", - "Mouser", + 'Cat', + 'Kitty', + 'Kitten', + 'Feline', + 'Meower', + 'Purrer', + 'Whiskers', + 'Paws', + 'Fur-ball', + 'Mouser', ], }, - "🐯": { + '🐯': { adjectives: [ - "Tiger", - "Striped", - "Orange", - "Black-striped", - "Fierce", - "Ferocious", - "Prowling", - "Stalking", - "Hunting", - "Roaring", + 'Tiger', + 'Striped', + 'Orange', + 'Black-striped', + 'Fierce', + 'Ferocious', + 'Prowling', + 'Stalking', + 'Hunting', + 'Roaring', ], nouns: [ - "Tiger", - "Stripes", - "Prowler", - "Stalker", - "Hunter", - "Predator", - "Big-cat", - "Roarer", - "Pouncer", - "Jungle-king", + 'Tiger', + 'Stripes', + 'Prowler', + 'Stalker', + 'Hunter', + 'Predator', + 'Big-cat', + 'Roarer', + 'Pouncer', + 'Jungle-king', ], }, - "🦁": { + '🦁': { adjectives: [ - "Lion", - "Maned", - "Golden", - "Majestic", - "King", - "Pride", - "Roaring", - "Fierce", - "Brave", - "Noble", + 'Lion', + 'Maned', + 'Golden', + 'Majestic', + 'King', + 'Pride', + 'Roaring', + 'Fierce', + 'Brave', + 'Noble', ], nouns: [ - "Lion", - "King", - "Mane", - "Pride-leader", - "Simba", - "Roarer", - "Savannah", - "Jungle-king", - "Big-cat", - "Monarch", + 'Lion', + 'King', + 'Mane', + 'Pride-leader', + 'Simba', + 'Roarer', + 'Savannah', + 'Jungle-king', + 'Big-cat', + 'Monarch', ], }, // === ANIMALS - SMALL CREATURES === - "🐭": { + '🐭': { adjectives: [ - "Mousy", - "Tiny", - "Small", - "Scurrying", - "Squeaking", - "Nibbling", - "Quick", - "Clever", - "Whisker", - "Cheese-loving", + 'Mousy', + 'Tiny', + 'Small', + 'Scurrying', + 'Squeaking', + 'Nibbling', + 'Quick', + 'Clever', + 'Whisker', + 'Cheese-loving', ], nouns: [ - "Mouse", - "Mousie", - "Squeaker", - "Mickey", - "Minnie", - "Nibbler", - "Whiskers", - "Cheese-lover", - "Critter", - "Rodent", + 'Mouse', + 'Mousie', + 'Squeaker', + 'Mickey', + 'Minnie', + 'Nibbler', + 'Whiskers', + 'Cheese-lover', + 'Critter', + 'Rodent', ], }, - "🐹": { + '🐹': { adjectives: [ - "Hamster", - "Fluffy", - "Chubby", - "Cheek-pouched", - "Wheel-running", - "Burrowing", - "Cute", - "Fuzzy", - "Round", - "Roly-poly", + 'Hamster', + 'Fluffy', + 'Chubby', + 'Cheek-pouched', + 'Wheel-running', + 'Burrowing', + 'Cute', + 'Fuzzy', + 'Round', + 'Roly-poly', ], nouns: [ - "Hamster", - "Hammie", - "Cheeks", - "Pouch", - "Wheel-runner", - "Burrow", - "Fluff", - "Fuzz", - "Cutie", - "Chonk", + 'Hamster', + 'Hammie', + 'Cheeks', + 'Pouch', + 'Wheel-runner', + 'Burrow', + 'Fluff', + 'Fuzz', + 'Cutie', + 'Chonk', ], }, - "🐰": { + '🐰': { adjectives: [ - "Bunny", - "Fluffy", - "Hopping", - "Cotton-tailed", - "Long-eared", - "Twitchy-nosed", - "Soft", - "Fuzzy", - "Cute", - "Easter", + 'Bunny', + 'Fluffy', + 'Hopping', + 'Cotton-tailed', + 'Long-eared', + 'Twitchy-nosed', + 'Soft', + 'Fuzzy', + 'Cute', + 'Easter', ], nouns: [ - "Bunny", - "Rabbit", - "Cottontail", - "Thumper", - "Peter", - "Hopper", - "Fluff", - "Cotton", - "Ears", - "Tail", + 'Bunny', + 'Rabbit', + 'Cottontail', + 'Thumper', + 'Peter', + 'Hopper', + 'Fluff', + 'Cotton', + 'Ears', + 'Tail', ], }, - "🦊": { + '🦊': { adjectives: [ - "Foxy", - "Clever", - "Cunning", - "Sly", - "Red", - "Orange", - "Bushy-tailed", - "Quick", - "Swift", - "Smart", + 'Foxy', + 'Clever', + 'Cunning', + 'Sly', + 'Red', + 'Orange', + 'Bushy-tailed', + 'Quick', + 'Swift', + 'Smart', ], nouns: [ - "Fox", - "Reynard", - "Vixen", - "Kit", - "Red-fox", - "Slyster", - "Trickster", - "Tail", - "Brush", - "Clever-clogs", + 'Fox', + 'Reynard', + 'Vixen', + 'Kit', + 'Red-fox', + 'Slyster', + 'Trickster', + 'Tail', + 'Brush', + 'Clever-clogs', ], }, // === ANIMALS - BEARS === - "🐻": { + '🐻': { adjectives: [ - "Bear", - "Grizzly", - "Brown", - "Big", - "Burly", - "Fuzzy", - "Cuddly", - "Strong", - "Hibernating", - "Honey-loving", + 'Bear', + 'Grizzly', + 'Brown', + 'Big', + 'Burly', + 'Fuzzy', + 'Cuddly', + 'Strong', + 'Hibernating', + 'Honey-loving', ], nouns: [ - "Bear", - "Grizzly", - "Bruin", - "Cub", - "Teddy", - "Honey-bear", - "Hugger", - "Cuddles", - "Fur", - "Paws", + 'Bear', + 'Grizzly', + 'Bruin', + 'Cub', + 'Teddy', + 'Honey-bear', + 'Hugger', + 'Cuddles', + 'Fur', + 'Paws', ], }, - "🐼": { + '🐼': { adjectives: [ - "Panda", - "Bamboo-eating", - "Black-and-white", - "Cuddly", - "Cute", - "Gentle", - "Lazy", - "Sleepy", - "Zen", - "Chill", + 'Panda', + 'Bamboo-eating', + 'Black-and-white', + 'Cuddly', + 'Cute', + 'Gentle', + 'Lazy', + 'Sleepy', + 'Zen', + 'Chill', ], nouns: [ - "Panda", - "Po", - "Bamboo-bear", - "Bamboo", - "Zen-master", - "Cuddles", - "Fluff", - "Chonk", - "Monochrome", - "Yin-yang", + 'Panda', + 'Po', + 'Bamboo-bear', + 'Bamboo', + 'Zen-master', + 'Cuddles', + 'Fluff', + 'Chonk', + 'Monochrome', + 'Yin-yang', ], }, - "🐻‍❄️": { + '🐻‍❄️': { adjectives: [ - "Polar", - "White", - "Arctic", - "Ice", - "Snow", - "Frozen", - "Cold", - "Winter", - "Frost", - "Glacial", + 'Polar', + 'White', + 'Arctic', + 'Ice', + 'Snow', + 'Frozen', + 'Cold', + 'Winter', + 'Frost', + 'Glacial', ], nouns: [ - "Polar-bear", - "Ice-bear", - "Snow-bear", - "Arctic", - "Frost", - "Ice", - "Snow", - "Winter", - "Glacier", - "Tundra", + 'Polar-bear', + 'Ice-bear', + 'Snow-bear', + 'Arctic', + 'Frost', + 'Ice', + 'Snow', + 'Winter', + 'Glacier', + 'Tundra', ], }, - "🐨": { + '🐨': { adjectives: [ - "Koala", - "Sleepy", - "Eucalyptus", - "Tree-hugging", - "Australian", - "Cuddly", - "Fuzzy", - "Chill", - "Relaxed", - "Zen", + 'Koala', + 'Sleepy', + 'Eucalyptus', + 'Tree-hugging', + 'Australian', + 'Cuddly', + 'Fuzzy', + 'Chill', + 'Relaxed', + 'Zen', ], nouns: [ - "Koala", - "Joey", - "Eucalyptus", - "Tree-hugger", - "Aussie", - "Down-under", - "Sleeper", - "Napper", - "Cuddles", - "Mate", + 'Koala', + 'Joey', + 'Eucalyptus', + 'Tree-hugger', + 'Aussie', + 'Down-under', + 'Sleeper', + 'Napper', + 'Cuddles', + 'Mate', ], }, // === ANIMALS - FARM === - "🐮": { + '🐮': { adjectives: [ - "Cow", - "Dairy", - "Milk", - "Moo", - "Spotted", - "Black-and-white", - "Holstein", - "Farm", - "Pastoral", - "Gentle", + 'Cow', + 'Dairy', + 'Milk', + 'Moo', + 'Spotted', + 'Black-and-white', + 'Holstein', + 'Farm', + 'Pastoral', + 'Gentle', ], nouns: [ - "Cow", - "Bessie", - "Daisy", - "Moo-cow", - "Dairy", - "Milker", - "Holstein", - "Farm", - "Pasture", - "Meadow", + 'Cow', + 'Bessie', + 'Daisy', + 'Moo-cow', + 'Dairy', + 'Milker', + 'Holstein', + 'Farm', + 'Pasture', + 'Meadow', ], }, - "🐷": { + '🐷': { adjectives: [ - "Pig", - "Piggy", - "Pink", - "Oinking", - "Snorting", - "Muddy", - "Cute", - "Chubby", - "Round", - "Smart", + 'Pig', + 'Piggy', + 'Pink', + 'Oinking', + 'Snorting', + 'Muddy', + 'Cute', + 'Chubby', + 'Round', + 'Smart', ], nouns: [ - "Pig", - "Piggy", - "Piglet", - "Porky", - "Hamlet", - "Oinker", - "Snorter", - "Bacon", - "Snout", - "Curly-tail", + 'Pig', + 'Piggy', + 'Piglet', + 'Porky', + 'Hamlet', + 'Oinker', + 'Snorter', + 'Bacon', + 'Snout', + 'Curly-tail', ], }, - "🐸": { + '🐸': { adjectives: [ - "Frog", - "Hopping", - "Jumping", - "Leaping", - "Croaking", - "Ribbiting", - "Green", - "Slimy", - "Wet", - "Pond", + 'Frog', + 'Hopping', + 'Jumping', + 'Leaping', + 'Croaking', + 'Ribbiting', + 'Green', + 'Slimy', + 'Wet', + 'Pond', ], nouns: [ - "Frog", - "Froggy", - "Kermit", - "Tadpole", - "Pollywog", - "Croaker", - "Ribbiter", - "Hopper", - "Leaper", - "Lily-pad", + 'Frog', + 'Froggy', + 'Kermit', + 'Tadpole', + 'Pollywog', + 'Croaker', + 'Ribbiter', + 'Hopper', + 'Leaper', + 'Lily-pad', ], }, // === ANIMALS - PRIMATES === - "🐵": { + '🐵': { adjectives: [ - "Monkey", - "Cheeky", - "Mischievous", - "Playful", - "Swinging", - "Climbing", - "Tree-dwelling", - "Banana-eating", - "Curious", - "Silly", + 'Monkey', + 'Cheeky', + 'Mischievous', + 'Playful', + 'Swinging', + 'Climbing', + 'Tree-dwelling', + 'Banana-eating', + 'Curious', + 'Silly', ], nouns: [ - "Monkey", - "Chimp", - "Primate", - "Curious-George", - "Banana", - "Tree-swinger", - "Climber", - "Tail", - "Mischief", - "Cheeky-monkey", + 'Monkey', + 'Chimp', + 'Primate', + 'Curious-George', + 'Banana', + 'Tree-swinger', + 'Climber', + 'Tail', + 'Mischief', + 'Cheeky-monkey', ], }, - "🙈": { + '🙈': { adjectives: [ - "See-no-evil", - "Hiding", - "Peeking", - "Shy", - "Embarrassed", - "Covering-eyes", - "Bashful", - "Modest", - "Innocent", - "Oops", + 'See-no-evil', + 'Hiding', + 'Peeking', + 'Shy', + 'Embarrassed', + 'Covering-eyes', + 'Bashful', + 'Modest', + 'Innocent', + 'Oops', ], nouns: [ - "See-nothing", - "Hide", - "Peeker", - "Shy-one", - "Bashful", - "Oops", - "Embarrassment", - "Modesty", - "Innocence", - "Cover", + 'See-nothing', + 'Hide', + 'Peeker', + 'Shy-one', + 'Bashful', + 'Oops', + 'Embarrassment', + 'Modesty', + 'Innocence', + 'Cover', ], }, - "🙉": { + '🙉': { adjectives: [ - "Hear-no-evil", - "Deaf", - "Ignoring", - "Covering-ears", - "La-la-la", - "Blocking-out", - "Tuning-out", - "Unlistening", - "Oblivious", - "Nope", + 'Hear-no-evil', + 'Deaf', + 'Ignoring', + 'Covering-ears', + 'La-la-la', + 'Blocking-out', + 'Tuning-out', + 'Unlistening', + 'Oblivious', + 'Nope', ], nouns: [ - "Hear-nothing", - "Deafness", - "Ignorance", - "La-la", - "Block", - "Tune-out", - "Nope", - "Denial", - "Oblivion", - "Cover", + 'Hear-nothing', + 'Deafness', + 'Ignorance', + 'La-la', + 'Block', + 'Tune-out', + 'Nope', + 'Denial', + 'Oblivion', + 'Cover', ], }, - "🙊": { + '🙊': { adjectives: [ - "Speak-no-evil", - "Silent", - "Quiet", - "Mute", - "Shushing", - "Covering-mouth", - "Secret-keeping", - "Whispering", - "Hush-hush", - "Zip", + 'Speak-no-evil', + 'Silent', + 'Quiet', + 'Mute', + 'Shushing', + 'Covering-mouth', + 'Secret-keeping', + 'Whispering', + 'Hush-hush', + 'Zip', ], nouns: [ - "Speak-nothing", - "Silence", - "Quiet", - "Mute", - "Shush", - "Secret", - "Whisper", - "Hush", - "Zip", - "Lips-sealed", + 'Speak-nothing', + 'Silence', + 'Quiet', + 'Mute', + 'Shush', + 'Secret', + 'Whisper', + 'Hush', + 'Zip', + 'Lips-sealed', ], }, - "🐒": { + '🐒': { adjectives: [ - "Monkey", - "Hanging", - "Tail-hanging", - "Swinging", - "Dangling", - "Acrobatic", - "Flexible", - "Agile", - "Nimble", - "Tree", + 'Monkey', + 'Hanging', + 'Tail-hanging', + 'Swinging', + 'Dangling', + 'Acrobatic', + 'Flexible', + 'Agile', + 'Nimble', + 'Tree', ], nouns: [ - "Monkey", - "Swinger", - "Hanger", - "Acrobat", - "Tail", - "Tree-monkey", - "Jungle-monkey", - "Branch", - "Vine", - "Swing", + 'Monkey', + 'Swinger', + 'Hanger', + 'Acrobat', + 'Tail', + 'Tree-monkey', + 'Jungle-monkey', + 'Branch', + 'Vine', + 'Swing', ], }, // === ANIMALS - BIRDS === - "🦆": { + '🦆': { adjectives: [ - "Duck", - "Quacking", - "Waddling", - "Swimming", - "Floating", - "Pond", - "Water", - "Yellow", - "Rubber", - "Dabbling", + 'Duck', + 'Quacking', + 'Waddling', + 'Swimming', + 'Floating', + 'Pond', + 'Water', + 'Yellow', + 'Rubber', + 'Dabbling', ], nouns: [ - "Duck", - "Duckling", - "Donald", - "Daffy", - "Quacker", - "Waddler", - "Pond", - "Water", - "Bill", - "Webfoot", + 'Duck', + 'Duckling', + 'Donald', + 'Daffy', + 'Quacker', + 'Waddler', + 'Pond', + 'Water', + 'Bill', + 'Webfoot', ], }, - "🐧": { + '🐧': { adjectives: [ - "Penguin", - "Waddling", - "Tuxedo", - "Black-and-white", - "Antarctic", - "Ice", - "Snow", - "Sliding", - "Swimming", - "Diving", + 'Penguin', + 'Waddling', + 'Tuxedo', + 'Black-and-white', + 'Antarctic', + 'Ice', + 'Snow', + 'Sliding', + 'Swimming', + 'Diving', ], nouns: [ - "Penguin", - "Tux", - "Waddle", - "Antarctica", - "Ice", - "Snow", - "Slide", - "Flipper", - "Waddles", - "Chilly", + 'Penguin', + 'Tux', + 'Waddle', + 'Antarctica', + 'Ice', + 'Snow', + 'Slide', + 'Flipper', + 'Waddles', + 'Chilly', ], }, - "🐦": { + '🐦': { adjectives: [ - "Bird", - "Flying", - "Singing", - "Chirping", - "Tweeting", - "Perching", - "Nesting", - "Feathered", - "Winged", - "Early", + 'Bird', + 'Flying', + 'Singing', + 'Chirping', + 'Tweeting', + 'Perching', + 'Nesting', + 'Feathered', + 'Winged', + 'Early', ], nouns: [ - "Bird", - "Birdie", - "Tweeter", - "Chirper", - "Singer", - "Flyer", - "Wing", - "Feather", - "Nest", - "Song", + 'Bird', + 'Birdie', + 'Tweeter', + 'Chirper', + 'Singer', + 'Flyer', + 'Wing', + 'Feather', + 'Nest', + 'Song', ], }, - "🐤": { + '🐤': { adjectives: [ - "Chick", - "Baby", - "Cute", - "Tiny", - "Fluffy", - "Yellow", - "Peeping", - "Chirping", - "Newly-hatched", - "Easter", + 'Chick', + 'Baby', + 'Cute', + 'Tiny', + 'Fluffy', + 'Yellow', + 'Peeping', + 'Chirping', + 'Newly-hatched', + 'Easter', ], nouns: [ - "Chick", - "Baby-bird", - "Peeper", - "Chirper", - "Fledgling", - "Hatchling", - "Fluff", - "Downy", - "Cutie", - "Spring", + 'Chick', + 'Baby-bird', + 'Peeper', + 'Chirper', + 'Fledgling', + 'Hatchling', + 'Fluff', + 'Downy', + 'Cutie', + 'Spring', ], }, - "🐣": { + '🐣': { adjectives: [ - "Hatching", - "Emerging", - "Breaking-out", - "Cracking", - "New", - "Born", - "Fresh", - "Young", - "Baby", - "Beginning", + 'Hatching', + 'Emerging', + 'Breaking-out', + 'Cracking', + 'New', + 'Born', + 'Fresh', + 'Young', + 'Baby', + 'Beginning', ], nouns: [ - "Hatchling", - "Newborn", - "Emergence", - "Birth", - "Beginning", - "Start", - "Origin", - "Crack", - "Shell", - "Egg", + 'Hatchling', + 'Newborn', + 'Emergence', + 'Birth', + 'Beginning', + 'Start', + 'Origin', + 'Crack', + 'Shell', + 'Egg', ], }, - "🐥": { + '🐥': { adjectives: [ - "Front-facing", - "Chick", - "Yellow", - "Fluffy", - "Cute", - "Baby", - "Little", - "Tiny", - "Peeping", - "Chirping", + 'Front-facing', + 'Chick', + 'Yellow', + 'Fluffy', + 'Cute', + 'Baby', + 'Little', + 'Tiny', + 'Peeping', + 'Chirping', ], nouns: [ - "Chick", - "Chickie", - "Baby-bird", - "Peeper", - "Chirper", - "Yellow", - "Fluff", - "Downy", - "Cuteness", - "Sweetie", + 'Chick', + 'Chickie', + 'Baby-bird', + 'Peeper', + 'Chirper', + 'Yellow', + 'Fluff', + 'Downy', + 'Cuteness', + 'Sweetie', ], }, - "🦅": { + '🦅': { adjectives: [ - "Eagle", - "Soaring", - "Majestic", - "Powerful", - "Sharp-eyed", - "Keen", - "High-flying", - "Fierce", - "Proud", - "Free", + 'Eagle', + 'Soaring', + 'Majestic', + 'Powerful', + 'Sharp-eyed', + 'Keen', + 'High-flying', + 'Fierce', + 'Proud', + 'Free', ], nouns: [ - "Eagle", - "Bald-eagle", - "Golden-eagle", - "Soarer", - "Hunter", - "Predator", - "Talon", - "Claw", - "Beak", - "Freedom", + 'Eagle', + 'Bald-eagle', + 'Golden-eagle', + 'Soarer', + 'Hunter', + 'Predator', + 'Talon', + 'Claw', + 'Beak', + 'Freedom', ], }, - "🦉": { + '🦉': { adjectives: [ - "Owl", - "Wise", - "Nocturnal", - "Hooting", - "Big-eyed", - "Night", - "Moon", - "Hunting", - "Silent", - "Observant", + 'Owl', + 'Wise', + 'Nocturnal', + 'Hooting', + 'Big-eyed', + 'Night', + 'Moon', + 'Hunting', + 'Silent', + 'Observant', ], nouns: [ - "Owl", - "Hoot", - "Hooter", - "Wise-one", - "Night-bird", - "Moon-bird", - "Hunter", - "Watcher", - "Observer", - "Wisdom", + 'Owl', + 'Hoot', + 'Hooter', + 'Wise-one', + 'Night-bird', + 'Moon-bird', + 'Hunter', + 'Watcher', + 'Observer', + 'Wisdom', ], }, - "🦇": { + '🦇': { adjectives: [ - "Bat", - "Flying", - "Night", - "Nocturnal", - "Cave", - "Echolocating", - "Upside-down", - "Hanging", - "Winged", - "Vampiric", + 'Bat', + 'Flying', + 'Night', + 'Nocturnal', + 'Cave', + 'Echolocating', + 'Upside-down', + 'Hanging', + 'Winged', + 'Vampiric', ], nouns: [ - "Bat", - "Batty", - "Night-flyer", - "Cave-dweller", - "Echo", - "Sonar", - "Wing", - "Membrane", - "Batman", - "Night", + 'Bat', + 'Batty', + 'Night-flyer', + 'Cave-dweller', + 'Echo', + 'Sonar', + 'Wing', + 'Membrane', + 'Batman', + 'Night', ], }, // === ANIMALS - WILD === - "🐗": { + '🐗': { adjectives: [ - "Boar", - "Wild", - "Tusked", - "Fierce", - "Charging", - "Bristly", - "Hairy", - "Tough", - "Rugged", - "Forest", + 'Boar', + 'Wild', + 'Tusked', + 'Fierce', + 'Charging', + 'Bristly', + 'Hairy', + 'Tough', + 'Rugged', + 'Forest', ], nouns: [ - "Boar", - "Wild-boar", - "Tusker", - "Bristleback", - "Charger", - "Beast", - "Tusk", - "Snout", - "Forest", - "Wild", + 'Boar', + 'Wild-boar', + 'Tusker', + 'Bristleback', + 'Charger', + 'Beast', + 'Tusk', + 'Snout', + 'Forest', + 'Wild', ], }, - "🐴": { + '🐴': { adjectives: [ - "Horse", - "Galloping", - "Running", - "Swift", - "Fast", - "Graceful", - "Majestic", - "Noble", - "Wild", - "Free", + 'Horse', + 'Galloping', + 'Running', + 'Swift', + 'Fast', + 'Graceful', + 'Majestic', + 'Noble', + 'Wild', + 'Free', ], nouns: [ - "Horse", - "Stallion", - "Mare", - "Steed", - "Mustang", - "Gallop", - "Runner", - "Wind", - "Thunder", - "Freedom", + 'Horse', + 'Stallion', + 'Mare', + 'Steed', + 'Mustang', + 'Gallop', + 'Runner', + 'Wind', + 'Thunder', + 'Freedom', ], }, - "🦄": { + '🦄': { adjectives: [ - "Unicorn", - "Magical", - "Mystical", - "Rainbow", - "Sparkly", - "Glittery", - "Horned", - "Mythical", - "Legendary", - "Enchanted", + 'Unicorn', + 'Magical', + 'Mystical', + 'Rainbow', + 'Sparkly', + 'Glittery', + 'Horned', + 'Mythical', + 'Legendary', + 'Enchanted', ], nouns: [ - "Unicorn", - "Magic", - "Rainbow", - "Sparkle", - "Glitter", - "Horn", - "Myth", - "Legend", - "Dream", - "Wonder", + 'Unicorn', + 'Magic', + 'Rainbow', + 'Sparkle', + 'Glitter', + 'Horn', + 'Myth', + 'Legend', + 'Dream', + 'Wonder', ], }, // === ANIMALS - INSECTS === - "🐝": { + '🐝': { adjectives: [ - "Bee", - "Buzzing", - "Busy", - "Honey-making", - "Pollen-gathering", - "Hive", - "Queen", - "Worker", - "Striped", - "Flying", + 'Bee', + 'Buzzing', + 'Busy', + 'Honey-making', + 'Pollen-gathering', + 'Hive', + 'Queen', + 'Worker', + 'Striped', + 'Flying', ], nouns: [ - "Bee", - "Honeybee", - "Bumblebee", - "Buzzer", - "Worker", - "Queen", - "Hive", - "Honey", - "Pollen", - "Nectar", + 'Bee', + 'Honeybee', + 'Bumblebee', + 'Buzzer', + 'Worker', + 'Queen', + 'Hive', + 'Honey', + 'Pollen', + 'Nectar', ], }, - "🐛": { + '🐛': { adjectives: [ - "Caterpillar", - "Crawling", - "Inch-worming", - "Green", - "Fuzzy", - "Hungry", - "Munching", - "Leaf-eating", - "Transforming", - "Metamorphosing", + 'Caterpillar', + 'Crawling', + 'Inch-worming', + 'Green', + 'Fuzzy', + 'Hungry', + 'Munching', + 'Leaf-eating', + 'Transforming', + 'Metamorphosing', ], nouns: [ - "Caterpillar", - "Larva", - "Inchworm", - "Crawler", - "Muncher", - "Leaf-eater", - "Cocoon", - "Chrysalis", - "Future-butterfly", - "Hungry", + 'Caterpillar', + 'Larva', + 'Inchworm', + 'Crawler', + 'Muncher', + 'Leaf-eater', + 'Cocoon', + 'Chrysalis', + 'Future-butterfly', + 'Hungry', ], }, - "🦋": { + '🦋': { adjectives: [ - "Butterfly", - "Fluttering", - "Flying", - "Colorful", - "Beautiful", - "Delicate", - "Graceful", - "Transformed", - "Metamorphosed", - "Winged", + 'Butterfly', + 'Fluttering', + 'Flying', + 'Colorful', + 'Beautiful', + 'Delicate', + 'Graceful', + 'Transformed', + 'Metamorphosed', + 'Winged', ], nouns: [ - "Butterfly", - "Flutter", - "Monarch", - "Swallowtail", - "Wing", - "Flight", - "Beauty", - "Grace", - "Transformation", - "Metamorphosis", + 'Butterfly', + 'Flutter', + 'Monarch', + 'Swallowtail', + 'Wing', + 'Flight', + 'Beauty', + 'Grace', + 'Transformation', + 'Metamorphosis', ], }, // === OBJECTS - STARS === - "⭐": { + '⭐': { adjectives: [ - "Star", - "Shining", - "Bright", - "Glowing", - "Stellar", - "Celestial", - "Five-pointed", - "Gold", - "Award", - "Trophy", + 'Star', + 'Shining', + 'Bright', + 'Glowing', + 'Stellar', + 'Celestial', + 'Five-pointed', + 'Gold', + 'Award', + 'Trophy', ], nouns: [ - "Star", - "Gold-star", - "Award", - "Trophy", - "Achievement", - "Excellence", - "Shine", - "Glow", - "Brightness", - "Winner", + 'Star', + 'Gold-star', + 'Award', + 'Trophy', + 'Achievement', + 'Excellence', + 'Shine', + 'Glow', + 'Brightness', + 'Winner', ], }, - "🌟": { + '🌟': { adjectives: [ - "Glowing", - "Radiating", - "Sparkling", - "Twinkling", - "Shimmering", - "Glittering", - "Dazzling", - "Brilliant", - "Luminous", - "Shining", + 'Glowing', + 'Radiating', + 'Sparkling', + 'Twinkling', + 'Shimmering', + 'Glittering', + 'Dazzling', + 'Brilliant', + 'Luminous', + 'Shining', ], nouns: [ - "Glow-star", - "Sparkle", - "Twinkle", - "Shimmer", - "Glitter", - "Dazzle", - "Brilliance", - "Luminosity", - "Radiance", - "Shine", + 'Glow-star', + 'Sparkle', + 'Twinkle', + 'Shimmer', + 'Glitter', + 'Dazzle', + 'Brilliance', + 'Luminosity', + 'Radiance', + 'Shine', ], }, - "💫": { + '💫': { adjectives: [ - "Dizzy", - "Spinning", - "Swirling", - "Whirling", - "Dazed", - "Stunned", - "Star-struck", - "Woozy", - "Disoriented", - "Seeing-stars", + 'Dizzy', + 'Spinning', + 'Swirling', + 'Whirling', + 'Dazed', + 'Stunned', + 'Star-struck', + 'Woozy', + 'Disoriented', + 'Seeing-stars', ], nouns: [ - "Dizzy-star", - "Swirl", - "Spin", - "Whirl", - "Daze", - "Stun", - "Wooziness", - "Disorientation", - "Stars", - "Vertigo", + 'Dizzy-star', + 'Swirl', + 'Spin', + 'Whirl', + 'Daze', + 'Stun', + 'Wooziness', + 'Disorientation', + 'Stars', + 'Vertigo', ], }, - "✨": { + '✨': { adjectives: [ - "Sparkly", - "Glittery", - "Shimmery", - "Twinkling", - "Magical", - "Enchanted", - "Fairy", - "Pixie-dust", - "Clean", - "New", + 'Sparkly', + 'Glittery', + 'Shimmery', + 'Twinkling', + 'Magical', + 'Enchanted', + 'Fairy', + 'Pixie-dust', + 'Clean', + 'New', ], nouns: [ - "Sparkle", - "Glitter", - "Shimmer", - "Twinkle", - "Magic", - "Pixie-dust", - "Fairy-dust", - "Shine", - "Gleam", - "Glitz", + 'Sparkle', + 'Glitter', + 'Shimmer', + 'Twinkle', + 'Magic', + 'Pixie-dust', + 'Fairy-dust', + 'Shine', + 'Gleam', + 'Glitz', ], }, // === OBJECTS - POWER === - "⚡": { + '⚡': { adjectives: [ - "Lightning", - "Electric", - "Shocking", - "Bolt", - "Thunder", - "Storm", - "Charged", - "High-voltage", - "Zapping", - "Striking", + 'Lightning', + 'Electric', + 'Shocking', + 'Bolt', + 'Thunder', + 'Storm', + 'Charged', + 'High-voltage', + 'Zapping', + 'Striking', ], nouns: [ - "Lightning", - "Thunder", - "Bolt", - "Shock", - "Zap", - "Voltage", - "Electricity", - "Charge", - "Storm", - "Strike", + 'Lightning', + 'Thunder', + 'Bolt', + 'Shock', + 'Zap', + 'Voltage', + 'Electricity', + 'Charge', + 'Storm', + 'Strike', ], }, - "🔥": { + '🔥': { adjectives: [ - "Fiery", - "Blazing", - "Burning", - "Flaming", - "Hot", - "Scorching", - "Searing", - "Lit", - "On-fire", - "Smoking", + 'Fiery', + 'Blazing', + 'Burning', + 'Flaming', + 'Hot', + 'Scorching', + 'Searing', + 'Lit', + 'On-fire', + 'Smoking', ], nouns: [ - "Fire", - "Flame", - "Blaze", - "Inferno", - "Heat", - "Burn", - "Ember", - "Ash", - "Phoenix", - "Hotness", + 'Fire', + 'Flame', + 'Blaze', + 'Inferno', + 'Heat', + 'Burn', + 'Ember', + 'Ash', + 'Phoenix', + 'Hotness', ], }, // === OBJECTS - RAINBOW === - "🌈": { + '🌈': { adjectives: [ - "Rainbow", - "Colorful", - "Multicolored", - "Prismatic", - "Spectrum", - "ROYGBIV", - "Vibrant", - "Bright", - "Cheerful", - "Hopeful", + 'Rainbow', + 'Colorful', + 'Multicolored', + 'Prismatic', + 'Spectrum', + 'ROYGBIV', + 'Vibrant', + 'Bright', + 'Cheerful', + 'Hopeful', ], nouns: [ - "Rainbow", - "Spectrum", - "Prism", - "Color", - "Arc", - "Promise", - "Hope", - "Dream", - "Wonder", - "Magic", + 'Rainbow', + 'Spectrum', + 'Prism', + 'Color', + 'Arc', + 'Promise', + 'Hope', + 'Dream', + 'Wonder', + 'Magic', ], }, // === ENTERTAINMENT === - "🎪": { + '🎪': { adjectives: [ - "Circus", - "Big-top", - "Tent", - "Carnival", - "Fair", - "Show", - "Spectacular", - "Amazing", - "Thrilling", - "Exciting", + 'Circus', + 'Big-top', + 'Tent', + 'Carnival', + 'Fair', + 'Show', + 'Spectacular', + 'Amazing', + 'Thrilling', + 'Exciting', ], nouns: [ - "Circus", - "Big-top", - "Tent", - "Carnival", - "Fair", - "Show", - "Ringmaster", - "Performance", - "Spectacle", - "Wonder", + 'Circus', + 'Big-top', + 'Tent', + 'Carnival', + 'Fair', + 'Show', + 'Ringmaster', + 'Performance', + 'Spectacle', + 'Wonder', ], }, - "🎨": { + '🎨': { adjectives: [ - "Artistic", - "Creative", - "Colorful", - "Painting", - "Drawing", - "Sketching", - "Imaginative", - "Expressive", - "Abstract", - "Vibrant", + 'Artistic', + 'Creative', + 'Colorful', + 'Painting', + 'Drawing', + 'Sketching', + 'Imaginative', + 'Expressive', + 'Abstract', + 'Vibrant', ], nouns: [ - "Artist", - "Painter", - "Creator", - "Palette", - "Brush", - "Canvas", - "Paint", - "Color", - "Art", - "Masterpiece", + 'Artist', + 'Painter', + 'Creator', + 'Palette', + 'Brush', + 'Canvas', + 'Paint', + 'Color', + 'Art', + 'Masterpiece', ], }, - "🎯": { + '🎯': { adjectives: [ - "Target", - "Bullseye", - "Accurate", - "Precise", - "Dart", - "Arrow", - "Aiming", - "Focused", - "On-target", - "Perfect", + 'Target', + 'Bullseye', + 'Accurate', + 'Precise', + 'Dart', + 'Arrow', + 'Aiming', + 'Focused', + 'On-target', + 'Perfect', ], nouns: [ - "Target", - "Bullseye", - "Dart", - "Arrow", - "Aim", - "Precision", - "Accuracy", - "Focus", - "Goal", - "Center", + 'Target', + 'Bullseye', + 'Dart', + 'Arrow', + 'Aim', + 'Precision', + 'Accuracy', + 'Focus', + 'Goal', + 'Center', ], }, - "🎲": { + '🎲': { adjectives: [ - "Dice", - "Rolling", - "Random", - "Chance", - "Lucky", - "Gambling", - "Gaming", - "Risk-taking", - "Board-game", - "Six-sided", + 'Dice', + 'Rolling', + 'Random', + 'Chance', + 'Lucky', + 'Gambling', + 'Gaming', + 'Risk-taking', + 'Board-game', + 'Six-sided', ], nouns: [ - "Dice", - "Die", - "Cube", - "Roller", - "Gambler", - "Risk", - "Chance", - "Luck", - "Fortune", - "Roll", + 'Dice', + 'Die', + 'Cube', + 'Roller', + 'Gambler', + 'Risk', + 'Chance', + 'Luck', + 'Fortune', + 'Roll', ], }, - "🎮": { + '🎮': { adjectives: [ - "Gaming", - "Controller", - "Console", - "Video-game", - "Arcade", - "Digital", - "Button-mashing", - "Level-up", - "High-score", - "Boss-battle", + 'Gaming', + 'Controller', + 'Console', + 'Video-game', + 'Arcade', + 'Digital', + 'Button-mashing', + 'Level-up', + 'High-score', + 'Boss-battle', ], nouns: [ - "Gamer", - "Player", - "Controller", - "Console", - "Game", - "Arcade", - "Joystick", - "Button", - "Level", - "Score", + 'Gamer', + 'Player', + 'Controller', + 'Console', + 'Game', + 'Arcade', + 'Joystick', + 'Button', + 'Level', + 'Score', ], }, - "🕹️": { + '🕹️': { adjectives: [ - "Joystick", - "Arcade", - "Retro", - "Classic", - "Old-school", - "Vintage", - "Atari", - "Stick", - "Up-down-left-right", - "8-bit", + 'Joystick', + 'Arcade', + 'Retro', + 'Classic', + 'Old-school', + 'Vintage', + 'Atari', + 'Stick', + 'Up-down-left-right', + '8-bit', ], nouns: [ - "Joystick", - "Stick", - "Arcade", - "Controller", - "Retro", - "Classic", - "Vintage", - "Gamer", - "Arcade-master", - "8-bit", + 'Joystick', + 'Stick', + 'Arcade', + 'Controller', + 'Retro', + 'Classic', + 'Vintage', + 'Gamer', + 'Arcade-master', + '8-bit', ], }, // === MUSIC === - "🎸": { + '🎸': { adjectives: [ - "Guitar", - "Rocking", - "Strumming", - "Shredding", - "Electric", - "Acoustic", - "String", - "Chord", - "Solo", - "Jamming", + 'Guitar', + 'Rocking', + 'Strumming', + 'Shredding', + 'Electric', + 'Acoustic', + 'String', + 'Chord', + 'Solo', + 'Jamming', ], nouns: [ - "Guitar", - "Guitarist", - "Rocker", - "Strummer", - "Shredder", - "Axe", - "String", - "Chord", - "Solo", - "Jam", + 'Guitar', + 'Guitarist', + 'Rocker', + 'Strummer', + 'Shredder', + 'Axe', + 'String', + 'Chord', + 'Solo', + 'Jam', ], }, - "🎺": { + '🎺': { adjectives: [ - "Trumpet", - "Brass", - "Blowing", - "Tooting", - "Fanfare", - "Jazz", - "Herald", - "Horn", - "Loud", - "Bright", + 'Trumpet', + 'Brass', + 'Blowing', + 'Tooting', + 'Fanfare', + 'Jazz', + 'Herald', + 'Horn', + 'Loud', + 'Bright', ], nouns: [ - "Trumpet", - "Trumpeter", - "Horn", - "Brass", - "Fanfare", - "Jazz", - "Herald", - "Toot", - "Blow", - "Sound", + 'Trumpet', + 'Trumpeter', + 'Horn', + 'Brass', + 'Fanfare', + 'Jazz', + 'Herald', + 'Toot', + 'Blow', + 'Sound', ], }, - "🎷": { + '🎷': { adjectives: [ - "Saxophone", - "Sax", - "Jazzy", - "Smooth", - "Sultry", - "Soulful", - "Blues", - "Tenor", - "Alto", - "Reed", + 'Saxophone', + 'Sax', + 'Jazzy', + 'Smooth', + 'Sultry', + 'Soulful', + 'Blues', + 'Tenor', + 'Alto', + 'Reed', ], nouns: [ - "Saxophone", - "Sax", - "Saxophonist", - "Jazz", - "Blues", - "Soul", - "Reed", - "Horn", - "Smooth", - "Cool", + 'Saxophone', + 'Sax', + 'Saxophonist', + 'Jazz', + 'Blues', + 'Soul', + 'Reed', + 'Horn', + 'Smooth', + 'Cool', ], }, - "🥁": { + '🥁': { adjectives: [ - "Drum", - "Drumming", - "Beating", - "Rhythmic", - "Percussion", - "Thumping", - "Pounding", - "Banging", - "Rock", - "Jazz", + 'Drum', + 'Drumming', + 'Beating', + 'Rhythmic', + 'Percussion', + 'Thumping', + 'Pounding', + 'Banging', + 'Rock', + 'Jazz', ], nouns: [ - "Drum", - "Drummer", - "Beat", - "Rhythm", - "Percussion", - "Thump", - "Pound", - "Bang", - "Stick", - "Roll", + 'Drum', + 'Drummer', + 'Beat', + 'Rhythm', + 'Percussion', + 'Thump', + 'Pound', + 'Bang', + 'Stick', + 'Roll', ], }, - "🎻": { + '🎻': { adjectives: [ - "Violin", - "String", - "Bowing", - "Classical", - "Orchestra", - "Symphony", - "Fiddle", - "Folk", - "Melodic", - "Lyrical", + 'Violin', + 'String', + 'Bowing', + 'Classical', + 'Orchestra', + 'Symphony', + 'Fiddle', + 'Folk', + 'Melodic', + 'Lyrical', ], nouns: [ - "Violin", - "Violinist", - "Fiddle", - "Fiddler", - "Bow", - "String", - "Orchestra", - "Symphony", - "Melody", - "Tune", + 'Violin', + 'Violinist', + 'Fiddle', + 'Fiddler', + 'Bow', + 'String', + 'Orchestra', + 'Symphony', + 'Melody', + 'Tune', ], }, - "🎤": { + '🎤': { adjectives: [ - "Microphone", - "Singing", - "Vocal", - "Karaoke", - "Performance", - "Stage", - "Crooning", - "Belting", - "Harmonizing", - "Rapping", + 'Microphone', + 'Singing', + 'Vocal', + 'Karaoke', + 'Performance', + 'Stage', + 'Crooning', + 'Belting', + 'Harmonizing', + 'Rapping', ], nouns: [ - "Microphone", - "Mic", - "Singer", - "Vocalist", - "Crooner", - "Performer", - "Star", - "Voice", - "Song", - "Karaoke", + 'Microphone', + 'Mic', + 'Singer', + 'Vocalist', + 'Crooner', + 'Performer', + 'Star', + 'Voice', + 'Song', + 'Karaoke', ], }, - "🎧": { + '🎧': { adjectives: [ - "Headphone", - "Listening", - "Music-loving", - "DJ", - "Beats", - "Bass", - "Audio", - "Sound", - "Jamming", - "Vibing", + 'Headphone', + 'Listening', + 'Music-loving', + 'DJ', + 'Beats', + 'Bass', + 'Audio', + 'Sound', + 'Jamming', + 'Vibing', ], nouns: [ - "Headphones", - "Headset", - "Listener", - "DJ", - "Music-lover", - "Beats", - "Bass", - "Sound", - "Audio", - "Vibe", + 'Headphones', + 'Headset', + 'Listener', + 'DJ', + 'Music-lover', + 'Beats', + 'Bass', + 'Sound', + 'Audio', + 'Vibe', ], }, // === FILM === - "🎬": { + '🎬': { adjectives: [ - "Clapboard", - "Action", - "Take", - "Scene", - "Directing", - "Filming", - "Movie", - "Cinema", - "Hollywood", - "Blockbuster", + 'Clapboard', + 'Action', + 'Take', + 'Scene', + 'Directing', + 'Filming', + 'Movie', + 'Cinema', + 'Hollywood', + 'Blockbuster', ], nouns: [ - "Clapboard", - "Clapper", - "Director", - "Action", - "Take", - "Scene", - "Movie", - "Film", - "Cinema", - "Cut", + 'Clapboard', + 'Clapper', + 'Director', + 'Action', + 'Take', + 'Scene', + 'Movie', + 'Film', + 'Cinema', + 'Cut', ], }, - "🎥": { + '🎥': { adjectives: [ - "Camera", - "Filming", - "Recording", - "Shooting", - "Video", - "Cinematography", - "Movie", - "Documentary", - "Rolling", - "Action", + 'Camera', + 'Filming', + 'Recording', + 'Shooting', + 'Video', + 'Cinematography', + 'Movie', + 'Documentary', + 'Rolling', + 'Action', ], nouns: [ - "Camera", - "Camcorder", - "Filmmaker", - "Cinematographer", - "Video", - "Film", - "Movie", - "Recording", - "Shoot", - "Roll", + 'Camera', + 'Camcorder', + 'Filmmaker', + 'Cinematographer', + 'Video', + 'Film', + 'Movie', + 'Recording', + 'Shoot', + 'Roll', ], }, // === FRUITS === - "🍎": { + '🍎': { adjectives: [ - "Apple", - "Red", - "Crisp", - "Crunchy", - "Juicy", - "Sweet", - "Tart", - "Fresh", - "Healthy", - "Teacher", + 'Apple', + 'Red', + 'Crisp', + 'Crunchy', + 'Juicy', + 'Sweet', + 'Tart', + 'Fresh', + 'Healthy', + 'Teacher', ], nouns: [ - "Apple", - "Red-delicious", - "Granny-smith", - "Fuji", - "Gala", - "Honeycrisp", - "Core", - "Seed", - "Pie", - "Cider", + 'Apple', + 'Red-delicious', + 'Granny-smith', + 'Fuji', + 'Gala', + 'Honeycrisp', + 'Core', + 'Seed', + 'Pie', + 'Cider', ], }, - "🍊": { + '🍊': { adjectives: [ - "Orange", - "Citrus", - "Tangy", - "Zesty", - "Vitamin-C", - "Juicy", - "Fresh", - "Bright", - "Sunny", - "Florida", + 'Orange', + 'Citrus', + 'Tangy', + 'Zesty', + 'Vitamin-C', + 'Juicy', + 'Fresh', + 'Bright', + 'Sunny', + 'Florida', ], nouns: [ - "Orange", - "Citrus", - "Vitamin-C", - "Juice", - "Zest", - "Peel", - "Slice", - "Segment", - "Florida", - "Sunshine", + 'Orange', + 'Citrus', + 'Vitamin-C', + 'Juice', + 'Zest', + 'Peel', + 'Slice', + 'Segment', + 'Florida', + 'Sunshine', ], }, - "🍌": { + '🍌': { adjectives: [ - "Banana", - "Yellow", - "Curved", - "Peelable", - "Potassium", - "Tropical", - "Smooth", - "Sweet", - "Monkey", - "Split", + 'Banana', + 'Yellow', + 'Curved', + 'Peelable', + 'Potassium', + 'Tropical', + 'Smooth', + 'Sweet', + 'Monkey', + 'Split', ], nouns: [ - "Banana", - "Plantain", - "Potassium", - "Peel", - "Bunch", - "Split", - "Smoothie", - "Bread", - "Foster", - "Monkey-food", + 'Banana', + 'Plantain', + 'Potassium', + 'Peel', + 'Bunch', + 'Split', + 'Smoothie', + 'Bread', + 'Foster', + 'Monkey-food', ], }, - "🍇": { + '🍇': { adjectives: [ - "Grape", - "Purple", - "Green", - "Bunch", - "Vine", - "Wine", - "Juicy", - "Sweet", - "Seedless", - "Cluster", + 'Grape', + 'Purple', + 'Green', + 'Bunch', + 'Vine', + 'Wine', + 'Juicy', + 'Sweet', + 'Seedless', + 'Cluster', ], nouns: [ - "Grape", - "Grapes", - "Bunch", - "Cluster", - "Vine", - "Vineyard", - "Wine", - "Raisin", - "Juice", - "Fruit", + 'Grape', + 'Grapes', + 'Bunch', + 'Cluster', + 'Vine', + 'Vineyard', + 'Wine', + 'Raisin', + 'Juice', + 'Fruit', ], }, - "🍓": { + '🍓': { adjectives: [ - "Strawberry", - "Red", - "Sweet", - "Juicy", - "Berry", - "Seeded", - "Fresh", - "Summer", - "Shortcake", - "Jam", + 'Strawberry', + 'Red', + 'Sweet', + 'Juicy', + 'Berry', + 'Seeded', + 'Fresh', + 'Summer', + 'Shortcake', + 'Jam', ], nouns: [ - "Strawberry", - "Berry", - "Seed", - "Shortcake", - "Jam", - "Preserves", - "Smoothie", - "Patch", - "Field", - "Summer", + 'Strawberry', + 'Berry', + 'Seed', + 'Shortcake', + 'Jam', + 'Preserves', + 'Smoothie', + 'Patch', + 'Field', + 'Summer', ], }, - "🥝": { + '🥝': { adjectives: [ - "Kiwi", - "Green", - "Fuzzy", - "Tart", - "Tangy", - "Vitamin-C", - "New-Zealand", - "Tropical", - "Exotic", - "Hairy", + 'Kiwi', + 'Green', + 'Fuzzy', + 'Tart', + 'Tangy', + 'Vitamin-C', + 'New-Zealand', + 'Tropical', + 'Exotic', + 'Hairy', ], nouns: [ - "Kiwi", - "Kiwifruit", - "New-Zealand", - "Vitamin-C", - "Fuzz", - "Green", - "Slice", - "Tropical", - "Exotic", - "Bird", + 'Kiwi', + 'Kiwifruit', + 'New-Zealand', + 'Vitamin-C', + 'Fuzz', + 'Green', + 'Slice', + 'Tropical', + 'Exotic', + 'Bird', ], }, - "🍑": { + '🍑': { adjectives: [ - "Peach", - "Fuzzy", - "Juicy", - "Sweet", - "Soft", - "Ripe", - "Georgia", - "Summer", - "Pit", - "Cobbler", + 'Peach', + 'Fuzzy', + 'Juicy', + 'Sweet', + 'Soft', + 'Ripe', + 'Georgia', + 'Summer', + 'Pit', + 'Cobbler', ], nouns: [ - "Peach", - "Fuzz", - "Pit", - "Stone", - "Georgia", - "Cobbler", - "Pie", - "Summer", - "Orchard", - "Tree", + 'Peach', + 'Fuzz', + 'Pit', + 'Stone', + 'Georgia', + 'Cobbler', + 'Pie', + 'Summer', + 'Orchard', + 'Tree', ], }, - "🥭": { + '🥭': { adjectives: [ - "Mango", - "Tropical", - "Juicy", - "Sweet", - "Golden", - "Exotic", - "Ripe", - "Succulent", - "Luscious", - "Island", + 'Mango', + 'Tropical', + 'Juicy', + 'Sweet', + 'Golden', + 'Exotic', + 'Ripe', + 'Succulent', + 'Luscious', + 'Island', ], nouns: [ - "Mango", - "Tropical", - "Exotic", - "Island", - "Paradise", - "Pit", - "Slice", - "Smoothie", - "Lassi", - "Chutney", + 'Mango', + 'Tropical', + 'Exotic', + 'Island', + 'Paradise', + 'Pit', + 'Slice', + 'Smoothie', + 'Lassi', + 'Chutney', ], }, - "🍍": { + '🍍': { adjectives: [ - "Pineapple", - "Tropical", - "Spiky", - "Crown", - "Golden", - "Tangy", - "Sweet", - "Juicy", - "Hawaii", - "Island", + 'Pineapple', + 'Tropical', + 'Spiky', + 'Crown', + 'Golden', + 'Tangy', + 'Sweet', + 'Juicy', + 'Hawaii', + 'Island', ], nouns: [ - "Pineapple", - "Tropical", - "Spikes", - "Crown", - "Hawaii", - "Island", - "Paradise", - "Juice", - "Pizza", - "Colada", + 'Pineapple', + 'Tropical', + 'Spikes', + 'Crown', + 'Hawaii', + 'Island', + 'Paradise', + 'Juice', + 'Pizza', + 'Colada', ], }, - "🥥": { + '🥥': { adjectives: [ - "Coconut", - "Tropical", - "Hairy", - "Hard-shell", - "Island", - "Paradise", - "Cream", - "Milk", - "Water", - "Palm", + 'Coconut', + 'Tropical', + 'Hairy', + 'Hard-shell', + 'Island', + 'Paradise', + 'Cream', + 'Milk', + 'Water', + 'Palm', ], nouns: [ - "Coconut", - "Tropical", - "Island", - "Palm", - "Shell", - "Milk", - "Water", - "Cream", - "Oil", - "Paradise", + 'Coconut', + 'Tropical', + 'Island', + 'Palm', + 'Shell', + 'Milk', + 'Water', + 'Cream', + 'Oil', + 'Paradise', ], }, // === VEGETABLES === - "🥑": { + '🥑': { adjectives: [ - "Avocado", - "Green", - "Creamy", - "Healthy", - "Millennial", - "Guacamole", - "Toast", - "Ripe", - "Pit", - "Nutritious", + 'Avocado', + 'Green', + 'Creamy', + 'Healthy', + 'Millennial', + 'Guacamole', + 'Toast', + 'Ripe', + 'Pit', + 'Nutritious', ], nouns: [ - "Avocado", - "Guac", - "Guacamole", - "Toast", - "Pit", - "Millennial", - "Health", - "Fat", - "Spread", - "Green", + 'Avocado', + 'Guac', + 'Guacamole', + 'Toast', + 'Pit', + 'Millennial', + 'Health', + 'Fat', + 'Spread', + 'Green', ], }, - "🍆": { + '🍆': { adjectives: [ - "Eggplant", - "Purple", - "Aubergine", - "Shiny", - "Glossy", - "Vegetable", - "Emoji", - "Suggestive", - "Mediterranean", - "Grilled", + 'Eggplant', + 'Purple', + 'Aubergine', + 'Shiny', + 'Glossy', + 'Vegetable', + 'Emoji', + 'Suggestive', + 'Mediterranean', + 'Grilled', ], nouns: [ - "Eggplant", - "Aubergine", - "Vegetable", - "Purple", - "Emoji", - "Parmesan", - "Mediterranean", - "Grill", - "Roast", - "Moussaka", + 'Eggplant', + 'Aubergine', + 'Vegetable', + 'Purple', + 'Emoji', + 'Parmesan', + 'Mediterranean', + 'Grill', + 'Roast', + 'Moussaka', ], }, - "🥕": { + '🥕': { adjectives: [ - "Carrot", - "Orange", - "Crunchy", - "Root", - "Vegetable", - "Vitamin-A", - "Bunny", - "Healthy", - "Fresh", - "Garden", + 'Carrot', + 'Orange', + 'Crunchy', + 'Root', + 'Vegetable', + 'Vitamin-A', + 'Bunny', + 'Healthy', + 'Fresh', + 'Garden', ], nouns: [ - "Carrot", - "Root", - "Vegetable", - "Vitamin-A", - "Bunny-food", - "Orange", - "Garden", - "Crunch", - "Stick", - "Top", + 'Carrot', + 'Root', + 'Vegetable', + 'Vitamin-A', + 'Bunny-food', + 'Orange', + 'Garden', + 'Crunch', + 'Stick', + 'Top', ], }, - "🌽": { + '🌽': { adjectives: [ - "Corn", - "Maize", - "Yellow", - "Kernel", - "Cob", - "Sweet", - "Buttery", - "Pop", - "Field", - "Farm", + 'Corn', + 'Maize', + 'Yellow', + 'Kernel', + 'Cob', + 'Sweet', + 'Buttery', + 'Pop', + 'Field', + 'Farm', ], nouns: [ - "Corn", - "Maize", - "Cob", - "Kernel", - "Pop", - "Popcorn", - "Field", - "Farm", - "Butter", - "Sweet-corn", + 'Corn', + 'Maize', + 'Cob', + 'Kernel', + 'Pop', + 'Popcorn', + 'Field', + 'Farm', + 'Butter', + 'Sweet-corn', ], }, - "🌶️": { + '🌶️': { adjectives: [ - "Pepper", - "Chili", - "Hot", - "Spicy", - "Fiery", - "Red", - "Burning", - "Scoville", - "Jalapeño", - "Habanero", + 'Pepper', + 'Chili', + 'Hot', + 'Spicy', + 'Fiery', + 'Red', + 'Burning', + 'Scoville', + 'Jalapeño', + 'Habanero', ], nouns: [ - "Pepper", - "Chili", - "Chile", - "Jalapeño", - "Habanero", - "Heat", - "Spice", - "Fire", - "Burn", - "Scoville", + 'Pepper', + 'Chili', + 'Chile', + 'Jalapeño', + 'Habanero', + 'Heat', + 'Spice', + 'Fire', + 'Burn', + 'Scoville', ], }, - "🫑": { + '🫑': { adjectives: [ - "Bell-pepper", - "Sweet", - "Crunchy", - "Colorful", - "Green", - "Red", - "Yellow", - "Fresh", - "Crisp", - "Stuffed", + 'Bell-pepper', + 'Sweet', + 'Crunchy', + 'Colorful', + 'Green', + 'Red', + 'Yellow', + 'Fresh', + 'Crisp', + 'Stuffed', ], nouns: [ - "Bell-pepper", - "Pepper", - "Capsicum", - "Bell", - "Sweet-pepper", - "Stuffed", - "Fajita", - "Garden", - "Crunch", - "Color", + 'Bell-pepper', + 'Pepper', + 'Capsicum', + 'Bell', + 'Sweet-pepper', + 'Stuffed', + 'Fajita', + 'Garden', + 'Crunch', + 'Color', ], }, - "🥒": { + '🥒': { adjectives: [ - "Cucumber", - "Green", - "Cool", - "Crisp", - "Crunchy", - "Refreshing", - "Hydrating", - "Pickle", - "Garden", - "Fresh", + 'Cucumber', + 'Green', + 'Cool', + 'Crisp', + 'Crunchy', + 'Refreshing', + 'Hydrating', + 'Pickle', + 'Garden', + 'Fresh', ], nouns: [ - "Cucumber", - "Cuke", - "Pickle", - "Gherkin", - "Cool-cucumber", - "Garden", - "Salad", - "Crunch", - "Green", - "Spa", + 'Cucumber', + 'Cuke', + 'Pickle', + 'Gherkin', + 'Cool-cucumber', + 'Garden', + 'Salad', + 'Crunch', + 'Green', + 'Spa', ], }, - "🥬": { + '🥬': { adjectives: [ - "Leafy", - "Green", - "Lettuce", - "Cabbage", - "Bok-choy", - "Fresh", - "Crisp", - "Healthy", - "Salad", - "Vegetable", + 'Leafy', + 'Green', + 'Lettuce', + 'Cabbage', + 'Bok-choy', + 'Fresh', + 'Crisp', + 'Healthy', + 'Salad', + 'Vegetable', ], nouns: [ - "Lettuce", - "Cabbage", - "Bok-choy", - "Greens", - "Leafy", - "Salad", - "Vegetable", - "Garden", - "Leaf", - "Green", + 'Lettuce', + 'Cabbage', + 'Bok-choy', + 'Greens', + 'Leafy', + 'Salad', + 'Vegetable', + 'Garden', + 'Leaf', + 'Green', ], }, - "🥦": { + '🥦': { adjectives: [ - "Broccoli", - "Green", - "Tree", - "Floret", - "Healthy", - "Vegetable", - "Cruciferous", - "Steamed", - "Nutritious", - "Forest", + 'Broccoli', + 'Green', + 'Tree', + 'Floret', + 'Healthy', + 'Vegetable', + 'Cruciferous', + 'Steamed', + 'Nutritious', + 'Forest', ], nouns: [ - "Broccoli", - "Tree", - "Floret", - "Forest", - "Mini-tree", - "Vegetable", - "Green", - "Health", - "Nutrient", - "Crown", + 'Broccoli', + 'Tree', + 'Floret', + 'Forest', + 'Mini-tree', + 'Vegetable', + 'Green', + 'Health', + 'Nutrient', + 'Crown', ], }, - "🧄": { + '🧄': { adjectives: [ - "Garlic", - "Pungent", - "Aromatic", - "Stinky", - "Clove", - "Bulb", - "Vampire-repelling", - "Flavorful", - "Strong", - "Italian", + 'Garlic', + 'Pungent', + 'Aromatic', + 'Stinky', + 'Clove', + 'Bulb', + 'Vampire-repelling', + 'Flavorful', + 'Strong', + 'Italian', ], nouns: [ - "Garlic", - "Clove", - "Bulb", - "Vampire-repellent", - "Flavor", - "Aroma", - "Stink", - "Italian", - "Aioli", - "Scampi", + 'Garlic', + 'Clove', + 'Bulb', + 'Vampire-repellent', + 'Flavor', + 'Aroma', + 'Stink', + 'Italian', + 'Aioli', + 'Scampi', ], }, - "🧅": { + '🧅': { adjectives: [ - "Onion", - "Layered", - "Tear-inducing", - "Pungent", - "Aromatic", - "Bulb", - "Caramelized", - "Red", - "White", - "Crying", + 'Onion', + 'Layered', + 'Tear-inducing', + 'Pungent', + 'Aromatic', + 'Bulb', + 'Caramelized', + 'Red', + 'White', + 'Crying', ], nouns: [ - "Onion", - "Bulb", - "Layer", - "Tear", - "Cry", - "Caramelization", - "Ring", - "Shallot", - "Scallion", - "Flavor", + 'Onion', + 'Bulb', + 'Layer', + 'Tear', + 'Cry', + 'Caramelization', + 'Ring', + 'Shallot', + 'Scallion', + 'Flavor', ], }, - "🍄": { + '🍄': { adjectives: [ - "Mushroom", - "Fungus", - "Toadstool", - "Spotted", - "Red", - "White", - "Forest", - "Mario", - "Power-up", - "Magic", + 'Mushroom', + 'Fungus', + 'Toadstool', + 'Spotted', + 'Red', + 'White', + 'Forest', + 'Mario', + 'Power-up', + 'Magic', ], nouns: [ - "Mushroom", - "Fungus", - "Toadstool", - "Shroom", - "Mario", - "Power-up", - "Forest", - "Fairy-ring", - "Cap", - "Stem", + 'Mushroom', + 'Fungus', + 'Toadstool', + 'Shroom', + 'Mario', + 'Power-up', + 'Forest', + 'Fairy-ring', + 'Cap', + 'Stem', ], }, - "🥜": { + '🥜': { adjectives: [ - "Peanut", - "Nutty", - "Legume", - "Shell", - "Roasted", - "Salty", - "Butter", - "Allergy", - "Baseball", - "Circus", + 'Peanut', + 'Nutty', + 'Legume', + 'Shell', + 'Roasted', + 'Salty', + 'Butter', + 'Allergy', + 'Baseball', + 'Circus', ], nouns: [ - "Peanut", - "Nut", - "Legume", - "Shell", - "Butter", - "PB", - "Baseball", - "Circus", - "Elephant", - "Allergy", + 'Peanut', + 'Nut', + 'Legume', + 'Shell', + 'Butter', + 'PB', + 'Baseball', + 'Circus', + 'Elephant', + 'Allergy', ], }, - "🌰": { + '🌰': { adjectives: [ - "Chestnut", - "Brown", - "Roasted", - "Nutty", - "Fall", - "Autumn", - "Winter", - "Fire", - "Open-fire", - "Christmas", + 'Chestnut', + 'Brown', + 'Roasted', + 'Nutty', + 'Fall', + 'Autumn', + 'Winter', + 'Fire', + 'Open-fire', + 'Christmas', ], nouns: [ - "Chestnut", - "Nut", - "Acorn", - "Brown", - "Roast", - "Fall", - "Autumn", - "Winter", - "Fire", - "Christmas", + 'Chestnut', + 'Nut', + 'Acorn', + 'Brown', + 'Roast', + 'Fall', + 'Autumn', + 'Winter', + 'Fire', + 'Christmas', ], }, -}; +} /** * Map emoji to theme category (for fallback when emoji-specific words don't exist) @@ -6440,236 +6429,236 @@ export const EMOJI_SPECIFIC_WORDS: Record = { */ export const EMOJI_TO_THEME: Record = { // Abacus - "🧮": "abacus", + '🧮': 'abacus', // Happy Faces - "😀": "happyFaces", - "😃": "happyFaces", - "😄": "happyFaces", - "😁": "happyFaces", - "😆": "happyFaces", - "😅": "happyFaces", - "🤣": "happyFaces", - "😂": "happyFaces", - "🙂": "happyFaces", - "😉": "happyFaces", - "😊": "happyFaces", - "😇": "happyFaces", + '😀': 'happyFaces', + '😃': 'happyFaces', + '😄': 'happyFaces', + '😁': 'happyFaces', + '😆': 'happyFaces', + '😅': 'happyFaces', + '🤣': 'happyFaces', + '😂': 'happyFaces', + '🙂': 'happyFaces', + '😉': 'happyFaces', + '😊': 'happyFaces', + '😇': 'happyFaces', // Love & Affection - "🥰": "loveAndAffection", - "😍": "loveAndAffection", - "🤩": "loveAndAffection", - "😘": "loveAndAffection", - "😗": "loveAndAffection", - "😚": "loveAndAffection", + '🥰': 'loveAndAffection', + '😍': 'loveAndAffection', + '🤩': 'loveAndAffection', + '😘': 'loveAndAffection', + '😗': 'loveAndAffection', + '😚': 'loveAndAffection', // Playful - "😋": "playful", - "😛": "playful", - "😝": "playful", - "😜": "playful", - "🤪": "playful", + '😋': 'playful', + '😛': 'playful', + '😝': 'playful', + '😜': 'playful', + '🤪': 'playful', // Clever - "🤨": "clever", - "🧐": "clever", - "🤓": "clever", + '🤨': 'clever', + '🧐': 'clever', + '🤓': 'clever', // Cool - "😎": "cool", - "🥸": "cool", + '😎': 'cool', + '🥸': 'cool', // Party - "🥳": "party", + '🥳': 'party', // Moody - "😏": "moody", - "😒": "moody", - "😞": "moody", - "😔": "moody", - "😟": "moody", - "😕": "moody", + '😏': 'moody', + '😒': 'moody', + '😞': 'moody', + '😔': 'moody', + '😟': 'moody', + '😕': 'moody', // Wild West - "🤠": "wildWest", + '🤠': 'wildWest', // Ninja - "🥷": "ninja", + '🥷': 'ninja', // Royal - "👑": "royal", + '👑': 'royal', // Theater - "🎭": "theater", + '🎭': 'theater', // Robot - "🤖": "robot", + '🤖': 'robot', // Spooky - "👻": "spooky", - "💀": "spooky", + '👻': 'spooky', + '💀': 'spooky', // Alien - "👽": "alien", + '👽': 'alien', // Circus - "🤡": "circus", + '🤡': 'circus', // Wizards - "🧙‍♂️": "wizards", - "🧙‍♀️": "wizards", + '🧙‍♂️': 'wizards', + '🧙‍♀️': 'wizards', // Fairies - "🧚‍♂️": "fairies", - "🧚‍♀️": "fairies", + '🧚‍♂️': 'fairies', + '🧚‍♀️': 'fairies', // Vampires - "🧛‍♂️": "vampires", - "🧛‍♀️": "vampires", + '🧛‍♂️': 'vampires', + '🧛‍♀️': 'vampires', // Merfolk - "🧜‍♂️": "merfolk", - "🧜‍♀️": "merfolk", + '🧜‍♂️': 'merfolk', + '🧜‍♀️': 'merfolk', // Elves - "🧝‍♂️": "elves", - "🧝‍♀️": "elves", + '🧝‍♂️': 'elves', + '🧝‍♀️': 'elves', // Heroes - "🦸‍♂️": "heroes", - "🦸‍♀️": "heroes", + '🦸‍♂️': 'heroes', + '🦸‍♀️': 'heroes', // Villains - "🦹‍♂️": "villains", + '🦹‍♂️': 'villains', // Canines - "🐶": "canines", - "🐺": "canines", + '🐶': 'canines', + '🐺': 'canines', // Felines - "🐱": "felines", - "🐯": "felines", - "🦁": "felines", + '🐱': 'felines', + '🐯': 'felines', + '🦁': 'felines', // Rodents - "🐭": "rodents", - "🐹": "rodents", + '🐭': 'rodents', + '🐹': 'rodents', // Rabbits - "🐰": "rabbits", + '🐰': 'rabbits', // Foxes - "🦊": "foxes", + '🦊': 'foxes', // Bears - "🐻": "bears", - "🐼": "bears", - "🐻‍❄️": "bears", + '🐻': 'bears', + '🐼': 'bears', + '🐻‍❄️': 'bears', // Koalas - "🐨": "koalas", + '🐨': 'koalas', // Bovine - "🐮": "bovine", + '🐮': 'bovine', // Pigs - "🐷": "pigs", + '🐷': 'pigs', // Frogs - "🐸": "frogs", + '🐸': 'frogs', // Monkeys - "🐵": "monkeys", - "🙈": "monkeys", - "🙉": "monkeys", - "🙊": "monkeys", - "🐒": "monkeys", + '🐵': 'monkeys', + '🙈': 'monkeys', + '🙉': 'monkeys', + '🙊': 'monkeys', + '🐒': 'monkeys', // Birds - "🦆": "birds", - "🐧": "birds", - "🐦": "birds", - "🐤": "birds", - "🐣": "birds", - "🐥": "birds", - "🦅": "birds", - "🦉": "birds", + '🦆': 'birds', + '🐧': 'birds', + '🐦': 'birds', + '🐤': 'birds', + '🐣': 'birds', + '🐥': 'birds', + '🦅': 'birds', + '🦉': 'birds', // Bats - "🦇": "bats", + '🦇': 'bats', // Boars - "🐗": "boars", + '🐗': 'boars', // Horses - "🐴": "horses", - "🦄": "horses", + '🐴': 'horses', + '🦄': 'horses', // Insects - "🐝": "insects", - "🐛": "insects", - "🦋": "insects", + '🐝': 'insects', + '🐛': 'insects', + '🦋': 'insects', // Stars - "⭐": "stars", - "🌟": "stars", - "💫": "stars", - "✨": "stars", + '⭐': 'stars', + '🌟': 'stars', + '💫': 'stars', + '✨': 'stars', // Power - "⚡": "power", - "🔥": "power", + '⚡': 'power', + '🔥': 'power', // Rainbow - "🌈": "rainbow", + '🌈': 'rainbow', // Entertainment - "🎪": "entertainment", - "🎨": "entertainment", - "🎯": "entertainment", - "🎲": "entertainment", - "🎮": "entertainment", - "🕹️": "entertainment", + '🎪': 'entertainment', + '🎨': 'entertainment', + '🎯': 'entertainment', + '🎲': 'entertainment', + '🎮': 'entertainment', + '🕹️': 'entertainment', // Music - "🎸": "music", - "🎺": "music", - "🎷": "music", - "🥁": "music", - "🎻": "music", - "🎤": "music", - "🎧": "music", + '🎸': 'music', + '🎺': 'music', + '🎷': 'music', + '🥁': 'music', + '🎻': 'music', + '🎤': 'music', + '🎧': 'music', // Film - "🎬": "film", - "🎥": "film", + '🎬': 'film', + '🎥': 'film', // Fruits - "🍎": "fruits", - "🍊": "fruits", - "🍌": "fruits", - "🍇": "fruits", - "🍓": "fruits", - "🥝": "fruits", - "🍑": "fruits", - "🥭": "fruits", - "🍍": "fruits", - "🥥": "fruits", + '🍎': 'fruits', + '🍊': 'fruits', + '🍌': 'fruits', + '🍇': 'fruits', + '🍓': 'fruits', + '🥝': 'fruits', + '🍑': 'fruits', + '🥭': 'fruits', + '🍍': 'fruits', + '🥥': 'fruits', // Vegetables - "🥑": "vegetables", - "🍆": "vegetables", - "🥕": "vegetables", - "🌽": "vegetables", - "🌶️": "vegetables", - "🫑": "vegetables", - "🥒": "vegetables", - "🥬": "vegetables", - "🥦": "vegetables", - "🧄": "vegetables", - "🧅": "vegetables", - "🍄": "vegetables", - "🥜": "vegetables", - "🌰": "vegetables", -}; + '🥑': 'vegetables', + '🍆': 'vegetables', + '🥕': 'vegetables', + '🌽': 'vegetables', + '🌶️': 'vegetables', + '🫑': 'vegetables', + '🥒': 'vegetables', + '🥬': 'vegetables', + '🥦': 'vegetables', + '🧄': 'vegetables', + '🧅': 'vegetables', + '🍄': 'vegetables', + '🥜': 'vegetables', + '🌰': 'vegetables', +} diff --git a/apps/web/src/utils/tutorialAudit.ts b/apps/web/src/utils/tutorialAudit.ts index a4112249..90d17f69 100644 --- a/apps/web/src/utils/tutorialAudit.ts +++ b/apps/web/src/utils/tutorialAudit.ts @@ -1,314 +1,284 @@ // Comprehensive audit of tutorial steps for abacus calculation errors -import { guidedAdditionSteps } from "./tutorialConverter"; +import { guidedAdditionSteps } from './tutorialConverter' interface AuditIssue { - stepId: string; - stepTitle: string; - issueType: "mathematical" | "highlighting" | "instruction" | "missing_beads"; - severity: "critical" | "major" | "minor"; - description: string; - currentState: string; - expectedState: string; + stepId: string + stepTitle: string + issueType: 'mathematical' | 'highlighting' | 'instruction' | 'missing_beads' + severity: 'critical' | 'major' | 'minor' + description: string + currentState: string + expectedState: string } // Helper function to calculate what beads should be active for a given value function calculateBeadState(value: number): { - heavenActive: boolean; - earthActive: number; // 0-4 earth beads + heavenActive: boolean + earthActive: number // 0-4 earth beads } { - const heavenActive = value >= 5; - const earthActive = heavenActive ? value - 5 : value; - return { heavenActive, earthActive }; + const heavenActive = value >= 5 + const earthActive = heavenActive ? value - 5 : value + return { heavenActive, earthActive } } // Helper to determine what beads need to be highlighted for an operation -function analyzeOperation( - startValue: number, - targetValue: number, - operation: string, -) { - const startState = calculateBeadState(startValue); - const targetState = calculateBeadState(targetValue); - const difference = targetValue - startValue; +function analyzeOperation(startValue: number, targetValue: number, operation: string) { + const startState = calculateBeadState(startValue) + const targetState = calculateBeadState(targetValue) + const difference = targetValue - startValue - console.log(`\n=== ${operation} ===`); - console.log( - `Start: ${startValue} -> Target: ${targetValue} (difference: ${difference})`, - ); - console.log( - `Start state: heaven=${startState.heavenActive}, earth=${startState.earthActive}`, - ); - console.log( - `Target state: heaven=${targetState.heavenActive}, earth=${targetState.earthActive}`, - ); + console.log(`\n=== ${operation} ===`) + console.log(`Start: ${startValue} -> Target: ${targetValue} (difference: ${difference})`) + console.log(`Start state: heaven=${startState.heavenActive}, earth=${startState.earthActive}`) + console.log(`Target state: heaven=${targetState.heavenActive}, earth=${targetState.earthActive}`) return { startState, targetState, difference, needsComplement: false, // Will be determined by specific analysis - }; + } } export function auditTutorialSteps(): AuditIssue[] { - const issues: AuditIssue[] = []; + const issues: AuditIssue[] = [] - console.log("🔍 Starting comprehensive tutorial audit...\n"); + console.log('🔍 Starting comprehensive tutorial audit...\n') guidedAdditionSteps.forEach((step, index) => { - console.log(`\n📝 Step ${index + 1}: ${step.title}`); + console.log(`\n📝 Step ${index + 1}: ${step.title}`) // 1. Verify mathematical correctness - if ( - step.startValue + (step.targetValue - step.startValue) !== - step.targetValue - ) { + if (step.startValue + (step.targetValue - step.startValue) !== step.targetValue) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "mathematical", - severity: "critical", - description: "Mathematical inconsistency in step values", + issueType: 'mathematical', + severity: 'critical', + description: 'Mathematical inconsistency in step values', currentState: `${step.startValue} + ? = ${step.targetValue}`, expectedState: `Should be mathematically consistent`, - }); + }) } // 2. Analyze the operation - const _analysis = analyzeOperation( - step.startValue, - step.targetValue, - step.problem, - ); - const _difference = step.targetValue - step.startValue; + const _analysis = analyzeOperation(step.startValue, step.targetValue, step.problem) + const _difference = step.targetValue - step.startValue // 3. Check specific operations switch (step.id) { - case "basic-1": // 0 + 1 + case 'basic-1': // 0 + 1 if (!step.highlightBeads || step.highlightBeads.length !== 1) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: "Should highlight exactly 1 earth bead", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight exactly 1 earth bead', currentState: `Highlights ${step.highlightBeads?.length || 0} beads`, - expectedState: "Should highlight 1 earth bead at position 0", - }); + expectedState: 'Should highlight 1 earth bead at position 0', + }) } - break; + break - case "basic-2": // 1 + 1 + case 'basic-2': // 1 + 1 if (!step.highlightBeads || step.highlightBeads[0]?.position !== 1) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: "Should highlight second earth bead (position 1)", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight second earth bead (position 1)', currentState: `Highlights position ${step.highlightBeads?.[0]?.position}`, - expectedState: "Should highlight earth bead at position 1", - }); + expectedState: 'Should highlight earth bead at position 1', + }) } - break; + break - case "basic-3": // 2 + 1 + case 'basic-3': // 2 + 1 if (!step.highlightBeads || step.highlightBeads[0]?.position !== 2) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: "Should highlight third earth bead (position 2)", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight third earth bead (position 2)', currentState: `Highlights position ${step.highlightBeads?.[0]?.position}`, - expectedState: "Should highlight earth bead at position 2", - }); + expectedState: 'Should highlight earth bead at position 2', + }) } - break; + break - case "basic-4": // 3 + 1 + case 'basic-4': // 3 + 1 if (!step.highlightBeads || step.highlightBeads[0]?.position !== 3) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: "Should highlight fourth earth bead (position 3)", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight fourth earth bead (position 3)', currentState: `Highlights position ${step.highlightBeads?.[0]?.position}`, - expectedState: "Should highlight earth bead at position 3", - }); + expectedState: 'Should highlight earth bead at position 3', + }) } - break; + break - case "heaven-intro": // 0 + 5 - if ( - !step.highlightBeads || - step.highlightBeads[0]?.beadType !== "heaven" - ) { + case 'heaven-intro': // 0 + 5 + if (!step.highlightBeads || step.highlightBeads[0]?.beadType !== 'heaven') { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: "Should highlight heaven bead for adding 5", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight heaven bead for adding 5', currentState: `Highlights ${step.highlightBeads?.[0]?.beadType}`, - expectedState: "Should highlight heaven bead", - }); + expectedState: 'Should highlight heaven bead', + }) } - break; + break - case "heaven-plus-earth": // 5 + 1 + case 'heaven-plus-earth': // 5 + 1 if ( !step.highlightBeads || - step.highlightBeads[0]?.beadType !== "earth" || + step.highlightBeads[0]?.beadType !== 'earth' || step.highlightBeads[0]?.position !== 0 ) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: - "Should highlight first earth bead to add to existing heaven", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight first earth bead to add to existing heaven', currentState: `Highlights ${step.highlightBeads?.[0]?.beadType} at position ${step.highlightBeads?.[0]?.position}`, - expectedState: "Should highlight earth bead at position 0", - }); + expectedState: 'Should highlight earth bead at position 0', + }) } - break; + break - case "complement-intro": // 3 + 4 = 7 (using 5 - 1) - console.log("🔍 Analyzing complement-intro: 3 + 4"); - console.log("Start: 3 earth beads active"); - console.log("Need to add 4, but only 1 earth space remaining"); - console.log( - "Complement: 4 = 5 - 1, so add heaven (5) then remove 1 earth", - ); + case 'complement-intro': // 3 + 4 = 7 (using 5 - 1) + console.log('🔍 Analyzing complement-intro: 3 + 4') + console.log('Start: 3 earth beads active') + console.log('Need to add 4, but only 1 earth space remaining') + console.log('Complement: 4 = 5 - 1, so add heaven (5) then remove 1 earth') if (!step.highlightBeads || step.highlightBeads.length !== 2) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: - "Should highlight heaven bead and first earth bead for 5-1 complement", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight heaven bead and first earth bead for 5-1 complement', currentState: `Highlights ${step.highlightBeads?.length || 0} beads`, - expectedState: "Should highlight heaven bead + earth position 0", - }); + expectedState: 'Should highlight heaven bead + earth position 0', + }) } - break; + break - case "complement-2": // 2 + 3 = 5 (using 5 - 2) - console.log("🔍 Analyzing complement-2: 2 + 3"); - console.log("Start: 2 earth beads active"); - console.log("Need to add 3, but only 2 earth spaces remaining"); - console.log( - "Complement: 3 = 5 - 2, so add heaven (5) then remove 2 earth", - ); + case 'complement-2': // 2 + 3 = 5 (using 5 - 2) + console.log('🔍 Analyzing complement-2: 2 + 3') + console.log('Start: 2 earth beads active') + console.log('Need to add 3, but only 2 earth spaces remaining') + console.log('Complement: 3 = 5 - 2, so add heaven (5) then remove 2 earth') if (!step.highlightBeads || step.highlightBeads.length !== 3) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: - "Should highlight heaven bead and 2 earth beads for 5-2 complement", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight heaven bead and 2 earth beads for 5-2 complement', currentState: `Highlights ${step.highlightBeads?.length || 0} beads`, - expectedState: "Should highlight heaven bead + earth positions 0,1", - }); + expectedState: 'Should highlight heaven bead + earth positions 0,1', + }) } - break; + break - case "complex-1": // 6 + 2 = 8 - console.log("🔍 Analyzing complex-1: 6 + 2"); - console.log("Start: heaven + 1 earth (6)"); - console.log("Add 2 more earth beads directly (space available)"); + case 'complex-1': // 6 + 2 = 8 + console.log('🔍 Analyzing complex-1: 6 + 2') + console.log('Start: heaven + 1 earth (6)') + console.log('Add 2 more earth beads directly (space available)') if (!step.highlightBeads || step.highlightBeads.length !== 2) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: "Should highlight 2 earth beads to add directly", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight 2 earth beads to add directly', currentState: `Highlights ${step.highlightBeads?.length || 0} beads`, - expectedState: "Should highlight earth positions 1,2", - }); + expectedState: 'Should highlight earth positions 1,2', + }) } - break; + break - case "complex-2": { + case 'complex-2': { // 7 + 4 = 11 (ten complement) - console.log("🔍 Analyzing complex-2: 7 + 4"); - console.log("Start: heaven + 2 earth (7)"); - console.log("Need to add 4, requires carrying to tens place"); + console.log('🔍 Analyzing complex-2: 7 + 4') + console.log('Start: heaven + 2 earth (7)') + console.log('Need to add 4, requires carrying to tens place') console.log( - "Method: Add 10 (tens heaven), subtract 6 (clear ones: 5+2=7, need to subtract 6)", - ); + 'Method: Add 10 (tens heaven), subtract 6 (clear ones: 5+2=7, need to subtract 6)' + ) if (!step.highlightBeads || step.highlightBeads.length !== 4) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: - "Should highlight tens heaven + all ones place beads to clear", + issueType: 'highlighting', + severity: 'major', + description: 'Should highlight tens heaven + all ones place beads to clear', currentState: `Highlights ${step.highlightBeads?.length || 0} beads`, - expectedState: - "Should highlight tens heaven + ones heaven + 2 ones earth", - }); + expectedState: 'Should highlight tens heaven + ones heaven + 2 ones earth', + }) } // Check if it highlights the correct beads const hasOnesHeaven = step.highlightBeads?.some( - (h) => h.placeValue === 0 && h.beadType === "heaven", - ); + (h) => h.placeValue === 0 && h.beadType === 'heaven' + ) const hasTensHeaven = step.highlightBeads?.some( - (h) => h.placeValue === 1 && h.beadType === "heaven", - ); + (h) => h.placeValue === 1 && h.beadType === 'heaven' + ) const onesEarthCount = - step.highlightBeads?.filter( - (h) => h.placeValue === 0 && h.beadType === "earth", - ).length || 0; + step.highlightBeads?.filter((h) => h.placeValue === 0 && h.beadType === 'earth').length || + 0 if (!hasOnesHeaven) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "missing_beads", - severity: "critical", - description: "Missing ones place heaven bead in highlighting", - currentState: "Ones heaven not highlighted", - expectedState: "Should highlight ones heaven bead for removal", - }); + issueType: 'missing_beads', + severity: 'critical', + description: 'Missing ones place heaven bead in highlighting', + currentState: 'Ones heaven not highlighted', + expectedState: 'Should highlight ones heaven bead for removal', + }) } if (!hasTensHeaven) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "missing_beads", - severity: "critical", - description: "Missing tens place heaven bead in highlighting", - currentState: "Tens heaven not highlighted", - expectedState: "Should highlight tens heaven bead for addition", - }); + issueType: 'missing_beads', + severity: 'critical', + description: 'Missing tens place heaven bead in highlighting', + currentState: 'Tens heaven not highlighted', + expectedState: 'Should highlight tens heaven bead for addition', + }) } if (onesEarthCount !== 2) { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "missing_beads", - severity: "major", - description: "Wrong number of ones earth beads highlighted", + issueType: 'missing_beads', + severity: 'major', + description: 'Wrong number of ones earth beads highlighted', currentState: `${onesEarthCount} ones earth beads highlighted`, - expectedState: "Should highlight 2 ones earth beads for removal", - }); + expectedState: 'Should highlight 2 ones earth beads for removal', + }) } - break; + break } } @@ -319,67 +289,66 @@ export function auditTutorialSteps(): AuditIssue[] { issues.push({ stepId: step.id, stepTitle: step.title, - issueType: "highlighting", - severity: "major", - description: "Invalid place value in highlighting", + issueType: 'highlighting', + severity: 'major', + description: 'Invalid place value in highlighting', currentState: `placeValue: ${bead.placeValue}`, - expectedState: - "Should use placeValue 0 (ones) or 1 (tens) for basic tutorial", - }); + expectedState: 'Should use placeValue 0 (ones) or 1 (tens) for basic tutorial', + }) } - }); + }) } - }); + }) - return issues; + return issues } // Run the audit and log results export function runTutorialAudit(): void { - console.log("🔍 Running comprehensive tutorial audit...\n"); + console.log('🔍 Running comprehensive tutorial audit...\n') - const issues = auditTutorialSteps(); + const issues = auditTutorialSteps() if (issues.length === 0) { - console.log("✅ No issues found! All tutorial steps appear correct."); - return; + console.log('✅ No issues found! All tutorial steps appear correct.') + return } - console.log(`\n🚨 Found ${issues.length} issues:\n`); + console.log(`\n🚨 Found ${issues.length} issues:\n`) // Group by severity - const critical = issues.filter((i) => i.severity === "critical"); - const major = issues.filter((i) => i.severity === "major"); - const minor = issues.filter((i) => i.severity === "minor"); + const critical = issues.filter((i) => i.severity === 'critical') + const major = issues.filter((i) => i.severity === 'major') + const minor = issues.filter((i) => i.severity === 'minor') if (critical.length > 0) { - console.log("🔴 CRITICAL ISSUES:"); + console.log('🔴 CRITICAL ISSUES:') critical.forEach((issue) => { - console.log(` • ${issue.stepTitle}: ${issue.description}`); - console.log(` Current: ${issue.currentState}`); - console.log(` Expected: ${issue.expectedState}\n`); - }); + console.log(` • ${issue.stepTitle}: ${issue.description}`) + console.log(` Current: ${issue.currentState}`) + console.log(` Expected: ${issue.expectedState}\n`) + }) } if (major.length > 0) { - console.log("🟠 MAJOR ISSUES:"); + console.log('🟠 MAJOR ISSUES:') major.forEach((issue) => { - console.log(` • ${issue.stepTitle}: ${issue.description}`); - console.log(` Current: ${issue.currentState}`); - console.log(` Expected: ${issue.expectedState}\n`); - }); + console.log(` • ${issue.stepTitle}: ${issue.description}`) + console.log(` Current: ${issue.currentState}`) + console.log(` Expected: ${issue.expectedState}\n`) + }) } if (minor.length > 0) { - console.log("🟡 MINOR ISSUES:"); + console.log('🟡 MINOR ISSUES:') minor.forEach((issue) => { - console.log(` • ${issue.stepTitle}: ${issue.description}`); - console.log(` Current: ${issue.currentState}`); - console.log(` Expected: ${issue.expectedState}\n`); - }); + console.log(` • ${issue.stepTitle}: ${issue.description}`) + console.log(` Current: ${issue.currentState}`) + console.log(` Expected: ${issue.expectedState}\n`) + }) } console.log( - `\n📊 Summary: ${critical.length} critical, ${major.length} major, ${minor.length} minor issues`, - ); + `\n📊 Summary: ${critical.length} critical, ${major.length} major, ${minor.length} minor issues` + ) } diff --git a/apps/web/src/utils/tutorialConverter.ts b/apps/web/src/utils/tutorialConverter.ts index e7d731a1..c8f84f9e 100644 --- a/apps/web/src/utils/tutorialConverter.ts +++ b/apps/web/src/utils/tutorialConverter.ts @@ -1,245 +1,231 @@ // Utility to extract and convert the existing GuidedAdditionTutorial data -import type { Tutorial } from "../types/tutorial"; -import type { Locale } from "../i18n/messages"; -import { generateAbacusInstructions } from "./abacusInstructionGenerator"; +import type { Tutorial } from '../types/tutorial' +import type { Locale } from '../i18n/messages' +import { generateAbacusInstructions } from './abacusInstructionGenerator' // Import the existing tutorial step interface to match the current structure interface ExistingTutorialStep { - id: string; - title: string; - problem: string; - description: string; - startValue: number; - targetValue: number; + id: string + title: string + problem: string + description: string + startValue: number + targetValue: number highlightBeads?: Array<{ - placeValue: number; - beadType: "heaven" | "earth"; - position?: number; - }>; - expectedAction: "add" | "remove" | "multi-step"; - actionDescription: string; + placeValue: number + beadType: 'heaven' | 'earth' + position?: number + }> + expectedAction: 'add' | 'remove' | 'multi-step' + actionDescription: string tooltip: { - content: string; - explanation: string; - }; + content: string + explanation: string + } // errorMessages removed - bead diff tooltip provides better guidance - multiStepInstructions?: string[]; + multiStepInstructions?: string[] } // The tutorial steps from GuidedAdditionTutorial.tsx (extracted from the actual file) export const guidedAdditionSteps: ExistingTutorialStep[] = [ // Phase 1: Basic Addition (1-4) { - id: "basic-1", - title: "Basic Addition: 0 + 1", - problem: "0 + 1", - description: "Start by adding your first earth bead", + id: 'basic-1', + title: 'Basic Addition: 0 + 1', + problem: '0 + 1', + description: 'Start by adding your first earth bead', startValue: 0, targetValue: 1, - highlightBeads: [{ placeValue: 0, beadType: "earth", position: 0 }], - expectedAction: "add", - actionDescription: "Click the first earth bead to move it up", + highlightBeads: [{ placeValue: 0, beadType: 'earth', position: 0 }], + expectedAction: 'add', + actionDescription: 'Click the first earth bead to move it up', tooltip: { - content: "Adding earth beads", - explanation: - "Earth beads (bottom) are worth 1 each. Push them UP to activate them.", + content: 'Adding earth beads', + explanation: 'Earth beads (bottom) are worth 1 each. Push them UP to activate them.', }, }, { - id: "basic-2", - title: "Basic Addition: 1 + 1", - problem: "1 + 1", - description: "Add the second earth bead to make 2", + id: 'basic-2', + title: 'Basic Addition: 1 + 1', + problem: '1 + 1', + description: 'Add the second earth bead to make 2', startValue: 1, targetValue: 2, - highlightBeads: [{ placeValue: 0, beadType: "earth", position: 1 }], - expectedAction: "add", - actionDescription: "Click the second earth bead to move it up", + highlightBeads: [{ placeValue: 0, beadType: 'earth', position: 1 }], + expectedAction: 'add', + actionDescription: 'Click the second earth bead to move it up', tooltip: { - content: "Building up earth beads", - explanation: - "Continue adding earth beads one by one for numbers 2, 3, and 4", + content: 'Building up earth beads', + explanation: 'Continue adding earth beads one by one for numbers 2, 3, and 4', }, }, { - id: "basic-3", - title: "Basic Addition: 2 + 1", - problem: "2 + 1", - description: "Add the third earth bead to make 3", + id: 'basic-3', + title: 'Basic Addition: 2 + 1', + problem: '2 + 1', + description: 'Add the third earth bead to make 3', startValue: 2, targetValue: 3, - highlightBeads: [{ placeValue: 0, beadType: "earth", position: 2 }], - expectedAction: "add", - actionDescription: "Click the third earth bead to move it up", + highlightBeads: [{ placeValue: 0, beadType: 'earth', position: 2 }], + expectedAction: 'add', + actionDescription: 'Click the third earth bead to move it up', tooltip: { - content: "Adding earth beads in sequence", - explanation: "Continue adding earth beads one by one until you reach 4", + content: 'Adding earth beads in sequence', + explanation: 'Continue adding earth beads one by one until you reach 4', }, }, { - id: "basic-4", - title: "Basic Addition: 3 + 1", - problem: "3 + 1", - description: "Add the fourth earth bead to make 4", + id: 'basic-4', + title: 'Basic Addition: 3 + 1', + problem: '3 + 1', + description: 'Add the fourth earth bead to make 4', startValue: 3, targetValue: 4, - highlightBeads: [{ placeValue: 0, beadType: "earth", position: 3 }], - expectedAction: "add", - actionDescription: "Click the fourth earth bead to complete 4", + highlightBeads: [{ placeValue: 0, beadType: 'earth', position: 3 }], + expectedAction: 'add', + actionDescription: 'Click the fourth earth bead to complete 4', tooltip: { - content: "Maximum earth beads", - explanation: - "Four earth beads is the maximum - next we need a different approach", + content: 'Maximum earth beads', + explanation: 'Four earth beads is the maximum - next we need a different approach', }, }, // Phase 2: Introduction to Heaven Bead { - id: "heaven-intro", - title: "Heaven Bead: 0 + 5", - problem: "0 + 5", - description: "Use the heaven bead to represent 5", + id: 'heaven-intro', + title: 'Heaven Bead: 0 + 5', + problem: '0 + 5', + description: 'Use the heaven bead to represent 5', startValue: 0, targetValue: 5, - highlightBeads: [{ placeValue: 0, beadType: "heaven" }], - expectedAction: "add", - actionDescription: "Click the heaven bead to activate it", + highlightBeads: [{ placeValue: 0, beadType: 'heaven' }], + expectedAction: 'add', + actionDescription: 'Click the heaven bead to activate it', tooltip: { - content: "Heaven bead = 5", - explanation: "The single bead above the bar represents 5", + content: 'Heaven bead = 5', + explanation: 'The single bead above the bar represents 5', }, }, { - id: "heaven-plus-earth", - title: "Combining: 5 + 1", - problem: "5 + 1", - description: "Add 1 to 5 by activating one earth bead", + id: 'heaven-plus-earth', + title: 'Combining: 5 + 1', + problem: '5 + 1', + description: 'Add 1 to 5 by activating one earth bead', startValue: 5, targetValue: 6, - highlightBeads: [{ placeValue: 0, beadType: "earth", position: 0 }], - expectedAction: "add", - actionDescription: "Click the first earth bead to make 6", + highlightBeads: [{ placeValue: 0, beadType: 'earth', position: 0 }], + expectedAction: 'add', + actionDescription: 'Click the first earth bead to make 6', tooltip: { - content: "Heaven + Earth = 6", - explanation: - "When you have room in the earth section, simply add directly", + content: 'Heaven + Earth = 6', + explanation: 'When you have room in the earth section, simply add directly', }, }, // Phase 3: Five Complements (when earth section is full) { - id: "complement-intro", - title: "Five Complement: 3 + 4", - problem: "3 + 4", - description: - "Need to add 4, but only have 1 earth bead space. Use complement: 4 = 5 - 1", + id: 'complement-intro', + title: 'Five Complement: 3 + 4', + problem: '3 + 4', + description: 'Need to add 4, but only have 1 earth bead space. Use complement: 4 = 5 - 1', startValue: 3, targetValue: 7, highlightBeads: [ - { placeValue: 0, beadType: "heaven" }, - { placeValue: 0, beadType: "earth", position: 0 }, + { placeValue: 0, beadType: 'heaven' }, + { placeValue: 0, beadType: 'earth', position: 0 }, ], - expectedAction: "multi-step", - actionDescription: "First add heaven bead (5), then remove 1 earth bead", + expectedAction: 'multi-step', + actionDescription: 'First add heaven bead (5), then remove 1 earth bead', multiStepInstructions: [ - "Click the heaven bead to add 5", - "Click the first earth bead to remove 1", + 'Click the heaven bead to add 5', + 'Click the first earth bead to remove 1', ], tooltip: { - content: "Five Complement: 4 = 5 - 1", - explanation: - "When you need to add 4 but only have 1 space, use: add 5, remove 1", + content: 'Five Complement: 4 = 5 - 1', + explanation: 'When you need to add 4 but only have 1 space, use: add 5, remove 1', }, }, { - id: "complement-2", - title: "Five Complement: 2 + 3", - problem: "2 + 3", - description: "Add 3 to make 5 by using complement: 3 = 5 - 2", + id: 'complement-2', + title: 'Five Complement: 2 + 3', + problem: '2 + 3', + description: 'Add 3 to make 5 by using complement: 3 = 5 - 2', startValue: 2, targetValue: 5, highlightBeads: [ - { placeValue: 0, beadType: "heaven" }, - { placeValue: 0, beadType: "earth", position: 0 }, - { placeValue: 0, beadType: "earth", position: 1 }, + { placeValue: 0, beadType: 'heaven' }, + { placeValue: 0, beadType: 'earth', position: 0 }, + { placeValue: 0, beadType: 'earth', position: 1 }, ], - expectedAction: "multi-step", - actionDescription: "Add heaven bead (5), then remove 2 earth beads", + expectedAction: 'multi-step', + actionDescription: 'Add heaven bead (5), then remove 2 earth beads', multiStepInstructions: [ - "Click the heaven bead to add 5", - "Click the first earth bead to remove it", - "Click the second earth bead to remove it", + 'Click the heaven bead to add 5', + 'Click the first earth bead to remove it', + 'Click the second earth bead to remove it', ], tooltip: { - content: "Five Complement: 3 = 5 - 2", - explanation: "To add 3, think: 3 = 5 - 2, so add 5 and take away 2", + content: 'Five Complement: 3 = 5 - 2', + explanation: 'To add 3, think: 3 = 5 - 2, so add 5 and take away 2', }, }, // Phase 4: More complex combinations { - id: "complex-1", - title: "Complex: 6 + 2", - problem: "6 + 2", - description: "Add 2 more to 6 (which has heaven + 1 earth)", + id: 'complex-1', + title: 'Complex: 6 + 2', + problem: '6 + 2', + description: 'Add 2 more to 6 (which has heaven + 1 earth)', startValue: 6, targetValue: 8, highlightBeads: [ - { placeValue: 0, beadType: "earth", position: 1 }, - { placeValue: 0, beadType: "earth", position: 2 }, + { placeValue: 0, beadType: 'earth', position: 1 }, + { placeValue: 0, beadType: 'earth', position: 2 }, ], - expectedAction: "add", - actionDescription: "Add two more earth beads", + expectedAction: 'add', + actionDescription: 'Add two more earth beads', tooltip: { - content: "Direct addition when possible", - explanation: - "When you have space in the earth section, just add directly", + content: 'Direct addition when possible', + explanation: 'When you have space in the earth section, just add directly', }, }, { - id: "complex-2", - title: "Complex: 7 + 4", - problem: "7 + 4", - description: - "Add 4 to 7, but no room for 4 earth beads. Use complement again", + id: 'complex-2', + title: 'Complex: 7 + 4', + problem: '7 + 4', + description: 'Add 4 to 7, but no room for 4 earth beads. Use complement again', startValue: 7, targetValue: 11, highlightBeads: [ - { placeValue: 1, beadType: "heaven" }, - { placeValue: 0, beadType: "heaven" }, - { placeValue: 0, beadType: "earth", position: 0 }, - { placeValue: 0, beadType: "earth", position: 1 }, + { placeValue: 1, beadType: 'heaven' }, + { placeValue: 0, beadType: 'heaven' }, + { placeValue: 0, beadType: 'earth', position: 0 }, + { placeValue: 0, beadType: 'earth', position: 1 }, ], - expectedAction: "multi-step", - actionDescription: - "Add tens heaven, then clear ones place: remove heaven + 2 earth", + expectedAction: 'multi-step', + actionDescription: 'Add tens heaven, then clear ones place: remove heaven + 2 earth', multiStepInstructions: [ - "Click the heaven bead in the tens column (left)", - "Click the heaven bead in the ones column to remove it", - "Click the first earth bead to remove it", - "Click the second earth bead to remove it", + 'Click the heaven bead in the tens column (left)', + 'Click the heaven bead in the ones column to remove it', + 'Click the first earth bead to remove it', + 'Click the second earth bead to remove it', ], tooltip: { - content: "Carrying to tens place", - explanation: "7 + 4 = 11, which needs the tens column heaven bead", + content: 'Carrying to tens place', + explanation: '7 + 4 = 11, which needs the tens column heaven bead', }, }, -]; +] // Convert the existing tutorial format to our new format -export function convertGuidedAdditionTutorial( - tutorialMessages: Record, -): Tutorial { +export function convertGuidedAdditionTutorial(tutorialMessages: Record): Tutorial { // Convert existing static steps to progressive step data const convertedSteps = guidedAdditionSteps.map((step) => { // Generate progressive instruction data - const generatedInstruction = generateAbacusInstructions( - step.startValue, - step.targetValue, - ); + const generatedInstruction = generateAbacusInstructions(step.startValue, step.targetValue) // Get translated strings for this step - const stepTranslations = tutorialMessages.steps?.[step.id] || {}; + const stepTranslations = tutorialMessages.steps?.[step.id] || {} return { ...step, @@ -252,8 +238,7 @@ export function convertGuidedAdditionTutorial( generatedInstruction.actionDescription, tooltip: { content: stepTranslations.tooltip?.content || step.tooltip.content, - explanation: - stepTranslations.tooltip?.explanation || step.tooltip.explanation, + explanation: stepTranslations.tooltip?.explanation || step.tooltip.explanation, }, multiStepInstructions: stepTranslations.multiStepInstructions || @@ -264,104 +249,95 @@ export function convertGuidedAdditionTutorial( totalSteps: generatedInstruction.totalSteps, // Update action description if multi-step was generated expectedAction: generatedInstruction.expectedAction, - }; - }); + } + }) // Create a smaller test set for easier navigation - const testSteps = convertedSteps.slice(0, 8); // Just first 8 steps for testing + const testSteps = convertedSteps.slice(0, 8) // Just first 8 steps for testing const tutorial: Tutorial = { - id: "guided-addition-tutorial", - title: "Progressive Multi-Step Tutorial", + id: 'guided-addition-tutorial', + title: 'Progressive Multi-Step Tutorial', description: - "Learn basic addition on the soroban abacus with progressive step-by-step guidance, direction indicators, and pedagogical decomposition", - category: "Basic Operations", - difficulty: "beginner", + 'Learn basic addition on the soroban abacus with progressive step-by-step guidance, direction indicators, and pedagogical decomposition', + category: 'Basic Operations', + difficulty: 'beginner', estimatedDuration: 15, // minutes steps: testSteps, tags: [ - "addition", - "basic", - "earth beads", - "heaven beads", - "complements", - "progressive", - "step-by-step", + 'addition', + 'basic', + 'earth beads', + 'heaven beads', + 'complements', + 'progressive', + 'step-by-step', ], - author: "Soroban Abacus System", - version: "2.0.0", - createdAt: new Date("2024-01-01"), + author: 'Soroban Abacus System', + version: '2.0.0', + createdAt: new Date('2024-01-01'), updatedAt: new Date(), isPublished: true, - }; + } - return tutorial; + return tutorial } // Helper to validate that the existing tutorial steps work with our new interfaces -export function validateTutorialConversion( - tutorialMessages: Record, -): { - isValid: boolean; - errors: string[]; +export function validateTutorialConversion(tutorialMessages: Record): { + isValid: boolean + errors: string[] } { - const errors: string[] = []; + const errors: string[] = [] try { - const tutorial = convertGuidedAdditionTutorial(tutorialMessages); + const tutorial = convertGuidedAdditionTutorial(tutorialMessages) // Basic validation if (!tutorial.id || !tutorial.title || !tutorial.steps.length) { - errors.push("Missing required tutorial fields"); + errors.push('Missing required tutorial fields') } // Validate each step tutorial.steps.forEach((step, index) => { if (!step.id || !step.title || !step.problem) { - errors.push(`Step ${index + 1}: Missing required fields`); + errors.push(`Step ${index + 1}: Missing required fields`) } - if ( - typeof step.startValue !== "number" || - typeof step.targetValue !== "number" - ) { - errors.push(`Step ${index + 1}: Invalid start or target value`); + if (typeof step.startValue !== 'number' || typeof step.targetValue !== 'number') { + errors.push(`Step ${index + 1}: Invalid start or target value`) } if (!step.tooltip?.content || !step.tooltip?.explanation) { - errors.push(`Step ${index + 1}: Missing tooltip content`); + errors.push(`Step ${index + 1}: Missing tooltip content`) } // errorMessages validation removed - no longer needed if ( - step.expectedAction === "multi-step" && + step.expectedAction === 'multi-step' && (!step.multiStepInstructions || step.multiStepInstructions.length === 0) ) { - errors.push( - `Step ${index + 1}: Multi-step action missing instructions`, - ); + errors.push(`Step ${index + 1}: Multi-step action missing instructions`) } - }); + }) } catch (error) { - errors.push(`Conversion failed: ${error}`); + errors.push(`Conversion failed: ${error}`) } return { isValid: errors.length === 0, errors, - }; + } } // Helper to export tutorial data for use in the editor -export function getTutorialForEditor( - tutorialMessages: Record, -): Tutorial { - const validation = validateTutorialConversion(tutorialMessages); +export function getTutorialForEditor(tutorialMessages: Record): Tutorial { + const validation = validateTutorialConversion(tutorialMessages) if (!validation.isValid) { - console.warn("Tutorial validation errors:", validation.errors); + console.warn('Tutorial validation errors:', validation.errors) } - return convertGuidedAdditionTutorial(tutorialMessages); + return convertGuidedAdditionTutorial(tutorialMessages) } diff --git a/apps/web/src/utils/unifiedStepGenerator.ts b/apps/web/src/utils/unifiedStepGenerator.ts index cef6e2fa..420b90a9 100644 --- a/apps/web/src/utils/unifiedStepGenerator.ts +++ b/apps/web/src/utils/unifiedStepGenerator.ts @@ -7,157 +7,153 @@ import { calculateBeadChanges, numberToAbacusState, type StepBeadHighlight, -} from "./abacusInstructionGenerator"; +} from './abacusInstructionGenerator' -export type PedagogicalRule = - | "Direct" - | "FiveComplement" - | "TenComplement" - | "Cascade"; +export type PedagogicalRule = 'Direct' | 'FiveComplement' | 'TenComplement' | 'Cascade' export interface SegmentReadable { - title: string; // "Make 10 — ones" or "Make 10 (carry) — ones" - subtitle?: string; // "Using pairs that make 10" - chips: Array<{ label: string; value: string }>; - why: string[]; // short, plain bullets - carryPath?: string; // "Tens is 9 → hundreds +1; tens → 0" - stepsFriendly: string[]; // bead verbs for each subterm - showMath?: { lines: string[] }; // ["We take away 5 here (that's 10 minus 5)."] + title: string // "Make 10 — ones" or "Make 10 (carry) — ones" + subtitle?: string // "Using pairs that make 10" + chips: Array<{ label: string; value: string }> + why: string[] // short, plain bullets + carryPath?: string // "Tens is 9 → hundreds +1; tens → 0" + stepsFriendly: string[] // bead verbs for each subterm + showMath?: { lines: string[] } // ["We take away 5 here (that's 10 minus 5)."] /** NEW: one or two sentences that explain the move in plain language */ - summary: string; + summary: string /** NEW: dev-only self-check of the summary against the segment's guards */ - validation?: { ok: boolean; issues: string[] }; + validation?: { ok: boolean; issues: string[] } } export interface SegmentDecision { /** Short, machine-readable rule fired at this segment */ - rule: PedagogicalRule; + rule: PedagogicalRule /** Guard conditions that selected this rule */ - conditions: string[]; // e.g., ["a+d=6 ≤ 9", "L+d=5 > 4"] + conditions: string[] // e.g., ["a+d=6 ≤ 9", "L+d=5 > 4"] /** Friendly bullets explaining the why */ - explanation: string[]; // e.g., ["No room for 3 lowers → use +5 − (5−3)"] + explanation: string[] // e.g., ["No room for 3 lowers → use +5 − (5−3)"] } export interface PedagogicalSegment { - id: string; // e.g., "P1-d4-#2" - place: number; // P - digit: number; // d - a: number; // digit currently showing at P before the segment - L: number; // lowers down at P - U: 0 | 1; // upper down? - goal: string; // "Increase tens by 4 without carry" - plan: SegmentDecision[]; // one or more rules (Cascade includes TenComplement+Cascade) + id: string // e.g., "P1-d4-#2" + place: number // P + digit: number // d + a: number // digit currently showing at P before the segment + L: number // lowers down at P + U: 0 | 1 // upper down? + goal: string // "Increase tens by 4 without carry" + plan: SegmentDecision[] // one or more rules (Cascade includes TenComplement+Cascade) /** Expression for the whole segment, e.g., "40" or "(100 - 90 - 6)" */ - expression: string; + expression: string /** Indices into the flat `steps` array that belong to this segment */ - stepIndices: number[]; + stepIndices: number[] /** Indices into the decompositionTerms list that belong to this segment */ - termIndices: number[]; + termIndices: number[] /** character range inside `fullDecomposition` spanning the expression */ - termRange: { startIndex: number; endIndex: number }; + termRange: { startIndex: number; endIndex: number } /** Segment start→end snapshot (optional but useful for UI tooltips) */ - startValue: number; - endValue: number; - startState: AbacusState; - endState: AbacusState; + startValue: number + endValue: number + startState: AbacusState + endState: AbacusState /** Learner-friendly descriptions without technical variables */ - readable: SegmentReadable; + readable: SegmentReadable } export interface TermProvenance { - rhs: number; // the addend (difference), e.g., 25 - rhsDigit: number; // e.g., 2 (for tens), 5 (for ones) - rhsPlace: number; // 1=tens, 0=ones, etc. - rhsPlaceName: string; // "tens" - rhsDigitIndex: number; // index of the digit in the addend string (for highlighting) - rhsValue: number; // digit * 10^place (e.g., 20) - groupId?: string; // same id for a complement group (e.g., +100 -90 -5) + rhs: number // the addend (difference), e.g., 25 + rhsDigit: number // e.g., 2 (for tens), 5 (for ones) + rhsPlace: number // 1=tens, 0=ones, etc. + rhsPlaceName: string // "tens" + rhsDigitIndex: number // index of the digit in the addend string (for highlighting) + rhsValue: number // digit * 10^place (e.g., 20) + groupId?: string // same id for a complement group (e.g., +100 -90 -5) // NEW: For terms that affect multiple columns (like complement operations) - termPlace?: number; // the actual place this specific term affects (overrides rhsPlace for column mapping) - termPlaceName?: string; // the name of the place this term affects - termValue?: number; // the actual value of this term (e.g., 100, -90, -5) + termPlace?: number // the actual place this specific term affects (overrides rhsPlace for column mapping) + termPlaceName?: string // the name of the place this term affects + termValue?: number // the actual value of this term (e.g., 100, -90, -5) } export interface UnifiedStepData { - stepIndex: number; + stepIndex: number // Pedagogical decomposition - the math term for this step - mathematicalTerm: string; // e.g., "10", "(5 - 1)", "-6" - termPosition: { startIndex: number; endIndex: number }; // Position in full decomposition + mathematicalTerm: string // e.g., "10", "(5 - 1)", "-6" + termPosition: { startIndex: number; endIndex: number } // Position in full decomposition // English instruction - what the user should do - englishInstruction: string; // e.g., "Click earth bead 1 in tens column" + englishInstruction: string // e.g., "Click earth bead 1 in tens column" // Expected ending state/value after this step - expectedValue: number; // e.g., 13, 17, 11 - expectedState: AbacusState; + expectedValue: number // e.g., 13, 17, 11 + expectedState: AbacusState // Bead movements for this step (for arrows/highlights) - beadMovements: StepBeadHighlight[]; + beadMovements: StepBeadHighlight[] // Validation - isValid: boolean; - validationIssues?: string[]; + isValid: boolean + validationIssues?: string[] /** Link to pedagogy segment this step belongs to */ - segmentId?: string; + segmentId?: string /** NEW: Provenance linking this term to its source digit in the addend */ - provenance?: TermProvenance; + provenance?: TermProvenance } export interface EquationAnchors { - differenceText: string; // "25" + differenceText: string // "25" rhsDigitPositions: Array<{ - digitIndex: number; - startIndex: number; - endIndex: number; - }>; + digitIndex: number + startIndex: number + endIndex: number + }> } export interface UnifiedInstructionSequence { // Overall pedagogical decomposition - fullDecomposition: string; // e.g., "3 + 14 = 3 + 10 + (5 - 1) = 17" + fullDecomposition: string // e.g., "3 + 14 = 3 + 10 + (5 - 1) = 17" // Whether the decomposition is meaningful (not redundant) - isMeaningfulDecomposition: boolean; + isMeaningfulDecomposition: boolean // Step-by-step breakdown - steps: UnifiedStepData[]; + steps: UnifiedStepData[] // Summary - startValue: number; - targetValue: number; - totalSteps: number; + startValue: number + targetValue: number + totalSteps: number /** NEW: Schema version for compatibility */ - schemaVersion?: "1" | "2"; + schemaVersion?: '1' | '2' /** NEW: High-level "chapters" that explain the why */ - segments: PedagogicalSegment[]; + segments: PedagogicalSegment[] /** NEW: Character positions for highlighting addend digits */ - equationAnchors?: EquationAnchors; + equationAnchors?: EquationAnchors } // Internal draft interface for building segments interface SegmentDraft { - id: string; - place: number; - digit: number; - a: number; - L: number; - U: 0 | 1; - plan: SegmentDecision[]; - goal: string; + id: string + place: number + digit: number + a: number + L: number + U: 0 | 1 + plan: SegmentDecision[] + goal: string /** contiguous indices into steps[] / terms[] for this segment */ - stepIndices: number[]; - termIndices: number[]; + stepIndices: number[] + termIndices: number[] // Value/state snapshots - startValue: number; - startState: AbacusState; - endValue: number; - endState: AbacusState; + startValue: number + startState: AbacusState + endValue: number + endState: AbacusState } /** @@ -165,116 +161,103 @@ interface SegmentDraft { */ function isPowerOfTen(n: number): boolean { - if (n < 1) return false; - return /^10*$/.test(n.toString()); + if (n < 1) return false + return /^10*$/.test(n.toString()) } -const isPowerOfTenGE10 = (n: number) => n >= 10 && isPowerOfTen(n); +const isPowerOfTenGE10 = (n: number) => n >= 10 && isPowerOfTen(n) function inferGoal(seg: SegmentDraft): string { - const placeName = getPlaceName(seg.place); + const placeName = getPlaceName(seg.place) switch (seg.plan[0]?.rule) { - case "Direct": - return `Increase ${placeName} by ${seg.digit} without carry`; - case "FiveComplement": - return `Add ${seg.digit} to ${placeName} using 5's complement`; - case "TenComplement": - return `Add ${seg.digit} to ${placeName} with a carry`; - case "Cascade": - return `Carry through ${placeName}+ to nearest non‑9 place`; + case 'Direct': + return `Increase ${placeName} by ${seg.digit} without carry` + case 'FiveComplement': + return `Add ${seg.digit} to ${placeName} using 5's complement` + case 'TenComplement': + return `Add ${seg.digit} to ${placeName} with a carry` + case 'Cascade': + return `Carry through ${placeName}+ to nearest non‑9 place` default: - return `Apply operation at ${placeName}`; + return `Apply operation at ${placeName}` } } -function _decisionForDirect( - a: number, - d: number, - L: number, -): SegmentDecision[] { +function _decisionForDirect(a: number, d: number, L: number): SegmentDecision[] { if (L + d <= 4) { return [ { - rule: "Direct", + rule: 'Direct', conditions: [`a+d=${a}+${d}=${a + d} ≤ 9`], - explanation: ["Fits inside this place; add earth beads directly."], + explanation: ['Fits inside this place; add earth beads directly.'], }, - ]; + ] } else { - const s = 5 - d; + const s = 5 - d return [ { - rule: "FiveComplement", - conditions: [ - `a+d=${a}+${d}=${a + d} ≤ 9`, - `L+d=${L}+${d}=${L + d} > 4`, - ], + rule: 'FiveComplement', + conditions: [`a+d=${a}+${d}=${a + d} ≤ 9`, `L+d=${L}+${d}=${L + d} > 4`], explanation: [ - "No room for that many earth beads.", + 'No room for that many earth beads.', `Use +5 − (5−${d}) = +5 − ${s}; subtraction is possible because lowers ≥ ${s}.`, ], }, - ]; + ] } } function decisionForFiveComplement(a: number, d: number): SegmentDecision[] { - const s = 5 - d; + const s = 5 - d return [ { - rule: "FiveComplement", + rule: 'FiveComplement', conditions: [`a+d=${a}+${d}=${a + d} ≤ 9`, `L+d > 4`], explanation: [ - "No room for that many earth beads.", + 'No room for that many earth beads.', `Use +5 − (5−${d}) = +5 − ${s}; subtraction is possible because lowers ≥ ${s}.`, ], }, - ]; + ] } -function decisionForTenComplement( - a: number, - d: number, - nextIs9: boolean, -): SegmentDecision[] { +function decisionForTenComplement(a: number, d: number, nextIs9: boolean): SegmentDecision[] { const base: SegmentDecision = { - rule: "TenComplement", + rule: 'TenComplement', conditions: [`a+d=${a}+${d}=${a + d} ≥ 10`, `a ≥ 10−d = ${10 - d}`], explanation: [ - "Need a carry to the next higher place.", + 'Need a carry to the next higher place.', `No borrow at this place because a ≥ ${10 - d}.`, ], - }; - if (!nextIs9) return [base]; + } + if (!nextIs9) return [base] return [ base, { - rule: "Cascade", - conditions: ["next place is 9 ⇒ ripple carry"], - explanation: ["Increment nearest non‑9 place; clear intervening 9s."], + rule: 'Cascade', + conditions: ['next place is 9 ⇒ ripple carry'], + explanation: ['Increment nearest non‑9 place; clear intervening 9s.'], }, - ]; + ] } function formatSegmentExpression(terms: string[]): string { - if (terms.length === 0) return ""; + if (terms.length === 0) return '' - const positives = terms.filter((t) => !t.startsWith("-")); - const negatives = terms - .filter((t) => t.startsWith("-")) - .map((t) => t.slice(1)); + const positives = terms.filter((t) => !t.startsWith('-')) + const negatives = terms.filter((t) => t.startsWith('-')).map((t) => t.slice(1)) // All positive → join with pluses (no parentheses) if (negatives.length === 0) { - return positives.join(" + "); + return positives.join(' + ') } // Complement group → (pos - n1 - n2 - …) - return `(${positives[0]} - ${negatives.join(" - ")})`; + return `(${positives[0]} - ${negatives.join(' - ')})` } function _formatSegmentGoal(digit: number, placeValue: number): string { - const placeName = getPlaceName(placeValue); - return `Add ${digit} to ${placeName}`; + const placeName = getPlaceName(placeValue) + return `Add ${digit} to ${placeName}` } function generateSegmentReadable( @@ -286,142 +269,127 @@ function generateSegmentReadable( steps: UnifiedStepData[], stepIndices: number[], startState: AbacusState, - _targetState: AbacusState, + _targetState: AbacusState ): SegmentReadable { - const placeName = getPlaceName(place); - const hasCascade = plan.some((p) => p.rule === "Cascade"); + const placeName = getPlaceName(place) + const hasCascade = plan.some((p) => p.rule === 'Cascade') // Pull first available provenance from this segment's steps - const provenance = stepIndices.map((i) => steps[i]?.provenance).find(Boolean); + const provenance = stepIndices.map((i) => steps[i]?.provenance).find(Boolean) // Helper numbers - const s5 = 5 - digit; - const s10 = 10 - digit; - const nextPlaceName = getPlaceName(place + 1); + const s5 = 5 - digit + const s10 = 10 - digit + const nextPlaceName = getPlaceName(place + 1) // Title is short + kid-friendly const title = - rule === "Direct" + rule === 'Direct' ? `Add ${digit} — ${placeName}` - : rule === "FiveComplement" + : rule === 'FiveComplement' ? `Make 5 — ${placeName}` - : rule === "TenComplement" + : rule === 'TenComplement' ? hasCascade ? `Make 10 (carry) — ${placeName}` : `Make 10 — ${placeName}` - : rule === "Cascade" + : rule === 'Cascade' ? `Carry ripple — ${placeName}` - : `Strategy — ${placeName}`; + : `Strategy — ${placeName}` // Minimal chips (0–2), provenance first if present - const chips: Array<{ label: string; value: string }> = []; + const chips: Array<{ label: string; value: string }> = [] if (provenance) { chips.push({ - label: "From addend", + label: 'From addend', value: `${provenance.rhsDigit} ${provenance.rhsPlaceName}`, - }); + }) } - chips.push({ label: "Rod shows", value: `${currentDigit}` }); + chips.push({ label: 'Rod shows', value: `${currentDigit}` }) // Carry path (kept terse) - let carryPath: string | undefined; - if (rule === "TenComplement") { + let carryPath: string | undefined + if (rule === 'TenComplement') { if (hasCascade) { - const nextPlace = place + 1; + const nextPlace = place + 1 const nextVal = - (startState[nextPlace]?.heavenActive ? 5 : 0) + - (startState[nextPlace]?.earthActive || 0); + (startState[nextPlace]?.heavenActive ? 5 : 0) + (startState[nextPlace]?.earthActive || 0) if (nextVal === 9) { // Find highest non‑9 to name the landing place - const maxPlace = - Math.max(0, ...Object.keys(startState).map(Number)) + 2; - let k = nextPlace + 1; + const maxPlace = Math.max(0, ...Object.keys(startState).map(Number)) + 2 + let k = nextPlace + 1 for (; k <= maxPlace; k++) { - const v = - (startState[k]?.heavenActive ? 5 : 0) + - (startState[k]?.earthActive || 0); - if (v !== 9) break; + const v = (startState[k]?.heavenActive ? 5 : 0) + (startState[k]?.earthActive || 0) + if (v !== 9) break } - const landingIsNewHighest = k > maxPlace; - const toName = landingIsNewHighest - ? "next higher place" - : getPlaceName(k); - carryPath = `${getPlaceName(nextPlace)} is 9 ⇒ ${toName} +1; clear 9s`; + const landingIsNewHighest = k > maxPlace + const toName = landingIsNewHighest ? 'next higher place' : getPlaceName(k) + carryPath = `${getPlaceName(nextPlace)} is 9 ⇒ ${toName} +1; clear 9s` } else { - carryPath = `${nextPlaceName} +1`; + carryPath = `${nextPlaceName} +1` } } else { - carryPath = `${nextPlaceName} +1`; + carryPath = `${nextPlaceName} +1` } } // Steps (kept for the expandable "details" UI) const stepsFriendly = stepIndices .map((i) => steps[i]?.englishInstruction) - .filter(Boolean) as string[]; + .filter(Boolean) as string[] // Semantic, 1–2 sentence summary - let summary = ""; - if (rule === "Direct") { + let summary = '' + if (rule === 'Direct') { if (digit <= 4) { - summary = `Add ${digit} to the ${placeName}. It fits here, so just move ${digit} lower bead${digit > 1 ? "s" : ""}.`; + summary = `Add ${digit} to the ${placeName}. It fits here, so just move ${digit} lower bead${digit > 1 ? 's' : ''}.` } else { - const rest = digit - 5; - summary = `Add ${digit} to the ${placeName} using the heaven bead: +5${rest ? ` + ${rest}` : ""}. No carry needed.`; + const rest = digit - 5 + summary = `Add ${digit} to the ${placeName} using the heaven bead: +5${rest ? ` + ${rest}` : ''}. No carry needed.` } - } else if (rule === "FiveComplement") { - summary = `Add ${digit} to the ${placeName}, but there isn't room for that many lower beads. Use 5's friend: press the heaven bead (5) and lift ${s5} — that's +5 − ${s5}.`; - } else if (rule === "TenComplement") { + } else if (rule === 'FiveComplement') { + summary = `Add ${digit} to the ${placeName}, but there isn't room for that many lower beads. Use 5's friend: press the heaven bead (5) and lift ${s5} — that's +5 − ${s5}.` + } else if (rule === 'TenComplement') { if (hasCascade) { - summary = `Add ${digit} to the ${placeName} to make 10. Carry to ${nextPlaceName}; because the next rod is 9, the carry ripples up, then take ${s10} here (that's +10 − ${s10}).`; + summary = `Add ${digit} to the ${placeName} to make 10. Carry to ${nextPlaceName}; because the next rod is 9, the carry ripples up, then take ${s10} here (that's +10 − ${s10}).` } else { - summary = `Add ${digit} to the ${placeName} to make 10: carry 1 to ${nextPlaceName} and take ${s10} here (that's +10 − ${s10}).`; + summary = `Add ${digit} to the ${placeName} to make 10: carry 1 to ${nextPlaceName} and take ${s10} here (that's +10 − ${s10}).` } } else { - summary = `Apply the strategy on the ${placeName}.`; + summary = `Apply the strategy on the ${placeName}.` } // Short subtitle (optional, reused from your rule badges) const subtitle = - rule === "Direct" + rule === 'Direct' ? digit <= 4 - ? "Simple move" - : "Heaven bead helps" - : rule === "FiveComplement" + ? 'Simple move' + : 'Heaven bead helps' + : rule === 'FiveComplement' ? "Using 5's friend" - : rule === "TenComplement" + : rule === 'TenComplement' ? "Using 10's friend" - : undefined; + : undefined // Tiny, dev-only validation of the summary against the selected rule - const issues: string[] = []; - const guards = plan.flatMap((p) => p.conditions); - if ( - rule === "FiveComplement" && - !guards.some((g) => /L\s*\+\s*d.*>\s*4/.test(g)) - ) { - issues.push("FiveComplement summary emitted but guard L+d>4 not present"); + const issues: string[] = [] + const guards = plan.flatMap((p) => p.conditions) + if (rule === 'FiveComplement' && !guards.some((g) => /L\s*\+\s*d.*>\s*4/.test(g))) { + issues.push('FiveComplement summary emitted but guard L+d>4 not present') } - if ( - rule === "TenComplement" && - !guards.some((g) => /a\s*\+\s*d.*(≥|>=)\s*10/.test(g)) - ) { - issues.push("TenComplement summary emitted but guard a+d≥10 not present"); + if (rule === 'TenComplement' && !guards.some((g) => /a\s*\+\s*d.*(≥|>=)\s*10/.test(g))) { + issues.push('TenComplement summary emitted but guard a+d≥10 not present') } - if ( - rule === "Direct" && - !guards.some((g) => /a\s*\+\s*d.*(≤|<=)\s*9/.test(g)) - ) { - issues.push("Direct summary emitted but guard a+d≤9 not present"); + if (rule === 'Direct' && !guards.some((g) => /a\s*\+\s*d.*(≤|<=)\s*9/.test(g))) { + issues.push('Direct summary emitted but guard a+d≤9 not present') } - const validation = { ok: issues.length === 0, issues }; + const validation = { ok: issues.length === 0, issues } // Minimal "show the math" for students who want it - const showMathLines: string[] = []; - if (rule === "FiveComplement") { - showMathLines.push(`+5 − ${s5} = +${digit} (at this rod)`); - } else if (rule === "TenComplement") { - showMathLines.push(`+10 − ${s10} = +${digit} (with a carry)`); + const showMathLines: string[] = [] + if (rule === 'FiveComplement') { + showMathLines.push(`+5 − ${s5} = +${digit} (at this rod)`) + } else if (rule === 'TenComplement') { + showMathLines.push(`+10 − ${s10} = +${digit} (with a carry)`) } return { @@ -434,36 +402,36 @@ function generateSegmentReadable( showMath: showMathLines.length ? { lines: showMathLines } : undefined, summary, validation, - }; + } } function buildSegmentsWithPositions( segmentsPlan: SegmentDraft[], fullDecomposition: string, - steps: UnifiedStepData[], + steps: UnifiedStepData[] ): PedagogicalSegment[] { return segmentsPlan.map((draft) => { const segmentTerms = draft.stepIndices .map((i) => steps[i]?.mathematicalTerm) - .filter((t): t is string => !!t); + .filter((t): t is string => !!t) // Range from steps -> exact, no string search const ranges = draft.stepIndices .map((i) => steps[i]?.termPosition) - .filter((r): r is { startIndex: number; endIndex: number } => !!r); + .filter((r): r is { startIndex: number; endIndex: number } => !!r) - let start = Math.min(...ranges.map((r) => r.startIndex)); - let end = Math.max(...ranges.map((r) => r.endIndex)); + let start = Math.min(...ranges.map((r) => r.startIndex)) + let end = Math.max(...ranges.map((r) => r.endIndex)) // Safely include surrounding parentheses for complement groups - const before = start > 0 ? fullDecomposition[start - 1] : ""; - const after = end < fullDecomposition.length ? fullDecomposition[end] : ""; - if (before === "(" && after === ")") { - start -= 1; - end += 1; + const before = start > 0 ? fullDecomposition[start - 1] : '' + const after = end < fullDecomposition.length ? fullDecomposition[end] : '' + if (before === '(' && after === ')') { + start -= 1 + end += 1 } - const primaryRule = draft.plan[0]?.rule || "Direct"; + const primaryRule = draft.plan[0]?.rule || 'Direct' return { id: draft.id, @@ -491,77 +459,73 @@ function buildSegmentsWithPositions( steps, draft.stepIndices, draft.startState, - draft.endState, + draft.endState ), - }; - }); + } + }) } function determineSegmentDecisions( digit: number, place: number, currentDigit: number, - steps: DecompositionStep[], + steps: DecompositionStep[] ): SegmentDecision[] { - const sum = currentDigit + digit; + const sum = currentDigit + digit // If there is exactly one step and it's positive, it's direct. - if (steps.length === 1 && !steps[0].operation.startsWith("-")) { + if (steps.length === 1 && !steps[0].operation.startsWith('-')) { return [ { - rule: "Direct", + rule: 'Direct', conditions: [`a+d=${currentDigit}+${digit}=${sum} ≤ 9`], - explanation: ["Fits in this place; add beads directly."], + explanation: ['Fits in this place; add beads directly.'], }, - ]; + ] } const positives = steps - .filter((s) => !s.operation.startsWith("-")) - .map((s) => parseInt(s.operation, 10)); + .filter((s) => !s.operation.startsWith('-')) + .map((s) => parseInt(s.operation, 10)) const negatives = steps - .filter((s) => s.operation.startsWith("-")) - .map((s) => Math.abs(parseInt(s.operation, 10))); + .filter((s) => s.operation.startsWith('-')) + .map((s) => Math.abs(parseInt(s.operation, 10))) // No negatives → it's a direct (possibly 5+earth remainder) entry, not complement if (negatives.length === 0) { return [ { - rule: "Direct", + rule: 'Direct', conditions: [`a+d=${currentDigit}+${digit}=${sum} ≤ 9`], - explanation: [ - "Heaven bead (5) plus lower beads: still direct addition.", - ], + explanation: ['Heaven bead (5) plus lower beads: still direct addition.'], }, - ]; + ] } // There are negatives → complement family - const hasFiveAdd = positives.some( - (v) => Number.isInteger(v / 5) && isPowerOfTen(v / 5), - ); - const tenAdd = positives.find((v) => isPowerOfTenGE10(v)); - const hasTenAdd = tenAdd !== undefined; + const hasFiveAdd = positives.some((v) => Number.isInteger(v / 5) && isPowerOfTen(v / 5)) + const tenAdd = positives.find((v) => isPowerOfTenGE10(v)) + const hasTenAdd = tenAdd !== undefined if (hasTenAdd) { - const tenAddPlace = Math.round(Math.log10(tenAdd!)); - const negPlaces = new Set(negatives.map((v) => Math.floor(Math.log10(v)))); - const cascades = tenAddPlace > place + 1 || negPlaces.size >= 2; - return decisionForTenComplement(currentDigit, digit, cascades); + const tenAddPlace = Math.round(Math.log10(tenAdd!)) + const negPlaces = new Set(negatives.map((v) => Math.floor(Math.log10(v)))) + const cascades = tenAddPlace > place + 1 || negPlaces.size >= 2 + return decisionForTenComplement(currentDigit, digit, cascades) } if (hasFiveAdd) { - return decisionForFiveComplement(currentDigit, digit); + return decisionForFiveComplement(currentDigit, digit) } // Fallback (unlikely with current generators) return [ { - rule: "Direct", + rule: 'Direct', conditions: [`processing digit ${digit} at ${getPlaceName(place)}`], - explanation: ["Standard operation."], + explanation: ['Standard operation.'], }, - ]; + ] } /** @@ -570,60 +534,52 @@ function determineSegmentDecisions( */ export function generateUnifiedInstructionSequence( startValue: number, - targetValue: number, + targetValue: number ): UnifiedInstructionSequence { - const _difference = targetValue - startValue; + const _difference = targetValue - startValue // Ensure consistent width across all state conversions to prevent place misalignment - const digits = (n: number) => - Math.max(1, Math.floor(Math.log10(Math.abs(n))) + 1); + const digits = (n: number) => Math.max(1, Math.floor(Math.log10(Math.abs(n))) + 1) const width = - Math.max( - digits(startValue), - digits(targetValue), - digits(Math.abs(targetValue - startValue)), - ) + 1; // +1 to absorb carries - const toState = (n: number) => numberToAbacusState(n, width); + Math.max(digits(startValue), digits(targetValue), digits(Math.abs(targetValue - startValue))) + + 1 // +1 to absorb carries + const toState = (n: number) => numberToAbacusState(n, width) // Step 1: Generate pedagogical decomposition terms and segment plan - const startState = toState(startValue); + const startState = toState(startValue) const { terms: decompositionTerms, segmentsPlan, decompositionSteps, - } = generateDecompositionTerms(startValue, targetValue, toState); + } = generateDecompositionTerms(startValue, targetValue, toState) // Step 3: Generate unified steps - each step computes ALL aspects simultaneously - const steps: UnifiedStepData[] = []; - let currentValue = startValue; - let currentState = { ...startState }; + const steps: UnifiedStepData[] = [] + let currentValue = startValue + let currentState = { ...startState } for (let stepIndex = 0; stepIndex < decompositionTerms.length; stepIndex++) { - const term = decompositionTerms[stepIndex]; + const term = decompositionTerms[stepIndex] // Calculate what this step should accomplish - const stepResult = calculateStepResult(currentValue, term); - const newValue = stepResult.newValue; - const newState = toState(newValue); + const stepResult = calculateStepResult(currentValue, term) + const newValue = stepResult.newValue + const newState = toState(newValue) // Find the bead movements for this specific step - const stepBeadMovements = calculateStepBeadMovements( - currentState, - newState, - stepIndex, - ); + const stepBeadMovements = calculateStepBeadMovements(currentState, newState, stepIndex) // Generate English instruction with hybrid approach // Use term-based for consistency with tests, bead-movements as validation const isComplementContext = - term === "5" && + term === '5' && stepIndex + 1 < decompositionTerms.length && - decompositionTerms[stepIndex + 1].startsWith("-"); + decompositionTerms[stepIndex + 1].startsWith('-') const englishInstruction = generateInstructionFromTerm(term, stepIndex, isComplementContext) || (stepBeadMovements.length > 0 ? generateStepInstruction(stepBeadMovements, term, stepResult) - : `perform operation: ${term}`); + : `perform operation: ${term}`) // Validate that everything is consistent const validation = validateStepConsistency( @@ -632,8 +588,8 @@ export function generateUnifiedInstructionSequence( currentValue, newValue, stepBeadMovements, - toState, - ); + toState + ) // Create the unified step data const stepData: UnifiedStepData = { @@ -647,28 +603,27 @@ export function generateUnifiedInstructionSequence( isValid: validation.isValid, validationIssues: validation.issues, provenance: decompositionSteps[stepIndex]?.provenance, - }; + } - steps.push(stepData); + steps.push(stepData) // Move to next step - currentValue = newValue; - currentState = { ...newState }; + currentValue = newValue + currentState = { ...newState } } // Step 4: Build full decomposition string and calculate term positions - const { fullDecomposition, termPositions } = - buildFullDecompositionWithPositions( - startValue, - targetValue, - decompositionTerms, - ); + const { fullDecomposition, termPositions } = buildFullDecompositionWithPositions( + startValue, + targetValue, + decompositionTerms + ) // Defensive check: ensure position count matches term count if (termPositions.length !== decompositionTerms.length) { throw new Error( - `Position count mismatch: ${termPositions.length} positions for ${decompositionTerms.length} terms`, - ); + `Position count mismatch: ${termPositions.length} positions for ${decompositionTerms.length} terms` + ) } // Step 5: Determine if this decomposition is meaningful @@ -676,37 +631,29 @@ export function generateUnifiedInstructionSequence( startValue, targetValue, decompositionTerms, - fullDecomposition, - ); + fullDecomposition + ) // Step 6: Attach term positions and segment ids to steps steps.forEach((step, idx) => { - if (termPositions[idx]) step.termPosition = termPositions[idx]; - }); + if (termPositions[idx]) step.termPosition = termPositions[idx] + }) // (optional) annotate steps with the segment they belong to segmentsPlan.forEach((seg) => seg.stepIndices.forEach((i) => { - if (steps[i]) steps[i].segmentId = seg.id; - }), - ); + if (steps[i]) steps[i].segmentId = seg.id + }) + ) // Step 7: Build segments using step positions (exact indices, robust) - const segments = buildSegmentsWithPositions( - segmentsPlan, - fullDecomposition, - steps, - ); + const segments = buildSegmentsWithPositions(segmentsPlan, fullDecomposition, steps) // Step 8: Build equation anchors for addend digit highlighting - const equationAnchors = buildEquationAnchors( - startValue, - targetValue, - fullDecomposition, - ); + const equationAnchors = buildEquationAnchors(startValue, targetValue, fullDecomposition) const result = { - schemaVersion: "2" as const, + schemaVersion: '2' as const, fullDecomposition, isMeaningfulDecomposition, steps, @@ -715,32 +662,29 @@ export function generateUnifiedInstructionSequence( targetValue, totalSteps: steps.length, equationAnchors, - }; - - // Development-time invariant checks - if ( - typeof process !== "undefined" && - process.env?.NODE_ENV !== "production" - ) { - assertSegments(result); } - return result; + // Development-time invariant checks + if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') { + assertSegments(result) + } + + return result } /** * Generate decomposition terms based on actual bead movements */ interface AbacusPlaceState { - heavenActive: boolean; - earthActive: number; // 0-4 + heavenActive: boolean + earthActive: number // 0-4 } interface DecompositionStep { - operation: string; // The mathematical term like "7", "(10 - 3)", etc. - description: string; // What this step does pedagogically - targetValue: number; // Expected value after this step - provenance?: TermProvenance; // NEW: Link to source digit + operation: string // The mathematical term like "7", "(10 - 3)", etc. + description: string // What this step does pedagogically + targetValue: number // Expected value after this step + provenance?: TermProvenance // NEW: Link to source digit } /** @@ -750,39 +694,38 @@ interface DecompositionStep { function generateDecompositionTerms( startValue: number, targetValue: number, - toState: (n: number) => AbacusState, + toState: (n: number) => AbacusState ): { - terms: string[]; - segmentsPlan: SegmentDraft[]; - decompositionSteps: DecompositionStep[]; + terms: string[] + segmentsPlan: SegmentDraft[] + decompositionSteps: DecompositionStep[] } { - const addend = targetValue - startValue; - if (addend === 0) - return { terms: [], segmentsPlan: [], decompositionSteps: [] }; + const addend = targetValue - startValue + if (addend === 0) return { terms: [], segmentsPlan: [], decompositionSteps: [] } if (addend < 0) { // TODO: Handle subtraction in separate sprint - throw new Error("Subtraction not implemented yet"); + throw new Error('Subtraction not implemented yet') } // Convert to abacus state representation with correct dimensions - let currentState = toState(startValue); - let currentValue = startValue; - const steps: DecompositionStep[] = []; - const segmentsPlan: SegmentDraft[] = []; + let currentState = toState(startValue) + let currentValue = startValue + const steps: DecompositionStep[] = [] + const segmentsPlan: SegmentDraft[] = [] // Process addend digit by digit from left to right (highest to lowest place) - const addendStr = addend.toString(); - const addendLength = addendStr.length; + const addendStr = addend.toString() + const addendLength = addendStr.length for (let digitIndex = 0; digitIndex < addendLength; digitIndex++) { - const digit = parseInt(addendStr[digitIndex], 10); - const placeValue = addendLength - 1 - digitIndex; + const digit = parseInt(addendStr[digitIndex], 10) + const placeValue = addendLength - 1 - digitIndex - if (digit === 0) continue; // Skip zeros + if (digit === 0) continue // Skip zeros // Get current digit at this place value - const currentDigitAtPlace = getDigitAtPlace(currentValue, placeValue); - const startStepCount = steps.length; + const currentDigitAtPlace = getDigitAtPlace(currentValue, placeValue) + const startStepCount = steps.length // DEBUG: Log the processing for troubleshooting // console.log(`Processing place ${placeValue}: digit=${digit}, current=${currentDigitAtPlace}, sum=${currentDigitAtPlace + digit}`) @@ -795,7 +738,7 @@ function generateDecompositionTerms( rhsPlaceName: getPlaceName(placeValue), rhsDigitIndex: digitIndex, rhsValue: digit * 10 ** placeValue, - }; + } // Apply the pedagogical algorithm decision tree const stepResult = processDigitAtPlace( @@ -805,42 +748,37 @@ function generateDecompositionTerms( currentState, addend, // Pass the full addend to determine if it's multi-place toState, // Pass consistent state converter - baseProvenance, // NEW: Pass provenance info - ); + baseProvenance // NEW: Pass provenance info + ) - const segmentId = `place-${placeValue}-digit-${digit}`; - const segmentStartValue = currentValue; - const segmentStartState = { ...currentState }; + const segmentId = `place-${placeValue}-digit-${digit}` + const segmentStartValue = currentValue + const segmentStartState = { ...currentState } const placeStart = segmentStartState[placeValue] ?? { heavenActive: false, earthActive: 0, - }; - const L = placeStart.earthActive; - const U: 0 | 1 = placeStart.heavenActive ? 1 : 0; + } + const L = placeStart.earthActive + const U: 0 | 1 = placeStart.heavenActive ? 1 : 0 // Apply the step result - steps.push(...stepResult.steps); - currentValue = stepResult.newValue; - currentState = stepResult.newState; + steps.push(...stepResult.steps) + currentValue = stepResult.newValue + currentState = stepResult.newState - const endStepCount = steps.length; + const endStepCount = steps.length const stepIndices = Array.from( { length: endStepCount - startStepCount }, - (_, i) => startStepCount + i, - ); + (_, i) => startStepCount + i + ) if (stepIndices.length === 0) { // skip building a segment with no terms/steps - continue; + continue } // Decide pedagogy - const plan = determineSegmentDecisions( - digit, - placeValue, - currentDigitAtPlace, - stepResult.steps, - ); + const plan = determineSegmentDecisions(digit, placeValue, currentDigitAtPlace, stepResult.steps) const goal = inferGoal({ id: segmentId, place: placeValue, @@ -849,14 +787,14 @@ function generateDecompositionTerms( L, U, plan, - goal: "", + goal: '', stepIndices, termIndices: stepIndices, startValue: segmentStartValue, startState: segmentStartState, endValue: currentValue, endState: { ...currentState }, - }); + }) const segment: SegmentDraft = { id: segmentId, @@ -873,14 +811,14 @@ function generateDecompositionTerms( startState: segmentStartState, endValue: currentValue, endState: { ...currentState }, - }; + } - segmentsPlan.push(segment); + segmentsPlan.push(segment) } // Convert steps to string terms for compatibility - const terms = steps.map((step) => step.operation); - return { terms, segmentsPlan, decompositionSteps: steps }; + const terms = steps.map((step) => step.operation) + return { terms, segmentsPlan, decompositionSteps: steps } } /** @@ -893,31 +831,18 @@ function processDigitAtPlace( currentState: AbacusState, addend: number, toState: (n: number) => AbacusState, - baseProvenance: TermProvenance, + baseProvenance: TermProvenance ): { steps: DecompositionStep[]; newValue: number; newState: AbacusState } { - const a = currentDigitAtPlace; - const d = digit; + const a = currentDigitAtPlace + const d = digit // Decision: Direct addition vs 10's complement if (a + d <= 9) { // Case A: Direct addition at this place - return processDirectAddition( - d, - placeValue, - currentState, - addend, - toState, - baseProvenance, - ); + return processDirectAddition(d, placeValue, currentState, addend, toState, baseProvenance) } else { // Case B: 10's complement required - return processTensComplement( - d, - placeValue, - currentState, - toState, - baseProvenance, - ); + return processTensComplement(d, placeValue, currentState, toState, baseProvenance) } } @@ -930,15 +855,15 @@ function processDirectAddition( currentState: AbacusState, _addend: number, _toState: (n: number) => AbacusState, - baseProvenance: TermProvenance, + baseProvenance: TermProvenance ): { steps: DecompositionStep[]; newValue: number; newState: AbacusState } { const placeState = currentState[placeValue] || { heavenActive: false, earthActive: 0, - }; - const L = placeState.earthActive; // Current earth beads (matches algorithm spec) - const steps: DecompositionStep[] = []; - const newState = { ...currentState }; + } + const L = placeState.earthActive // Current earth beads (matches algorithm spec) + const steps: DecompositionStep[] = [] + const newState = { ...currentState } if (digit <= 4) { // For digits 1-4: try to add earth beads directly @@ -946,7 +871,7 @@ function processDirectAddition( // Direct earth bead addition steps.push({ operation: (digit * 10 ** placeValue).toString(), - description: `Add ${digit} earth bead${digit > 1 ? "s" : ""} at place ${placeValue}`, + description: `Add ${digit} earth bead${digit > 1 ? 's' : ''} at place ${placeValue}`, targetValue: 0, // Will be calculated later provenance: { ...baseProvenance, @@ -954,19 +879,19 @@ function processDirectAddition( termPlaceName: getPlaceName(placeValue), termValue: digit * 10 ** placeValue, }, - }); + }) newState[placeValue] = { ...placeState, earthActive: L + digit, - }; + } } else if (!placeState.heavenActive) { // Use 5's complement: digit = (5 - (5 - digit)) when pedagogically valuable - const complement = 5 - digit; - const groupId = `5comp-${baseProvenance.rhsPlace}-${baseProvenance.rhsDigit}`; + const complement = 5 - digit + const groupId = `5comp-${baseProvenance.rhsPlace}-${baseProvenance.rhsDigit}` // Always show five-complement pedagogy as separate steps - const fiveValue = 5 * 10 ** placeValue; - const subtractValue = complement * 10 ** placeValue; + const fiveValue = 5 * 10 ** placeValue + const subtractValue = complement * 10 ** placeValue steps.push({ operation: fiveValue.toString(), @@ -979,7 +904,7 @@ function processDirectAddition( termPlaceName: getPlaceName(placeValue), termValue: fiveValue, }, - }); + }) steps.push({ operation: `-${subtractValue}`, @@ -992,19 +917,19 @@ function processDirectAddition( termPlaceName: getPlaceName(placeValue), termValue: -subtractValue, }, - }); + }) newState[placeValue] = { heavenActive: true, earthActive: placeState.earthActive - complement, - }; + } } } else { // For digits 5-9: always fits under Case A assumption (a + d ≤ 9) // Activate heaven bead and add remainder earth beads - const earthBeadsNeeded = digit - 5; - const fiveValue = 5 * 10 ** placeValue; - const remainderValue = earthBeadsNeeded * 10 ** placeValue; + const earthBeadsNeeded = digit - 5 + const fiveValue = 5 * 10 ** placeValue + const remainderValue = earthBeadsNeeded * 10 ** placeValue steps.push({ operation: fiveValue.toString(), @@ -1016,7 +941,7 @@ function processDirectAddition( termPlaceName: getPlaceName(placeValue), termValue: fiveValue, }, - }); + }) if (earthBeadsNeeded > 0) { steps.push({ @@ -1029,20 +954,20 @@ function processDirectAddition( termPlaceName: getPlaceName(placeValue), termValue: remainderValue, }, - }); + }) } newState[placeValue] = { heavenActive: true, earthActive: placeState.earthActive + earthBeadsNeeded, - }; + } } // Calculate new total value - const _currentValue = abacusStateToNumber(currentState); - const newValue = abacusStateToNumber(newState); + const _currentValue = abacusStateToNumber(currentState) + const newValue = abacusStateToNumber(newState) - return { steps, newValue, newState }; + return { steps, newValue, newState } } /** @@ -1053,17 +978,17 @@ function processTensComplement( placeValue: number, currentState: AbacusState, toState: (n: number) => AbacusState, - baseProvenance: TermProvenance, + baseProvenance: TermProvenance ): { steps: DecompositionStep[]; newValue: number; newState: AbacusState } { - const steps: DecompositionStep[] = []; - const complementToSubtract = 10 - digit; - const currentValue = abacusStateToNumber(currentState); + const steps: DecompositionStep[] = [] + const complementToSubtract = 10 - digit + const currentValue = abacusStateToNumber(currentState) // Check if this requires cascading (next place is 9) - const nextPlaceDigit = getDigitAtPlace(currentValue, placeValue + 1); - const requiresCascading = nextPlaceDigit === 9; + const nextPlaceDigit = getDigitAtPlace(currentValue, placeValue + 1) + const requiresCascading = nextPlaceDigit === 9 - const groupId = `10comp-${baseProvenance.rhsPlace}-${baseProvenance.rhsDigit}`; + const groupId = `10comp-${baseProvenance.rhsPlace}-${baseProvenance.rhsDigit}` if (requiresCascading) { // Generate cascading complement terms in parenthesized format @@ -1072,13 +997,13 @@ function processTensComplement( placeValue, complementToSubtract, baseProvenance, - groupId, - ); - steps.push(...cascadeSteps); + groupId + ) + steps.push(...cascadeSteps) } else { // Simple ten-complement: generate separate add/subtract steps - const higherPlaceValue = 10 ** (placeValue + 1); - const subtractValue = complementToSubtract * 10 ** placeValue; + const higherPlaceValue = 10 ** (placeValue + 1) + const subtractValue = complementToSubtract * 10 ** placeValue steps.push({ operation: higherPlaceValue.toString(), @@ -1091,7 +1016,7 @@ function processTensComplement( termPlaceName: getPlaceName(placeValue + 1), termValue: higherPlaceValue, }, - }); + }) steps.push({ operation: `-${subtractValue}`, @@ -1104,17 +1029,17 @@ function processTensComplement( termPlaceName: getPlaceName(placeValue), termValue: -subtractValue, }, - }); + }) } // Calculate new value mathematically - const newValue = currentValue + digit * 10 ** placeValue; + const newValue = currentValue + digit * 10 ** placeValue return { steps, newValue, newState: toState(newValue), - }; + } } /** @@ -1125,23 +1050,19 @@ function generateCascadeComplementSteps( startPlace: number, onesComplement: number, baseProvenance: TermProvenance, - groupId: string, + groupId: string ): DecompositionStep[] { - const steps: DecompositionStep[] = []; + const steps: DecompositionStep[] = [] // First, add to the highest non-9 place - let checkPlace = startPlace + 1; - const maxCheck = - Math.max(1, Math.floor(Math.log10(Math.max(1, currentValue))) + 1) + 2; - while ( - getDigitAtPlace(currentValue, checkPlace) === 9 && - checkPlace <= maxCheck - ) { - checkPlace += 1; + let checkPlace = startPlace + 1 + const maxCheck = Math.max(1, Math.floor(Math.log10(Math.max(1, currentValue))) + 1) + 2 + while (getDigitAtPlace(currentValue, checkPlace) === 9 && checkPlace <= maxCheck) { + checkPlace += 1 } // Add 1 to the highest place (this creates the cascade) - const higherPlaceValue = 10 ** checkPlace; + const higherPlaceValue = 10 ** checkPlace steps.push({ operation: higherPlaceValue.toString(), description: `Add 1 to ${getPlaceName(checkPlace)} (cascade trigger)`, @@ -1153,13 +1074,13 @@ function generateCascadeComplementSteps( termPlaceName: getPlaceName(checkPlace), termValue: higherPlaceValue, }, - }); + }) // Clear all the 9s in between (working downward) for (let clearPlace = checkPlace - 1; clearPlace > startPlace; clearPlace--) { - const digitAtClearPlace = getDigitAtPlace(currentValue, clearPlace); + const digitAtClearPlace = getDigitAtPlace(currentValue, clearPlace) if (digitAtClearPlace === 9) { - const clearValue = 9 * 10 ** clearPlace; + const clearValue = 9 * 10 ** clearPlace steps.push({ operation: `-${clearValue}`, description: `Remove 9 from ${getPlaceName(clearPlace)} (cascade)`, @@ -1171,12 +1092,12 @@ function generateCascadeComplementSteps( termPlaceName: getPlaceName(clearPlace), termValue: -clearValue, }, - }); + }) } } // Finally, subtract at the original place - const onesSubtractValue = onesComplement * 10 ** startPlace; + const onesSubtractValue = onesComplement * 10 ** startPlace steps.push({ operation: `-${onesSubtractValue}`, description: `Remove ${onesComplement} earth beads (ten's complement)`, @@ -1188,9 +1109,9 @@ function generateCascadeComplementSteps( termPlaceName: getPlaceName(startPlace), termValue: -onesSubtractValue, }, - }); + }) - return steps; + return steps } /** @@ -1199,90 +1120,88 @@ function generateCascadeComplementSteps( function generateInstructionFromTerm( term: string, _stepIndex: number, - isComplementContext: boolean = false, + isComplementContext: boolean = false ): string { // Parse the term to determine what instruction to give // Handle negative numbers FIRST - if (term.startsWith("-")) { - const value = parseInt(term.substring(1), 10); + if (term.startsWith('-')) { + const value = parseInt(term.substring(1), 10) if (value <= 4) { - return `remove ${value} earth bead${value > 1 ? "s" : ""} in ones column`; + return `remove ${value} earth bead${value > 1 ? 's' : ''} in ones column` } else if (value === 5) { - return "deactivate heaven bead"; + return 'deactivate heaven bead' } else if (value >= 6 && value <= 9) { - const e = value - 5; - return `deactivate heaven bead and remove ${e} earth bead${e > 1 ? "s" : ""} in ones column`; + const e = value - 5 + return `deactivate heaven bead and remove ${e} earth bead${e > 1 ? 's' : ''} in ones column` } else if (isPowerOfTenGE10(value)) { - const place = Math.round(Math.log10(value)); - return `remove 1 from ${getPlaceName(place)}`; + const place = Math.round(Math.log10(value)) + return `remove 1 from ${getPlaceName(place)}` } else if (value >= 10 && !isPowerOfTenGE10(value)) { - const place = Math.floor(Math.log10(value)); - const digit = Math.floor(value / 10 ** place); - if (digit === 5) - return `deactivate heaven bead in ${getPlaceName(place)} column`; + const place = Math.floor(Math.log10(value)) + const digit = Math.floor(value / 10 ** place) + if (digit === 5) return `deactivate heaven bead in ${getPlaceName(place)} column` if (digit > 5) - return `deactivate heaven bead and remove ${digit - 5} earth beads in ${getPlaceName(place)} column`; + return `deactivate heaven bead and remove ${digit - 5} earth beads in ${getPlaceName(place)} column` // (digit 6..9 handled above; digit 1..4 would be rare here) - return `remove ${digit} from ${getPlaceName(place)}`; + return `remove ${digit} from ${getPlaceName(place)}` } } // Handle simple positive numbers - const value = parseInt(term, 10); + const value = parseInt(term, 10) if (!Number.isNaN(value) && value > 0) { if (value === 5) { - return isComplementContext ? "add 5" : "activate heaven bead"; + return isComplementContext ? 'add 5' : 'activate heaven bead' } else if (value <= 4) { - return `add ${value} earth bead${value > 1 ? "s" : ""} in ones column`; + return `add ${value} earth bead${value > 1 ? 's' : ''} in ones column` } else if (value >= 6 && value <= 9) { - const earthBeads = value - 5; - return `activate heaven bead and add ${earthBeads} earth beads in ones column`; + const earthBeads = value - 5 + return `activate heaven bead and add ${earthBeads} earth beads in ones column` } else if (isPowerOfTenGE10(value)) { - const place = Math.round(Math.log10(value)); - return `add 1 to ${getPlaceName(place)}`; + const place = Math.round(Math.log10(value)) + return `add 1 to ${getPlaceName(place)}` } else if (value >= 10 && !isPowerOfTenGE10(value)) { - const place = Math.floor(Math.log10(value)); - const digit = Math.floor(value / 10 ** place); - if (digit === 5) - return `activate heaven bead in ${getPlaceName(place)} column`; + const place = Math.floor(Math.log10(value)) + const digit = Math.floor(value / 10 ** place) + if (digit === 5) return `activate heaven bead in ${getPlaceName(place)} column` if (digit > 5) - return `activate heaven bead and add ${digit - 5} earth beads in ${getPlaceName(place)} column`; - return `add ${digit} to ${getPlaceName(place)}`; + return `activate heaven bead and add ${digit - 5} earth beads in ${getPlaceName(place)} column` + return `add ${digit} to ${getPlaceName(place)}` } } - return `perform operation: ${term}`; + return `perform operation: ${term}` } function getPlaceName(place: number): string { const names = [ - "ones", - "tens", - "hundreds", - "thousands", - "ten-thousands", - "hundred-thousands", - "millions", - ]; - return names[place] ?? `${place} place`; + 'ones', + 'tens', + 'hundreds', + 'thousands', + 'ten-thousands', + 'hundred-thousands', + 'millions', + ] + return names[place] ?? `${place} place` } /** * Helper functions */ function getDigitAtPlace(value: number, placeValue: number): number { - return Math.floor(value / 10 ** placeValue) % 10; + return Math.floor(value / 10 ** placeValue) % 10 } function abacusStateToNumber(state: AbacusState): number { - let total = 0; + let total = 0 Object.entries(state).forEach(([place, beadState]) => { - const placeNum = parseInt(place, 10); - const placeValue = (beadState.heavenActive ? 5 : 0) + beadState.earthActive; - total += placeValue * 10 ** placeNum; - }); - return total; + const placeNum = parseInt(place, 10) + const placeValue = (beadState.heavenActive ? 5 : 0) + beadState.earthActive + total += placeValue * 10 ** placeNum + }) + return total } /** @@ -1290,37 +1209,37 @@ function abacusStateToNumber(state: AbacusState): number { */ function calculateStepResult( currentValue: number, - term: string, + term: string ): { - newValue: number; - operation: "add" | "subtract"; - addAmount?: number; - subtractAmount?: number; + newValue: number + operation: 'add' | 'subtract' + addAmount?: number + subtractAmount?: number } { // Parse the term to understand the operation - if (term.startsWith("-")) { + if (term.startsWith('-')) { // Pure subtraction like "-6" - const amount = parseInt(term.substring(1), 10); + const amount = parseInt(term.substring(1), 10) return { newValue: currentValue - amount, - operation: "subtract", + operation: 'subtract', subtractAmount: amount, - }; + } } else { // Pure addition like "10" - const amount = parseInt(term, 10); + const amount = parseInt(term, 10) return { newValue: currentValue + amount, - operation: "add", + operation: 'add', addAmount: amount, - }; + } } // Fallback return { newValue: currentValue, - operation: "add", - }; + operation: 'add', + } } /** @@ -1329,46 +1248,46 @@ function calculateStepResult( function calculateStepBeadMovements( fromState: AbacusState, toState: AbacusState, - stepIndex: number, + stepIndex: number ): StepBeadHighlight[] { - const { additions, removals } = calculateBeadChanges(fromState, toState); - const movements: StepBeadHighlight[] = []; + const { additions, removals } = calculateBeadChanges(fromState, toState) + const movements: StepBeadHighlight[] = [] // Convert additions to step bead movements additions.forEach((bead, index) => { movements.push({ ...bead, stepIndex, - direction: "activate", + direction: 'activate', order: index, - }); - }); + }) + }) // Convert removals to step bead movements removals.forEach((bead, index) => { movements.push({ ...bead, stepIndex, - direction: "deactivate", + direction: 'deactivate', order: additions.length + index, - }); - }); + }) + }) // Stabilize movement ordering for consistent UI animations // Priority: higher place → heaven beads → activations first movements.sort((a, b) => { - if (a.placeValue !== b.placeValue) return b.placeValue - a.placeValue; - if (a.beadType !== b.beadType) return a.beadType === "heaven" ? -1 : 1; - if (a.direction !== b.direction) return a.direction === "activate" ? -1 : 1; - return 0; - }); + if (a.placeValue !== b.placeValue) return b.placeValue - a.placeValue + if (a.beadType !== b.beadType) return a.beadType === 'heaven' ? -1 : 1 + if (a.direction !== b.direction) return a.direction === 'activate' ? -1 : 1 + return 0 + }) // Reassign order indices after sorting movements.forEach((movement, index) => { - movement.order = index; - }); + movement.order = index + }) - return movements; + return movements } /** @@ -1377,65 +1296,57 @@ function calculateStepBeadMovements( function generateStepInstruction( beadMovements: StepBeadHighlight[], _mathematicalTerm: string, - _stepResult: any, + _stepResult: any ): string { if (beadMovements.length === 0) { - return "No bead movements required"; + return 'No bead movements required' } // Group by place and direction const byPlace: { [place: number]: { - adds: StepBeadHighlight[]; - removes: StepBeadHighlight[]; - }; - } = {}; + adds: StepBeadHighlight[] + removes: StepBeadHighlight[] + } + } = {} beadMovements.forEach((bead) => { if (!byPlace[bead.placeValue]) { - byPlace[bead.placeValue] = { adds: [], removes: [] }; + byPlace[bead.placeValue] = { adds: [], removes: [] } } - if (bead.direction === "activate") { - byPlace[bead.placeValue].adds.push(bead); + if (bead.direction === 'activate') { + byPlace[bead.placeValue].adds.push(bead) } else { - byPlace[bead.placeValue].removes.push(bead); + byPlace[bead.placeValue].removes.push(bead) } - }); + }) // Generate instruction for each place - const instructions: string[] = []; + const instructions: string[] = [] Object.keys(byPlace) .map((p) => parseInt(p, 10)) .sort((a, b) => b - a) // Pedagogical order: highest place first .forEach((place) => { - const placeName = getPlaceName(place); + const placeName = getPlaceName(place) - const placeData = byPlace[place]; + const placeData = byPlace[place] // Handle additions if (placeData.adds.length > 0) { - const instruction = generatePlaceInstruction( - placeData.adds, - "add", - placeName, - ); - instructions.push(instruction); + const instruction = generatePlaceInstruction(placeData.adds, 'add', placeName) + instructions.push(instruction) } // Handle removals if (placeData.removes.length > 0) { - const instruction = generatePlaceInstruction( - placeData.removes, - "remove", - placeName, - ); - instructions.push(instruction); + const instruction = generatePlaceInstruction(placeData.removes, 'remove', placeName) + instructions.push(instruction) } - }); + }) - return instructions.join(", then "); + return instructions.join(', then ') } /** @@ -1443,30 +1354,30 @@ function generateStepInstruction( */ function generatePlaceInstruction( beads: StepBeadHighlight[], - action: "add" | "remove", - placeName: string, + action: 'add' | 'remove', + placeName: string ): string { - const heavenBeads = beads.filter((b) => b.beadType === "heaven"); - const earthBeads = beads.filter((b) => b.beadType === "earth"); + const heavenBeads = beads.filter((b) => b.beadType === 'heaven') + const earthBeads = beads.filter((b) => b.beadType === 'earth') - const parts: string[] = []; + const parts: string[] = [] if (heavenBeads.length > 0) { parts.push( - action === "add" + action === 'add' ? `activate heaven bead in ${placeName} column` - : `deactivate heaven bead in ${placeName} column`, - ); + : `deactivate heaven bead in ${placeName} column` + ) } if (earthBeads.length > 0) { - const verb = action === "add" ? "add" : "remove"; - const count = earthBeads.length; - const beadText = count === 1 ? "earth bead" : `${count} earth beads`; - parts.push(`${verb} ${beadText} in ${placeName} column`); + const verb = action === 'add' ? 'add' : 'remove' + const count = earthBeads.length + const beadText = count === 1 ? 'earth bead' : `${count} earth beads` + parts.push(`${verb} ${beadText} in ${placeName} column`) } - return parts.join(" and "); + return parts.join(' and ') } /** @@ -1478,100 +1389,94 @@ function validateStepConsistency( startValue: number, expectedValue: number, beadMovements: StepBeadHighlight[], - toState: (n: number) => AbacusState, + toState: (n: number) => AbacusState ): { isValid: boolean; issues: string[] } { - const issues: string[] = []; + const issues: string[] = [] // Validate that bead movements produce the expected value - const startState = toState(startValue); - const expectedState = toState(expectedValue); + const startState = toState(startValue) + const expectedState = toState(expectedValue) // Apply bead movements to start state - const simulatedState = { ...startState }; + const simulatedState = { ...startState } beadMovements.forEach((movement) => { // Ensure place exists before mutating if (!simulatedState[movement.placeValue]) { simulatedState[movement.placeValue] = { heavenActive: false, earthActive: 0, - }; + } } - if (movement.direction === "activate") { - if (movement.beadType === "heaven") { - simulatedState[movement.placeValue].heavenActive = true; + if (movement.direction === 'activate') { + if (movement.beadType === 'heaven') { + simulatedState[movement.placeValue].heavenActive = true } else { - simulatedState[movement.placeValue].earthActive++; + simulatedState[movement.placeValue].earthActive++ } } else { - if (movement.beadType === "heaven") { - simulatedState[movement.placeValue].heavenActive = false; + if (movement.beadType === 'heaven') { + simulatedState[movement.placeValue].heavenActive = false } else { - simulatedState[movement.placeValue].earthActive--; + simulatedState[movement.placeValue].earthActive-- } } - }); + }) // Validate bead ranges after applying movements for (const place in simulatedState) { - const placeNum = parseInt(place, 10); - const state = simulatedState[placeNum]; + const placeNum = parseInt(place, 10) + const state = simulatedState[placeNum] if (state.earthActive < 0 || state.earthActive > 4) { - issues.push( - `Place ${place}: earth beads out of range (${state.earthActive})`, - ); + issues.push(`Place ${place}: earth beads out of range (${state.earthActive})`) } - if (typeof state.heavenActive !== "boolean") { - issues.push( - `Place ${place}: heaven bead state invalid (${state.heavenActive})`, - ); + if (typeof state.heavenActive !== 'boolean') { + issues.push(`Place ${place}: heaven bead state invalid (${state.heavenActive})`) } } // Check if simulated state matches expected state for (const place in expectedState) { - const placeNum = parseInt(place, 10); - const expected = expectedState[placeNum]; - const simulated = simulatedState[placeNum]; + const placeNum = parseInt(place, 10) + const expected = expectedState[placeNum] + const simulated = simulatedState[placeNum] if (!simulated) { - issues.push(`Place ${place}: missing in simulated state`); - continue; + issues.push(`Place ${place}: missing in simulated state`) + continue } if (expected.heavenActive !== simulated.heavenActive) { - issues.push(`Place ${place}: heaven bead mismatch`); + issues.push(`Place ${place}: heaven bead mismatch`) } if (expected.earthActive !== simulated.earthActive) { - issues.push(`Place ${place}: earth bead count mismatch`); + issues.push(`Place ${place}: earth bead count mismatch`) } } // Check for extra places in simulated state that shouldn't exist for (const place in simulatedState) { if (!(place in expectedState)) { - const placeNum = parseInt(place, 10); - const s = simulatedState[placeNum]; + const placeNum = parseInt(place, 10) + const s = simulatedState[placeNum] if (s.heavenActive || s.earthActive > 0) { - issues.push(`Place ${place}: unexpected nonzero state in simulation`); + issues.push(`Place ${place}: unexpected nonzero state in simulation`) } } } // Final numeric equivalence check - const simulatedValue = abacusStateToNumber(simulatedState); + const simulatedValue = abacusStateToNumber(simulatedState) if (simulatedValue !== expectedValue) { - issues.push( - `Numeric mismatch: simulated=${simulatedValue}, expected=${expectedValue}`, - ); + issues.push(`Numeric mismatch: simulated=${simulatedValue}, expected=${expectedValue}`) } return { isValid: issues.length === 0, issues, - }; + } } /** @@ -1580,183 +1485,174 @@ function validateStepConsistency( export function buildFullDecompositionWithPositions( startValue: number, targetValue: number, - terms: string[], + terms: string[] ): { - fullDecomposition: string; - termPositions: Array<{ startIndex: number; endIndex: number }>; + fullDecomposition: string + termPositions: Array<{ startIndex: number; endIndex: number }> } { - const difference = targetValue - startValue; + const difference = targetValue - startValue // Handle zero difference special case if (difference === 0) { return { fullDecomposition: `${startValue} + 0 = ${targetValue}`, termPositions: [], - }; + } } // Group consecutive complement terms into segments const segments: Array<{ - terms: string[]; - isComplement: boolean; - }> = []; + terms: string[] + isComplement: boolean + }> = [] - let i = 0; + let i = 0 while (i < terms.length) { - const currentTerm = terms[i]; + const currentTerm = terms[i] // Check if this starts a complement sequence (positive term followed by negative(s)) - if ( - i + 1 < terms.length && - !currentTerm.startsWith("-") && - terms[i + 1].startsWith("-") - ) { + if (i + 1 < terms.length && !currentTerm.startsWith('-') && terms[i + 1].startsWith('-')) { // Collect all consecutive negative terms after this positive term - const complementTerms = [currentTerm]; - let j = i + 1; - while (j < terms.length && terms[j].startsWith("-")) { - complementTerms.push(terms[j]); - j++; + const complementTerms = [currentTerm] + let j = i + 1 + while (j < terms.length && terms[j].startsWith('-')) { + complementTerms.push(terms[j]) + j++ } segments.push({ terms: complementTerms, isComplement: true, - }); - i = j; // Jump past all consumed terms + }) + i = j // Jump past all consumed terms } else { // Single term (not part of complement) segments.push({ terms: [currentTerm], isComplement: false, - }); - i++; + }) + i++ } } // Build decomposition string with proper segment formatting - let termString = ""; - const termPositions: Array<{ startIndex: number; endIndex: number }> = []; + let termString = '' + const termPositions: Array<{ startIndex: number; endIndex: number }> = [] segments.forEach((segment, segmentIndex) => { if (segment.isComplement) { // Format as parenthesized complement: (10 - 3) or (1000 - 900 - 90 - 2) - const positiveStr = segment.terms[0]; - const negativeStrs = segment.terms.slice(1).map((t) => t.substring(1)); // Remove - signs + const positiveStr = segment.terms[0] + const negativeStrs = segment.terms.slice(1).map((t) => t.substring(1)) // Remove - signs - const segmentStr = `(${positiveStr} - ${negativeStrs.join(" - ")})`; + const segmentStr = `(${positiveStr} - ${negativeStrs.join(' - ')})` if (segmentIndex === 0) { - termString = segmentStr; + termString = segmentStr } else { - termString += ` + ${segmentStr}`; + termString += ` + ${segmentStr}` } } else { // Single term - const term = segment.terms[0]; + const term = segment.terms[0] if (segmentIndex === 0) { - termString = term; - } else if (term.startsWith("-")) { - termString += ` ${term}`; // Keep negative sign + termString = term + } else if (term.startsWith('-')) { + termString += ` ${term}` // Keep negative sign } else { - termString += ` + ${term}`; + termString += ` + ${term}` } } - }); + }) // Build full decomposition - const leftSide = `${startValue} + ${difference} = ${startValue} + `; - const rightSide = ` = ${targetValue}`; - const fullDecomposition = leftSide + termString + rightSide; + const leftSide = `${startValue} + ${difference} = ${startValue} + ` + const rightSide = ` = ${targetValue}` + const fullDecomposition = leftSide + termString + rightSide // Calculate precise positions for each original term - let currentPos = leftSide.length; - let segmentTermIndex = 0; + let currentPos = leftSide.length + let segmentTermIndex = 0 segments.forEach((segment, segmentIndex) => { if (segment.isComplement) { // Account for " + " delimiter before complement segments (except first) if (segmentIndex > 0) { - currentPos += 3; // Skip " + " + currentPos += 3 // Skip " + " } // Position within parenthesized complement - currentPos += 1; // Skip opening '(' + currentPos += 1 // Skip opening '(' segment.terms.forEach((term, termInSegmentIndex) => { - const startIndex = currentPos; + const startIndex = currentPos if (termInSegmentIndex === 0) { // Positive term termPositions[segmentTermIndex] = { startIndex, endIndex: startIndex + term.length, - }; - currentPos += term.length; + } + currentPos += term.length } else { // Negative term (but we position on just the number part) - currentPos += 3; // Skip ' - ' - const numberStr = term.substring(1); // Remove '-' + currentPos += 3 // Skip ' - ' + const numberStr = term.substring(1) // Remove '-' termPositions[segmentTermIndex] = { startIndex: currentPos, endIndex: currentPos + numberStr.length, - }; - currentPos += numberStr.length; + } + currentPos += numberStr.length } - segmentTermIndex++; - }); + segmentTermIndex++ + }) - currentPos += 1; // Skip closing ')' + currentPos += 1 // Skip closing ')' } else { // Single term segment - const term = segment.terms[0]; + const term = segment.terms[0] if (segmentIndex > 0) { - if (term.startsWith("-")) { - currentPos += 1; // Skip ' ' before negative + if (term.startsWith('-')) { + currentPos += 1 // Skip ' ' before negative } else { - currentPos += 3; // Skip ' + ' + currentPos += 3 // Skip ' + ' } } - const isNegative = term.startsWith("-"); - const startIndex = isNegative ? currentPos + 1 : currentPos; // skip the '−' for mapping - const endIndex = isNegative - ? startIndex + (term.length - 1) - : startIndex + term.length; - termPositions[segmentTermIndex] = { startIndex, endIndex }; - currentPos += term.length; // actual text includes the '−' - segmentTermIndex++; + const isNegative = term.startsWith('-') + const startIndex = isNegative ? currentPos + 1 : currentPos // skip the '−' for mapping + const endIndex = isNegative ? startIndex + (term.length - 1) : startIndex + term.length + termPositions[segmentTermIndex] = { startIndex, endIndex } + currentPos += term.length // actual text includes the '−' + segmentTermIndex++ } - }); + }) - return { fullDecomposition, termPositions }; + return { fullDecomposition, termPositions } } function assertSegments(seq: UnifiedInstructionSequence) { // 1) Every step that has a segmentId belongs to a segment that includes it - const byId = new Map(seq.segments.map((s) => [s.id, s])); + const byId = new Map(seq.segments.map((s) => [s.id, s])) seq.steps.forEach((st, i) => { - if (!st.segmentId) return; - const seg = byId.get(st.segmentId); - if (!seg) - throw new Error(`step[${i}] has unknown segmentId ${st.segmentId}`); + if (!st.segmentId) return + const seg = byId.get(st.segmentId) + if (!seg) throw new Error(`step[${i}] has unknown segmentId ${st.segmentId}`) if (!seg.stepIndices.includes(i)) { - throw new Error( - `step[${i}] not contained in its segment ${st.segmentId}`, - ); + throw new Error(`step[${i}] not contained in its segment ${st.segmentId}`) } - }); + }) // 2) Segment ranges are contiguous and non-empty seq.segments.forEach((seg) => { if (seg.stepIndices.length === 0) { - throw new Error(`segment ${seg.id} has no steps`); + throw new Error(`segment ${seg.id} has no steps`) } if (seg.termRange.endIndex <= seg.termRange.startIndex) { - throw new Error(`segment ${seg.id} has empty term range`); + throw new Error(`segment ${seg.id} has empty term range`) } - }); + }) } /** @@ -1765,22 +1661,22 @@ function assertSegments(seq: UnifiedInstructionSequence) { function buildEquationAnchors( startValue: number, targetValue: number, - fullDecomposition: string, + fullDecomposition: string ): EquationAnchors { - const addend = targetValue - startValue; - const addendText = addend.toString(); - const expectedPrefix = `${startValue} + `; + const addend = targetValue - startValue + const addendText = addend.toString() + const expectedPrefix = `${startValue} + ` // Addend starts immediately after "startValue + " - const startIndex = expectedPrefix.length; + const startIndex = expectedPrefix.length // Optional sanity check (no throw in prod) - if (process.env.NODE_ENV !== "production") { - const head = fullDecomposition.slice(0, startIndex + addendText.length); + if (process.env.NODE_ENV !== 'production') { + const head = fullDecomposition.slice(0, startIndex + addendText.length) if (head !== `${expectedPrefix}${addendText}`) { // fall back to a search if format changes - const idx = fullDecomposition.indexOf(`${expectedPrefix}${addendText}`); + const idx = fullDecomposition.indexOf(`${expectedPrefix}${addendText}`) if (idx !== -1) { - const start = idx + expectedPrefix.length; + const start = idx + expectedPrefix.length return { differenceText: Math.abs(addend).toString(), rhsDigitPositions: Array.from(addendText).map((_, i) => ({ @@ -1788,7 +1684,7 @@ function buildEquationAnchors( startIndex: start + i, endIndex: start + i + 1, })), - }; + } } } } @@ -1800,7 +1696,7 @@ function buildEquationAnchors( startIndex: startIndex + i, endIndex: startIndex + i + 1, })), - }; + } } /** @@ -1814,21 +1710,21 @@ function isDecompositionMeaningful( startValue: number, targetValue: number, decompositionTerms: string[], - fullDecomposition: string, + fullDecomposition: string ): boolean { - const difference = targetValue - startValue; + const difference = targetValue - startValue // No change = not meaningful if (difference === 0) { - return false; + return false } // Check if we have complement expressions (parentheses) - const hasComplementOperations = fullDecomposition.includes("("); + const hasComplementOperations = fullDecomposition.includes('(') // Complement operations are always meaningful (they show soroban technique) if (hasComplementOperations) { - return true; + return true } // Multiple terms without complements can be meaningful for multi-place operations @@ -1836,38 +1732,32 @@ function isDecompositionMeaningful( if (decompositionTerms.length > 1) { // Check if it's a simple natural breakdown (like 5 + 1, 5 + 2, 5 + 3, 5 + 4) if (decompositionTerms.length === 2) { - const [first, second] = decompositionTerms; - if ( - first === "5" && - parseInt(second, 10) >= 1 && - parseInt(second, 10) <= 4 - ) { - return false; // Natural soroban representation, not pedagogically meaningful + const [first, second] = decompositionTerms + if (first === '5' && parseInt(second, 10) >= 1 && parseInt(second, 10) <= 4) { + return false // Natural soroban representation, not pedagogically meaningful } } - return true; + return true } // Single term that equals the difference = not meaningful (redundant) if (decompositionTerms.length === 1) { - const term = decompositionTerms[0]; + const term = decompositionTerms[0] if (term === Math.abs(difference).toString()) { - return false; + return false } } // For complex cases with multiple breakdowns, check if it's just restating - const decompositionPart = fullDecomposition.split(" = ")[1]?.split(" = ")[0]; + const decompositionPart = fullDecomposition.split(' = ')[1]?.split(' = ')[0] if (decompositionPart) { // If the middle part is just the same as "start + difference", it's redundant - const simplePattern = `${startValue} + ${Math.abs(difference)}`; - if ( - decompositionPart.replace(/\s/g, "") === simplePattern.replace(/\s/g, "") - ) { - return false; + const simplePattern = `${startValue} + ${Math.abs(difference)}` + if (decompositionPart.replace(/\s/g, '') === simplePattern.replace(/\s/g, '')) { + return false } } // Default to meaningful - return true; + return true } diff --git a/apps/web/src/workers/openscad.worker.ts b/apps/web/src/workers/openscad.worker.ts index 51fa85a5..1a08a049 100644 --- a/apps/web/src/workers/openscad.worker.ts +++ b/apps/web/src/workers/openscad.worker.ts @@ -1,109 +1,98 @@ /// -import { createOpenSCAD } from "openscad-wasm-prebuilt"; +import { createOpenSCAD } from 'openscad-wasm-prebuilt' -declare const self: DedicatedWorkerGlobalScope; +declare const self: DedicatedWorkerGlobalScope -let openscad: Awaited> | null = null; -let simplifiedStlData: ArrayBuffer | null = null; -let isInitializing = false; -let initPromise: Promise | null = null; +let openscad: Awaited> | null = null +let simplifiedStlData: ArrayBuffer | null = null +let isInitializing = false +let initPromise: Promise | null = null // Message types interface RenderRequest { - type: "render"; - columns: number; - scaleFactor: number; + type: 'render' + columns: number + scaleFactor: number } interface InitRequest { - type: "init"; + type: 'init' } -type WorkerRequest = RenderRequest | InitRequest; +type WorkerRequest = RenderRequest | InitRequest // Initialize OpenSCAD instance and load base STL file async function initialize() { - if (openscad) return; // Already initialized - if (isInitializing) return initPromise; // Already initializing, return existing promise + if (openscad) return // Already initialized + if (isInitializing) return initPromise // Already initializing, return existing promise - isInitializing = true; + isInitializing = true initPromise = (async () => { try { - console.log("[OpenSCAD Worker] Initializing..."); + console.log('[OpenSCAD Worker] Initializing...') // Create OpenSCAD instance - openscad = await createOpenSCAD(); - console.log("[OpenSCAD Worker] OpenSCAD WASM loaded"); + openscad = await createOpenSCAD() + console.log('[OpenSCAD Worker] OpenSCAD WASM loaded') // Fetch the simplified STL file once - const stlResponse = await fetch("/3d-models/simplified.abacus.stl"); + const stlResponse = await fetch('/3d-models/simplified.abacus.stl') if (!stlResponse.ok) { - throw new Error(`Failed to fetch STL: ${stlResponse.statusText}`); + throw new Error(`Failed to fetch STL: ${stlResponse.statusText}`) } - simplifiedStlData = await stlResponse.arrayBuffer(); - console.log( - "[OpenSCAD Worker] Simplified STL loaded", - simplifiedStlData.byteLength, - "bytes", - ); + simplifiedStlData = await stlResponse.arrayBuffer() + console.log('[OpenSCAD Worker] Simplified STL loaded', simplifiedStlData.byteLength, 'bytes') - self.postMessage({ type: "ready" }); + self.postMessage({ type: 'ready' }) } catch (error) { - console.error("[OpenSCAD Worker] Initialization failed:", error); + console.error('[OpenSCAD Worker] Initialization failed:', error) self.postMessage({ - type: "error", - error: error instanceof Error ? error.message : "Initialization failed", - }); - throw error; + type: 'error', + error: error instanceof Error ? error.message : 'Initialization failed', + }) + throw error } finally { - isInitializing = false; + isInitializing = false } - })(); + })() - return initPromise; + return initPromise } async function render(columns: number, scaleFactor: number) { // Wait for initialization if not ready if (!openscad || !simplifiedStlData) { - await initialize(); + await initialize() } if (!openscad || !simplifiedStlData) { - throw new Error("Worker not initialized"); + throw new Error('Worker not initialized') } try { - console.log( - `[OpenSCAD Worker] Rendering with columns=${columns}, scaleFactor=${scaleFactor}`, - ); + console.log(`[OpenSCAD Worker] Rendering with columns=${columns}, scaleFactor=${scaleFactor}`) // Get low-level instance for filesystem access - const instance = openscad.getInstance(); + const instance = openscad.getInstance() // Create directory if it doesn't exist try { - instance.FS.mkdir("/3d-models"); - console.log("[OpenSCAD Worker] Created /3d-models directory"); + instance.FS.mkdir('/3d-models') + console.log('[OpenSCAD Worker] Created /3d-models directory') } catch (e: any) { // Check if it's EEXIST (directory already exists) - errno 20 if (e.errno === 20) { - console.log("[OpenSCAD Worker] /3d-models directory already exists"); + console.log('[OpenSCAD Worker] /3d-models directory already exists') } else { - console.error("[OpenSCAD Worker] Failed to create directory:", e); - throw new Error( - `Failed to create /3d-models directory: ${e.message || e}`, - ); + console.error('[OpenSCAD Worker] Failed to create directory:', e) + throw new Error(`Failed to create /3d-models directory: ${e.message || e}`) } } // Write STL file - instance.FS.writeFile( - "/3d-models/simplified.abacus.stl", - new Uint8Array(simplifiedStlData), - ); - console.log("[OpenSCAD Worker] Wrote simplified STL to filesystem"); + instance.FS.writeFile('/3d-models/simplified.abacus.stl', new Uint8Array(simplifiedStlData)) + console.log('[OpenSCAD Worker] Wrote simplified STL to filesystem') // Generate the SCAD code with parameters const scadCode = ` @@ -145,80 +134,76 @@ scale([scale_factor, scale_factor, scale_factor]) { translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus(); half_abacus(); } -`; +` // Use high-level renderToStl API - console.log("[OpenSCAD Worker] Calling renderToStl..."); - const stlBuffer = await openscad.renderToStl(scadCode); - console.log( - "[OpenSCAD Worker] Rendering complete:", - stlBuffer.byteLength, - "bytes", - ); + console.log('[OpenSCAD Worker] Calling renderToStl...') + const stlBuffer = await openscad.renderToStl(scadCode) + console.log('[OpenSCAD Worker] Rendering complete:', stlBuffer.byteLength, 'bytes') // Send the result back self.postMessage( { - type: "result", + type: 'result', stl: stlBuffer, }, - [stlBuffer], - ); // Transfer ownership of the buffer + [stlBuffer] + ) // Transfer ownership of the buffer // Clean up STL file try { - instance.FS.unlink("/3d-models/simplified.abacus.stl"); + instance.FS.unlink('/3d-models/simplified.abacus.stl') } catch (e) { // Ignore cleanup errors } } catch (error) { - console.error("[OpenSCAD Worker] Rendering failed:", error); + console.error('[OpenSCAD Worker] Rendering failed:', error) // Try to get more error details - let errorMessage = "Rendering failed"; + let errorMessage = 'Rendering failed' if (error instanceof Error) { - errorMessage = error.message; - console.error("[OpenSCAD Worker] Error stack:", error.stack); + errorMessage = error.message + console.error('[OpenSCAD Worker] Error stack:', error.stack) } // Check if it's an Emscripten FS error - if (error && typeof error === "object" && "errno" in error) { - console.error("[OpenSCAD Worker] FS errno:", (error as any).errno); - console.error("[OpenSCAD Worker] FS error details:", error); + if (error && typeof error === 'object' && 'errno' in error) { + console.error('[OpenSCAD Worker] FS errno:', (error as any).errno) + console.error('[OpenSCAD Worker] FS error details:', error) } self.postMessage({ - type: "error", + type: 'error', error: errorMessage, - }); + }) } } // Message handler self.onmessage = async (event: MessageEvent) => { - const { data } = event; + const { data } = event try { switch (data.type) { - case "init": - await initialize(); - break; + case 'init': + await initialize() + break - case "render": - await render(data.columns, data.scaleFactor); - break; + case 'render': + await render(data.columns, data.scaleFactor) + break default: - console.error("[OpenSCAD Worker] Unknown message type:", data); + console.error('[OpenSCAD Worker] Unknown message type:', data) } } catch (error) { - console.error("[OpenSCAD Worker] Message handler error:", error); + console.error('[OpenSCAD Worker] Message handler error:', error) self.postMessage({ - type: "error", - error: error instanceof Error ? error.message : "Unknown error", - }); + type: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }) } -}; +} // Auto-initialize on worker start -initialize(); +initialize() diff --git a/apps/web/svg-processing-test.js b/apps/web/svg-processing-test.js index c03c2ef2..75df392f 100644 --- a/apps/web/svg-processing-test.js +++ b/apps/web/svg-processing-test.js @@ -3,110 +3,102 @@ // Test script to verify SVG processing is working // This will test both browser-side and server-side processing -const { generateSorobanSVG } = require("./src/lib/typst-soroban.ts"); +const { generateSorobanSVG } = require('./src/lib/typst-soroban.ts') async function testSVGProcessing() { - console.log("🧪 Testing SVG processing...\n"); + console.log('🧪 Testing SVG processing...\n') // Test configuration const testConfig = { number: 23, - width: "120pt", - height: "160pt", - beadShape: "diamond", - colorScheme: "place-value", + width: '120pt', + height: '160pt', + beadShape: 'diamond', + colorScheme: 'place-value', hideInactiveBeads: false, enableServerFallback: false, - }; + } try { - console.log("📋 Test Configuration:"); - console.log(JSON.stringify(testConfig, null, 2)); - console.log("\n🚀 Generating SVG...\n"); + console.log('📋 Test Configuration:') + console.log(JSON.stringify(testConfig, null, 2)) + console.log('\n🚀 Generating SVG...\n') - const svg = await generateSorobanSVG(testConfig); + const svg = await generateSorobanSVG(testConfig) - console.log("✅ SVG generated successfully!"); - console.log(`📏 SVG length: ${svg.length} characters`); + console.log('✅ SVG generated successfully!') + console.log(`📏 SVG length: ${svg.length} characters`) // Check for processing evidence - console.log("\n🔍 Checking for processing evidence:"); + console.log('\n🔍 Checking for processing evidence:') // Check viewBox - const viewBoxMatch = svg.match(/viewBox="([^"]*)"/); + const viewBoxMatch = svg.match(/viewBox="([^"]*)"/) if (viewBoxMatch) { - console.log(`✓ ViewBox found: ${viewBoxMatch[1]}`); + console.log(`✓ ViewBox found: ${viewBoxMatch[1]}`) } else { - console.log("❌ No viewBox found"); + console.log('❌ No viewBox found') } // Check for crop marks (should be present but could be removed) - const hasCropMarks = svg.includes("crop-mark://"); + const hasCropMarks = svg.includes('crop-mark://') console.log( - `${hasCropMarks ? "✓" : "❌"} Crop marks: ${hasCropMarks ? "present" : "not found"}`, - ); + `${hasCropMarks ? '✓' : '❌'} Crop marks: ${hasCropMarks ? 'present' : 'not found'}` + ) // Check for bead data attributes (evidence of processing) - const hasBeadData = svg.includes("data-bead-"); + const hasBeadData = svg.includes('data-bead-') console.log( - `${hasBeadData ? "✓" : "❌"} Bead data attributes: ${hasBeadData ? "present" : "not found"}`, - ); + `${hasBeadData ? '✓' : '❌'} Bead data attributes: ${hasBeadData ? 'present' : 'not found'}` + ) // Check for original typst elements vs processed elements - const hasTypstGroups = svg.includes('class="typst-group"'); + const hasTypstGroups = svg.includes('class="typst-group"') console.log( - `${hasTypstGroups ? "✓" : "❌"} Typst groups: ${hasTypstGroups ? "present" : "not found"}`, - ); + `${hasTypstGroups ? '✓' : '❌'} Typst groups: ${hasTypstGroups ? 'present' : 'not found'}` + ) // Extract some sample data attributes to verify processing - const dataAttrMatches = svg.match(/data-bead-[^=]+=["'][^"']*["']/g); + const dataAttrMatches = svg.match(/data-bead-[^=]+=["'][^"']*["']/g) if (dataAttrMatches && dataAttrMatches.length > 0) { - console.log(`✓ Found ${dataAttrMatches.length} bead data attributes:`); + console.log(`✓ Found ${dataAttrMatches.length} bead data attributes:`) dataAttrMatches.slice(0, 3).forEach((attr) => { - console.log(` - ${attr}`); - }); + console.log(` - ${attr}`) + }) if (dataAttrMatches.length > 3) { - console.log(` - ... and ${dataAttrMatches.length - 3} more`); + console.log(` - ... and ${dataAttrMatches.length - 3} more`) } } // Check for size optimization evidence - const dimensions = svg.match(/width="([^"]*)".*height="([^"]*)"/); + const dimensions = svg.match(/width="([^"]*)".*height="([^"]*)"/) if (dimensions) { - console.log(`✓ Dimensions: ${dimensions[1]} × ${dimensions[2]}`); + console.log(`✓ Dimensions: ${dimensions[1]} × ${dimensions[2]}`) } - console.log("\n📊 Summary:"); - console.log(`- SVG generation: ✅ SUCCESS`); - console.log(`- ViewBox optimization: ${viewBoxMatch ? "✅" : "❌"}`); - console.log(`- Bead annotation processing: ${hasBeadData ? "✅" : "❌"}`); - console.log(`- Crop mark detection: ${hasCropMarks ? "✅" : "❌"}`); + console.log('\n📊 Summary:') + console.log(`- SVG generation: ✅ SUCCESS`) + console.log(`- ViewBox optimization: ${viewBoxMatch ? '✅' : '❌'}`) + console.log(`- Bead annotation processing: ${hasBeadData ? '✅' : '❌'}`) + console.log(`- Crop mark detection: ${hasCropMarks ? '✅' : '❌'}`) if (hasBeadData && viewBoxMatch) { - console.log("\n🎯 CONCLUSION: SVG processor is working correctly!"); - console.log(" - ViewBox has been optimized for cropping"); - console.log( - " - Bead annotations have been extracted and converted to data attributes", - ); + console.log('\n🎯 CONCLUSION: SVG processor is working correctly!') + console.log(' - ViewBox has been optimized for cropping') + console.log(' - Bead annotations have been extracted and converted to data attributes') } else { - console.log( - "\n⚠️ CONCLUSION: SVG processor may not be working as expected", - ); - console.log( - " - Check the processSVG function calls in the generation pipeline", - ); + console.log('\n⚠️ CONCLUSION: SVG processor may not be working as expected') + console.log(' - Check the processSVG function calls in the generation pipeline') } // Save a sample for manual inspection - require("fs").writeFileSync("./sample-processed.svg", svg); - console.log( - "\n💾 Sample SVG saved to ./sample-processed.svg for manual inspection", - ); + require('fs').writeFileSync('./sample-processed.svg', svg) + console.log('\n💾 Sample SVG saved to ./sample-processed.svg for manual inspection') } catch (error) { - console.error("❌ Test failed:", error.message); - console.error("Stack:", error.stack); + console.error('❌ Test failed:', error.message) + console.error('Stack:', error.stack) } } // Run the test -testSVGProcessing().catch(console.error); +testSVGProcessing().catch(console.error) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 45ffed28..3ace83c6 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,7 +1,7 @@ /// -import path from "path"; -import { defineConfig } from "vitest/config"; +import path from 'path' +import { defineConfig } from 'vitest/config' export default defineConfig({ esbuild: { @@ -9,12 +9,12 @@ export default defineConfig({ }, test: { globals: true, - environment: "jsdom", - setupFiles: ["./src/test/setup.ts"], + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], }, resolve: { alias: { - "@": path.resolve(__dirname, "./src"), + '@': path.resolve(__dirname, './src'), }, }, -}); +})