Add Translation Quiz game mode, winning streak, high score, timer expiry reveal, and Playwright E2E tests#13
Conversation
…r expiry fix Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/007c00e6-8b9e-41b7-94e2-049f7fd65e11 Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com>
…RateLimit helper, fix point/points text Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/007c00e6-8b9e-41b7-94e2-049f7fd65e11 Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com>
|
@copilot you were supposed to branch from |
There was a problem hiding this comment.
Pull request overview
Adds a new “Translation Quiz” game mode to the demo (multiple choice translations), plus client-side streak/high-score tracking and automatic answer reveal on timer expiry, alongside backend endpoints and supporting services.
Changes:
- Backend: introduce
TranslationQuizGameService+ contracts + endpoints (/game/translation-quiz/...) and refactor shared rate-limit parsing intoGameServiceHelpers. - Backend: adjust
SenseSprintGameService.GiveUpAsyncto return an"expired"result (instead of throwing) so the client can reveal answers on timeout. - Frontend: add mode tabs, Translation Quiz UI, streak/high score persistence, and timer-expiry auto-reveal behavior; update Vite proxy for
/languages.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| source/Lexicala.NET.Tests/TranslationQuizGameServiceTests.cs | Adds unit tests for Translation Quiz service validation/error paths. |
| source/Lexicala.NET.Tests/SenseSprintGameServiceTests.cs | Adds unit tests for Sense Sprint service validation/not-found behavior. |
| source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj | Adds Demo API project reference to allow testing demo game services. |
| source/Demo/sense-sprint-web/vite.config.ts | Proxies /languages during local dev. |
| source/Demo/sense-sprint-web/src/App.tsx | Splits UI into two game mode components; adds streak/high score + expiry auto-reveal + quiz UI. |
| source/Demo/sense-sprint-web/src/App.css | Updates layout for additional KPIs and adds styles for mode tabs + quiz UI. |
| source/Demo/sense-sprint-web/package-lock.json | Lockfile update (dependency metadata adjustments). |
| source/Demo/Lexicala.NET.Demo.Api/Program.cs | Registers new game service and maps Translation Quiz endpoints. |
| source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs | Implements Translation Quiz round generation, answer submission, and expiry handling. |
| source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizContracts.cs | Adds request/response contracts for Translation Quiz. |
| source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs | Changes give-up behavior on expiry and uses shared rate-limit helper. |
| source/Demo/Lexicala.NET.Demo.Api/Game/ITranslationQuizGameService.cs | Defines Translation Quiz service interface. |
| source/Demo/Lexicala.NET.Demo.Api/Game/GameServiceHelpers.cs | Adds shared BuildRateLimit helper. |
Files not reviewed (1)
- source/Demo/sense-sprint-web/package-lock.json: Language not supported
| setPoints((current) => { | ||
| const next = current + result.awardedPoints | ||
| setHighScore((hs) => { | ||
| const updated = Math.max(hs, next) | ||
| saveHighScore(HIGH_SCORE_KEY, updated) | ||
| return updated | ||
| }) | ||
| return next | ||
| }) |
There was a problem hiding this comment.
setPoints's functional updater performs side effects (setHighScore(...) and localStorage.setItem(...)). In React 18 dev/StrictMode, updater functions may be invoked more than once, so this can write to localStorage multiple times and makes the updater non-pure. Consider computing nextPoints outside the state setter and updating highScore/localStorage separately (e.g., via a useEffect that watches points or by calling setHighScore/saveHighScore after setPoints).
| setPoints((current) => { | |
| const next = current + result.awardedPoints | |
| setHighScore((hs) => { | |
| const updated = Math.max(hs, next) | |
| saveHighScore(HIGH_SCORE_KEY, updated) | |
| return updated | |
| }) | |
| return next | |
| }) | |
| const nextPoints = points + result.awardedPoints | |
| setPoints(nextPoints) | |
| if (nextPoints > highScore) { | |
| setHighScore(nextPoints) | |
| saveHighScore(HIGH_SCORE_KEY, nextPoints) | |
| } |
| if (data.isCorrect) { | ||
| setPoints((current) => { | ||
| const next = current + data.awardedPoints | ||
| setHighScore((hs) => { | ||
| const updated = Math.max(hs, next) | ||
| saveHighScore(QUIZ_HIGH_SCORE_KEY, updated) | ||
| return updated | ||
| }) | ||
| return next | ||
| }) |
There was a problem hiding this comment.
Same pattern here: the setPoints functional updater includes side effects (setHighScore + localStorage write). To keep the updater pure (and avoid duplicate side effects under React StrictMode), move the persistence/update logic out of the updater (e.g., compute nextPoints first, or persist high score in an effect triggered by points/highScore).
| public async Task<CreateQuizRoundResponse> CreateRoundAsync(string targetLanguage = "de", CancellationToken cancellationToken = default) | ||
| { | ||
| if (!SupportedTargetLanguages.Contains(targetLanguage, StringComparer.OrdinalIgnoreCase)) | ||
| { | ||
| throw new ArgumentException($"Target language '{targetLanguage}' is not supported. Supported: {string.Join(", ", SupportedTargetLanguages)}."); | ||
| } | ||
|
|
||
| for (var attempt = 1; attempt <= MaxGenerationAttempts; attempt++) | ||
| { | ||
| var generated = await TryGenerateRoundAsync(targetLanguage, cancellationToken); | ||
| if (generated is null) |
There was a problem hiding this comment.
targetLanguage is validated case-insensitively, but the original casing is then used for FindTranslation(...) (dictionary key lookup is case-sensitive) and for FlukySearchAsync(..., targetLanguage, ...). A request like targetLanguage=DE will pass validation but likely fail to find translations / generate rounds. Normalize once (e.g., targetLanguage = targetLanguage.Trim().ToLowerInvariant()) and use the normalized value throughout (including the response).
| public void CreateRoundAsync_SupportedLanguages_DoesNotThrowArgumentException(string language) | ||
| { | ||
| // Arrange: make the client throw so we don't need a real API, but the validation should pass before the first API call. | ||
| _clientMock | ||
| .Setup(c => c.FlukySearchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<System.Threading.CancellationToken>())) | ||
| .ThrowsAsync(new InvalidOperationException("API not available in test")); | ||
|
|
||
| // Act + Assert: argument validation passes (InvalidOperationException, not ArgumentException) | ||
| Should.NotThrow(() => | ||
| { | ||
| _ = _service.CreateRoundAsync(language).ContinueWith(_ => { }); | ||
| }); |
There was a problem hiding this comment.
This test doesn't actually assert that the async call proceeds past argument validation: CreateRoundAsync(...) returns a Task immediately, so Should.NotThrow will pass even if the task later faults. If the goal is to verify validation accepts supported languages, assert the returned task faults with the mocked InvalidOperationException (or await the call and Should.ThrowAsync<InvalidOperationException>), rather than using ContinueWith.
| public void CreateRoundAsync_SupportedLanguages_DoesNotThrowArgumentException(string language) | |
| { | |
| // Arrange: make the client throw so we don't need a real API, but the validation should pass before the first API call. | |
| _clientMock | |
| .Setup(c => c.FlukySearchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<System.Threading.CancellationToken>())) | |
| .ThrowsAsync(new InvalidOperationException("API not available in test")); | |
| // Act + Assert: argument validation passes (InvalidOperationException, not ArgumentException) | |
| Should.NotThrow(() => | |
| { | |
| _ = _service.CreateRoundAsync(language).ContinueWith(_ => { }); | |
| }); | |
| public async Task CreateRoundAsync_SupportedLanguages_DoesNotThrowArgumentException(string language) | |
| { | |
| // Arrange: make the client throw so we don't need a real API, but the validation should pass before the first API call. | |
| _clientMock | |
| .Setup(c => c.FlukySearchAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<System.Threading.CancellationToken>())) | |
| .ThrowsAsync(new InvalidOperationException("API not available in test")); | |
| // Act + Assert: supported languages pass validation and execution reaches the mocked client call. | |
| await Should.ThrowAsync<InvalidOperationException>(() => | |
| _service.CreateRoundAsync(language)); |
| useEffect(() => { | ||
| if (timeLeft !== 0 || round?.roundStatus !== 'in-progress' || loading || expiryHandledRef.current) { | ||
| return | ||
| } |
There was a problem hiding this comment.
timeLeft is computed with Math.floor((expires - Date.now()) / 1000), so it becomes 0 up to ~999ms before expiresAtUtc. Since the expiry handler triggers on timeLeft === 0, rounds can be auto-expired up to ~1 second early (and Sense Sprint may come back as roundStatus: lost, not expired). Consider using Math.ceil(...) for the countdown, or trigger expiry based on Date.now() >= expiresAtUtc instead of timeLeft === 0.
| const timer = window.setInterval(() => { | ||
| const expires = new Date(round.expiresAtUtc).getTime() | ||
| const diff = Math.max(0, Math.floor((expires - Date.now()) / 1000)) | ||
| setTimeLeft(diff) | ||
| }, 250) | ||
|
|
||
| return () => window.clearInterval(timer) | ||
| }, [round]) | ||
|
|
||
| // Reveal answer when timer expires | ||
| useEffect(() => { | ||
| if (timeLeft !== 0 || round?.roundStatus !== 'in-progress' || loading || expiryHandledRef.current) { | ||
| return | ||
| } |
There was a problem hiding this comment.
Same off-by-one behavior here: timeLeft uses Math.floor, so the expiry effect can call /expire up to ~1 second early. Consider Math.ceil for display and/or basing the expiry trigger on comparing Date.now() to expiresAtUtc directly.
| @@ -164,6 +256,47 @@ function App() { | |||
| return () => window.clearInterval(timer) | |||
| }, [round]) | |||
|
|
|||
| // Automatically give up when the timer expires | |||
| useEffect(() => { | |||
| if (timeLeft !== 0 || round?.roundStatus !== 'in-progress' || loading || expiryHandledRef.current) { | |||
| return | |||
| } | |||
|
|
|||
| expiryHandledRef.current = true | |||
| const roundId = round.roundId | |||
There was a problem hiding this comment.
The timer-expiry effect triggers when timeLeft === 0, but timeLeft starts at 0 and is never initialized when a new round is created. Because the countdown effect only sets timeLeft on the first 250ms interval tick, the expiry effect can immediately call /give-up right after setRound(...) on round start (even on the very first round). Initialize timeLeft when starting a round (e.g., compute from created.expiresAtUtc immediately) or set timeLeft once synchronously inside the timer useEffect before starting the interval.
|
|
||
| try { | ||
| const result = await api.createQuizRound(targetLanguage) | ||
| const created = result.data |
There was a problem hiding this comment.
Same issue as Sense Sprint: timeLeft is initialized to 0 and not set when a new quiz round is created, so the expiry useEffect can immediately call /expire after setRound(...) (before the first interval tick updates timeLeft). Set timeLeft immediately when starting a round (from created.expiresAtUtc / created.roundSeconds) or update the timer effect to set the initial value before setInterval.
| const created = result.data | |
| const created = result.data | |
| const initialTimeLeft = Math.max( | |
| 0, | |
| Math.ceil((new Date(created.expiresAtUtc).getTime() - Date.now()) / 1000), | |
| ) | |
| setTimeLeft(initialTimeLeft) |
|
|
||
| public sealed class TranslationQuizGameService : ITranslationQuizGameService | ||
| { | ||
| private static readonly string[] SupportedTargetLanguages = ["de", "nl", "fr", "es"]; |
There was a problem hiding this comment.
@copilot why only these languages? can't we use all available languages and pick four random ones per round?
There was a problem hiding this comment.
Done in commit d9c472d. The hardcoded ["de", "nl", "fr", "es"] list is gone.
Backend (TranslationQuizGameService): A new GetTargetLanguagesAsync method fetches the available target_languages from the Lexicala /languages API (falling back to source_languages if the explicit list is absent), strips "en", caches the result for 1 hour, and falls back to the curated 4-language list only if the API call fails entirely. When targetLanguage is null or empty, the service now picks a random language from the full live list. Any explicit language code is accepted without an ArgumentException.
Frontend (TranslationQuiz component): The language dropdown is populated dynamically from the /languages API (the same call used by SenseSprint). The first option is "Random (any language)" — when selected, the server picks the language for that round and the UI shows the actual language name from the round response.
The API returns 21 target languages (ar, br, da, de, el, es, fr, he, it, ja, ko, nl, no, pl, pt, ru, sv, tr, zh, …) so players can now practise translations across a much wider range of languages.
…base Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com>
Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/d177bf24-f555-4100-b189-80f9fba242cd Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com>
Done — I merged To complete the fix on GitHub's side, please update this PR's base branch from The merge commit is |
|
@copilot where are the playwright tests that were also part of the plan? |
|
@copilot fix all review comments and any other comments that are still pending and are directed at you |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.
Files not reviewed (1)
- source/Demo/sense-sprint-web/package-lock.json: Language not supported
Comments suppressed due to low confidence (4)
source/Demo/sense-sprint-web/src/App.tsx:342
/game/sense-sprint/roundsis called with a JSON body containing{ language }, but the backend endpoint currently doesn't bind a request body or accept a language parameter (Program.cs maps onlyISenseSprintGameService, CancellationToken). This means the selected language will be ignored and the API response will not contain thelanguagefield the frontend expects.
async createRound(language: string): Promise<ApiResult<CreateRoundResponse>> {
const response = await fetch('/game/sense-sprint/rounds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language }),
})
source/Demo/sense-sprint-web/src/App.tsx:616
- The expiry
tick()can trigger multiple parallel/give-upcalls: oncediffhits 0 the interval keeps firing every 250ms and there is no guard/ref/interval-cancel to prevent re-entry while the request is in flight. Also the client forcesroundStatus: 'expired'regardless of the server response (anddiff===0can occur up to ~1s before actual expiry due toMath.floor). Add a one-shot guard (e.g., a ref) and only treat as expired whenDate.now() >= expiresAtand/or use the server-returnedroundStatus.
const tick = () => {
const diff = getSecondsRemaining(round.expiresAtUtc)
setTimeLeft(diff)
if (diff > 0) {
return
}
// Auto-giveup when timer expires to reveal answer
setLoading(true)
api
.giveUp(round.roundId)
.then((result) => {
const streakBeforeExpiry = stats.currentStreak
setRound((current) => {
if (!current || current.roundId !== round.roundId) {
return current
}
return {
...current,
roundStatus: 'expired',
answer: result.data.correctAnswer,
}
})
source/Demo/sense-sprint-web/src/App.tsx:490
console.logcalls were added in the languages-loading effect. This will ship noisy logs in the demo UI; consider removing them or gating behind a debug flag so production/dev builds stay clean.
console.log('Languages response:', languageResult.data)
const languageNames = languageResult.data.language_names ?? {}
const sourceLanguages = languageResult.data.resources?.global?.source_languages ?? []
console.log('Parsed sourceLanguages:', sourceLanguages)
source/Demo/sense-sprint-web/src/App.tsx:716
created.languageis assumed to exist in the Sense Sprint create-round response, but the backendCreateRoundResponsecontract currently has noLanguagefield. At runtime this will setround.language/selectedLanguagetoundefined, causing the language picker (controlled select) and session persistence validation to behave incorrectly.
const createdResult = await api.createRound(selectedLanguage)
const created = createdResult.data
setRound({
roundId: created.roundId,
language: created.language,
clue: created.clue,
clueIndex: created.clueIndex,
maxClues: created.maxClues,
expiresAtUtc: created.expiresAtUtc,
scoreIfCorrect: created.scoreIfCorrect,
roundStatus: 'in-progress',
answer: null,
})
setSelectedLanguage(created.language)
setRevealedClues([created.clue])
setTimeLeft(getSecondsRemaining(created.expiresAtUtc))
|
|
||
| if (round.IsCompleted) | ||
| { | ||
| throw new InvalidOperationException("Round already completed. Start a new round."); | ||
| } | ||
|
|
||
| round.IsCompleted = true; | ||
| _cache.Set(roundId, round, round.ExpiresAtUtc); | ||
|
|
||
| var isExpired = DateTimeOffset.UtcNow > round.ExpiresAtUtc; |
There was a problem hiding this comment.
GiveUpAsync now throws when round.IsCompleted. Since "give up" is a client action that can be retried (or auto-triggered on timer expiry), making it non-idempotent can surface as avoidable 400s (especially if the client calls it more than once). Consider returning a consistent response for already-completed rounds (e.g., roundStatus: "completed" + correctAnswer) similar to TranslationQuizGameService, instead of throwing.
| if (round.IsCompleted) | |
| { | |
| throw new InvalidOperationException("Round already completed. Start a new round."); | |
| } | |
| round.IsCompleted = true; | |
| _cache.Set(roundId, round, round.ExpiresAtUtc); | |
| var isExpired = DateTimeOffset.UtcNow > round.ExpiresAtUtc; | |
| var isExpired = DateTimeOffset.UtcNow > round.ExpiresAtUtc; | |
| if (round.IsCompleted) | |
| { | |
| return Task.FromResult(new GuessResponse( | |
| roundId, | |
| false, | |
| "completed", | |
| 0, | |
| round.CurrentClueIndex, | |
| round.Answer, | |
| isExpired ? "Round already completed. The answer was already revealed when time expired." : "Round already completed. The answer was already revealed.")); | |
| } | |
| round.IsCompleted = true; | |
| _cache.Set(roundId, round, round.ExpiresAtUtc); |
| setStats((current) => ({ | ||
| ...current, | ||
| currentScore: current.currentScore + data.awardedPoints, | ||
| highScore: Math.max(current.highScore, current.currentScore + data.awardedPoints), |
There was a problem hiding this comment.
In Translation Quiz stats, highScore is computed from the cumulative currentScore (Math.max(current.highScore, current.currentScore + awardedPoints)). Since currentScore only ever increases, highScore will always end up equal to currentScore, making it redundant. Either track high score as best single-round award (like Sense Sprint), or reset currentScore at appropriate boundaries (e.g., per session/run) so a meaningful "high score" can exist.
| highScore: Math.max(current.highScore, current.currentScore + data.awardedPoints), | |
| highScore: Math.max(current.highScore, data.awardedPoints), |
| public Task<QuizAnswerResponse> ExpireRoundAsync(Guid roundId, CancellationToken cancellationToken = default) | ||
| { | ||
| var round = GetRequiredRound(roundId); | ||
|
|
||
| if (round.IsCompleted) | ||
| { | ||
| return Task.FromResult(new QuizAnswerResponse( | ||
| roundId, | ||
| false, | ||
| "completed", | ||
| 0, | ||
| round.CorrectAnswer, | ||
| "Round already completed. Start a new round.")); | ||
| } | ||
|
|
||
| round.IsCompleted = true; | ||
| _cache.Set(roundId, round, round.ExpiresAtUtc); | ||
|
|
||
| return Task.FromResult(new QuizAnswerResponse( | ||
| roundId, | ||
| false, | ||
| "expired", | ||
| 0, | ||
| round.CorrectAnswer, | ||
| "Time's up!")); | ||
| } |
There was a problem hiding this comment.
ExpireRoundAsync marks the round as expired regardless of whether the round has actually reached ExpiresAtUtc. This makes it possible to call /expire immediately to reveal the answer early (and also makes client-side rounding errors silently force-expire). Consider validating DateTimeOffset.UtcNow >= round.ExpiresAtUtc and returning a 400 (or treating it as a give-up) when called too early.
| public async Task<CreateQuizRoundResponse> CreateRoundAsync(string targetLanguage = "de", CancellationToken cancellationToken = default) | ||
| { | ||
| if (!SupportedTargetLanguages.Contains(targetLanguage, StringComparer.OrdinalIgnoreCase)) | ||
| { | ||
| throw new ArgumentException($"Target language '{targetLanguage}' is not supported. Supported: {string.Join(", ", SupportedTargetLanguages)}."); | ||
| } |
There was a problem hiding this comment.
targetLanguage validation is case-insensitive, but the value is then used as a key in sense.Translations.TryGetValue(targetLanguage, ...) (case-sensitive Dictionary). Passing DE/Fr will pass validation but likely fail to find translations, causing round generation to fail. Normalize once (e.g., targetLanguage = targetLanguage.Trim().ToLowerInvariant()) and use the normalized value consistently for dictionary lookups and responses.
| var roundId = Guid.NewGuid(); | ||
| _cache.Set(roundId, generated, generated.ExpiresAtUtc); | ||
|
|
There was a problem hiding this comment.
Rounds are cached with an absolute expiration equal to ExpiresAtUtc. When the timer elapses, the cache entry can be considered expired and TryGetValue will fail, making /expire (and late /answer) return "Round not found" instead of revealing the correct answer. Consider keeping rounds in cache for a short grace period after expiry (e.g., ExpiresAtUtc + TimeSpan.FromMinutes(5)) while still using ExpiresAtUtc for game logic.
…nslationQuiz Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/d8c58822-39c0-4958-a685-6a3921c1ea7e Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com>
…ranslation Quiz Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/ef800131-a254-42c1-8608-2f3e9a0643d7 Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com>
Added in commit
All tests use Playwright's
All pending review comments addressed in commit
Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
* game update part 1 Co-authored-by: Copilot <copilot@github.com> * Add Translation Quiz game mode, winning streak, high score, timer expiry reveal, and Playwright E2E tests (#13) * Initial plan * Implement Translation Quiz mode, winning streak, high score, and timer expiry fix Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/007c00e6-8b9e-41b7-94e2-049f7fd65e11 Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> * Address review feedback: add ExpireRoundAsync endpoint, extract BuildRateLimit helper, fix point/points text Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/007c00e6-8b9e-41b7-94e2-049f7fd65e11 Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> * Merge feature/game-update and rebased Translation Quiz onto it Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/d177bf24-f555-4100-b189-80f9fba242cd Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> * Use all available languages from API instead of hardcoded list in TranslationQuiz Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/d8c58822-39c0-4958-a685-6a3921c1ea7e Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> * Fix all pending review comments and add 13 Playwright E2E tests for Translation Quiz Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/ef800131-a254-42c1-8608-2f3e9a0643d7 Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> * Increase web server timeout to 120 seconds in Playwright configuration --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> Co-authored-by: Hanno Zwikstra <hanno@hannozwikstra.nl> * Enhance game modes with language support, update README, and add E2E tests for wrong guesses tracking Co-authored-by: Copilot <copilot@github.com> * Enhance API documentation with Swagger integration and detailed endpoint descriptions Co-authored-by: Copilot <copilot@github.com> * Fix ETag caching bug in loadLanguagesWithCaching: set sessionLanguagesCache once per path Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/07eeeb2c-863f-4cfe-acf2-ff02d30145af Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix ExpireRoundAsync: move IsCompleted/cache write to after near-expiry check Agent-Logs-Url: https://github.com/HannoZ/Lexicala.NET/sessions/9ff422d1-645e-4487-aa53-262ac59191da Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Implements the planned demo game enhancements: a new Translation Quiz mode with multiple-choice translation selection, winning streak and persistent high score tracking, and automatic answer reveal when the round timer expires.
Backend
TranslationQuizGameService— picks a random English word via FlukySearch, finds its translation in a dynamically chosen target language from the entry's senses, then fetches 3 distractor headwords in parallel via FlukySearch in the target language. Shuffles all 4 into choices. The target language is resolved at runtime from the Lexicala/languagesAPI (all available target languages, cached for 1 hour) rather than a hardcoded list. When no language is specified, the service picks one at random from the full live list. Any explicit language code is normalised to lowercase before use.POST /game/translation-quiz/rounds?targetLanguage=de(language is optional; omit for random),.../answer,.../expireExpireRoundAsync— validates that the round has actually expired before accepting the request (3-second client-side tolerance); calls made more than 3 seconds early are treated as a give-up (roundStatus: "lost")ExpiresAtUtcso that/expireand late/answercalls succeed even when the client request arrives slightly after the server deadlineSenseSprintGameService.GiveUpAsync— made idempotent: returnsroundStatus: "completed"with the correct answer instead of throwing when called on an already-completed roundGameServiceHelpers—BuildRateLimitextracted from both game services to eliminate duplicationFrontend
SenseSprintandTranslationQuizare now separate components switched by a tab barlocalStoragegetSecondsRemainingand all timer initialisations useMath.ceilinstead ofMath.floor, preventing ~1 second early expiryuseEffectwatchestimeLeft; at 0 it calls/give-up(SenseSprint) or/expire(TranslationQuiz) to reveal the answer automatically. The expiry effect no longer includesloadingin its dependency array, preventing React's effect cleanup from cancelling an in-flight API call/languagesAPI with all available target languages (21+) plus a "Random (any language)" default option; large source-word display; 4 choice buttons that highlight green/red after selection/languagesentry so the languages endpoint works during local devTests
TargetLanguagespreference, and API fallback behaviour; project reference from test project toDemo.Apiaddedtests/e2e/translation-quiz.spec.ts), usingpage.route()to mock the API (no live backend required), covering:/expirewhen the countdown reaches 0Original prompt
Start implementation
The user has attached the following file paths as relevant context:
- .github\instructions\repository-information.instructions.md
[Chronological Review: The conversation began with the user requesting improvements to the demo game, specifically adding a winning streak feature and tracking high scores. The user then requested updates to the README files, including instructions for API configuration and moving extensive game documentation. The user also raised concerns about the language dropdown functionality and API key loading. The conversation progressed to debugging issues related to loading languages and displaying answers when the timer expires. The user proposed adding a new game mode that involves selecting translations from multiple languages and requested a plan for its implementation. The user concluded by asking to start implementation in the cloud with a specific branch and to add Playwright tests.][Intent Mapping:
[Technical Inventory:
[Code Archaeology:
[Progress Assessment:
[Context Validation: All necessary context for continuing work on the demo game and API has been captured, including user requests for features and identified bugs.]
[Recent Commands Analysis:
1. Conversation Overview: - Primary Objectives: User requested enhancements to the demo game, including a winning streak feature, high score tracking, and a new game mode for translation selection. - Session Context: The conversation flowed from feature requests to debugging issues and planning for implementation, culminating in a request to start coding. - User Intent Evolution: The user shifted from discussing features to addressing bugs and finally to initiating the implementation phase.- Technical Foundation:
- C#: Primary programming language used for the project.
- ASP.NET: Framework for building the API.
- Playwright: Tool for implementing automated tests.
- Codebase Status:
- Program.cs:
- Purpose: Main entry point for the application.
- Current State: Awaiting implementation of new features.
- SenseSprintGameService.cs:
- Purpose: Contains game logic and service methods.
- Current State: Undergoing review for async method behavior.
- SenseSprintContracts.cs:
- Purpose: Defines contracts for game services.
- Current State: Recently modified.
- ISenseSprintGameService.cs:
- Purpose: Interface for game service implementation.
- Current State: Recently modified.
- Problem Resolution:
- Issues Encountered: Language dropdown not functioning, timer not displaying answers, async method concerns.
- Solutions Implemented: Initial plans for addressing features and bugs.
- Debugging Context: Ongoing troubleshooting for language retrieval and timer functionality.
- Lessons Learned: Importance of thorough testing and documentation.
- Progress Track...
Created from VS Code.