Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions evolution_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 2 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 17 additions & 8 deletions scripts/sync-sql-assets.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
34 changes: 31 additions & 3 deletions src/components/DataPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function DataPreview() {
);
}

if (!queryResult || queryResult.values.length === 0) {
if (!queryResult) {
return (
<div className="h-full flex flex-col overflow-hidden fade-in-up" style={panelStyle}>
<div style={headerStyle} className="flex items-center gap-2">
Expand All @@ -49,9 +49,37 @@ export default function DataPreview() {
Query Results
</span>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-xs" style={{ color: 'var(--lcb-muted)', fontFamily: 'var(--font-ibm-plex-mono)' }}>
Run a query to see results
Nothing on the wire yet.
</p>
<p className="text-xs leading-5" style={{ color: 'var(--lcb-muted)', opacity: 0.7, fontFamily: 'var(--font-ibm-plex-mono)' }}>
Run your query to inspect its output — or explore freely, e.g.{' '}
<span style={{ color: 'var(--lcb-gold)' }}>SELECT * FROM customers LIMIT 5</span>
</p>
</div>
</div>
);
}

if (queryResult.values.length === 0) {
return (
<div className="h-full flex flex-col overflow-hidden fade-in-up" style={panelStyle}>
<div style={headerStyle} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="w-3.5 h-3.5" style={{ color: 'var(--lcb-gold)' }} />
<span className="text-xs font-semibold uppercase tracking-widest" style={{ fontFamily: 'var(--font-ibm-plex-mono)', color: 'var(--lcb-white)' }}>
Query Results
</span>
</div>
<span className="text-xs" style={{ color: 'var(--lcb-muted)', fontFamily: 'var(--font-ibm-plex-mono)' }}>
0 rows
</span>
</div>
<div className="flex-1 flex items-center justify-center px-6 text-center">
<p className="text-xs leading-5" style={{ color: 'var(--lcb-muted)', fontFamily: 'var(--font-ibm-plex-mono)' }}>
The query ran fine but returned no rows — check your WHERE conditions
(values are case-sensitive: &apos;Active&apos; ≠ &apos;active&apos;).
</p>
</div>
</div>
Expand Down
46 changes: 26 additions & 20 deletions src/components/FlockCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,7 +40,7 @@ export default function FlockCanvas({ width, height }: FlockCanvasProps) {
const frameRef = useRef<number>(0);
const initializedRef = useRef<boolean>(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]);
Expand Down Expand Up @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading