Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions source/Demo/Lexicala.NET.Demo.Api/Game/GameServiceHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Lexicala.NET.Response;

namespace Lexicala.NET.Demo.Api.Game;

internal static class GameServiceHelpers
{
internal static RateLimitDebugResponse? BuildRateLimit(ResponseMetadata? metadata)
{
var limits = metadata?.RateLimits;
if (limits is null)
{
return null;
}

if (limits.Limit < 0 || limits.LimitRemaining < 0 || limits.Reset < 0)
{
return null;
}

return new RateLimitDebugResponse(limits.Limit, limits.LimitRemaining, limits.Reset);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Lexicala.NET.Demo.Api.Game;

public interface ITranslationQuizGameService
{
Task<CreateQuizRoundResponse> CreateRoundAsync(string? targetLanguage = null, CancellationToken cancellationToken = default);

Task<QuizAnswerResponse> SubmitAnswerAsync(Guid roundId, string choice, CancellationToken cancellationToken = default);

Task<QuizAnswerResponse> ExpireRoundAsync(Guid roundId, CancellationToken cancellationToken = default);
}
36 changes: 16 additions & 20 deletions source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,19 +157,31 @@ public Task<GuessResponse> SubmitGuessAsync(Guid roundId, string guess, Cancella
public Task<GuessResponse> GiveUpAsync(Guid roundId, CancellationToken cancellationToken = default)
{
var round = GetRequiredRound(roundId);
EnsureRoundIsActive(roundId, round);

if (round.IsCompleted)
{
return Task.FromResult(new GuessResponse(
roundId,
false,
"completed",
0,
round.CurrentClueIndex,
round.Answer,
"Round already completed. Start a new round."));
}

round.IsCompleted = true;
_cache.Set(roundId, round, round.ExpiresAtUtc);

var isExpired = DateTimeOffset.UtcNow > round.ExpiresAtUtc;
Comment on lines +160 to +176
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.
return Task.FromResult(new GuessResponse(
roundId,
false,
"lost",
isExpired ? "expired" : "lost",
0,
round.CurrentClueIndex,
round.Answer,
"Round ended. Better luck next time."));
isExpired ? "Time's up! The answer was revealed." : "Round ended. Better luck next time."));
}

private async Task<SenseSprintRoundState?> TryGenerateRoundAsync(CancellationToken cancellationToken)
Expand Down Expand Up @@ -208,26 +220,10 @@ public Task<GuessResponse> GiveUpAsync(Guid roundId, CancellationToken cancellat
CurrentClueIndex = 0,
IsCompleted = false,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(RoundSeconds),
RateLimit = BuildRateLimit(entry.Metadata) ?? BuildRateLimit(fluky.Metadata)
RateLimit = GameServiceHelpers.BuildRateLimit(entry.Metadata) ?? GameServiceHelpers.BuildRateLimit(fluky.Metadata)
};
}

private static RateLimitDebugResponse? BuildRateLimit(ResponseMetadata? metadata)
{
var limits = metadata?.RateLimits;
if (limits is null)
{
return null;
}

if (limits.Limit < 0 || limits.LimitRemaining < 0 || limits.Reset < 0)
{
return null;
}

return new RateLimitDebugResponse(limits.Limit, limits.LimitRemaining, limits.Reset);
}

private static List<string> BuildClues(Entry entry, Sense sense, string answer)
{
var clues = new List<string>(capacity: 4)
Expand Down
25 changes: 25 additions & 0 deletions source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;

namespace Lexicala.NET.Demo.Api.Game;

public sealed record CreateQuizRoundResponse(
Guid RoundId,
string SourceWord,
string SourceLanguage,
string TargetLanguage,
string[] Choices,
DateTimeOffset ExpiresAtUtc,
int RoundSeconds,
RateLimitDebugResponse? RateLimit
);

public sealed record QuizAnswerRequest(string Choice);

public sealed record QuizAnswerResponse(
Guid RoundId,
bool IsCorrect,
string RoundStatus,
int AwardedPoints,
string CorrectAnswer,
string? Message
);
Loading
Loading