From a1fd1bd30b79359c033597ee79375211f47f5fa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 12:38:15 +0000 Subject: [PATCH] Rework Chase mode into a karaoke-style letter stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the falling-groups arcade with a continuous karaoke timeline: - Letters stream right-to-left as notes on 11 tone lanes (300-800 Hz in 50 Hz steps); each letter's Morse audio plays at its lane tone when the note crosses the hit line. - Type what you hear inside each note's answer window. Typing a later pending letter drops the earlier ones as missed, and unanswered notes expire — training the "let go and move on" skill of long conversations. - The pace wanders randomly inside the shared effective-WPM range and adapts: clean copy speeds the stream up, mistakes ease it off. Periodic calm "breather" phases stretch the gaps so runs stay fun, not tiring. - No lives: play as long as you like, then Finish to see conversation copy stats — accuracy heat-line, per-letter log, best combo, peak pace. - New pure engine in src/lib/chase/karaoke.ts (lanes, pace controller, keystroke resolution, scoring) with full unit tests; session machine, store slice, hook, views, settings form, and copy updated to match. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012QHyoSMqo5wHvkJ1efiZSZ --- app/globals.css | 62 +- .../features/chase/ChaseTrainingView.test.tsx | 151 ++-- .../TrainingRouter.resilience.test.tsx | 22 +- src/__tests__/lib/chase/karaoke.test.ts | 173 +++++ src/__tests__/lib/chase/progression.test.ts | 116 --- .../lib/training/chaseSessionMachine.test.ts | 47 +- src/__tests__/store/selectors.test.ts | 2 +- .../features/chase/ChaseHomeView.tsx | 51 +- .../features/chase/ChaseResultsView.tsx | 110 ++- .../features/chase/ChaseTrainingView.tsx | 459 ++++++------ src/components/features/sidebar/Sidebar.tsx | 5 +- .../features/training/TrainingRouter.tsx | 22 +- src/components/ui/forms/ChaseSettingsForm.tsx | 114 +-- .../ui/forms/TrainingSettingsForm.tsx | 4 +- src/hooks/useChaseTrainingSession.ts | 676 ++++++++++-------- src/hooks/useTrainingAudio.ts | 19 +- src/lib/chase/index.ts | 37 +- src/lib/chase/karaoke.ts | 197 +++++ src/lib/chase/progression.ts | 100 +-- src/lib/training/chaseSessionMachine.ts | 145 ++-- src/store/slices/training-runtime.slice.ts | 19 +- src/types/domain.ts | 14 +- 22 files changed, 1492 insertions(+), 1053 deletions(-) create mode 100644 src/__tests__/lib/chase/karaoke.test.ts delete mode 100644 src/__tests__/lib/chase/progression.test.ts create mode 100644 src/lib/chase/karaoke.ts diff --git a/app/globals.css b/app/globals.css index c4a0321..04e33bf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -86,42 +86,64 @@ html[data-training-mode='chase'] #data-notice code { color: rgb(226 232 240); } -@keyframes chase-fall { - from { - transform: translate(-50%, 0); +@keyframes chase-note-pop { + 0% { + opacity: 1; + transform: translate(-50%, -50%) scale(1.35); + } + + 45% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.92); + } +} + +@keyframes chase-note-drift { + 0% { + opacity: 0.9; + transform: translate(-50%, -50%) scale(1); } - to { - transform: translate(-50%, 19rem); + 100% { + opacity: 0; + transform: translate(-50%, -30%) scale(0.85); } } -@keyframes chase-danger-flash { +@keyframes chase-note-live { 0%, 100% { - box-shadow: inset 0 0 0 rgba(244, 63, 94, 0); - filter: saturate(1); + transform: translate(-50%, -50%) scale(1.1); } - 18%, - 58% { - box-shadow: - inset 0 0 80px rgba(244, 63, 94, 0.48), - 0 0 42px rgba(244, 63, 94, 0.42); - filter: saturate(1.5); + 50% { + transform: translate(-50%, -50%) scale(1.22); } } -@keyframes chase-target-missed { +@keyframes chase-hitline-pulse { 0%, 100% { - opacity: 0; - transform: translateY(0) scale(0.98); + opacity: 0.75; + } + + 50% { + opacity: 1; + } +} + +@keyframes chase-calm-breathe { + 0%, + 100% { + opacity: 0.5; } - 18%, - 70% { + 50% { opacity: 1; - transform: translateY(-0.35rem) scale(1); } } diff --git a/src/__tests__/components/features/chase/ChaseTrainingView.test.tsx b/src/__tests__/components/features/chase/ChaseTrainingView.test.tsx index 62a6ed1..efc9bf4 100644 --- a/src/__tests__/components/features/chase/ChaseTrainingView.test.tsx +++ b/src/__tests__/components/features/chase/ChaseTrainingView.test.tsx @@ -1,80 +1,135 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { ChaseTrainingView } from '@/components/features/chase/ChaseTrainingView'; +import type { ChaseNote, ChaseRecentLetter } from '@/hooks/useChaseTrainingSession'; + +const now = Date.now(); + +const makeNote = (overrides: Partial = {}): ChaseNote => ({ + id: 1, + char: 'K', + laneIndex: 4, + toneHz: 500, + spawnedAt: now - 1000, + hitAt: now + 1000, + windowEndAt: now + 3000, + status: 'incoming', + ...overrides, +}); const baseProps = { - target: { - id: 1, - group: 'KMURE', - lanePercent: 50, - level: 2, - spawnedAt: 1000, - deadlineAt: 7000, - fallMs: 6000, - }, - lastResolvedTarget: null, - userInput: '', - lives: 5, + notes: [] as readonly ChaseNote[], + recentLetters: [] as readonly ChaseRecentLetter[], + wpm: 16, + wpmMin: 10, + wpmMax: 25, + calm: false, level: 2, score: 1200, - streak: 3, - bestStreak: 4, + combo: 3, + bestCombo: 7, levelProgress: 0.4, - groupsCompleted: 8, - onChange: jest.fn(), - onSubmit: jest.fn(), + lettersHeard: 8, + correctCount: 6, + wrongCount: 1, + missedCount: 1, + onKeystroke: jest.fn(), onStop: jest.fn(), }; describe('ChaseTrainingView', () => { - it('shows auto-fire copy and high-threat styling for longer groups', () => { + it('renders the HUD with score, combo, live copy rate, and pace', () => { + render(); + + expect(screen.getByText('1200')).toBeInTheDocument(); + expect(screen.getByText(/×3/)).toBeInTheDocument(); + expect(screen.getByText(/best ×7/)).toBeInTheDocument(); + // 6 of 8 resolved letters copied = 75% + expect(screen.getByText('75%')).toBeInTheDocument(); + expect(screen.getByText('16')).toBeInTheDocument(); + expect(screen.getByText('Finish')).toBeInTheDocument(); + }); + + it('renders tone lane labels across the 300-800 Hz band', () => { render(); - expect(screen.getByPlaceholderText('COPY 5')).toBeInTheDocument(); - expect(screen.getByText(/Auto-fires as soon as 5 characters are typed/i)).toBeInTheDocument(); - expect(screen.getByText(/High threat/i)).toBeInTheDocument(); + expect(screen.getByText('300 Hz')).toBeInTheDocument(); + expect(screen.getByText('550 Hz')).toBeInTheDocument(); + expect(screen.getByText('800 Hz')).toBeInTheDocument(); }); - it('shows a miss warning for missed targets', () => { + it('hides the letter on unresolved notes and reveals it once resolved', () => { render( , ); - expect(screen.getByText(/Missed target/i)).toBeInTheDocument(); - expect(screen.getByText(/Missed group/i)).toBeInTheDocument(); - expect(screen.getByText('Answer')).toBeInTheDocument(); - expect(screen.getByText('Typed')).toBeInTheDocument(); - expect(screen.getAllByText('_').length).toBe(3); + expect(screen.getByTestId('chase-note-1')).toHaveTextContent('♪'); + expect(screen.getByTestId('chase-note-2')).toHaveTextContent('M'); + expect(screen.getByTestId('chase-note-3')).toHaveTextContent('R'); }); - it('shows the true answer and typed answer for wrong targets', () => { + it('shows the wrong typed character under a wrong note', () => { render( , ); - expect(screen.getByText(/Wrong group/i)).toBeInTheDocument(); - expect(screen.getByText('Answer')).toBeInTheDocument(); - expect(screen.getByText('Typed')).toBeInTheDocument(); - expect(screen.getAllByText('K').length).toBeGreaterThan(1); - expect(screen.getAllByText('M').length).toBeGreaterThan(1); + const note = screen.getByTestId('chase-note-5'); + expect(note).toHaveTextContent('U'); + expect(note).toHaveTextContent('V'); + }); + + it('renders the recent-letter ticker with outcomes', () => { + render( + , + ); + + const ticker = screen.getByTestId('chase-ticker'); + expect(ticker).toHaveTextContent('K'); + expect(ticker).toHaveTextContent('M'); + }); + + it('shows the breather indicator during calm phases', () => { + render(); + + expect(screen.getByText(/Breather/i)).toBeInTheDocument(); + expect(screen.getByText(/cruising/i)).toBeInTheDocument(); + }); + + it('forwards typed characters as keystrokes', () => { + const onKeystroke = jest.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('TYPE WHAT YOU HEAR'), { + target: { value: 'ab' }, + }); + + expect(onKeystroke).toHaveBeenCalledTimes(2); + expect(onKeystroke).toHaveBeenNthCalledWith(1, 'a'); + expect(onKeystroke).toHaveBeenNthCalledWith(2, 'b'); + }); + + it('calls onStop when Finish is pressed', () => { + const onStop = jest.fn(); + render(); + + fireEvent.click(screen.getByText('Finish')); + expect(onStop).toHaveBeenCalled(); }); }); diff --git a/src/__tests__/components/features/training/TrainingRouter.resilience.test.tsx b/src/__tests__/components/features/training/TrainingRouter.resilience.test.tsx index 9163db4..78488b7 100644 --- a/src/__tests__/components/features/training/TrainingRouter.resilience.test.tsx +++ b/src/__tests__/components/features/training/TrainingRouter.resilience.test.tsx @@ -66,23 +66,25 @@ const echoTraining = { const chaseTraining = { status: 'idle', isTraining: false, - target: null, - lastResolvedTarget: null, - userInput: '', - lives: 3, + notes: [], + recentLetters: [], + wpm: 15, + calm: false, level: 1, score: 0, - streak: 0, - bestStreak: 0, + combo: 0, + bestCombo: 0, correctInLevel: 0, levelProgress: 0, - groupsCompleted: 0, + lettersHeard: 0, + correctCount: 0, + wrongCount: 0, + missedCount: 0, lastSessionResult: null, startTraining: jest.fn(), stopTraining: jest.fn(), dismissResults: jest.fn(), - handleInputChange: jest.fn(), - submitAnswer: jest.fn(), + handleKeystroke: jest.fn(), } as UseChaseTrainingSessionReturn; describe('TrainingRouter active session resilience', () => { @@ -221,6 +223,6 @@ describe('TrainingRouter active session resilience', () => { ); expect(screen.getByText(/Chase Mode/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Start Chase/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Start the Stream/i })).toBeInTheDocument(); }); }); diff --git a/src/__tests__/lib/chase/karaoke.test.ts b/src/__tests__/lib/chase/karaoke.test.ts new file mode 100644 index 0000000..edb1196 --- /dev/null +++ b/src/__tests__/lib/chase/karaoke.test.ts @@ -0,0 +1,173 @@ +import { DEFAULT_TRAINING_SETTINGS } from '@/config/training.config'; +import { + advanceKaraokePace, + applyKaraokeOutcomeToPace, + CHASE_LETTERS_PER_LEVEL, + computeChaseLevelProgress, + computeChaseLevelSettings, + computeKaraokeAccuracy, + createKaraokePace, + KARAOKE_LANE_COUNT, + KARAOKE_TONE_MAX_HZ, + KARAOKE_TONE_MIN_HZ, + karaokeCharAudioMs, + karaokeGapMs, + karaokeLeadMs, + karaokeScoreDelta, + karaokeSlotMs, + karaokeWindowMs, + laneToneHz, + pickNextLane, + resolveKaraokeKeystroke, +} from '@/lib/chase'; + +describe('karaoke tone lanes', () => { + it('spans 300-800 Hz in 50 Hz steps', () => { + expect(KARAOKE_LANE_COUNT).toBe(11); + expect(laneToneHz(0)).toBe(KARAOKE_TONE_MIN_HZ); + expect(laneToneHz(KARAOKE_LANE_COUNT - 1)).toBe(KARAOKE_TONE_MAX_HZ); + expect(laneToneHz(5)).toBe(550); + }); + + it('clamps out-of-range lane indexes', () => { + expect(laneToneHz(-3)).toBe(KARAOKE_TONE_MIN_HZ); + expect(laneToneHz(99)).toBe(KARAOKE_TONE_MAX_HZ); + }); + + it('walks melodically between adjacent lanes and stays in range', () => { + let lane = pickNextLane(null, () => 0.5); + for (let i = 0; i < 200; i++) { + const next = pickNextLane(lane, Math.random); + expect(next).toBeGreaterThanOrEqual(0); + expect(next).toBeLessThan(KARAOKE_LANE_COUNT); + expect(Math.abs(next - lane)).toBeLessThanOrEqual(3); + lane = next; + } + }); +}); + +describe('karaoke pace', () => { + it('starts inside the configured range and never leaves it', () => { + let pace = createKaraokePace(10, 25, () => 0.5); + expect(pace.wpm).toBeGreaterThanOrEqual(10); + expect(pace.wpm).toBeLessThanOrEqual(25); + for (let i = 0; i < 300; i++) { + pace = advanceKaraokePace(pace); + expect(pace.wpm).toBeGreaterThanOrEqual(10); + expect(pace.wpm).toBeLessThanOrEqual(25); + } + }); + + it('slows down on mistakes and speeds up on clean copy', () => { + const pace = createKaraokePace(10, 25, () => 0.5); + expect(applyKaraokeOutcomeToPace(pace, 'correct').wpm).toBeGreaterThan(pace.wpm); + expect(applyKaraokeOutcomeToPace(pace, 'wrong').wpm).toBeLessThan(pace.wpm); + expect(applyKaraokeOutcomeToPace(pace, 'missed').wpm).toBeLessThan( + applyKaraokeOutcomeToPace(pace, 'wrong').wpm, + ); + }); + + it('cycles between flow and calm breather phases', () => { + let pace = createKaraokePace(10, 25, () => 0.5); + expect(pace.calmRemaining).toBe(0); + const seenCalm: boolean[] = []; + for (let i = 0; i < 120; i++) { + pace = advanceKaraokePace(pace, () => 0.5); + seenCalm.push(pace.calmRemaining > 0); + } + expect(seenCalm).toContain(true); + expect(seenCalm).toContain(false); + // Calm phases end and flow resumes. + expect(seenCalm.lastIndexOf(false)).toBeGreaterThan(seenCalm.indexOf(true)); + }); + + it('stretches the gap between letters during a breather', () => { + const flowing = { ...createKaraokePace(10, 25, () => 0.5), calmRemaining: 0 }; + const calm = { ...flowing, calmRemaining: 3 }; + expect(karaokeGapMs(calm)).toBeGreaterThan(karaokeGapMs(flowing)); + }); + + it('derives slot, lead, and window times from the pace', () => { + expect(karaokeSlotMs(20)).toBe(600); + expect(karaokeSlotMs(12)).toBe(1000); + expect(karaokeLeadMs(20)).toBeGreaterThanOrEqual(1600); + expect(karaokeWindowMs(20)).toBeGreaterThanOrEqual(1400); + // Slower pace gives more travel and answer time. + expect(karaokeLeadMs(8)).toBeGreaterThan(karaokeLeadMs(30)); + expect(karaokeWindowMs(8)).toBeGreaterThan(karaokeWindowMs(30)); + }); + + it('estimates per-character audio duration', () => { + // E = one dot = 1 unit; T = one dash = 3 units. + expect(karaokeCharAudioMs('E', 20)).toBe(60); + expect(karaokeCharAudioMs('T', 20)).toBe(180); + expect(karaokeCharAudioMs('0', 20)).toBeGreaterThan(karaokeCharAudioMs('E', 20)); + expect(karaokeCharAudioMs('#', 20)).toBe(0); + }); +}); + +describe('resolveKaraokeKeystroke', () => { + const pending = [ + { id: 1, char: 'K' }, + { id: 2, char: 'M' }, + { id: 3, char: 'R' }, + ]; + + it('matches the oldest pending note', () => { + expect(resolveKaraokeKeystroke(pending, 'k')).toEqual([{ id: 1, outcome: 'correct' }]); + }); + + it('typing ahead marks the earlier letters missed', () => { + expect(resolveKaraokeKeystroke(pending, 'R')).toEqual([ + { id: 1, outcome: 'missed' }, + { id: 2, outcome: 'missed' }, + { id: 3, outcome: 'correct' }, + ]); + }); + + it('marks the oldest note wrong when nothing matches', () => { + expect(resolveKaraokeKeystroke(pending, 'Z')).toEqual([{ id: 1, outcome: 'wrong' }]); + }); + + it('ignores keystrokes when nothing is pending', () => { + expect(resolveKaraokeKeystroke([], 'K')).toEqual([]); + }); +}); + +describe('karaoke scoring and accuracy', () => { + it('pays more for faster pace and longer combos', () => { + expect(karaokeScoreDelta(5, 25, 1)).toBeGreaterThan(karaokeScoreDelta(5, 12, 1)); + expect(karaokeScoreDelta(10, 20, 1)).toBeGreaterThan(karaokeScoreDelta(1, 20, 1)); + expect(karaokeScoreDelta(1, 20, 4)).toBeGreaterThan(karaokeScoreDelta(1, 20, 1)); + }); + + it('computes accuracy over resolved letters', () => { + expect(computeKaraokeAccuracy(0, 0)).toBe(0); + expect(computeKaraokeAccuracy(3, 4)).toBe(0.75); + expect(computeKaraokeAccuracy(9, 9)).toBe(1); + }); +}); + +describe('chase level progression', () => { + it('unlocks more Koch characters as levels increase', () => { + const settings = computeChaseLevelSettings(DEFAULT_TRAINING_SETTINGS, 4); + + expect(settings.kochLevel).toBe(DEFAULT_TRAINING_SETTINGS.kochLevel + 2); + expect(settings.digitsLevel).toBe(DEFAULT_TRAINING_SETTINGS.digitsLevel + 1); + }); + + it('keeps the character level fixed when auto-level is disabled', () => { + const settings = computeChaseLevelSettings( + { ...DEFAULT_TRAINING_SETTINGS, chaseAutoLevelEnabled: false }, + 4, + ); + + expect(settings.kochLevel).toBe(DEFAULT_TRAINING_SETTINGS.kochLevel); + }); + + it('tracks per-level completion progress', () => { + expect(computeChaseLevelProgress(0)).toBe(0); + expect(computeChaseLevelProgress(CHASE_LETTERS_PER_LEVEL)).toBe(1); + expect(computeChaseLevelProgress(3, 6)).toBe(0.5); + }); +}); diff --git a/src/__tests__/lib/chase/progression.test.ts b/src/__tests__/lib/chase/progression.test.ts deleted file mode 100644 index 508dc32..0000000 --- a/src/__tests__/lib/chase/progression.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { DEFAULT_TRAINING_SETTINGS } from '@/config/training.config'; -import { - CHASE_GROUPS_PER_LEVEL, - CHASE_MIN_FALL_MS, - computeChaseFallMs, - computeChaseLevelProgress, - computeChaseLevelSettings, - resolveChaseTarget, -} from '@/lib/chase'; - -describe('chase progression', () => { - it('ramps fall timing down without going below the minimum', () => { - const early = computeChaseFallMs({ level: 1, targetIndex: 0 }); - const late = computeChaseFallMs({ level: 12, targetIndex: 80 }); - - expect(late).toBeLessThan(early); - expect(late).toBeGreaterThanOrEqual(CHASE_MIN_FALL_MS); - }); - - it('uses Chase timing settings for pressure ramping', () => { - const fallMs = computeChaseFallMs({ - level: 3, - targetIndex: 4, - groupsPerLevel: 2, - startFallMs: 5000, - minFallMs: 2000, - levelSpeedupMs: 500, - groupSpeedupMs: 100, - }); - - expect(fallMs).toBe(3400); - }); - - it('unlocks more Koch characters as Chase levels increase', () => { - const settings = computeChaseLevelSettings(DEFAULT_TRAINING_SETTINGS, 4); - - expect(settings.kochLevel).toBe(DEFAULT_TRAINING_SETTINGS.kochLevel + 2); - expect(settings.digitsLevel).toBe(DEFAULT_TRAINING_SETTINGS.digitsLevel + 1); - }); - - it('keeps Chase character level fixed when Chase auto-level is disabled', () => { - const settings = computeChaseLevelSettings( - { ...DEFAULT_TRAINING_SETTINGS, chaseAutoLevelEnabled: false }, - 4, - ); - - expect(settings.kochLevel).toBe(DEFAULT_TRAINING_SETTINGS.kochLevel); - }); - - it('tracks per-level completion progress', () => { - expect(computeChaseLevelProgress(0)).toBe(0); - expect(computeChaseLevelProgress(CHASE_GROUPS_PER_LEVEL)).toBe(1); - expect(computeChaseLevelProgress(3, 6)).toBe(0.5); - }); - - it('awards streak score for correct groups', () => { - const result = resolveChaseTarget({ - expected: 'KM', - received: 'KM', - outcome: 'correct', - level: 2, - lives: 3, - score: 100, - streak: 1, - remainingMs: 2500, - }); - - expect(result.correct).toBe(true); - expect(result.lives).toBe(3); - expect(result.streak).toBe(2); - expect(result.score).toBeGreaterThan(100); - }); - - it('awards more points for longer groups', () => { - const short = resolveChaseTarget({ - expected: 'KM', - received: 'KM', - outcome: 'correct', - level: 1, - lives: 3, - score: 0, - streak: 0, - remainingMs: 1000, - }); - const long = resolveChaseTarget({ - expected: 'KMURE', - received: 'KMURE', - outcome: 'correct', - level: 1, - lives: 3, - score: 0, - streak: 0, - remainingMs: 1000, - }); - - expect(long.scoreDelta).toBeGreaterThan(short.scoreDelta); - }); - - it('removes a life and resets streak for wrong groups', () => { - const result = resolveChaseTarget({ - expected: 'KM', - received: 'KK', - outcome: 'wrong', - level: 2, - lives: 3, - score: 100, - streak: 4, - remainingMs: 1200, - }); - - expect(result.correct).toBe(false); - expect(result.lives).toBe(2); - expect(result.streak).toBe(0); - expect(result.score).toBe(100); - }); -}); diff --git a/src/__tests__/lib/training/chaseSessionMachine.test.ts b/src/__tests__/lib/training/chaseSessionMachine.test.ts index 32bf6e2..8ce331c 100644 --- a/src/__tests__/lib/training/chaseSessionMachine.test.ts +++ b/src/__tests__/lib/training/chaseSessionMachine.test.ts @@ -4,25 +4,60 @@ import { completeChaseTrainingSession, isChaseTrainingRuntimeActive, isChaseTrainingRuntimeBlockingSync, + patchChaseTrainingRuntime, } from '@/lib/training/chaseSessionMachine'; describe('chaseSessionMachine', () => { it('blocks sync while running and clears after cancel or results', () => { - const running = beginChaseTrainingSession({ sessionId: 1, startedAt: 100, lives: 3 }); + const running = beginChaseTrainingSession({ sessionId: 1, startedAt: 100, startWpm: 15 }); expect(isChaseTrainingRuntimeActive(running)).toBe(true); expect(isChaseTrainingRuntimeBlockingSync(running)).toBe(true); const results = completeChaseTrainingSession({ accuracy: 0.5, - groups: [], - avgResponseMs: 0, score: 10, maxLevel: 2, - survivedMs: 1000, - bestStreak: 3, - livesLost: 3, + durationMs: 1000, + bestCombo: 3, + lettersTotal: 8, + correctCount: 4, + wrongCount: 2, + missedCount: 2, + peakWpm: 18, + avgResponseMs: 700, + letters: [{ char: 'K', typed: 'K', outcome: 'correct' }], }); expect(isChaseTrainingRuntimeBlockingSync(results)).toBe(false); expect(cancelChaseTrainingSession().status).toBe('idle'); }); + + it('patches karaoke runtime fields only while active', () => { + const running = beginChaseTrainingSession({ sessionId: 1, startedAt: 100, startWpm: 15 }); + const patched = patchChaseTrainingRuntime(running, { + wpm: 18.5, + calm: true, + combo: 4, + notes: [ + { + id: 0, + char: 'K', + laneIndex: 3, + toneHz: 450, + spawnedAt: 100, + hitAt: 2100, + windowEndAt: 4100, + status: 'heard', + }, + ], + }); + if (patched.status === 'idle' || patched.status === 'results') { + throw new Error('expected an active snapshot after patching'); + } + expect(patched.wpm).toBe(18.5); + expect(patched.calm).toBe(true); + expect(patched.combo).toBe(4); + expect(patched.notes).toHaveLength(1); + + expect(patchChaseTrainingRuntime({ status: 'idle' }, { wpm: 20 }).status).toBe('idle'); + }); }); diff --git a/src/__tests__/store/selectors.test.ts b/src/__tests__/store/selectors.test.ts index b3ae0dc..c6a211e 100644 --- a/src/__tests__/store/selectors.test.ts +++ b/src/__tests__/store/selectors.test.ts @@ -90,7 +90,7 @@ describe('selectTrainingSessionActive', () => { const running = beginChaseTrainingSession({ sessionId: 1, startedAt: Date.now(), - lives: 3, + startWpm: 15, }); expect( selectTrainingSessionActive( diff --git a/src/components/features/chase/ChaseHomeView.tsx b/src/components/features/chase/ChaseHomeView.tsx index 703f129..9bb00d8 100644 --- a/src/components/features/chase/ChaseHomeView.tsx +++ b/src/components/features/chase/ChaseHomeView.tsx @@ -2,6 +2,11 @@ import React from 'react'; +import { + KARAOKE_TONE_MAX_HZ, + KARAOKE_TONE_MIN_HZ, + KARAOKE_TONE_STEP_HZ, +} from '@/lib/chase'; import type { TrainingSettings } from '@/types'; interface ChaseHomeViewProps { @@ -20,36 +25,44 @@ export function ChaseHomeView({ return (
-
+
-

- Arcade Copy +

+ Karaoke Copy

Chase Mode

- Falling Morse groups are closing in. Copy the full group before it reaches the danger - zone. Every level tightens the timer and unlocks another character from your current - training sequence. + Letters stream across the timeline like karaoke notes, each sung at its own tone + between {KARAOKE_TONE_MIN_HZ} and {KARAOKE_TONE_MAX_HZ} Hz. Type what you hear + before the note drifts away. The pace wanders like a real conversation — clean copy + speeds it up, mistakes ease it off, and breather phases give you room to reset.

-
-

Lives

-

{settings.chaseLives}

-
-

Groups / level

-

{settings.chaseGroupsPerLevel}

+

Pace range

+

+ {settings.effectiveWpmMin}–{settings.effectiveWpmMax} + wpm +

+
+
+

Tone lanes

+

+ {KARAOKE_TONE_MIN_HZ}–{KARAOKE_TONE_MAX_HZ} + Hz +

+

{KARAOKE_TONE_STEP_HZ} Hz steps

Koch start

{settings.kochLevel}

-

Best latest

+

Last copy

{sessionCount > 0 ? `${lastAccuracyPercent}%` : 'New'}

@@ -57,10 +70,12 @@ export function ChaseHomeView({
-

How to survive

+

How to flow

- Listen first, type the full group, then press Enter. Wrong answers and missed groups - cost a life. Streaks and fast confirmations push your score up. + Type each letter as it crosses the line. If one slips past, let it go — typing a + later letter drops the ones before it, just like real conversations move on. There + are no lives: play as long as it feels good, then press Finish to see how well you + copied the whole exchange.

@@ -68,9 +83,9 @@ export function ChaseHomeView({
diff --git a/src/components/features/chase/ChaseResultsView.tsx b/src/components/features/chase/ChaseResultsView.tsx index fb360f2..8aa7606 100644 --- a/src/components/features/chase/ChaseResultsView.tsx +++ b/src/components/features/chase/ChaseResultsView.tsx @@ -2,7 +2,6 @@ import React from 'react'; -import { CharacterComparison } from '@/components/ui/training/CharacterComparison'; import type { ChaseSessionResultSummary } from '@/hooks/useChaseTrainingSession'; interface ChaseResultsViewProps { @@ -18,25 +17,45 @@ function formatDuration(ms: number): string { return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`; } +const OUTCOME_SEGMENT: Record = { + correct: 'bg-emerald-400', + wrong: 'bg-rose-400', + missed: 'bg-slate-600', +}; + +const OUTCOME_TILE: Record = { + correct: 'border-emerald-400/50 bg-emerald-400/10 text-emerald-200', + wrong: 'border-rose-400/50 bg-rose-400/10 text-rose-200', + missed: 'border-slate-500/50 bg-slate-500/10 text-slate-400', +}; + export function ChaseResultsView({ result, onTrainAgain, onBack, }: ChaseResultsViewProps): JSX.Element { + const copyPercent = Math.round(result.accuracy * 100); + const headline = + copyPercent >= 90 + ? 'Beautiful copy — you rode the whole conversation.' + : copyPercent >= 70 + ? 'Solid copy — you stayed with the flow.' + : copyPercent >= 50 + ? 'Good fight — letting go is half the skill.' + : 'Rough band conditions — every pass makes the next one easier.'; + return (
-
+
-

- Chase Over +

+ Conversation Over

-

- You survived {formatDuration(result.survivedMs)} -

+

{copyPercent}% copied

- Final score {Math.round(result.score)} with {Math.round(result.accuracy * 100)}% copy. + {headline} {result.lettersTotal} letters over {formatDuration(result.durationMs)}.

@@ -45,43 +64,64 @@ export function ChaseResultsView({

Score

{Math.round(result.score)}

+
+

Best combo

+

×{result.bestCombo}

+
-

Max level

+

Peak pace

+

+ {result.peakWpm} + wpm +

+
+
+

Max level

{result.maxLevel}

-
-

Best streak

-

{result.bestStreak}

+
+ +
+
+

+ Conversation heat-line +

+

+ {result.correctCount} copied + {' · '} + {result.wrongCount} wrong + {' · '} + {result.missedCount} let go +

-
-

Groups

-

{result.groups.length}

+
+ {result.letters.map((letter, index) => ( + + ))}

- Final wave log + Letter log

-
- {result.groups.map((group, index) => ( -
+ {result.letters.map((letter, index) => ( + -
- Target {index + 1} - - {group.correct ? 'Copied' : 'Lost'} + {letter.char} + {letter.outcome === 'wrong' ? ( + + {letter.typed} -
- -
+ ) : null} + ))}
@@ -90,9 +130,9 @@ export function ChaseResultsView({
inputRef.current?.focus()} > -
-
-

- Danger Zone -

+ {/* Lane grid + tone labels */} + {laneLabels.map((tone, i) => { + const laneIndex = KARAOKE_LANE_COUNT - 1 - i; + return ( +
+ + {tone} Hz + +
+ ); + })} + + {/* Answer window: everything left of the hit line is "type it now" space */} +
+ + {/* Hit line */} +
+ + Now +
- {target ? ( -
-

- {threat?.name ?? 'Threat'} · {target.group.length} chars -

-

- {'?'.repeat(target.group.length)} -

-
-
+ {calm ? ( +
+
+ ☕ Breather
) : null} - {lastResolvedTarget && dangerFlash ? ( -
-

- {lastResolvedTarget.outcome === 'missed' ? 'Missed target' : 'Wrong copy'} -

-

- {lastResolvedTarget.sent} -

-
- ) : null} + {notes.map((note) => ( + + ))}
-
-
- Groups copied: {groupsCompleted} - Best streak: {bestStreak} - {lastResolvedTarget ? ( - - Last: {lastResolvedTarget.sent} / {lastResolvedTarget.received || 'MISS'} - - ) : null} -
- {lastResolvedTarget && dangerFlash ? ( -
-

- {lastResolvedTarget.outcome === 'missed' ? 'Missed group' : 'Wrong group'} -

- + {/* Pace gauge */} +
+ + {wpm.toFixed(0)} WPM + +
+
- ) : null} -
{ - event.preventDefault(); - onSubmit(); + + {calm ? 'cruising' : 'pace'} + +
+ + {/* Conversation copy ticker */} +
+ {recentLetters.length === 0 ? ( + + Copy each letter as it crosses the line — heard {lettersHeard} so far. + + ) : ( + recentLetters.map((letter) => ( + + {letter.char} + + )) + )} +
+ + { + for (const char of event.target.value) { + onKeystroke(char); + } }} - className="flex gap-2" - > - onChange(event.target.value)} - className="min-w-0 flex-1 rounded-xl border border-cyan-300/40 bg-slate-950 px-4 py-3 text-2xl font-black uppercase tracking-[0.25em] text-cyan-100 outline-none ring-cyan-300/30 transition focus:ring-4" - placeholder={target ? `COPY ${target.group.length}` : 'COPY'} - autoComplete="off" - autoFocus - spellCheck={false} - /> - - -

- Auto-fires as soon as {target ? target.group.length : 'the final'} characters are typed. + className="w-full rounded-xl border border-cyan-300/40 bg-slate-950 px-4 py-3 text-center text-lg font-black uppercase tracking-[0.3em] text-cyan-100 outline-none ring-cyan-300/30 transition placeholder:text-slate-600 focus:ring-4" + placeholder="TYPE WHAT YOU HEAR" + autoComplete="off" + autoFocus + spellCheck={false} + /> +

+ Missed one? Let it go — the next letter matters more. Typing a later letter drops the + ones before it.

-
+ +
[ for dot and ] for dash.
  • - Chase: Copy falling Morse groups before - they hit the danger zone. Speed rises and lives are limited. + Chase: Karaoke-style letter stream — + notes fly by at different tones; type what you hear before each one drifts + away. The pace adapts to how well you copy.
  • Player: Type any text and play it as diff --git a/src/components/features/training/TrainingRouter.tsx b/src/components/features/training/TrainingRouter.tsx index 14e0678..7345937 100644 --- a/src/components/features/training/TrainingRouter.tsx +++ b/src/components/features/training/TrainingRouter.tsx @@ -289,18 +289,22 @@ export function TrainingRouter({ ) { return ( ); diff --git a/src/components/ui/forms/ChaseSettingsForm.tsx b/src/components/ui/forms/ChaseSettingsForm.tsx index 39de8f6..cca0cf7 100644 --- a/src/components/ui/forms/ChaseSettingsForm.tsx +++ b/src/components/ui/forms/ChaseSettingsForm.tsx @@ -14,7 +14,7 @@ interface ChaseSettingsFormProps { function SectionBadge(): JSX.Element { return ( - + Chase only ); @@ -25,16 +25,7 @@ export function ChaseSettingsForm({ setSettings, onSaveSettings, }: ChaseSettingsFormProps): JSX.Element { - const livesField = useNumberField(settings.chaseLives ?? 3, { - min: 1, - max: 6, - step: 1, - fieldName: 'chaseLives', - onChange: (n) => setSettings((prev) => ({ ...prev, chaseLives: n })), - ...(onSaveSettings !== undefined ? { onSave: onSaveSettings } : {}), - }); - - const groupsPerLevelField = useNumberField(settings.chaseGroupsPerLevel ?? 5, { + const lettersPerLevelField = useNumberField(settings.chaseGroupsPerLevel ?? 5, { min: 1, max: 50, step: 1, @@ -43,50 +34,15 @@ export function ChaseSettingsForm({ ...(onSaveSettings !== undefined ? { onSave: onSaveSettings } : {}), }); - const startFallField = useNumberField(settings.chaseStartFallMs ?? 7200, { - min: Math.max(500, settings.chaseMinFallMs ?? 1800), - max: 60000, - step: 100, - fieldName: 'chaseStartFallMs', - onChange: (n) => setSettings((prev) => ({ ...prev, chaseStartFallMs: n })), - ...(onSaveSettings !== undefined ? { onSave: onSaveSettings } : {}), - }); - - const minFallField = useNumberField(settings.chaseMinFallMs ?? 1800, { - min: 500, - max: Math.max(500, settings.chaseStartFallMs ?? 7200), - step: 100, - fieldName: 'chaseMinFallMs', - onChange: (n) => setSettings((prev) => ({ ...prev, chaseMinFallMs: n })), - ...(onSaveSettings !== undefined ? { onSave: onSaveSettings } : {}), - }); - - const levelSpeedupField = useNumberField(settings.chaseLevelSpeedupMs ?? 430, { - min: 0, - max: 5000, - step: 10, - fieldName: 'chaseLevelSpeedupMs', - onChange: (n) => setSettings((prev) => ({ ...prev, chaseLevelSpeedupMs: n })), - ...(onSaveSettings !== undefined ? { onSave: onSaveSettings } : {}), - }); - - const groupSpeedupField = useNumberField(settings.chaseGroupSpeedupMs ?? 28, { - min: 0, - max: 5000, - step: 1, - fieldName: 'chaseGroupSpeedupMs', - onChange: (n) => setSettings((prev) => ({ ...prev, chaseGroupSpeedupMs: n })), - ...(onSaveSettings !== undefined ? { onSave: onSaveSettings } : {}), - }); - return ( -
    +
    -

    Chase game progression

    +

    Chase karaoke stream

    - These settings affect only Chase mode. Shared Morse audio, character set, and group size - are above. + The stream's pace wanders inside your shared effective WPM range + above: clean copy pushes it toward the top, mistakes ease it back down. Notes play at + tones from 300 to 800 Hz regardless of the shared side-tone setting.

    @@ -94,27 +50,17 @@ export function ChaseSettingsForm({
    - - -
    ); diff --git a/src/components/ui/forms/TrainingSettingsForm.tsx b/src/components/ui/forms/TrainingSettingsForm.tsx index 4afd24a..a47658c 100644 --- a/src/components/ui/forms/TrainingSettingsForm.tsx +++ b/src/components/ui/forms/TrainingSettingsForm.tsx @@ -810,7 +810,7 @@ export function TrainingSettingsForm({

    WPM and group size are shared. The session count below is for Group/Echo sessions; - Chase has its own group count setting when Chase is selected. + Chase streams single letters and paces itself inside the effective WPM range.