Add MVP design spec and update .gitignore
Covers architecture, schema, user flows, audio engine, mnemonic validation, scoring, content data, Docker deployment, and brand implementation for the Pack 404 pilot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -216,3 +216,6 @@ public/uploads/
|
||||
forge-config.json
|
||||
.forge/
|
||||
deployment-keys/
|
||||
|
||||
# Superpowers brainstorm sessions
|
||||
.superpowers/
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
# MorseQuest MVP Design Spec
|
||||
**Date:** 2026-04-29
|
||||
**Deployment:** morsequest.keylinkit.net (Docker on VPS)
|
||||
**Purpose:** Pack 404 pilot — feedback-first, rough edges acceptable
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
### In scope (MVP)
|
||||
- Magic link email authentication (allegiance pattern)
|
||||
- Mnemonic creation system — the core differentiator
|
||||
- Novice level: 12 letters (A E T I N S H R D L U O)
|
||||
- Operator level: simple 3-letter words from Novice letters
|
||||
- Full Web Audio API + synchronized visual flash playback
|
||||
- Points, streaks, daily bonus scoring
|
||||
- Level-up flow (Novice → Operator)
|
||||
- Basic admin panel (user list, stats, per-user progress)
|
||||
- Docker deployment matching allegiance pattern
|
||||
|
||||
### Out of scope (Phase 2)
|
||||
- Levels 3–6 (General, Extra, Expert, Legend)
|
||||
- Ham radio license modules
|
||||
- Payment / Authorize.net integration
|
||||
- Leaderboards / social features
|
||||
- PWA offline mode
|
||||
- White-label / multi-tenant
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
**Frontend:** React 18 + TypeScript, Vite bundler
|
||||
**Backend:** Node.js HTTP server (no framework), better-sqlite3
|
||||
**Auth:** Magic link email via nodemailer (mail.keylinkit.net:587)
|
||||
**Deployment:** Single Docker container, node:20-alpine, named volume
|
||||
|
||||
### Project structure
|
||||
```
|
||||
morsequest/
|
||||
├── client/
|
||||
│ ├── index.html
|
||||
│ └── src/
|
||||
│ ├── main.tsx
|
||||
│ ├── App.tsx # routing: login / onboarding / game / admin
|
||||
│ ├── lib/
|
||||
│ │ ├── morse.ts # MORSE map, timing constants, word pools
|
||||
│ │ └── audio.ts # MorseAudioEngine class
|
||||
│ ├── hooks/
|
||||
│ │ ├── useAuth.ts # session token, /api/auth/me
|
||||
│ │ ├── useMorseAudio.ts # wraps MorseAudioEngine, exposes currentSignal
|
||||
│ │ └── useProgress.ts # score, streak, level from /api/progress
|
||||
│ ├── pages/
|
||||
│ │ ├── LoginPage.tsx
|
||||
│ │ ├── OnboardingPage.tsx # mnemonic builder
|
||||
│ │ ├── GamePage.tsx # practice loop
|
||||
│ │ └── AdminPage.tsx
|
||||
│ └── components/
|
||||
│ ├── FlashArea.tsx # visual signal display
|
||||
│ ├── MnemonicBuilder.tsx # step-through letter mnemonic entry
|
||||
│ ├── PracticeCard.tsx # challenge + answer input
|
||||
│ └── ScoreBar.tsx
|
||||
├── server/
|
||||
│ ├── server.js # Node HTTP, serves /client/dist, handles /api routes
|
||||
│ ├── db.js # better-sqlite3, schema init, all query functions
|
||||
│ └── mailer.js # nodemailer magic link (allegiance pattern)
|
||||
├── data/ # SQLite db lives here (Docker volume)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── package.json # root scripts: dev, build, start
|
||||
```
|
||||
|
||||
### Dev vs production
|
||||
- **Dev:** `vite dev` on :5173 with proxy → Node on :3001
|
||||
- **Production:** `npm run build` compiles React to `/client/dist`; Node serves `/client/dist` as static + all `/api` routes on port 8080
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
`better-sqlite3`, schema created on startup in `db.js`.
|
||||
|
||||
```sql
|
||||
-- Auth (allegiance pattern)
|
||||
CREATE TABLE profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE magic_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
profile_id INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
profile_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Progress
|
||||
CREATE TABLE user_progress (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
level INTEGER NOT NULL DEFAULT 1, -- 1=Novice, 2=Operator
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
streak INTEGER NOT NULL DEFAULT 0,
|
||||
best_streak INTEGER NOT NULL DEFAULT 0,
|
||||
total_correct INTEGER NOT NULL DEFAULT 0,
|
||||
total_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_session_at INTEGER,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Mnemonics — one row per letter per user
|
||||
CREATE TABLE user_mnemonics (
|
||||
profile_id INTEGER NOT NULL,
|
||||
letter TEXT NOT NULL,
|
||||
phrase TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (profile_id, letter)
|
||||
);
|
||||
|
||||
-- Per-letter accuracy
|
||||
CREATE TABLE letter_stats (
|
||||
profile_id INTEGER NOT NULL,
|
||||
letter TEXT NOT NULL,
|
||||
correct INTEGER NOT NULL DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (profile_id, letter)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Routes
|
||||
|
||||
All routes return JSON. Auth routes match allegiance pattern. Protected routes require `Authorization: Bearer <session_token>` header.
|
||||
|
||||
```
|
||||
POST /api/auth/request { email } → sends magic link
|
||||
GET /api/auth/verify?token= → 302 to /?session=<token>
|
||||
GET /api/auth/me → { id, email, display_name } [auth]
|
||||
DELETE /api/auth/session → log out [auth]
|
||||
|
||||
GET /api/progress → { level, score, streak, ... } [auth]
|
||||
POST /api/progress/answer { letter, correct } [auth]
|
||||
GET /api/mnemonics → { A: "phrase", ... } [auth]
|
||||
POST /api/mnemonics { letter, phrase } [auth]
|
||||
|
||||
GET /admin → admin HTML (ADMIN_PASSWORD gate)
|
||||
GET /api/admin/users → user list with stats [admin-token]
|
||||
GET /api/admin/stats → global aggregate stats [admin-token]
|
||||
|
||||
# [admin-token]: request must include Authorization: Bearer <ADMIN_PASSWORD>
|
||||
# AdminPage.tsx stores the password in sessionStorage after the user enters it,
|
||||
# and attaches it as a bearer token to all /api/admin/* requests.
|
||||
# Server checks: token === process.env.ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Core User Flows
|
||||
|
||||
### First visit
|
||||
1. Landing page — enter email → POST /api/auth/request
|
||||
2. "Check your email" screen
|
||||
3. Click magic link → GET /api/auth/verify → session token in URL → stored in localStorage
|
||||
4. Redirect to `/onboarding` (mnemonic builder)
|
||||
5. Step through all 12 Novice letters:
|
||||
- Show letter + Morse pattern (dot/dash display)
|
||||
- Play audio + flash on request
|
||||
- Player types phrase — real-time validation:
|
||||
- Word count must equal signal element count
|
||||
- Words ≤2 chars = dot, words ≥3 chars = dash
|
||||
- Save → 50 pts, next letter
|
||||
- Skip available — skipped letters marked, caps rank at Operator
|
||||
6. Onboarding complete → `/game` (Novice)
|
||||
|
||||
### Practice loop
|
||||
1. Load challenge: random letter/word from current level, weighted by accuracy (lower accuracy = more frequent)
|
||||
2. Display signal: visual flash (FlashArea) + audio (useMorseAudio), with Play button
|
||||
3. Player types answer, submits
|
||||
4. **Correct:** show their mnemonic phrase as reinforcement, +points, streak++
|
||||
5. **Incorrect:** "Try again, adventurer!" — replay, no penalty
|
||||
6. 10 consecutive correct → level-up modal
|
||||
|
||||
### Session token extraction
|
||||
After the magic link redirect, `App.tsx` reads `?session=<token>` from the URL on mount, writes it to `localStorage`, strips it from the URL with `history.replaceState`, then fetches `/api/auth/me`. All subsequent API calls attach `Authorization: Bearer <token>`.
|
||||
|
||||
### Returning user
|
||||
1. Landing page — if `localStorage` has a session token, `App.tsx` immediately fetches `/api/auth/me`
|
||||
2. Valid session → redirect to `/game` at current level (no email entry needed)
|
||||
3. Expired/missing → show login page
|
||||
|
||||
### Admin
|
||||
- `/admin` path, guarded by ADMIN_PASSWORD (same env var pattern as allegiance)
|
||||
- Table: all users, email, display name, level, score, joined, last seen
|
||||
- Click user → per-letter accuracy breakdown, mnemonic completion count
|
||||
|
||||
---
|
||||
|
||||
## 6. Audio Engine
|
||||
|
||||
`client/src/lib/audio.ts` — pure TypeScript, no dependencies.
|
||||
|
||||
```typescript
|
||||
// Timing: ITU standard, pilot starts at 10 WPM
|
||||
// dot=120ms, dash=360ms, element gap=120ms, char gap=360ms, word gap=840ms
|
||||
|
||||
const MORSE: Record<string, string> = { A:'.-', B:'-...', /* ... */ }
|
||||
|
||||
class MorseAudioEngine {
|
||||
private ctx: AudioContext
|
||||
private stopFlag = false
|
||||
|
||||
// Emits events: 'signal-start' | 'signal-end' with payload {type:'dot'|'dash'}
|
||||
on(event: string, cb: Function): void
|
||||
async play(text: string, wpm?: number): Promise<void>
|
||||
stop(): void
|
||||
}
|
||||
```
|
||||
|
||||
`useMorseAudio()` hook:
|
||||
- Wraps `MorseAudioEngine`
|
||||
- Exposes `{ play, stop, isPlaying, currentSignal }` where `currentSignal: 'dot'|'dash'|null`
|
||||
- `currentSignal` drives `FlashArea` CSS class toggle
|
||||
|
||||
`FlashArea` component:
|
||||
- Large colored div — off-state: dark parchment, active-state: quest gold (#D4AF37)
|
||||
- Dot flash: 120ms, Dash flash: 360ms
|
||||
- Synchronized to audio engine events (not setTimeout-based)
|
||||
|
||||
---
|
||||
|
||||
## 7. Mnemonic Validation Logic
|
||||
|
||||
Real-time validation as player types:
|
||||
|
||||
```typescript
|
||||
function validatePhrase(phrase: string, morsePattern: string): ValidationResult {
|
||||
const words = phrase.trim().split(/\s+/)
|
||||
const elements = morsePattern.split('') // '.', '-'
|
||||
|
||||
if (words.length !== elements.length) {
|
||||
return { valid: false, message: `Need ${elements.length} words, got ${words.length}` }
|
||||
}
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const isDot = elements[i] === '.'
|
||||
const isShort = words[i].length <= 2 // 1-2 chars = dot
|
||||
if (isDot && !isShort) return { valid: false, message: `Word ${i+1} should be short (dot)` }
|
||||
if (!isDot && isShort) return { valid: false, message: `Word ${i+1} should be long (dash)` }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
```
|
||||
|
||||
Letters with Morse patterns for Novice set:
|
||||
```
|
||||
A .- E . T - I ..
|
||||
N -. S ... H .... R .-.
|
||||
D -.. L .-.. U ..- O ---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Scoring
|
||||
|
||||
```typescript
|
||||
function calculatePoints(level: number, streak: number, isFirstSessionToday: boolean): number {
|
||||
const base = level * 10 // Novice=10, Operator=20
|
||||
const streakMult = streak >= 10 ? 2 : 1
|
||||
const dailyMult = isFirstSessionToday ? 1.25 : 1
|
||||
return Math.floor(base * streakMult * dailyMult)
|
||||
}
|
||||
|
||||
// Mnemonic creation: flat 50 pts per letter
|
||||
// Level-up trigger: 10 consecutive correct
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Content Data
|
||||
|
||||
Static in `client/src/lib/morse.ts`:
|
||||
|
||||
```typescript
|
||||
// Novice word pool (Operator level) — 3-letter words from A,E,T,I,N,S,H,R,D,L,U,O
|
||||
export const OPERATOR_WORDS = [
|
||||
'SEA', 'SUN', 'TAN', 'RAN', 'HIT', 'RUN', 'ANT', 'DEN',
|
||||
'HEN', 'INN', 'OAR', 'OUR', 'USE', 'TIN', 'SIN', 'HIS',
|
||||
'AIR', 'EAR', 'OIL', 'AND', 'THE', 'HOT', 'NET', 'SET'
|
||||
]
|
||||
```
|
||||
|
||||
Challenge selection weights: letters/words with lower `correct/attempts` ratio selected more frequently. Avoids repeating the same letter 3× in a row.
|
||||
|
||||
---
|
||||
|
||||
## 10. Docker & Deployment
|
||||
|
||||
### Dockerfile
|
||||
Two-stage build: build stage installs all deps (including Vite) and compiles the React app; runtime stage copies only the built output and production deps.
|
||||
|
||||
```dockerfile
|
||||
# --- Build stage ---
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund # all deps, including vite
|
||||
COPY . .
|
||||
RUN npm run build # vite build → client/dist
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM node:20-alpine
|
||||
ENV NODE_ENV=production PORT=8080
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ # for better-sqlite3 native rebuild
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
COPY --from=builder /app/client/dist ./client/dist
|
||||
COPY server/ ./server/
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:8080/ || exit 1
|
||||
CMD ["node", "server/server.js"]
|
||||
```
|
||||
|
||||
### docker-compose.yml
|
||||
```yaml
|
||||
services:
|
||||
morsequest:
|
||||
image: morsequest:latest
|
||||
container_name: morsequest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3004:8080"
|
||||
volumes:
|
||||
- morsequest-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
NODE_ENV: production
|
||||
SMTP_HOST: mail.keylinkit.net
|
||||
SMTP_PORT: "587"
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASS: ${SMTP_PASS}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
APP_URL: https://morsequest.keylinkit.net
|
||||
|
||||
volumes:
|
||||
morsequest-data:
|
||||
```
|
||||
|
||||
Host nginx proxies `morsequest.keylinkit.net` → `127.0.0.1:3004` (same pattern as allegiance).
|
||||
|
||||
---
|
||||
|
||||
## 11. Brand Implementation
|
||||
|
||||
From brand guidelines:
|
||||
- **Colors as CSS custom properties:** `--color-gold: #D4AF37`, `--color-blue: #4169E1`, `--color-parchment: #F5E6D3`, `--color-forest: #3C2415`
|
||||
- **Fonts:** Cinzel (headers, Google Fonts), Open Sans (body), Source Code Pro (Morse display)
|
||||
- **Voice:** "Try again, adventurer!" not "Incorrect". "Quest complete!" not "Level up".
|
||||
- **Existing SVG assets** from `assets/` used directly (logos, icons, badges)
|
||||
|
||||
---
|
||||
|
||||
## 12. Environment Variables
|
||||
|
||||
```
|
||||
SMTP_HOST mail.keylinkit.net
|
||||
SMTP_PORT 587
|
||||
SMTP_USER noreply@morsequest.keylinkit.net (or similar)
|
||||
SMTP_PASS <password>
|
||||
ADMIN_PASSWORD <admin panel password>
|
||||
APP_URL https://morsequest.keylinkit.net
|
||||
PORT 8080
|
||||
NODE_ENV production
|
||||
```
|
||||
Reference in New Issue
Block a user