From c6ac6b90194a968053448ea70f322054d158251c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 03:49:04 +0000 Subject: [PATCH] feat(iter-7): Run/Submit split, reachable ranks, self-hosted Monaco, UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Learning & UX: - Split the graded Run button into Run (free exploration, ⌘↵) and Submit (graded, ⇧⌘↵) — exploring the data no longer counts as failure - Results tab auto-activates on every run; scrolls into view on mobile; stale results cleared when navigating between levels - Monaco keybindings registered with the editor (shortcuts never fired while typing); handlers read the live editor model, not lagging state - Clearing the editor no longer resurrects the seed scaffold - "Compare with the model answer" reveal in the completion modal - Distinct empty / 0-rows / error states in the Results panel Gamification integrity: - Career ranks derived from level data — VP/MD were unreachable (thresholds 1000/3000/7000 XP vs 1,285 total in the game) - Replays show "XP already banked" instead of a phantom +XP - Fleet rehydrates from saved progress after reload - Fixed double ship-docking per solve (spawn request never consumed) Infrastructure: - Monaco self-hosted from node_modules like sql.js — the editor no longer depends on cdn.jsdelivr.net being reachable - Confetti honours prefers-reduced-motion; responsive header/drawer fixes - New progression tests + store tests (296 total) https://claude.ai/code/session_01Drf7PoXAovLNSEJmxw634F --- evolution_log.md | 61 ++++++++++++ package-lock.json | 7 +- package.json | 1 + scripts/sync-sql-assets.mjs | 25 +++-- src/components/DataPreview.tsx | 34 ++++++- src/components/FlockCanvas.tsx | 46 +++++---- src/components/GameProvider.tsx | 50 ++++++---- src/components/LevelNavigator.tsx | 2 +- src/components/LevelUpModal.tsx | 66 +++++++++++-- src/components/OnboardingOverlay.tsx | 7 +- src/components/SQLPanel.tsx | 143 ++++++++++++++++++++++----- src/lib/progression.ts | 44 +++++++++ src/store/useGameStore.ts | 42 +++++++- tests/progression.test.ts | 59 +++++++++++ tests/store.test.ts | 37 +++++++ 15 files changed, 529 insertions(+), 95 deletions(-) create mode 100644 tests/progression.test.ts diff --git a/evolution_log.md b/evolution_log.md index 13c950a..4ae27b6 100644 --- a/evolution_log.md +++ b/evolution_log.md @@ -4,6 +4,67 @@ Each entry records one autonomous improvement iteration. --- +## Iteration 7 — 2026-06-12 + +### [UI/UX Improvements] + +- **Run vs Submit split** (`SQLPanel.tsx`): the single graded "Run" button became two actions. + **Run** (⌘↵ / Ctrl↵) executes the query and shows its results with no grading and no + failed-attempt penalty — exploring the data (`SELECT * FROM …`) no longer produces a + confusing "wrong column count" failure. **Submit** (⇧⌘↵) grades against the expected + report, drives hints/attempt escalation, and completes the level. Onboarding step 2 + rewritten around the new flow ("Run Freely, Submit to Earn"). +- **Results visibility**: query output previously landed in a right-panel tab that defaults + to Schema — players could run queries and never see their results. The tab is renamed + **Results**, auto-activates on every run, and on stacked mobile layouts the panel scrolls + into view. Query results and errors are now cleared when navigating between levels + (`useGameStore.ts`) so a previous level's output can't mislead. +- **Editor keyboard shortcuts actually work**: Monaco swallows ⌘↵/Ctrl↵ before they reach + the window listener, so the advertised shortcut never fired while typing in the editor. + Keybindings are now registered with Monaco itself (`editor.addCommand`), and handlers read + the live editor model rather than React state (fast type-then-⌘↵ could execute stale SQL). + The shortcut labels also show `Ctrl` instead of `⌘` on non-Mac platforms. +- **Editor seed bug fixed**: clearing the editor no longer makes the seed scaffold reappear + (the controlled value fell back to `seedQuery` whenever the state was empty). +- **Model answer reveal** (`LevelUpModal.tsx`): after completing a level, a "Compare with the + model answer" toggle shows the canonical style-guide solution — players who solved it + differently learn the idiomatic pattern at the moment of success. +- **Distinct Results states** (`DataPreview.tsx`): "no query run yet" (with an exploration + suggestion) is now distinguishable from "query ran but returned 0 rows" (with a + case-sensitivity nudge). + +### [Game Design Tweaks] + +- **Career ranks are now reachable** (`progression.ts`): the header ladder was hardcoded at + 1000/3000/7000 XP while the whole game only awards 1,285 — VP and Managing Director were + unreachable. `RANK_LADDER` now derives thresholds from level data (each epoch's rank is + reached by banking all prior epochs' XP), with `rankInfo()` consumed by the header. +- **Honest replay rewards**: replaying a completed level shows "Replay — XP already banked" + in the completion modal and skips the +XP float (XP was already awarded only once; the UI + just claimed otherwise). +- **Fleet survives reloads** (`useGameStore.rehydrateFleet`): up to 10 ships from the most + recently completed levels sail back into the harbour after a page reload (boids were never + persisted, so the harbour was empty despite a non-zero Fleet count). +- **Double-docking bug fixed** (`FlockCanvas.tsx`): `lastSpawnedBird` was never consumed, so + the spawn effect re-fired when the level advanced on modal close — every solve docked two + ships and inflated the Fleet counter. The spawn request is now cleared after use. + +### [Database & Code Optimizations] + +- **Monaco self-hosted** (`scripts/sync-sql-assets.mjs`, `monaco-editor` pinned): the editor + — the core of the product — was loaded from cdn.jsdelivr.net at runtime and the entire + terminal broke when the CDN was unreachable. It is now copied out of `node_modules` into + `public/vendor/monaco` at dev/build time, exactly like the sql.js runtime. +- `levelToShipType` in `FlockCanvas` now derives from `epochOf()` instead of duplicating + hardcoded level boundaries; confetti respects `prefers-reduced-motion`; the level-navigator + drawer is capped at 92vw on small screens; header rank bar hides below `md` to prevent + overflow. +- **Tests**: new `tests/progression.test.ts` (rank ladder reachability, monotonic thresholds, + `rankInfo` boundaries) and store tests for result-clearing on navigation and + `rehydrateFleet` idempotency — 296 tests total. + +--- + ## Iteration 6 — 2026-06-11 ### [UI/UX Improvements] diff --git a/package-lock.json b/package-lock.json index 9071d8b..dd46b6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "canvas-confetti": "^1.9.4", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", + "monaco-editor": "^0.55.1", "next": "16.2.1", "react": "19.2.4", "react-dom": "19.2.4", @@ -1957,8 +1958,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", @@ -3419,7 +3419,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -5580,7 +5579,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -5650,7 +5648,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" diff --git a/package.json b/package.json index 6b558b2..2b620f4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "canvas-confetti": "^1.9.4", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", + "monaco-editor": "^0.55.1", "next": "16.2.1", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/scripts/sync-sql-assets.mjs b/scripts/sync-sql-assets.mjs index 5939b35..cc9ade5 100644 --- a/scripts/sync-sql-assets.mjs +++ b/scripts/sync-sql-assets.mjs @@ -1,16 +1,25 @@ -// Copies the sql.js runtime out of node_modules into public/ so the game -// serves the exact version pinned in package.json instead of a CDN build. +// Copies third-party runtime assets out of node_modules into public/ so the +// game serves the exact versions pinned in package.json instead of CDN +// builds (no outage/CSP/supply-chain exposure for the core experience). // Runs automatically via the predev/prebuild npm hooks. -import { copyFileSync, mkdirSync } from 'node:fs'; +import { copyFileSync, cpSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const src = join(root, 'node_modules', 'sql.js', 'dist'); -const dest = join(root, 'public', 'vendor', 'sqljs'); -mkdirSync(dest, { recursive: true }); +// sql.js — the in-browser SQLite engine +const sqlSrc = join(root, 'node_modules', 'sql.js', 'dist'); +const sqlDest = join(root, 'public', 'vendor', 'sqljs'); +mkdirSync(sqlDest, { recursive: true }); for (const file of ['sql-wasm.js', 'sql-wasm.wasm']) { - copyFileSync(join(src, file), join(dest, file)); + copyFileSync(join(sqlSrc, file), join(sqlDest, file)); } -console.log(`Copied sql.js runtime to ${dest}`); +console.log(`Copied sql.js runtime to ${sqlDest}`); + +// monaco-editor — the SQL editor itself (otherwise pulled from jsdelivr at +// runtime, taking the whole terminal down whenever the CDN is unreachable) +const monacoSrc = join(root, 'node_modules', 'monaco-editor', 'min', 'vs'); +const monacoDest = join(root, 'public', 'vendor', 'monaco', 'vs'); +cpSync(monacoSrc, monacoDest, { recursive: true }); +console.log(`Copied monaco-editor runtime to ${monacoDest}`); diff --git a/src/components/DataPreview.tsx b/src/components/DataPreview.tsx index 65f38ad..78bc1dc 100644 --- a/src/components/DataPreview.tsx +++ b/src/components/DataPreview.tsx @@ -40,7 +40,7 @@ export default function DataPreview() { ); } - if (!queryResult || queryResult.values.length === 0) { + if (!queryResult) { return (
@@ -49,9 +49,37 @@ export default function DataPreview() { Query Results
-
+

- Run a query to see results + Nothing on the wire yet. +

+

+ Run your query to inspect its output — or explore freely, e.g.{' '} + SELECT * FROM customers LIMIT 5 +

+
+
+ ); + } + + if (queryResult.values.length === 0) { + return ( +
+
+
+ + + Query Results + +
+ + 0 rows + +
+
+

+ The query ran fine but returned no rows — check your WHERE conditions + (values are case-sensitive: 'Active' ≠ 'active').

diff --git a/src/components/FlockCanvas.tsx b/src/components/FlockCanvas.tsx index 51bf977..7d1b2ea 100644 --- a/src/components/FlockCanvas.tsx +++ b/src/components/FlockCanvas.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useGameStore } from '@/store/useGameStore'; -import { shipFor } from '@/lib/progression'; +import { epochOf, shipFor } from '@/lib/progression'; interface ShipEntity { id: number; @@ -40,7 +40,7 @@ export default function FlockCanvas({ width, height }: FlockCanvasProps) { const frameRef = useRef(0); const initializedRef = useRef(false); - const { boids, currentLevel, addBoid, lastSpawnedBird } = useGameStore(); + const { boids, currentLevel, addBoid, lastSpawnedBird, setLastSpawnedBird } = useGameStore(); // Horizon line splits sky (above) from water (below) const horizonY = useCallback(() => height * 0.58, [height]); @@ -84,15 +84,19 @@ export default function FlockCanvas({ width, height }: FlockCanvasProps) { const laneIdx = shipsRef.current.length % 5; const laneY = hz + waterH * (0.12 + laneIdx * 0.17); - for (let i = 0; i < 8; i++) { - spawnEffectsRef.current.push({ - id: Date.now() + i, - x: boid.x || width * 0.5, - y: laneY, - color: info.color, - age: 0, - maxAge: 50, - }); + // Negative ids are fleet rehydrated from saved progress — they sail + // straight in without the horn-ring fanfare of a fresh solve. + if (boid.id >= 0) { + for (let i = 0; i < 8; i++) { + spawnEffectsRef.current.push({ + id: Date.now() + i, + x: boid.x || width * 0.5, + y: laneY, + color: info.color, + age: 0, + maxAge: 50, + }); + } } shipsRef.current.push({ @@ -114,10 +118,8 @@ export default function FlockCanvas({ width, height }: FlockCanvasProps) { }, [boids, width, height]); // ── Handle newly spawned ship ───────────────────────────────────────────── - const lastSpawnRef = useRef<{ x: number; y: number } | null>(null); useEffect(() => { - if (!lastSpawnedBird || lastSpawnRef.current) return; - lastSpawnRef.current = lastSpawnedBird; + if (!lastSpawnedBird) return; const info = shipFor(currentLevel); const hz = height * 0.58; @@ -152,8 +154,10 @@ export default function FlockCanvas({ width, height }: FlockCanvasProps) { trail: [], }); - setTimeout(() => { lastSpawnRef.current = null; }, 100); - }, [lastSpawnedBird, currentLevel, addBoid, height]); + // Consume the spawn request so later effect re-runs (e.g. the level + // advancing when the modal closes) don't dock a duplicate ship. + setLastSpawnedBird(null); + }, [lastSpawnedBird, currentLevel, addBoid, setLastSpawnedBird, height]); // ── Sky gradient progression ────────────────────────────────────────────── const getSkyColors = useCallback(() => { @@ -289,10 +293,12 @@ export default function FlockCanvas({ width, height }: FlockCanvasProps) { // ── Helpers ────────────────────────────────────────────────────────────────── function levelToShipType(level: number): ShipEntity['type'] { - if (level <= 15) return 'tugboat'; - if (level <= 30) return 'cargo'; - if (level <= 40) return 'container'; - return 'supertanker'; + switch (epochOf(level)) { + case 'Foundational': return 'tugboat'; + case 'Intermediate': return 'cargo'; + case 'Advanced': return 'container'; + default: return 'supertanker'; + } } function hullWidth(type: ShipEntity['type'], size: number): number { diff --git a/src/components/GameProvider.tsx b/src/components/GameProvider.tsx index 31e725d..3b21c06 100644 --- a/src/components/GameProvider.tsx +++ b/src/components/GameProvider.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, Fragment } from 'react'; +import { useEffect, useState, useRef, useCallback, Fragment } from 'react'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import SQLPanel from './SQLPanel'; @@ -10,7 +10,7 @@ import LevelUpModal from './LevelUpModal'; import LevelNavigator from './LevelNavigator'; import { useGameStore } from '@/store/useGameStore'; import { initDatabase } from '@/lib/db'; -import { EPOCH_RANGES, MAX_LEVEL } from '@/lib/progression'; +import { EPOCH_RANGES, MAX_LEVEL, rankInfo } from '@/lib/progression'; import SchemaViewer from './SchemaViewer'; import LevelProgressMap from './LevelProgressMap'; import OnboardingOverlay from './OnboardingOverlay'; @@ -26,16 +26,12 @@ export default function GameProvider() { const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const [activeRightTab, setActiveRightTab] = useState<'schema' | 'data'>('schema'); const [showLevelNavigator, setShowLevelNavigator] = useState(false); - const { currentLevel, totalXp, currentStreak } = useGameStore(); + const rightPanelRef = useRef(null); + const { currentLevel, totalXp, currentStreak, rehydrateFleet } = useGameStore(); - // Career-rank progression — mirrors a senior-DS ladder (Analyst → MD). - const getRankInfo = (pts: number) => { - if (pts < 1000) return { name: 'Analyst', progress: pts / 1000, xpToNext: 1000 - pts, nextName: 'Senior Analyst' }; - if (pts < 3000) return { name: 'Senior Analyst', progress: (pts - 1000) / 2000, xpToNext: 3000 - pts, nextName: 'VP Analytics' }; - if (pts < 7000) return { name: 'VP Analytics', progress: (pts - 3000) / 4000, xpToNext: 7000 - pts, nextName: 'MD' }; - return { name: 'Managing Director', progress: 1, xpToNext: 0, nextName: '' }; - }; - const rank = getRankInfo(totalXp); + // Career-rank ladder derived from the level data (see progression.ts), + // so every rank up to Managing Director is actually reachable. + const rank = rankInfo(totalXp); useEffect(() => { initDatabase() @@ -43,6 +39,20 @@ export default function GameProvider() { .catch((err) => setDbError(err instanceof Error ? err.message : 'Failed to initialise database')); }, []); + // Restore docked ships from saved progress once the harbour has a size. + useEffect(() => { + if (isDbReady && dimensions.width > 0) rehydrateFleet(dimensions.width); + }, [isDbReady, dimensions.width, rehydrateFleet]); + + // Whenever a query runs, surface its output: switch the right panel to the + // Results tab and, on stacked (mobile) layouts, bring it into view. + const handleQueryRun = useCallback(() => { + setActiveRightTab('data'); + if (window.innerWidth < 1024) { + rightPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, []); + useEffect(() => { const onResize = () => setDimensions({ width: window.innerWidth, height: window.innerHeight }); onResize(); @@ -125,8 +135,8 @@ export default function GameProvider() { > Lion City Bank - | - + | + SQL Analytics Terminal
@@ -134,7 +144,7 @@ export default function GameProvider() { LEVEL {currentLevel} / {MAX_LEVEL} {/* Rank + XP progress bar */} -
0 ? `${rank.xpToNext} XP to ${rank.nextName}` : 'Top rank reached'}> +
0 ? `${rank.xpToNext} XP to ${rank.nextName}` : 'Top rank reached'}> {rank.name} {totalXp}
@@ -147,8 +157,8 @@ export default function GameProvider() { }} />
- {rank.xpToNext > 0 && ( - → {rank.nextName} + {rank.nextName && ( + → {rank.nextName} )}
@@ -230,11 +240,11 @@ export default function GameProvider() {
{/* Left: SQL Editor */}
- +
- {/* Right: Schema / Data + status */} -
+ {/* Right: Schema / Results + status */} +
{/* Tab bar */}
- {tab === 'schema' ? 'Schema' : 'Data'} + {tab === 'schema' ? 'Schema' : 'Results'} ))}
diff --git a/src/components/LevelNavigator.tsx b/src/components/LevelNavigator.tsx index 332db29..c213b48 100644 --- a/src/components/LevelNavigator.tsx +++ b/src/components/LevelNavigator.tsx @@ -47,7 +47,7 @@ export default function LevelNavigator({ isOpen, onClose }: LevelNavigatorProps) role="dialog" aria-modal="true" aria-label="Level navigator" - className="absolute right-0 top-0 bottom-0 w-[360px] flex flex-col" + className="absolute right-0 top-0 bottom-0 w-[360px] max-w-[92vw] flex flex-col" style={{ background: 'var(--lcb-panel)', borderLeft: '1px solid var(--lcb-border)' }} > {/* Header */} diff --git a/src/components/LevelUpModal.tsx b/src/components/LevelUpModal.tsx index 147deb7..c71d659 100644 --- a/src/components/LevelUpModal.tsx +++ b/src/components/LevelUpModal.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import confetti from 'canvas-confetti'; import { useGameStore } from '@/store/useGameStore'; +import { levels } from '@/data/levels'; import { epochOf, shipFor, xpFor, EPOCH_RANK, MAX_LEVEL } from '@/lib/progression'; -import { X, Anchor, ArrowRight } from 'lucide-react'; +import { X, Anchor, ArrowRight, ChevronDown, ChevronRight } from 'lucide-react'; const EPOCH_NEXT_HINT: Record = { 10: 'Continue mastering SELECT and WHERE filters', @@ -23,17 +24,22 @@ const EPOCH_NEXT_HINT: Record = { }; export default function LevelUpModal() { - const { showLevelUp, setShowLevelUp, currentLevel, completeLevel, flockSize } = useGameStore(); + const { showLevelUp, setShowLevelUp, currentLevel, completeLevel, completedLevels, flockSize } = useGameStore(); + const [showSolution, setShowSolution] = useState(false); const ship = shipFor(currentLevel); const epoch = epochOf(currentLevel); + const level = levels.find((l) => l.id === currentLevel); + // completeLevel runs on close, so at display time this still tells us + // whether the solve was a replay (XP is only banked once per level). + const isReplay = completedLevels.includes(currentLevel); useEffect(() => { if (!showLevelUp) return; const end = Date.now() + 1800; const frame = () => { - confetti({ particleCount: 2, angle: 60, spread: 55, origin: { x: 0, y: 0.7 }, colors: ['#c9a84c','#e8e6e0','#22c55e'] }); - confetti({ particleCount: 2, angle: 120, spread: 55, origin: { x: 1, y: 0.7 }, colors: ['#c9a84c','#e8e6e0','#22c55e'] }); + confetti({ particleCount: 2, angle: 60, spread: 55, origin: { x: 0, y: 0.7 }, colors: ['#c9a84c','#e8e6e0','#22c55e'], disableForReducedMotion: true }); + confetti({ particleCount: 2, angle: 120, spread: 55, origin: { x: 1, y: 0.7 }, colors: ['#c9a84c','#e8e6e0','#22c55e'], disableForReducedMotion: true }); if (Date.now() < end) requestAnimationFrame(frame); }; frame(); @@ -41,6 +47,7 @@ export default function LevelUpModal() { const handleClose = useCallback(() => { setShowLevelUp(false); + setShowSolution(false); // collapsed again for the next solve completeLevel(currentLevel); }, [setShowLevelUp, completeLevel, currentLevel]); @@ -76,7 +83,7 @@ export default function LevelUpModal() { animate={{ scale: 1, opacity: 1, y: 0 }} exit={{ scale: 0.88, opacity: 0, y: 16 }} transition={{ type: 'spring', damping: 22, stiffness: 320 }} - className="relative w-full max-w-sm overflow-hidden" + className="relative w-full max-w-sm max-h-[90vh] overflow-y-auto" style={{ background: 'var(--lcb-panel)', border: '1px solid var(--lcb-border)', borderRadius: 8, borderTop: '3px solid var(--lcb-gold)' }} > {/* Top accent row */} @@ -175,10 +182,55 @@ export default function LevelUpModal() { > - +{xpEarned} XP Earned + {isReplay ? 'Replay — XP already banked' : `+${xpEarned} XP Earned`} + {/* Model answer — compare your approach with the house style */} + {level && ( + + + {showSolution && ( +
+                    {level.solutionQuery}
+                  
+ )} +
+ )} + {/* Next level hint */} {currentLevel < MAX_LEVEL && ( ), - title: 'Write SQL, Earn XP', - body: 'Each challenge presents a real-world banking problem. Write your SQL query in the editor, press ⌘↵ or click Run to execute. Correct solutions earn XP and advance you through 4 epochs: Foundational → Intermediate → Advanced → Expert.', + title: 'Run Freely, Submit to Earn', + body: 'Each challenge is a real banking problem. Run (⌘↵) executes your SQL and shows the results — explore the data as much as you like, it\'s never penalised. When your report looks right, Submit (⇧⌘↵) it to the Harbour Master to earn XP and advance through 4 epochs: Foundational → Intermediate → Advanced → Expert.', }, { icon: ( @@ -40,7 +41,7 @@ const STEPS = [ ), title: 'Navigate the Harbour', - body: 'Use the Schema panel to explore tables. The level map strip shows your voyage across 65 challenges. Stuck? Click the hint button after your first attempt. The harbour master is always watching.', + body: `Use the Schema panel to explore tables. The level map strip shows your voyage across ${MAX_LEVEL} challenges. Stuck? A hint appears after your first submission, and after three the Harbour Master reveals the expected report's shape.`, }, ]; diff --git a/src/components/SQLPanel.tsx b/src/components/SQLPanel.tsx index daada67..c5590d8 100644 --- a/src/components/SQLPanel.tsx +++ b/src/components/SQLPanel.tsx @@ -1,13 +1,19 @@ 'use client'; import { useState, useCallback, useRef, useEffect } from 'react'; -import Editor, { Monaco } from '@monaco-editor/react'; -import { Play, Lightbulb, X, Anchor } from 'lucide-react'; +import Editor, { Monaco, loader } from '@monaco-editor/react'; +import { Play, Lightbulb, X, Anchor, Check } from 'lucide-react'; import { useGameStore } from '@/store/useGameStore'; +import { executeQuery } from '@/lib/db'; import { validateQuery, getExpectedShape } from '@/lib/validator'; import { levels } from '@/data/levels'; import { epochOf, xpFor, EPOCH_RANK } from '@/lib/progression'; +// Serve Monaco from /public (synced from node_modules by +// scripts/sync-sql-assets.mjs) instead of the default jsdelivr CDN — the +// editor is the product, so it must not depend on a third party being up. +loader.config({ paths: { vs: '/vendor/monaco/vs' } }); + const SQL_SNIPPETS = [ { label: 'SELECT', insert: 'SELECT ' }, { label: 'FROM', insert: '\nFROM ' }, @@ -18,7 +24,12 @@ const SQL_SNIPPETS = [ { label: 'LIMIT', insert: '\nLIMIT ' }, ]; -export default function SQLPanel() { +interface SQLPanelProps { + /** Called whenever a query produced output (or an error) worth looking at. */ + onQueryRun?: () => void; +} + +export default function SQLPanel({ onQueryRun }: SQLPanelProps) { const [query, setQuery] = useState(''); const [error, setError] = useState(null); const [failedAttempts, setFailedAttempts] = useState(0); @@ -26,9 +37,13 @@ export default function SQLPanel() { const editorRef = useRef(null); const monacoRef = useRef(null); const dismissTimerRef = useRef | null>(null); + // Monaco swallows ⌘↵/Ctrl↵ before they reach the window listener, so the + // editor needs its own keybindings; routed through a ref to stay current. + const handlersRef = useRef({ run: () => {}, submit: () => {} }); const { currentLevel, + completedLevels, isExecuting, hasAttemptedCurrent, setIsExecuting, @@ -41,6 +56,12 @@ export default function SQLPanel() { const level = levels.find((l) => l.id === currentLevel); + // ⌘ on Mac, Ctrl everywhere else — shown on the buttons. + const [modKey, setModKey] = useState('⌘'); + useEffect(() => { + if (!/Mac|iP(hone|ad|od)/.test(navigator.platform)) setModKey('Ctrl'); + }, []); + const handleEditorMount = (editor: unknown, monaco: Monaco) => { editorRef.current = editor; monacoRef.current = monaco; @@ -66,7 +87,17 @@ export default function SQLPanel() { }, }); monaco.editor.setTheme('lcb-terminal'); - (editor as { focus?: () => void }).focus?.(); + + const ed = editor as { + focus?: () => void; + addCommand?: (keybinding: number, handler: () => void) => void; + }; + ed.addCommand?.(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => handlersRef.current.run()); + ed.addCommand?.( + monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter, + () => handlersRef.current.submit() + ); + ed.focus?.(); }; const handleSnippetClick = (insert: string) => { @@ -84,9 +115,45 @@ export default function SQLPanel() { } }; - const handleExecute = useCallback(async () => { - const activeQuery = query.trim() || (level?.seedQuery ?? '').trim(); - if (!activeQuery) { setError('Please enter a SQL query'); return; } + // The editor model is the source of truth: React's onChange state can lag + // a fast type-then-⌘↵ by a beat, which would silently run stale SQL. + const getEditorText = useCallback(() => { + const ed = editorRef.current as { getValue?: () => string } | null; + return ed?.getValue?.() ?? query; + }, [query]); + + // Run: execute the query and show its results — no grading, no penalty. + // Exploring the data is how analysts actually work, so it's free. + const handleRun = useCallback(() => { + const activeQuery = getEditorText().trim(); + if (!activeQuery) { + setError('Type a query first — try SELECT * FROM customers LIMIT 5'); + return; + } + setIsExecuting(true); + setError(null); + setStoreError(null); + try { + const result = executeQuery(activeQuery); + setQueryResult(result); + } catch (err) { + const message = err instanceof Error ? err.message : 'Query execution failed'; + setError(message); + setStoreError(message); + setQueryResult(null); + } finally { + setIsExecuting(false); + onQueryRun?.(); + } + }, [getEditorText, setIsExecuting, setQueryResult, setStoreError, onQueryRun]); + + // Submit: grade the query against the level's expected report. + const handleSubmit = useCallback(() => { + const activeQuery = getEditorText().trim(); + if (!activeQuery) { + setError('Write your query before submitting — the Harbour Master expects a report.'); + return; + } setHasAttemptedCurrent(true); setIsExecuting(true); setError(null); @@ -96,33 +163,42 @@ export default function SQLPanel() { const result = validateQuery(activeQuery, level?.solutionQuery || '', { orderMatters: level?.orderMatters, }); + setQueryResult(result.userResult || null); if (result.success) { - setQueryResult(result.userResult || null); - setDoubloonAmt(xpFor(currentLevel)); + if (!completedLevels.includes(currentLevel)) setDoubloonAmt(xpFor(currentLevel)); const editor = editorRef.current as { getPosition?: () => { lineNumber: number; column: number } | null } | null; const pos = editor?.getPosition?.(); if (pos) setLastSpawnedBird({ x: 150 + pos.column * 8, y: 100 + pos.lineNumber * 20 }); setShowLevelUp(true); } else { setError(result.message); - setQueryResult(result.userResult || null); setFailedAttempts((n) => n + 1); } } catch (err) { - setError(err instanceof Error ? err.message : 'Query execution failed'); + const message = err instanceof Error ? err.message : 'Query execution failed'; + setError(message); + setStoreError(message); setFailedAttempts((n) => n + 1); } finally { setIsExecuting(false); + onQueryRun?.(); } - }, [query, level, currentLevel, setIsExecuting, setQueryResult, setStoreError, setShowLevelUp, setLastSpawnedBird, setHasAttemptedCurrent]); + }, [getEditorText, level, currentLevel, completedLevels, setIsExecuting, setQueryResult, setStoreError, setShowLevelUp, setLastSpawnedBird, setHasAttemptedCurrent, onQueryRun]); + + useEffect(() => { + handlersRef.current = { run: handleRun, submit: handleSubmit }; + }, [handleRun, handleSubmit]); useEffect(() => { const onKey = (e: globalThis.KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); handleExecute(); } + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + if (e.shiftKey) handleSubmit(); else handleRun(); + } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [handleExecute]); + }, [handleRun, handleSubmit]); // Auto-dismiss error toast after 7s useEffect(() => { @@ -134,7 +210,8 @@ export default function SQLPanel() { }, [error]); useEffect(() => { - setQuery(''); + setQuery(levels.find((l) => l.id === currentLevel)?.seedQuery ?? ''); + setError(null); setFailedAttempts(0); const editor = editorRef.current as { focus?: () => void } | null; if (editor?.focus) setTimeout(() => editor.focus?.(), 50); @@ -153,10 +230,10 @@ export default function SQLPanel() { > {/* ── Panel header ────────────────────────────────────────────────── */}
-
+

-
+
{doubloonAmt !== null && (
)} +
@@ -252,7 +347,7 @@ export default function SQLPanel() { height="100%" defaultLanguage="sql" theme="lcb-terminal" - value={query || level?.seedQuery || ''} + value={query} onChange={(v) => setQuery(v || '')} onMount={handleEditorMount} options={{ diff --git a/src/lib/progression.ts b/src/lib/progression.ts index 381fb36..8e4c74c 100644 --- a/src/lib/progression.ts +++ b/src/lib/progression.ts @@ -57,3 +57,47 @@ export const EPOCH_RANGES: { name: Epoch; min: number; max: number }[] = levels. /** Level ids that begin a new epoch (used for divider markers). */ export const EPOCH_STARTS = new Set(EPOCH_RANGES.slice(1).map((r) => r.min)); + +/** Total XP available across every level (replays never award XP). */ +export const TOTAL_XP = levels.reduce((sum, l) => sum + xpFor(l.id), 0); + +/** + * Header career ladder. Each epoch's rank is reached by banking the XP of + * all epochs before it, so — unlike a hardcoded table — every rank is + * genuinely attainable within the game's total XP budget. + */ +export const RANK_LADDER: { name: string; minXp: number }[] = (() => { + let cumulative = 0; + const ladder: { name: string; minXp: number }[] = []; + for (const range of EPOCH_RANGES) { + ladder.push({ name: EPOCH_RANK[range.name], minXp: cumulative }); + cumulative += levels + .filter((l) => l.id >= range.min && l.id <= range.max) + .reduce((sum, l) => sum + xpFor(l.id), 0); + } + return ladder; +})(); + +export interface RankInfo { + name: string; + nextName: string | null; + /** 0–1 progress towards the next rank (1 at the top rank). */ + progress: number; + xpToNext: number; +} + +export function rankInfo(totalXp: number): RankInfo { + let idx = 0; + for (let i = 0; i < RANK_LADDER.length; i++) { + if (totalXp >= RANK_LADDER[i].minXp) idx = i; + } + const current = RANK_LADDER[idx]; + const next = RANK_LADDER[idx + 1]; + if (!next) return { name: current.name, nextName: null, progress: 1, xpToNext: 0 }; + return { + name: current.name, + nextName: next.name, + progress: (totalXp - current.minXp) / (next.minXp - current.minXp), + xpToNext: next.minXp - totalXp, + }; +} diff --git a/src/store/useGameStore.ts b/src/store/useGameStore.ts index b4089d8..d3b94db 100644 --- a/src/store/useGameStore.ts +++ b/src/store/useGameStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { xpFor, MAX_LEVEL } from '@/lib/progression'; +import { xpFor, shipFor, MAX_LEVEL } from '@/lib/progression'; import type { Boid, QueryResult } from '@/types'; interface GameState { @@ -34,6 +34,7 @@ interface GameState { // Actions setCurrentLevel: (level: number) => void; completeLevel: (level: number) => void; + rehydrateFleet: (width: number) => void; addBoid: (boid: Boid) => void; removeBoid: (id: number) => void; setQueryResult: (result: QueryResult | null) => void; @@ -67,14 +68,17 @@ export const useGameStore = create()( hasSeenOnboarding: false, userName: null, - setCurrentLevel: (level) => + setCurrentLevel: (level) => set((state) => ({ currentLevel: level, // Add to history when navigating to a new level - levelHistory: state.levelHistory.includes(level) - ? state.levelHistory + levelHistory: state.levelHistory.includes(level) + ? state.levelHistory : [...state.levelHistory, level], hasAttemptedCurrent: false, + // A previous level's results would be misleading on the new task. + queryResult: null, + error: null, })), completeLevel: (level) => @@ -92,11 +96,39 @@ export const useGameStore = create()( ? state.levelHistory : [...state.levelHistory, nextLevel], hasAttemptedCurrent: false, + queryResult: null, + error: null, totalXp: isFirstCompletion ? state.totalXp + xpFor(level) : state.totalXp, currentStreak: state.currentStreak + 1, }; }), + // Repopulates the harbour from saved progress after a page reload + // (boids themselves are not persisted). Negative ids mark rehydrated + // ships so the canvas skips the spawn fanfare. + rehydrateFleet: (width) => + set((state) => { + if (state.boids.length > 0 || state.completedLevels.length === 0) return {}; + const recent = state.completedLevels.slice(-10); + return { + boids: recent.map((levelId, i) => { + const info = shipFor(levelId); + return { + id: -(i + 1), + x: ((i + 0.5) / recent.length) * width, + y: 0, + vx: 0, + vy: 0, + level: levelId, + species: info.species, + color: info.color, + size: info.size, + trail: [], + }; + }), + }; + }), + addBoid: (boid) => set((state) => ({ boids: [...state.boids, boid], @@ -143,6 +175,8 @@ export const useGameStore = create()( currentLevel: previousLevel, levelHistory: newHistory, hasAttemptedCurrent: false, + queryResult: null, + error: null, }); } }, diff --git a/tests/progression.test.ts b/tests/progression.test.ts new file mode 100644 index 0000000..bcaadd4 --- /dev/null +++ b/tests/progression.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_LEVEL, + TOTAL_XP, + RANK_LADDER, + rankInfo, + xpFor, + EPOCH_RANGES, +} from '@/lib/progression'; +import { levels } from '@/data/levels'; + +describe('RANK_LADDER', () => { + it('has one rank per epoch with strictly increasing thresholds', () => { + expect(RANK_LADDER.length).toBe(EPOCH_RANGES.length); + for (let i = 1; i < RANK_LADDER.length; i++) { + expect(RANK_LADDER[i].minXp).toBeGreaterThan(RANK_LADDER[i - 1].minXp); + } + }); + + it('keeps every rank reachable within the game XP budget', () => { + const top = RANK_LADDER[RANK_LADDER.length - 1]; + expect(top.minXp).toBeLessThan(TOTAL_XP); + }); + + it('matches the sum of per-level XP', () => { + expect(TOTAL_XP).toBe(levels.reduce((sum, l) => sum + xpFor(l.id), 0)); + }); +}); + +describe('rankInfo', () => { + it('starts at the first rank with zero XP', () => { + const r = rankInfo(0); + expect(r.name).toBe(RANK_LADDER[0].name); + expect(r.nextName).toBe(RANK_LADDER[1].name); + expect(r.progress).toBe(0); + }); + + it('reaches the top rank when all XP is banked', () => { + const r = rankInfo(TOTAL_XP); + expect(r.name).toBe(RANK_LADDER[RANK_LADDER.length - 1].name); + expect(r.nextName).toBeNull(); + expect(r.progress).toBe(1); + expect(r.xpToNext).toBe(0); + }); + + it('reports progress within a rank band', () => { + const second = RANK_LADDER[1]; + const r = rankInfo(second.minXp - 1); + expect(r.name).toBe(RANK_LADDER[0].name); + expect(r.xpToNext).toBe(1); + expect(r.progress).toBeGreaterThan(0); + expect(r.progress).toBeLessThan(1); + }); + + it('finishing the whole game makes Managing Director', () => { + const allXp = Array.from({ length: MAX_LEVEL }, (_, i) => xpFor(i + 1)).reduce((a, b) => a + b, 0); + expect(rankInfo(allXp).name).toBe('Managing Director'); + }); +}); diff --git a/tests/store.test.ts b/tests/store.test.ts index c3e205f..d00f34a 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -84,6 +84,43 @@ describe('navigation', () => { }); }); +describe('query state on navigation', () => { + it('clears stale results and errors when moving between levels', () => { + useGameStore.getState().setQueryResult({ columns: ['a'], values: [[1]] }); + useGameStore.getState().setError('boom'); + useGameStore.getState().setCurrentLevel(5); + expect(useGameStore.getState().queryResult).toBeNull(); + expect(useGameStore.getState().error).toBeNull(); + + useGameStore.getState().setQueryResult({ columns: ['a'], values: [[1]] }); + useGameStore.getState().completeLevel(5); + expect(useGameStore.getState().queryResult).toBeNull(); + }); +}); + +describe('rehydrateFleet', () => { + it('restores up to ten ships from completed levels', () => { + for (let i = 1; i <= 12; i++) useGameStore.getState().completeLevel(i); + useGameStore.getState().rehydrateFleet(1200); + const boids = useGameStore.getState().boids; + expect(boids.length).toBe(10); + // Most recent completions, negative ids (no spawn fanfare on the canvas) + expect(boids.map((b) => b.level)).toEqual([3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + expect(boids.every((b) => b.id < 0)).toBe(true); + expect(boids.every((b) => b.x >= 0 && b.x <= 1200)).toBe(true); + }); + + it('is a no-op when ships already exist or nothing is completed', () => { + useGameStore.getState().rehydrateFleet(1200); + expect(useGameStore.getState().boids.length).toBe(0); + + useGameStore.getState().completeLevel(1); + useGameStore.getState().rehydrateFleet(1200); + useGameStore.getState().rehydrateFleet(1200); + expect(useGameStore.getState().boids.length).toBe(1); + }); +}); + describe('resetGame', () => { it('returns progression to the initial state', () => { useGameStore.getState().completeLevel(1);