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 80e73f0..f3a72cc 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, useNavigate } 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,8 @@ 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(() => { @@ -129,6 +132,7 @@ export default function Play({ loaderData }: Route.ComponentProps) { combo: 0, remainingSeconds: 60, solvedIssueIds: [], + allSolvedIssueIds: [], tappedLines: {}, problemCount: 0, usedProblemIds: firstProblem ? [firstProblem.id] : [], @@ -139,6 +143,7 @@ export default function Play({ loaderData }: Route.ComponentProps) { type: 'correct' | 'wrong' | 'complete'; text: string; } | null>(null); + const [error, setError] = useState(null); // Timer countdown useEffect(() => { @@ -168,6 +173,61 @@ export default function Play({ loaderData }: Route.ComponentProps) { } }, [feedbackMessage]); + // Save score when game ends + useEffect(() => { + 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)) + .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' } + ); + } + }, [gameEnded, fetcher.state, fetcher.data, 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; error?: string }; + + if (data.success && 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, lang]); + /** * Handle line tap */ @@ -199,6 +259,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 +271,7 @@ export default function Play({ loaderData }: Route.ComponentProps) { score: prev.score + scoreGain, combo: newCombo, solvedIssueIds: newSolvedIssueIds, + allSolvedIssueIds: newAllSolvedIssueIds, tappedLines: newTappedLines, }; } else { @@ -279,6 +341,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 +366,21 @@ export default function Play({ loaderData }: Route.ComponentProps) { : `Solved ${gameState.problemCount} problems`} + + {isSaving && ( +
+ {lang === 'ja' ? 'スコアを保存中...' : 'Saving score...'} +
+ )} + + {error && ( +
+ {error} +
+ )} + + + )} + + + {/* 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..6ca476a --- /dev/null +++ b/app/routes/result.create.tsx @@ -0,0 +1,86 @@ +import { 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(); + + // 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 }); + } +} diff --git a/app/utils/nanoid.ts b/app/utils/nanoid.ts new file mode 100644 index 0000000..ea8da85 --- /dev/null +++ b/app/utils/nanoid.ts @@ -0,0 +1,22 @@ +// Monotonic counter for collision prevention in fallback +let fallbackCounter = 0; + +/** + * 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 for legacy environments: combines timestamp, counter, and multiple random segments + // to significantly reduce collision risk even under high throughput + fallbackCounter = (fallbackCounter + 1) % 1000000; + const timestamp = Date.now().toString(36); + const counter = fallbackCounter.toString(36).padStart(5, '0'); + const random1 = Math.random().toString(36).substring(2, 11); + const random2 = Math.random().toString(36).substring(2, 11); + return `${timestamp}-${counter}-${random1}${random2}`; +} 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);