Skip to content

Add Translation Quiz game mode, winning streak, high score, timer expiry reveal, and Playwright E2E tests#13

Merged
HannoZ merged 8 commits into
feature/game-updatefrom
copilot/start-implementation-cloud
Apr 30, 2026
Merged

Add Translation Quiz game mode, winning streak, high score, timer expiry reveal, and Playwright E2E tests#13
HannoZ merged 8 commits into
feature/game-updatefrom
copilot/start-implementation-cloud

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 29, 2026

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 /languages API (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.
  • New endpoints: POST /game/translation-quiz/rounds?targetLanguage=de (language is optional; omit for random), .../answer, .../expire
  • ExpireRoundAsync — 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")
  • Round cache grace period — rounds are kept in cache for 5 minutes after ExpiresAtUtc so that /expire and late /answer calls succeed even when the client request arrives slightly after the server deadline
  • SenseSprintGameService.GiveUpAsync — made idempotent: returns roundStatus: "completed" with the correct answer instead of throwing when called on an already-completed round
  • GameServiceHelpersBuildRateLimit extracted from both game services to eliminate duplication

Frontend

  • Mode tabsSenseSprint and TranslationQuiz are now separate components switched by a tab bar
  • Winning streak — increments on correct answer, resets to 0 on loss/give-up/expiry; shown in the stats panel
  • High score — tracked per game mode as best single-round award, persisted in localStorage
  • Timer accuracygetSecondsRemaining and all timer initialisations use Math.ceil instead of Math.floor, preventing ~1 second early expiry
  • Timer expiryuseEffect watches timeLeft; at 0 it calls /give-up (SenseSprint) or /expire (TranslationQuiz) to reveal the answer automatically. The expiry effect no longer includes loading in its dependency array, preventing React's effect cleanup from cancelling an in-flight API call
  • Translation Quiz UI — language selector dropdown populated dynamically from the /languages API 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
  • Vite dev proxy — added /languages entry so the languages endpoint works during local dev

Tests

  • 22 new MSTest unit tests for both game services covering input validation, not-found error paths, language list caching, English exclusion, TargetLanguages preference, and API fallback behaviour; project reference from test project to Demo.Api added
  • 13 Playwright E2E tests for the Translation Quiz UI (tests/e2e/translation-quiz.spec.ts), using page.route() to mock the API (no live backend required), covering:
    • Language dropdown: "Random" option, API-populated list, graceful fallback on API failure
    • Round lifecycle: source word and 4 choices displayed, language name in prompt, correct/wrong choice highlighting, choices disabled after submission, "New Round" button
    • Stats: win streak increment/reset, rounds played counter
    • Timer expiry: automatic answer reveal via /expire when the countdown reaches 0
Original 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:

  1. "let's improve the demo game." - User wants to enhance the game with new features.
  2. "do both" - User wants both the winning streak feature and high score tracking implemented.
  3. "update the readme..." - User requests updates to documentation for clarity.
  4. "the language dropdown only shows english..." - User identifies a bug in language retrieval.
  5. "when timer expires the round ends..." - User reports an issue with the game not showing answers.
  6. "I want to add another game modus..." - User proposes a new game mode for translation selection.
  7. "start implementation" - User initiates the coding phase for the new features.]

[Technical Inventory:

  • Technologies: C#, .NET, Playwright for testing.
  • Frameworks: ASP.NET for API development.
  • Architectural Decisions: Implementing features like winning streaks, high score tracking, and new game modes within the existing game structure.]

[Code Archaeology:

  • Files Discussed:
    • Program.cs: Main entry point for the application.
    • SenseSprintGameService.cs: Game logic and service layer.
    • SenseSprintContracts.cs: Contracts for game services.
    • ISenseSprintGameService.cs: Interface for game service implementation.
  • Key Functions:
    • SubmitGuessAsync, RevealNextClueAsync, GiveUpAsync - methods under scrutiny for async behavior.]

[Progress Assessment:

  • Completed Tasks: Initial requests for game improvements and README updates.
  • Partially Complete Work: Implementation of winning streaks, high score tracking, and new game mode planning.
  • Validated Outcomes: Some features are confirmed working, but issues remain with language loading and timer functionality.]

[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:

  • Last Agent Commands: User requested to "start implementation" for the new game mode and Playwright tests.
  • Tool Results Summary: No specific tool results were returned as the focus was on planning and initiating implementation.
  • Pre-Summary State: The agent was preparing to start the implementation of the new game mode and associated tests when the token budget was exceeded.
  • Operation Context: The commands were executed to move forward with the development of the demo game, aligning with the user's goals of enhancing gameplay and testing.]
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.
  1. Technical Foundation:
  • C#: Primary programming language used for the project.
  • ASP.NET: Framework for building the API.
  • Playwright: Tool for implementing automated tests.
  1. 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.
  1. 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.
  1. Progress Track...

Created from VS Code.

Copilot AI and others added 2 commits April 29, 2026 13:03
…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 AI changed the title [WIP] Start implementation for winning streak feature and high scores Add Translation Quiz game mode, winning streak, high score, and timer expiry reveal Apr 29, 2026
Copilot AI requested a review from HannoZ April 29, 2026 13:15
@HannoZ HannoZ requested a review from Copilot April 29, 2026 14:08
@HannoZ
Copy link
Copy Markdown
Owner

HannoZ commented Apr 29, 2026

@copilot you were supposed to branch from feature/game-update, instead you took master as base branch. Can this still be fixed?

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 into GameServiceHelpers.
  • Backend: adjust SenseSprintGameService.GiveUpAsync to 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

Comment on lines +403 to +411
setPoints((current) => {
const next = current + result.awardedPoints
setHighScore((hs) => {
const updated = Math.max(hs, next)
saveHighScore(HIGH_SCORE_KEY, updated)
return updated
})
return next
})
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
Comment on lines +745 to +754
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
})
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +45
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)
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +67
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(_ => { });
});
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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));

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +263
useEffect(() => {
if (timeLeft !== 0 || round?.roundStatus !== 'in-progress' || loading || expiryHandledRef.current) {
return
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +636 to +649
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
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +245 to +266
@@ -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
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

try {
const result = await api.createQuizRound(targetLanguage)
const created = result.data
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.

public sealed class TranslationQuizGameService : ITranslationQuizGameService
{
private static readonly string[] SupportedTargetLanguages = ["de", "nl", "fr", "es"];
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot why only these languages? can't we use all available languages and pick four random ones per round?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI and others added 2 commits April 29, 2026 14:31
…base

Co-authored-by: HannoZ <29945379+HannoZ@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 29, 2026

@copilot you were supposed to branch from feature/game-update, instead you took master as base branch. Can this still be fixed?

Done — I merged feature/game-update (commit c97fe6e) into this branch so all its rich Sense Sprint features (streak multiplier, saved sessions, lifetime stats, language selector, major celebrations) are now proper ancestors of this PR's commits.

To complete the fix on GitHub's side, please update this PR's base branch from master to feature/game-update using the "Edit" button on the PR. Once done, the PR diff will show only the Translation Quiz additions (backend service + frontend component + tests), not the feature/game-update changes that are already in that branch.

The merge commit is 661c478. All 93 tests still pass.

Copilot AI requested a review from HannoZ April 29, 2026 14:36
@HannoZ HannoZ requested a review from Copilot April 29, 2026 14:39
@HannoZ HannoZ changed the base branch from master to feature/game-update April 29, 2026 14:40
@HannoZ
Copy link
Copy Markdown
Owner

HannoZ commented Apr 29, 2026

@copilot where are the playwright tests that were also part of the plan?

@HannoZ
Copy link
Copy Markdown
Owner

HannoZ commented Apr 29, 2026

@copilot fix all review comments and any other comments that are still pending and are directed at you

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/rounds is 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 only ISenseSprintGameService, CancellationToken). This means the selected language will be ignored and the API response will not contain the language field 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-up calls: once diff hits 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 forces roundStatus: 'expired' regardless of the server response (and diff===0 can occur up to ~1s before actual expiry due to Math.floor). Add a one-shot guard (e.g., a ref) and only treat as expired when Date.now() >= expiresAt and/or use the server-returned roundStatus.
    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.log calls 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.language is assumed to exist in the Sense Sprint create-round response, but the backend CreateRoundResponse contract currently has no Language field. At runtime this will set round.language/selectedLanguage to undefined, 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))

Comment on lines +160 to +169

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;
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
setStats((current) => ({
...current,
currentScore: current.currentScore + data.awardedPoints,
highScore: Math.max(current.highScore, current.currentScore + data.awardedPoints),
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
highScore: Math.max(current.highScore, current.currentScore + data.awardedPoints),
highScore: Math.max(current.highScore, data.awardedPoints),

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +146
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!"));
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +40
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)}.");
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +53
var roundId = Guid.NewGuid();
_cache.Set(roundId, generated, generated.ExpiresAtUtc);

Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 29, 2026

@copilot where are the playwright tests that were also part of the plan?

Added in commit 833283c. There are now 13 Playwright E2E tests in source/Demo/sense-sprint-web/tests/e2e/translation-quiz.spec.ts covering:

  • Language dropdown — "Random (any language)" first option, all API-provided target languages populated, graceful fallback when /languages returns 500
  • Round lifecycle — source word and 4 choices displayed, language name shown in the prompt, correct choice highlights green, wrong choice highlights red (with correct answer revealed), choices disabled after submission, "New Round" button appears
  • Stats — win streak increments on correct answer, resets on wrong answer, rounds played counter increments
  • Timer expiry — automatically calls /expire and reveals the correct answer when the 2-second round timer reaches 0

All tests use Playwright's page.route() to mock the API so no live backend is needed. Run with npm run test:e2e from source/Demo/sense-sprint-web/.

@copilot fix all review comments and any other comments that are still pending and are directed at you

All pending review comments addressed in commit 833283c:

  • SenseSprintGameService.GiveUpAsync — now idempotent: returns roundStatus: "completed" with the correct answer instead of throwing when called on an already-completed round
  • TranslationQuiz round cache — rounds now cached for ExpiresAtUtc + 5 minutes (with a comment explaining the rationale), so /expire and late /answer calls still succeed after the exact deadline
  • TranslationQuizGameService.ExpireRoundAsync — validates the round has actually expired (3-second tolerance for client-side rounding); calls more than 3 s before expiry return roundStatus: "lost" with message "Round expiry requested before the timer has elapsed."
  • Translation Quiz highScore — now tracks best single-round awardedPoints (Math.max(current.highScore, data.awardedPoints)), matching the SenseSprint pattern
  • Math.floor → Math.ceil in getSecondsRemaining, the TranslationQuiz interval timer, and startRound initialisation — prevents ~1 second early timer expiry
  • TranslationQuiz expiry effect — removed loading from the dependency array so React's effect cleanup can't cancel an in-flight /expire API call; removed the cancelled flag pattern tied to effect cleanup

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:

  • fonts.googleapis.com
    • Triggering command: /home/REDACTED/.cache/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell /home/REDACTED/.cache/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell --disable-field-trial-config --disable-REDACTED-networking --disable-REDACTED-timer-throttling --disable-REDACTEDing-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-REDACTED-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Transl .bin/sh (dns block)
    • Triggering command: /home/REDACTED/.cache/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell /home/REDACTED/.cache/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell --disable-field-trial-config --disable-REDACTED-networking --disable-REDACTED-timer-throttling --disable-REDACTEDing-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-REDACTED-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Transl (dns block)
    • Triggering command: /home/REDACTED/.cache/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell /home/REDACTED/.cache/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-linux64/chrome-headless-shell --disable-field-trial-config --disable-REDACTED-networking --disable-REDACTED-timer-throttling --disable-REDACTEDing-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-REDACTED-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Transl p/bin/git (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI changed the title Add Translation Quiz game mode, winning streak, high score, and timer expiry reveal Add Translation Quiz game mode, winning streak, high score, timer expiry reveal, and Playwright E2E tests Apr 29, 2026
@HannoZ HannoZ marked this pull request as ready for review April 30, 2026 18:50
@HannoZ HannoZ merged commit 9209b81 into feature/game-update Apr 30, 2026
1 check passed
@HannoZ HannoZ deleted the copilot/start-implementation-cloud branch April 30, 2026 18:54
HannoZ added a commit that referenced this pull request May 4, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants