From d6acfc59a74863e1d5db5437d62615cf27e5c5bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 06:37:35 +0000 Subject: [PATCH 1/4] feat: Implement D1 score saving with useFetcher - Add D1 database schema migration for scores table - Create result/create route to save game scores to D1 - Create result/$id route to display saved game results - Update play route to submit scores using useFetcher - Add nanoid utility for generating unique IDs - Track all solved issues across game sessions for accurate statistics - Support player name registration on result page Features: - Automatic score saving when game ends - Result page with score, accuracy, and statistics - Share result URL functionality - Optional player name registration --- app/routes/$lang.$codeLanguage.play.tsx | 61 +++++- app/routes/result.$id.tsx | 249 ++++++++++++++++++++++++ app/routes/result.create.tsx | 86 ++++++++ app/utils/nanoid.ts | 15 ++ migrations/0001_create_scores_table.sql | 20 ++ 5 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 app/routes/result.$id.tsx create mode 100644 app/routes/result.create.tsx create mode 100644 app/utils/nanoid.ts create mode 100644 migrations/0001_create_scores_table.sql diff --git a/app/routes/$lang.$codeLanguage.play.tsx b/app/routes/$lang.$codeLanguage.play.tsx index 80e73f0..b9aaae5 100644 --- a/app/routes/$lang.$codeLanguage.play.tsx +++ b/app/routes/$lang.$codeLanguage.play.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { redirect } from 'react-router'; +import { redirect, useFetcher } from 'react-router'; import type { Route } from './+types/$lang.$codeLanguage.play'; import type { SupportedLanguage } from '../locales'; import { t } from '../locales'; @@ -16,7 +16,8 @@ type GameState = { score: number; combo: number; remainingSeconds: number; // 60 → 0 - solvedIssueIds: string[]; // IDs of issues that have been found + solvedIssueIds: string[]; // IDs of issues that have been found in current problem + allSolvedIssueIds: string[]; // IDs of all issues found across all problems tappedLines: Record; // Map of problem ID to tapped lines problemCount: number; // Number of problems solved usedProblemIds: string[]; // IDs of problems that have been used @@ -118,6 +119,7 @@ function selectNextProblemWithLevelAdvance( */ export default function Play({ loaderData }: Route.ComponentProps) { const { lang, codeLanguage } = loaderData; + const fetcher = useFetcher(); // Initialize game state const [gameState, setGameState] = useState(() => { @@ -129,12 +131,14 @@ export default function Play({ loaderData }: Route.ComponentProps) { combo: 0, remainingSeconds: 60, solvedIssueIds: [], + allSolvedIssueIds: [], tappedLines: {}, problemCount: 0, usedProblemIds: firstProblem ? [firstProblem.id] : [], }; }); const [gameEnded, setGameEnded] = useState(false); + const [scoreSaved, setScoreSaved] = useState(false); const [feedbackMessage, setFeedbackMessage] = useState<{ type: 'correct' | 'wrong' | 'complete'; text: string; @@ -168,6 +172,46 @@ export default function Play({ loaderData }: Route.ComponentProps) { } }, [feedbackMessage]); + // Save score when game ends + useEffect(() => { + if (gameEnded && !scoreSaved) { + // Calculate total issues across all problems + const allProblems = getProblems(codeLanguage, 1) + .concat(getProblems(codeLanguage, 2)) + .concat(getProblems(codeLanguage, 3)); + + const usedProblems = allProblems.filter((p) => + gameState.usedProblemIds.includes(p.id) + ); + + const totalIssues = usedProblems.reduce( + (sum, problem) => sum + problem.issues.length, + 0 + ); + + const issuesFound = gameState.allSolvedIssueIds.length; + const accuracy = totalIssues > 0 ? issuesFound / totalIssues : 0; + + // Prepare result data + const resultData = { + score: gameState.score, + issuesFound: issuesFound, + totalIssues: totalIssues, + accuracy: accuracy, + uiLanguage: lang, + codeLanguage: codeLanguage, + }; + + // Submit to result/create action + fetcher.submit( + { payload: JSON.stringify(resultData) }, + { method: 'post', action: '/result/create' } + ); + + setScoreSaved(true); + } + }, [gameEnded, scoreSaved, gameState, codeLanguage, lang, fetcher]); + /** * Handle line tap */ @@ -199,6 +243,7 @@ export default function Play({ loaderData }: Route.ComponentProps) { const newCombo = prev.combo + 1; const scoreGain = calculateScore(hitIssue.score, newCombo); const newSolvedIssueIds = [...prev.solvedIssueIds, hitIssue.id]; + const newAllSolvedIssueIds = [...prev.allSolvedIssueIds, hitIssue.id]; setFeedbackMessage({ type: 'correct', @@ -210,6 +255,7 @@ export default function Play({ loaderData }: Route.ComponentProps) { score: prev.score + scoreGain, combo: newCombo, solvedIssueIds: newSolvedIssueIds, + allSolvedIssueIds: newAllSolvedIssueIds, tappedLines: newTappedLines, }; } else { @@ -279,6 +325,8 @@ export default function Play({ loaderData }: Route.ComponentProps) { // Show game over screen if (gameEnded) { + const isSaving = fetcher.state === 'submitting' || fetcher.state === 'loading'; + return ( <>
@@ -302,8 +350,15 @@ export default function Play({ loaderData }: Route.ComponentProps) { : `Solved ${gameState.problemCount} problems`} + + {isSaving && ( +
+ {lang === 'ja' ? 'スコアを保存中...' : 'Saving score...'} +
+ )} + + + )} + + + {/* Action Buttons */} +
+ + {lang === 'ja' ? 'もう一度プレイ' : 'Play Again'} + + + {lang === 'ja' ? 'ランキングを見る' : 'View Ranking'} + +
+ + {/* Share Link */} +
+
+ {lang === 'ja' ? 'この結果をシェア' : 'Share this result'} +
+
+ e.currentTarget.select()} + /> + +
+
+ + + + ); +} diff --git a/app/routes/result.create.tsx b/app/routes/result.create.tsx new file mode 100644 index 0000000..2814419 --- /dev/null +++ b/app/routes/result.create.tsx @@ -0,0 +1,86 @@ +import { redirect, data } from 'react-router'; +import type { Route } from './+types/result.create'; +import { nanoid } from '../utils/nanoid'; + +/** + * Type definition for game result data + */ +type GameResultData = { + score: number; + issuesFound: number; + totalIssues: number; + accuracy: number; + uiLanguage: string; + codeLanguage: string; +}; + +/** + * Action to create a new score record in D1 + */ +export async function action({ request, context }: Route.ActionArgs) { + // Get D1 database from Cloudflare bindings + const db = context.cloudflare.env.DB; + + if (!db) { + throw data({ error: 'Database not configured' }, { status: 500 }); + } + + // Parse form data + const formData = await request.formData(); + const payloadStr = formData.get('payload'); + + if (!payloadStr || typeof payloadStr !== 'string') { + throw data({ error: 'Invalid payload' }, { status: 400 }); + } + + let result: GameResultData; + try { + result = JSON.parse(payloadStr); + } catch (e) { + throw data({ error: 'Invalid JSON payload' }, { status: 400 }); + } + + // Validate required fields + if ( + typeof result.score !== 'number' || + typeof result.issuesFound !== 'number' || + typeof result.totalIssues !== 'number' || + typeof result.accuracy !== 'number' || + typeof result.uiLanguage !== 'string' || + typeof result.codeLanguage !== 'string' + ) { + throw data({ error: 'Missing or invalid fields' }, { status: 400 }); + } + + // Generate unique ID + const id = nanoid(); + const createdAt = new Date().toISOString(); + + try { + // Insert into D1 database + await db + .prepare( + `INSERT INTO scores ( + id, score, issues_found, total_issues, accuracy, + ui_language, code_language, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + id, + result.score, + result.issuesFound, + result.totalIssues, + result.accuracy, + result.uiLanguage, + result.codeLanguage, + createdAt + ) + .run(); + + // Redirect to result page + return redirect(`/result/${id}`); + } catch (error) { + console.error('Failed to save score:', error); + throw data({ error: 'Failed to save score' }, { status: 500 }); + } +} diff --git a/app/utils/nanoid.ts b/app/utils/nanoid.ts new file mode 100644 index 0000000..415db14 --- /dev/null +++ b/app/utils/nanoid.ts @@ -0,0 +1,15 @@ +/** + * Generate a unique ID similar to nanoid + * Uses crypto.randomUUID() for better entropy + */ +export function nanoid(): string { + // Use crypto.randomUUID() if available (modern browsers and Node.js 16+) + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // Fallback: simple ID generator + const timestamp = Date.now().toString(36); + const randomPart = Math.random().toString(36).substring(2, 15); + return `${timestamp}-${randomPart}`; +} diff --git a/migrations/0001_create_scores_table.sql b/migrations/0001_create_scores_table.sql new file mode 100644 index 0000000..69d0e13 --- /dev/null +++ b/migrations/0001_create_scores_table.sql @@ -0,0 +1,20 @@ +-- Migration: Create scores table for Bug Sniper game results +-- Created: 2025-11-22 + +CREATE TABLE IF NOT EXISTS scores ( + id TEXT PRIMARY KEY, + score INTEGER NOT NULL, + issues_found INTEGER NOT NULL, + total_issues INTEGER NOT NULL, + accuracy REAL NOT NULL, + ui_language TEXT NOT NULL, + code_language TEXT NOT NULL, + player_name TEXT, + created_at TEXT NOT NULL, + llm_feedback TEXT +); + +-- Create index for faster ranking queries +CREATE INDEX IF NOT EXISTS idx_scores_created_at ON scores(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_scores_score ON scores(score DESC); +CREATE INDEX IF NOT EXISTS idx_scores_code_language ON scores(code_language); From 7e16c5d0f1d4bb9d61537eb8c8d9e135caace464 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 07:00:22 +0000 Subject: [PATCH 2/4] fix: Navigate to result page after score submission - Change result.create action to return result ID instead of redirect - Add useNavigate to handle client-side navigation after score save - Fix 404 error that occurred when game ended - useFetcher doesn't automatically follow redirects, so we handle navigation manually --- app/routes/$lang.$codeLanguage.play.tsx | 13 ++++++++++++- app/routes/result.create.tsx | 6 +++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/routes/$lang.$codeLanguage.play.tsx b/app/routes/$lang.$codeLanguage.play.tsx index b9aaae5..5444503 100644 --- a/app/routes/$lang.$codeLanguage.play.tsx +++ b/app/routes/$lang.$codeLanguage.play.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { redirect, useFetcher } from 'react-router'; +import { redirect, useFetcher, useNavigate } from 'react-router'; import type { Route } from './+types/$lang.$codeLanguage.play'; import type { SupportedLanguage } from '../locales'; import { t } from '../locales'; @@ -120,6 +120,7 @@ function selectNextProblemWithLevelAdvance( export default function Play({ loaderData }: Route.ComponentProps) { const { lang, codeLanguage } = loaderData; const fetcher = useFetcher(); + const navigate = useNavigate(); // Initialize game state const [gameState, setGameState] = useState(() => { @@ -212,6 +213,16 @@ export default function Play({ loaderData }: Route.ComponentProps) { } }, [gameEnded, scoreSaved, gameState, codeLanguage, lang, fetcher]); + // Navigate to result page when score is saved + useEffect(() => { + if (fetcher.data && fetcher.state === 'idle') { + const data = fetcher.data as { success: boolean; id: string }; + if (data.success && data.id) { + navigate(`/result/${data.id}`); + } + } + }, [fetcher.data, fetcher.state, navigate]); + /** * Handle line tap */ diff --git a/app/routes/result.create.tsx b/app/routes/result.create.tsx index 2814419..6ca476a 100644 --- a/app/routes/result.create.tsx +++ b/app/routes/result.create.tsx @@ -1,4 +1,4 @@ -import { redirect, data } from 'react-router'; +import { data } from 'react-router'; import type { Route } from './+types/result.create'; import { nanoid } from '../utils/nanoid'; @@ -77,8 +77,8 @@ export async function action({ request, context }: Route.ActionArgs) { ) .run(); - // Redirect to result page - return redirect(`/result/${id}`); + // Return the result ID for client-side navigation + return { success: true, id }; } catch (error) { console.error('Failed to save score:', error); throw data({ error: 'Failed to save score' }, { status: 500 }); From 3d64affecfa07a127e4af631e2128ba3f1ebafca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 07:30:56 +0000 Subject: [PATCH 3/4] fix: Add result routes to routes.ts and debug logging - Add result/create and result/:id routes to routes.ts configuration - React Router v7 requires explicit route definitions - Add comprehensive debug logging for troubleshooting - Fix 404 error by properly registering result routes --- app/routes.ts | 2 ++ app/routes/$lang.$codeLanguage.play.tsx | 6 ++++++ app/routes/result.create.tsx | 19 +++++++++++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/routes.ts b/app/routes.ts index c446427..f709beb 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -4,4 +4,6 @@ export default [ index('routes/_index.tsx'), route(':lang', 'routes/$lang.tsx'), route(':lang/:codeLanguage/play', 'routes/$lang.$codeLanguage.play.tsx'), + route('result/create', 'routes/result.create.tsx'), + route('result/:id', 'routes/result.$id.tsx'), ] satisfies RouteConfig; diff --git a/app/routes/$lang.$codeLanguage.play.tsx b/app/routes/$lang.$codeLanguage.play.tsx index 5444503..2b67fac 100644 --- a/app/routes/$lang.$codeLanguage.play.tsx +++ b/app/routes/$lang.$codeLanguage.play.tsx @@ -215,9 +215,15 @@ export default function Play({ loaderData }: Route.ComponentProps) { // Navigate to result page when score is saved useEffect(() => { + console.log('[play] Fetcher state:', fetcher.state); + console.log('[play] Fetcher data:', fetcher.data); + if (fetcher.data && fetcher.state === 'idle') { const data = fetcher.data as { success: boolean; id: string }; + console.log('[play] Received data:', data); + if (data.success && data.id) { + console.log('[play] Navigating to result page:', `/result/${data.id}`); navigate(`/result/${data.id}`); } } diff --git a/app/routes/result.create.tsx b/app/routes/result.create.tsx index 6ca476a..78e6ee3 100644 --- a/app/routes/result.create.tsx +++ b/app/routes/result.create.tsx @@ -18,25 +18,35 @@ type GameResultData = { * Action to create a new score record in D1 */ export async function action({ request, context }: Route.ActionArgs) { + console.log('[result.create] Action called'); + // Get D1 database from Cloudflare bindings const db = context.cloudflare.env.DB; if (!db) { + console.error('[result.create] Database not configured'); throw data({ error: 'Database not configured' }, { status: 500 }); } + console.log('[result.create] Database available'); + // Parse form data const formData = await request.formData(); const payloadStr = formData.get('payload'); + console.log('[result.create] Payload:', payloadStr); + if (!payloadStr || typeof payloadStr !== 'string') { + console.error('[result.create] Invalid payload'); throw data({ error: 'Invalid payload' }, { status: 400 }); } let result: GameResultData; try { result = JSON.parse(payloadStr); + console.log('[result.create] Parsed result:', result); } catch (e) { + console.error('[result.create] JSON parse error:', e); throw data({ error: 'Invalid JSON payload' }, { status: 400 }); } @@ -57,8 +67,10 @@ export async function action({ request, context }: Route.ActionArgs) { const createdAt = new Date().toISOString(); try { + console.log('[result.create] Inserting into D1 with ID:', id); + // Insert into D1 database - await db + const insertResult = await db .prepare( `INSERT INTO scores ( id, score, issues_found, total_issues, accuracy, @@ -77,10 +89,13 @@ export async function action({ request, context }: Route.ActionArgs) { ) .run(); + console.log('[result.create] Insert result:', insertResult); + console.log('[result.create] Successfully saved, returning ID:', id); + // Return the result ID for client-side navigation return { success: true, id }; } catch (error) { - console.error('Failed to save score:', error); + console.error('[result.create] Failed to save score:', error); throw data({ error: 'Failed to save score' }, { status: 500 }); } } From e1391411a702e96450152ed34780674d0ace686d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 08:14:03 +0000 Subject: [PATCH 4/4] refactor: Improve error handling and ID generation - Remove all debug console.log statements from play and result.create routes - Fix scoreSaved state management: only mark as saved when request succeeds - Add error state and UI to display save failures to users - Improve nanoid fallback with monotonic counter and multiple random segments to reduce collision risk under high throughput - Navigate effect now handles error cases properly --- app/routes/$lang.$codeLanguage.play.tsx | 29 +++++++++++++++---------- app/routes/result.create.tsx | 19 ++-------------- app/utils/nanoid.ts | 13 ++++++++--- 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/app/routes/$lang.$codeLanguage.play.tsx b/app/routes/$lang.$codeLanguage.play.tsx index 2b67fac..f3a72cc 100644 --- a/app/routes/$lang.$codeLanguage.play.tsx +++ b/app/routes/$lang.$codeLanguage.play.tsx @@ -139,11 +139,11 @@ export default function Play({ loaderData }: Route.ComponentProps) { }; }); const [gameEnded, setGameEnded] = useState(false); - const [scoreSaved, setScoreSaved] = useState(false); const [feedbackMessage, setFeedbackMessage] = useState<{ type: 'correct' | 'wrong' | 'complete'; text: string; } | null>(null); + const [error, setError] = useState(null); // Timer countdown useEffect(() => { @@ -175,7 +175,9 @@ export default function Play({ loaderData }: Route.ComponentProps) { // Save score when game ends useEffect(() => { - if (gameEnded && !scoreSaved) { + const scoreSaved = fetcher.state === 'idle' && fetcher.data?.success; + + if (gameEnded && !scoreSaved && fetcher.state === 'idle') { // Calculate total issues across all problems const allProblems = getProblems(codeLanguage, 1) .concat(getProblems(codeLanguage, 2)) @@ -208,26 +210,23 @@ export default function Play({ loaderData }: Route.ComponentProps) { { payload: JSON.stringify(resultData) }, { method: 'post', action: '/result/create' } ); - - setScoreSaved(true); } - }, [gameEnded, scoreSaved, gameState, codeLanguage, lang, fetcher]); + }, [gameEnded, fetcher.state, fetcher.data, gameState, codeLanguage, lang, fetcher]); // Navigate to result page when score is saved useEffect(() => { - console.log('[play] Fetcher state:', fetcher.state); - console.log('[play] Fetcher data:', fetcher.data); - if (fetcher.data && fetcher.state === 'idle') { - const data = fetcher.data as { success: boolean; id: string }; - console.log('[play] Received data:', data); + const data = fetcher.data as { success?: boolean; id?: string; error?: string }; if (data.success && data.id) { - console.log('[play] Navigating to result page:', `/result/${data.id}`); navigate(`/result/${data.id}`); + } else if (data.error) { + setError(data.error); + } else if (!data.success) { + setError(lang === 'ja' ? 'スコアの保存に失敗しました' : 'Failed to save score'); } } - }, [fetcher.data, fetcher.state, navigate]); + }, [fetcher.data, fetcher.state, navigate, lang]); /** * Handle line tap @@ -374,6 +373,12 @@ export default function Play({ loaderData }: Route.ComponentProps) { )} + {error && ( +
+ {error} +
+ )} +