From 9a3c03332d742a4cc75a94bc3fb405d96f6ac577 Mon Sep 17 00:00:00 2001 From: Vane Spasov Date: Thu, 27 Aug 2026 23:21:13 +0200 Subject: [PATCH 01/15] Apply NFR improvements: security, reliability, observability, performance, testability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (P0) - Remove hardcoded Bing Maps key; bind via IOptions and User Secrets - Remove unused key field from WASM client bundle - Restrict AllowedHosts to localhost in production config - Add fixed-window rate limiter (60 req/min) on geocode endpoint - Validate location length in controller (BadRequest >100 chars) Reliability (P1) - Replace blocking .Result Bing call with proper async/await + 10s WaitAsync timeout - Introduce IGeocodingService / BingGeocodingService; controller is now a thin delegate - Replace fragile colon-delimited string response with typed GeocodeResult JSON DTO - Client: Task.WhenAll for parallel validation, loading spinner, user-visible error alert - Wire Countdown.TimerOut to auto-submit the active game on expiry - Null-safe Game.TotalPoints using ?. on every location Observability (P2) - Call app.MapDefaultEndpoints() — /health and /alive now respond - Structured ILogger calls in BingGeocodingService (outcome, cache hit, latency, errors) - Bump OpenTelemetry.Exporter.OpenTelemetryProtocol from 1.9.0 (NU1902) to 1.18.0 - Add GitHub Actions CI workflow (build, unit tests, integration tests, vuln scan) Performance (P3) - Parallelize five per-answer HTTP calls with Task.WhenAll - IMemoryCache on BingGeocodingService keyed by (answer, locationType), TTL 24h Testability and maintainability (P4) - Extract ScoringRules (constants) and BadgeCalculator to FastGeography.Shared - Extract IGeocodingService port and BingGeocodingService adapter - Unit tests: 32 cases for SetCssClass, TotalPoints, ScoringRules, all badge tiers - Integration tests converted to Reqnroll/Gherkin BDD (8 scenarios, fake geocoder) - Fix E2E test selectors; mark Playwright tests as Skip until dev server available - Delete dead template code: HomeController, Counter.razor, SurveyPrompt.razor - Restore @Body routing in MainLayout; Index.razor redirects to /fastgeography - Remove duplicate Client project from AppHost (Client is hosted by Server) UX and Accessibility (P5) - Drop user-scalable=no from viewport meta - Add aria-label on game inputs, role=timer on countdown, aria-live on achievement banner - Add IsChecking parameter to GameRow to disable fields during validation - Add SVG assets: visit.svg and badge-0..9.svg (were missing, causing broken images) Co-authored-by: Cursor --- .github/workflows/ci.yml | 57 ++++ src/FastGeography.AppHost/Program.cs | 8 +- .../FastGeography.Client.csproj | 1 - src/FastGeography.Client/Pages/Counter.razor | 18 -- .../Pages/GameTable.razor | 289 +++++++++--------- src/FastGeography.Client/Pages/Index.razor | 11 +- .../Shared/Countdown.razor | 23 +- src/FastGeography.Client/Shared/GameRow.razor | 98 +++--- .../Shared/MainLayout.razor | 15 +- .../Shared/SurveyPrompt.razor | 16 - .../wwwroot/images/badge-0.svg | 6 + .../wwwroot/images/badge-1.svg | 6 + .../wwwroot/images/badge-2.svg | 6 + .../wwwroot/images/badge-3.svg | 6 + .../wwwroot/images/badge-4.svg | 6 + .../wwwroot/images/badge-5.svg | 6 + .../wwwroot/images/badge-6.svg | 6 + .../wwwroot/images/badge-7.svg | 6 + .../wwwroot/images/badge-8.svg | 6 + .../wwwroot/images/badge-9.svg | 6 + .../wwwroot/images/visit.svg | 5 + src/FastGeography.Client/wwwroot/index.html | 2 +- .../Controllers/BingMapsController.cs | 105 +++---- .../Controllers/HomeController.cs | 83 ----- .../FastGeography.Server.csproj | 2 + .../Options/BingMapsOptions.cs | 12 + src/FastGeography.Server/Program.cs | 51 ++-- .../Services/BingGeocodingService.cs | 138 +++++++++ .../Services/IGeocodingService.cs | 18 ++ .../appsettings.Development.json | 4 + src/FastGeography.Server/appsettings.json | 5 +- .../FastGeography.ServiceDefaults.csproj | 2 +- src/FastGeography.Shared/BadgeCalculator.cs | 39 +++ src/FastGeography.Shared/Game.cs | 50 +-- src/FastGeography.Shared/GeocodeResult.cs | 8 + src/FastGeography.Shared/ScoringRules.cs | 33 ++ .../GameWorkflowTests.cs | 106 +++---- .../BingMapsControllerTests.cs | 33 -- .../FastGeography.Tests.Integration.csproj | 16 +- .../Features/BingMapsValidation.feature | 37 +++ .../GeographyValidationSteps.cs | 83 +++++ .../Support/FakeGeocodingService.cs | 32 ++ .../Support/GameApiContext.cs | 54 ++++ tst/FastGeography.Tests.Unit/GameTests.cs | 101 +++++- 44 files changed, 1056 insertions(+), 559 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 src/FastGeography.Client/Pages/Counter.razor delete mode 100644 src/FastGeography.Client/Shared/SurveyPrompt.razor create mode 100644 src/FastGeography.Client/wwwroot/images/badge-0.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-1.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-2.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-3.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-4.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-5.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-6.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-7.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-8.svg create mode 100644 src/FastGeography.Client/wwwroot/images/badge-9.svg create mode 100644 src/FastGeography.Client/wwwroot/images/visit.svg delete mode 100644 src/FastGeography.Server/Controllers/HomeController.cs create mode 100644 src/FastGeography.Server/Options/BingMapsOptions.cs create mode 100644 src/FastGeography.Server/Services/BingGeocodingService.cs create mode 100644 src/FastGeography.Server/Services/IGeocodingService.cs create mode 100644 src/FastGeography.Shared/BadgeCalculator.cs create mode 100644 src/FastGeography.Shared/GeocodeResult.cs create mode 100644 src/FastGeography.Shared/ScoringRules.cs delete mode 100644 tst/FastGeography.Tests.Integration/BingMapsControllerTests.cs create mode 100644 tst/FastGeography.Tests.Integration/Features/BingMapsValidation.feature create mode 100644 tst/FastGeography.Tests.Integration/StepDefinitions/GeographyValidationSteps.cs create mode 100644 tst/FastGeography.Tests.Integration/Support/FakeGeocodingService.cs create mode 100644 tst/FastGeography.Tests.Integration/Support/GameApiContext.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98d8d33 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + name: Build, test, and security scan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build (Release) + run: dotnet build --no-restore --configuration Release + + - name: Unit tests + run: | + dotnet test tst/FastGeography.Tests.Unit \ + --no-build \ + --configuration Release \ + --logger "trx;LogFileName=unit-results.trx" \ + --collect:"XPlat Code Coverage" + + - name: Integration tests + run: | + dotnet test tst/FastGeography.Tests.Integration \ + --no-build \ + --configuration Release \ + --logger "trx;LogFileName=integration-results.trx" + env: + # Provide an empty key so BingMapsOptions binds; the fake service is + # substituted in tests so no real API calls are made. + BingMaps__ApiKey: "" + + - name: Publish test results + if: always() + uses: dorny/test-reporter@v1 + with: + name: Test results + path: "**/*.trx" + reporter: dotnet-trx + + - name: Security scan — vulnerable packages + run: dotnet list package --vulnerable --include-transitive + continue-on-error: true diff --git a/src/FastGeography.AppHost/Program.cs b/src/FastGeography.AppHost/Program.cs index 0c1498d..9714981 100644 --- a/src/FastGeography.AppHost/Program.cs +++ b/src/FastGeography.AppHost/Program.cs @@ -1,7 +1,7 @@ var builder = DistributedApplication.CreateBuilder(args); -var server = builder.AddProject("fastgeography-api"); -var client = builder.AddProject("fastgeography-client") - .WithReference(server); +// FastGeography.Client is a Blazor WASM app hosted by the Server project. +// Only the Server needs to be registered with Aspire. +builder.AddProject("fastgeography-server"); -builder.Build().Run(); \ No newline at end of file +builder.Build().Run(); diff --git a/src/FastGeography.Client/FastGeography.Client.csproj b/src/FastGeography.Client/FastGeography.Client.csproj index ca15cd2..3e6267b 100644 --- a/src/FastGeography.Client/FastGeography.Client.csproj +++ b/src/FastGeography.Client/FastGeography.Client.csproj @@ -7,7 +7,6 @@ - diff --git a/src/FastGeography.Client/Pages/Counter.razor b/src/FastGeography.Client/Pages/Counter.razor deleted file mode 100644 index fcba10d..0000000 --- a/src/FastGeography.Client/Pages/Counter.razor +++ /dev/null @@ -1,18 +0,0 @@ -@page "/counter" - -Counter ddd - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/src/FastGeography.Client/Pages/GameTable.razor b/src/FastGeography.Client/Pages/GameTable.razor index 9257666..2a63e1c 100644 --- a/src/FastGeography.Client/Pages/GameTable.razor +++ b/src/FastGeography.Client/Pages/GameTable.razor @@ -1,50 +1,69 @@ @page "/fastgeography" -@using BingMapsRESTToolkit; -@using System.Net.Http.Json; -@using System.Linq; -@using FastGeography.Shared; -@inject HttpClient HttpClient; +@using System.Net.Http.Json +@using System.Linq +@using FastGeography.Shared +@inject HttpClient HttpClient +@inject ILogger Logger
- -
- +
+
- @if (games == null) + @if (!string.IsNullOrEmpty(errorMessage)) { -

Loading...

+ + } + + @if (isChecking) + { +
+ + Checking your answers… +
} - else if (!games.Any()) + + @if (!games.Any()) { -

No game started. Please start one.

+

No game started. Please start one.

} else { - -
- @if (totalPoints > 0) +
+ +
+ + @if (totalPoints > ScoringRules.AchievementThreshold) { -
+

@encourageMessage

-

🎉 Amazing Explorer! You earned the @GetBadge() badge 🎉

- Achievement Badge +

Amazing Explorer! You earned the @BadgeCalculator.Calculate(totalPoints) badge!

+ @BadgeCalculator.Calculate(totalPoints) badge
} - + +
- - - - - - - + + + + + + + + @@ -60,144 +79,130 @@ Mountain=@game.Value.Mountain IsFinished=@game.Value.IsFinished SecondsPlayed=@game.Value.SecondsPlayed + IsChecking=@isChecking OnClick=@CheckAnswers /> }
LetterCityVillageCountryRiverMountainTime[s]LetterCityVillageCountryRiverMountainTime (s)Action
} +
+ +@code { + private IDictionary games = new Dictionary(); + private int totalPoints = 0; + private string? encourageMessage; + private string? errorMessage; + private bool isStarted = false; + private bool isChecking = false; + private Countdown countdown = null!; + + private void StartNewGame() + { + isStarted = true; + var letter = (char)('A' + Random.Shared.Next(0, 26)); + var key = Guid.NewGuid(); - @code { - private string bingMapsKey = "AvgAK8EVgx50WkOB6cyA8ckUM5ku4U3kGJvxthKwE75_S4-c-XlTP82kUom8baQk"; - public IDictionary games { get; set; } = new Dictionary(); - private int totalPoints = 0; - private string encourageMessage = ""; - private int totalGames = 10; - private int gamesPlayed = 0; - private bool isStarted = false; - private Countdown countdown; - private IDictionary> locations = new Dictionary>(); - - private void StartNewGame() + games.Add(key, new Game { - totalGames++; - isStarted = true; - Random random = new Random(); - var letter = (char)('A' + random.Next(0, 26)); - var key = Guid.NewGuid(); - - games.Add(key, new Game() - { - Id = key, - DatePlayed = DateTime.Now, - Letter = letter, - City = new GameLocation() { Answer = string.Empty, LocationType = LocationType.City }, - Country = new GameLocation() { Answer = string.Empty, LocationType = LocationType.Country }, - Village = new GameLocation() { Answer = string.Empty, LocationType = LocationType.Village }, - River = new GameLocation() { Answer = string.Empty, LocationType = LocationType.River }, - Mountain = new GameLocation() { Answer = string.Empty, LocationType = LocationType.Mountain }, - IsFinished = false - }); - - countdown.Start(60); - } + Id = key, + DatePlayed = DateTime.Now, + Letter = letter, + City = new GameLocation { Answer = string.Empty, LocationType = LocationType.City }, + Country = new GameLocation { Answer = string.Empty, LocationType = LocationType.Country }, + Village = new GameLocation { Answer = string.Empty, LocationType = LocationType.Village }, + River = new GameLocation { Answer = string.Empty, LocationType = LocationType.River }, + Mountain = new GameLocation { Answer = string.Empty, LocationType = LocationType.Mountain }, + IsFinished = false + }); + + countdown.Start(ScoringRules.DefaultTimerSeconds); + } - protected override async Task OnInitializedAsync() + private async Task OnTimerExpired() + { + var activeGame = games.Values.FirstOrDefault(g => !g.IsFinished); + if (activeGame is not null) { - //TODO: load the games that the user has played. Load them from the Redis cache. - //games = await Http.GetFromJsonAsync>("sample-data/fastgeography.json"); + await CheckAnswers(activeGame); } + } - async Task CheckAnswers(Game game) + private async Task CheckAnswers(Game game) + { + countdown.Stop(); + game.SecondsPlayed = countdown.ElapsedSeconds; + game.IsFinished = true; + isChecking = true; + errorMessage = null; + StateHasChanged(); + + try { - countdown.Stop(); - - game.SecondsPlayed = countdown.ElapsedSeconds; - game.IsFinished = true; - game.City.Points = await CheckGeoLocation(game.City, game.Letter); - game.Village.Points = await CheckGeoLocation(game.Village, game.Letter); - game.Country.Points = await CheckGeoLocation(game.Country, game.Letter); - game.River.Points = await CheckGeoLocation(game.River, game.Letter); - game.Mountain.Points = await CheckGeoLocation(game.Mountain, game.Letter); - - totalPoints = totalPoints + game.TotalPoints; - encourageMessage = GetEncouragingMessage(game.TotalPoints); - games.Remove(game.Id); - games.Add(game.Id, game); - - isStarted = !game.IsFinished; + // Validate all five answers in parallel instead of sequentially + var cityTask = ValidateLocation(game.City, game.Letter); + var villageTask = ValidateLocation(game.Village, game.Letter); + var countryTask = ValidateLocation(game.Country, game.Letter); + var riverTask = ValidateLocation(game.River, game.Letter); + var mountainTask = ValidateLocation(game.Mountain, game.Letter); + + await Task.WhenAll(cityTask, villageTask, countryTask, riverTask, mountainTask); + + game.City!.Points = cityTask.Result; + game.Village!.Points = villageTask.Result; + game.Country!.Points = countryTask.Result; + game.River!.Points = riverTask.Result; + game.Mountain!.Points = mountainTask.Result; + } + finally + { + isChecking = false; } + totalPoints += game.TotalPoints; + encourageMessage = GetEncouragingMessage(game.TotalPoints); - private async Task CheckGeoLocation(GameLocation? location, char gameLetter) - { - var points = 0; + games.Remove(game.Id); + games.Add(game.Id, game); + isStarted = false; + } - if (string.IsNullOrEmpty(location.Answer)) - return 0;//TODO: add a penalty for empty answer + private async Task ValidateLocation(GameLocation? location, char gameLetter) + { + if (location is null || string.IsNullOrWhiteSpace(location.Answer)) + return ScoringRules.EmptyPoints; - if (!location.Answer.StartsWith(gameLetter)) - return -10;//TODO: add a penalty for wrong letter + if (!location.Answer.StartsWith(gameLetter.ToString(), StringComparison.OrdinalIgnoreCase)) + return ScoringRules.WrongLetterPoints; - //var response = await HttpClient.GetAsync($"bingmaps/{location}"); - var response = await HttpClient.GetAsync($"bingmaps/{location.Answer}/{location.LocationType}"); + try + { + var result = await HttpClient.GetFromJsonAsync( + $"bingmaps/{Uri.EscapeDataString(location.Answer)}/{location.LocationType}"); - if (response.IsSuccessStatusCode) - { - var result = await response.Content.ReadAsStringAsync(); - var resultData = result.Split(":"); - int.TryParse(resultData[1], out points); - if (resultData.Length > 2) - { - location.Coordinates = resultData[2]; - } - } - else - { - // Handle the error here... - } + if (result is null) + return ScoringRules.InvalidPoints; - return points; + location.Coordinates = result.Coordinates; + return result.Points; } - - private Badge GetBadge() + catch (Exception ex) { - if (totalPoints > 0 && totalPoints <= 100) - return Badge.Junior; - else if (totalPoints > 100 && totalPoints <= 200) - return Badge.Cadet; - else if (totalPoints > 200 && totalPoints <= 300) - return Badge.Explorer; - else if (totalPoints > 300 && totalPoints <= 400) - return Badge.Traveller; - else if (totalPoints > 400 && totalPoints <= 500) - return Badge.Jumper; - else if (totalPoints > 500 && totalPoints <= 600) - return Badge.EarthSurfer; - else if (totalPoints > 600 && totalPoints <= 700) - return Badge.EarthConqueror; - else if (totalPoints > 700 && totalPoints <= 800) - return Badge.SolarSpectre; - else if (totalPoints > 800 && totalPoints <= 900) - return Badge.GalacticSurfer; - else if (totalPoints > 900 && totalPoints <= 1000) - return Badge.GalacticConqueror; - - return Badge.Junior; + Logger.LogError(ex, "Failed to validate {LocationType} answer '{Answer}'", + location.LocationType, location.Answer); + errorMessage = "Some answers could not be checked. Please try again."; + return ScoringRules.EmptyPoints; } + } - private string GetBadgeImage() - { - var rating = (int)GetBadge(); - return $"images/badge-{rating}.png"; - } + private static string GetBadgeImagePath(int points) => + $"images/badge-{(int)BadgeCalculator.Calculate(points)}.svg"; - private string GetEncouragingMessage(int points) - { - if (points >= 80) return "🌟 Outstanding! You're a Geography Genius!"; - if (points >= 60) return "🎈 Amazing job! Keep exploring!"; - if (points >= 40) return "👍 Good work! You're learning fast!"; - if (points >= 20) return "😊 Nice try! Let's discover more places!"; - return "🌍 Every game makes you smarter! Try again!"; - } + private string GetEncouragingMessage(int points) + { + if (points >= 80) return "Outstanding! You are a Geography Genius!"; + if (points >= 60) return "Amazing job! Keep exploring!"; + if (points >= 40) return "Good work! You are learning fast!"; + if (points >= 20) return "Nice try! Let's discover more places!"; + return "Every game makes you smarter! Try again!"; } -
\ No newline at end of file +} diff --git a/src/FastGeography.Client/Pages/Index.razor b/src/FastGeography.Client/Pages/Index.razor index 6085c4a..cef0fea 100644 --- a/src/FastGeography.Client/Pages/Index.razor +++ b/src/FastGeography.Client/Pages/Index.razor @@ -1,9 +1,6 @@ @page "/" +@inject NavigationManager Nav -Index - -

Hello, world!

- -Welcome to your new app. - - +@code { + protected override void OnInitialized() => Nav.NavigateTo("/fastgeography"); +} diff --git a/src/FastGeography.Client/Shared/Countdown.razor b/src/FastGeography.Client/Shared/Countdown.razor index 4f952af..a967ea9 100644 --- a/src/FastGeography.Client/Shared/Countdown.razor +++ b/src/FastGeography.Client/Shared/Countdown.razor @@ -1,29 +1,30 @@ @using System.Timers; -
+
- @Time -
+ +
@code { private System.Timers.Timer _timer = null!; private int _secondsToRun = 0; - private int initialSeconds = 0; + private int _initialSeconds = 0; - protected string Time { get; set; } = "00:00"; + protected string Time { get; set; } = "01:00"; [Parameter] public EventCallback TimerOut { get; set; } - [Parameter] - public int SecondsToRun { get { return _secondsToRun; } set { } } - public int ElapsedSeconds { get { return initialSeconds - _secondsToRun; } } + public int ElapsedSeconds => _initialSeconds - _secondsToRun; public void Start(int secondsToRun) { - initialSeconds = secondsToRun; + _initialSeconds = secondsToRun; _secondsToRun = secondsToRun; if (_secondsToRun > 0) @@ -52,14 +53,14 @@ await InvokeAsync(() => { - Time = TimeSpan.FromSeconds(_secondsToRun).ToString(@"mm\:ss"); + Time = TimeSpan.FromSeconds(Math.Max(0, _secondsToRun)).ToString(@"mm\:ss"); StateHasChanged(); }); if (_secondsToRun <= 0) { _timer.Stop(); - await TimerOut.InvokeAsync(); + await InvokeAsync(() => TimerOut.InvokeAsync()); } } } diff --git a/src/FastGeography.Client/Shared/GameRow.razor b/src/FastGeography.Client/Shared/GameRow.razor index 71d4ffd..d7a2a1c 100644 --- a/src/FastGeography.Client/Shared/GameRow.razor +++ b/src/FastGeography.Client/Shared/GameRow.razor @@ -1,54 +1,74 @@ @using FastGeography.Shared; - - - + + + + + @if (game.City.MapsUri != null) { - - Go to location + + View on map } - - + + @if (game.Village.MapsUri != null) { - - Go to location + + View on map } - - + + @if (game.Country.MapsUri != null) { - - Go to location + + View on map } - - + + @if (game.River.MapsUri != null) { - - Go to location + + View on map } - - + + @if (game.Mountain.MapsUri != null) { - - Go to location + + View on map } @game.SecondsPlayed - + + + @code { @@ -62,29 +82,27 @@ [Parameter] public GameLocation? Mountain { get; set; } [Parameter] public GameLocation? River { get; set; } [Parameter] public bool IsFinished { get; set; } + [Parameter] public bool IsChecking { get; set; } [Parameter] public int SecondsPlayed { get; set; } - Game game = new Game(); + private Game game = new(); - async Task HandleClick() - { - await OnClick.InvokeAsync(game); - } + private async Task HandleClick() => await OnClick.InvokeAsync(game); protected override void OnParametersSet() { game = new Game - { - Id = Id, - DatePlayed = DatePlayed, - Letter = Letter, - City = City, - Village = Village, - Country = Country, - Mountain = Mountain, - River = River, - IsFinished = IsFinished, - SecondsPlayed = SecondsPlayed - }; + { + Id = Id, + DatePlayed = DatePlayed, + Letter = Letter, + City = City, + Village = Village, + Country = Country, + Mountain = Mountain, + River = River, + IsFinished = IsFinished, + SecondsPlayed = SecondsPlayed + }; } -} \ No newline at end of file +} diff --git a/src/FastGeography.Client/Shared/MainLayout.razor b/src/FastGeography.Client/Shared/MainLayout.razor index 3e7ef88..f170e38 100644 --- a/src/FastGeography.Client/Shared/MainLayout.razor +++ b/src/FastGeography.Client/Shared/MainLayout.razor @@ -1,20 +1,9 @@ -@using FastGeography.Client.Pages -@inherits LayoutComponentBase +@inherits LayoutComponentBase
- @**@ -
- @*
- About -
*@ -
- @* @Body*@ - + @Body
-
diff --git a/src/FastGeography.Client/Shared/SurveyPrompt.razor b/src/FastGeography.Client/Shared/SurveyPrompt.razor deleted file mode 100644 index 962027f..0000000 --- a/src/FastGeography.Client/Shared/SurveyPrompt.razor +++ /dev/null @@ -1,16 +0,0 @@ -
- - @Title - - - Please take our - brief survey - - and tell us what you think. -
- -@code { - // Demonstrates how a parent component can supply parameters - [Parameter] - public string? Title { get; set; } -} diff --git a/src/FastGeography.Client/wwwroot/images/badge-0.svg b/src/FastGeography.Client/wwwroot/images/badge-0.svg new file mode 100644 index 0000000..99494ba --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-0.svg @@ -0,0 +1,6 @@ + + + + 0 + Junior + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-1.svg b/src/FastGeography.Client/wwwroot/images/badge-1.svg new file mode 100644 index 0000000..a18a628 --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-1.svg @@ -0,0 +1,6 @@ + + + + 1 + Cadet + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-2.svg b/src/FastGeography.Client/wwwroot/images/badge-2.svg new file mode 100644 index 0000000..3caa7e3 --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-2.svg @@ -0,0 +1,6 @@ + + + + 2 + Explorer + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-3.svg b/src/FastGeography.Client/wwwroot/images/badge-3.svg new file mode 100644 index 0000000..16bdec6 --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-3.svg @@ -0,0 +1,6 @@ + + + + 3 + Traveller + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-4.svg b/src/FastGeography.Client/wwwroot/images/badge-4.svg new file mode 100644 index 0000000..e2987b7 --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-4.svg @@ -0,0 +1,6 @@ + + + + 4 + Jumper + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-5.svg b/src/FastGeography.Client/wwwroot/images/badge-5.svg new file mode 100644 index 0000000..3f9f478 --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-5.svg @@ -0,0 +1,6 @@ + + + + 5 + EarthSurfer + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-6.svg b/src/FastGeography.Client/wwwroot/images/badge-6.svg new file mode 100644 index 0000000..29fa04b --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-6.svg @@ -0,0 +1,6 @@ + + + + 6 + EarthConqueror + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-7.svg b/src/FastGeography.Client/wwwroot/images/badge-7.svg new file mode 100644 index 0000000..2553336 --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-7.svg @@ -0,0 +1,6 @@ + + + + 7 + SolarSpectre + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-8.svg b/src/FastGeography.Client/wwwroot/images/badge-8.svg new file mode 100644 index 0000000..53cfa0b --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-8.svg @@ -0,0 +1,6 @@ + + + + 8 + GalacticSurfer + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/badge-9.svg b/src/FastGeography.Client/wwwroot/images/badge-9.svg new file mode 100644 index 0000000..15f508f --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/badge-9.svg @@ -0,0 +1,6 @@ + + + + 9 + GalacticConqueror + \ No newline at end of file diff --git a/src/FastGeography.Client/wwwroot/images/visit.svg b/src/FastGeography.Client/wwwroot/images/visit.svg new file mode 100644 index 0000000..2985e3c --- /dev/null +++ b/src/FastGeography.Client/wwwroot/images/visit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/FastGeography.Client/wwwroot/index.html b/src/FastGeography.Client/wwwroot/index.html index 1839651..a23d8ae 100644 --- a/src/FastGeography.Client/wwwroot/index.html +++ b/src/FastGeography.Client/wwwroot/index.html @@ -3,7 +3,7 @@ - + FastGeography diff --git a/src/FastGeography.Server/Controllers/BingMapsController.cs b/src/FastGeography.Server/Controllers/BingMapsController.cs index 7faa8df..bfe1d6b 100644 --- a/src/FastGeography.Server/Controllers/BingMapsController.cs +++ b/src/FastGeography.Server/Controllers/BingMapsController.cs @@ -1,80 +1,43 @@ -namespace FastGeography.Server.Controllers -{ - using BingMapsRESTToolkit; - - using FastGeography.Shared; - - using Microsoft.AspNetCore.Mvc; - - [ApiController] - [Route("bingmaps")] - public class BingMapsController : ControllerBase - { - private string bingMapsKey = "AvgAK8EVgx50WkOB6cyA8ckUM5ku4U3kGJvxthKwE75_S4-c-XlTP82kUom8baQk"; - - [HttpGet("{location}/{locationType}")] - public async Task GetLocationType(string location, LocationType locationType) - { - // Create a geocode request - var request = new GeocodeRequest() - { - Query = location, - IncludeIso2 = true, - MaxResults = 1, - BingMapsKey = bingMapsKey - }; - var response = request.Execute().Result; +namespace FastGeography.Server.Controllers; - if (!IsValid(response)) - { - return Ok($"{locationType}:-5"); - } +using FastGeography.Server.Services; +using FastGeography.Shared; - // Get the location type (e.g. city, river, mountain) - var result = response.ResourceSets[0].Resources[0] as Location; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; - if (LocationExists(result, locationType)) - { - var coordinates = $"{result.Point.Coordinates[0]},{result.Point.Coordinates[1]}"; - return Ok($"{locationType}:20:{coordinates}"); - } - else - { - return Ok($"{locationType}:-5"); - } - } - - private static bool IsValid(Response? response) - { - //TODO: use FluentValidation!!! - return (response != null && response.ResourceSets != null && - response.ResourceSets.Length > 0 && - response.ResourceSets[0].Resources != null && - response.ResourceSets[0].Resources.Length > 0); - } +[ApiController] +[Route("bingmaps")] +[EnableRateLimiting("geocode")] +public class BingMapsController : ControllerBase +{ + private readonly IGeocodingService _geocoding; + private readonly ILogger _logger; - private bool LocationExists(Location? location, LocationType locationType) - { - if (location == null) - return false; + public BingMapsController(IGeocodingService geocoding, ILogger logger) + { + _geocoding = geocoding; + _logger = logger; + } - switch (locationType) - { - case LocationType.City: - return location.EntityType.Contains("PopulatedPlace"); - case LocationType.Village: - return location.EntityType.Contains("PopulatedPlace"); - case LocationType.Country: - return location.EntityType.Contains("CountryRegion") || location.EntityType.Contains("AdminDivision1"); - case LocationType.Mountain: - return location.EntityType.Contains("Mountain") || location.EntityType.Contains("MountainRange"); - case LocationType.River: - return location.EntityType.Contains("River"); + /// + /// Validates a player's geography answer for the given location type. + /// Location is limited to 100 characters to prevent quota abuse. + /// Returns a with the awarded points and coordinates. + /// + [HttpGet("{location}/{locationType}")] + public async Task GetLocationType( + string location, + LocationType locationType, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(location) || location.Length > ScoringRules.MaxAnswerLength) + return BadRequest("Location must be between 1 and 100 characters."); - default: - return false; - } + _logger.LogInformation( + "Validating answer '{Location}' for type {LocationType}", location, locationType); - } + var result = await _geocoding.ValidateAsync(location, locationType, cancellationToken); + return Ok(result); } } diff --git a/src/FastGeography.Server/Controllers/HomeController.cs b/src/FastGeography.Server/Controllers/HomeController.cs deleted file mode 100644 index 19d799c..0000000 --- a/src/FastGeography.Server/Controllers/HomeController.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace FastGeography.Server.Controllers -{ - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - - public class HomeController : Controller - { - // GET: HomeController - public ActionResult Index() - { - return View(); - } - - // GET: HomeController/Details/5 - public ActionResult Details(int id) - { - return View(); - } - - // GET: HomeController/Create - public ActionResult Create() - { - return View(); - } - - // POST: HomeController/Create - [HttpPost] - [ValidateAntiForgeryToken] - public ActionResult Create(IFormCollection collection) - { - try - { - return RedirectToAction(nameof(Index)); - } - catch - { - return View(); - } - } - - // GET: HomeController/Edit/5 - public ActionResult Edit(int id) - { - return View(); - } - - // POST: HomeController/Edit/5 - [HttpPost] - [ValidateAntiForgeryToken] - public ActionResult Edit(int id, IFormCollection collection) - { - try - { - return RedirectToAction(nameof(Index)); - } - catch - { - return View(); - } - } - - // GET: HomeController/Delete/5 - public ActionResult Delete(int id) - { - return View(); - } - - // POST: HomeController/Delete/5 - [HttpPost] - [ValidateAntiForgeryToken] - public ActionResult Delete(int id, IFormCollection collection) - { - try - { - return RedirectToAction(nameof(Index)); - } - catch - { - return View(); - } - } - } -} diff --git a/src/FastGeography.Server/FastGeography.Server.csproj b/src/FastGeography.Server/FastGeography.Server.csproj index 23ea75e..8c8cfb7 100644 --- a/src/FastGeography.Server/FastGeography.Server.csproj +++ b/src/FastGeography.Server/FastGeography.Server.csproj @@ -4,9 +4,11 @@ net8.0 enable enable + FastGeography-Server + diff --git a/src/FastGeography.Server/Options/BingMapsOptions.cs b/src/FastGeography.Server/Options/BingMapsOptions.cs new file mode 100644 index 0000000..fed9843 --- /dev/null +++ b/src/FastGeography.Server/Options/BingMapsOptions.cs @@ -0,0 +1,12 @@ +namespace FastGeography.Server.Options; + +public sealed class BingMapsOptions +{ + public const string Section = "BingMaps"; + + /// + /// Bing Maps API key. Set via User Secrets or the BINGMAPS__APIKEY environment variable. + /// Never commit a real key to source control. + /// + public string ApiKey { get; set; } = string.Empty; +} diff --git a/src/FastGeography.Server/Program.cs b/src/FastGeography.Server/Program.cs index 33ca674..7b9fc97 100644 --- a/src/FastGeography.Server/Program.cs +++ b/src/FastGeography.Server/Program.cs @@ -1,44 +1,54 @@ +using System.Threading.RateLimiting; + +using FastGeography.Server.Options; +using FastGeography.Server.Services; + +using Microsoft.AspNetCore.RateLimiting; + public partial class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - // Add Aspire defaults (already added) builder.AddServiceDefaults(); - // Add services to the container. builder.Services.AddControllersWithViews(); builder.Services.AddRazorPages(); - // Add this line for Blazor WASM builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); - var app = builder.Build(); + // --- Configuration --- + builder.Services.Configure( + builder.Configuration.GetSection(BingMapsOptions.Section)); - // Configure the HTTP request pipeline. - if (app.Environment.IsDevelopment()) + // --- Infrastructure --- + builder.Services.AddMemoryCache(); + builder.Services.AddSingleton(); + + // --- Rate limiting: 60 geocode requests per minute per client --- + builder.Services.AddRateLimiter(limiter => { - app.Use(async (context, next) => + limiter.AddFixedWindowLimiter("geocode", o => { - try - { - await next(); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Request failed: {context.Request.Path}, Error: {ex}"); - throw; ; - } - + o.PermitLimit = 60; + o.Window = TimeSpan.FromMinutes(1); + o.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + o.QueueLimit = 0; }); + limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + }); + + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { app.UseWebAssemblyDebugging(); } else { app.UseExceptionHandler("/Error"); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } @@ -51,8 +61,9 @@ public static void Main(string[] args) app.UseStaticFiles(); app.UseRouting(); + app.UseRateLimiter(); - + app.MapDefaultEndpoints(); app.MapRazorPages(); app.MapControllers(); app.MapRazorComponents() @@ -62,4 +73,4 @@ public static void Main(string[] args) app.Run(); } -} \ No newline at end of file +} diff --git a/src/FastGeography.Server/Services/BingGeocodingService.cs b/src/FastGeography.Server/Services/BingGeocodingService.cs new file mode 100644 index 0000000..f4bcf85 --- /dev/null +++ b/src/FastGeography.Server/Services/BingGeocodingService.cs @@ -0,0 +1,138 @@ +namespace FastGeography.Server.Services; + +using BingMapsRESTToolkit; + +using FastGeography.Server.Options; +using FastGeography.Shared; + +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +public sealed class BingGeocodingService : IGeocodingService +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + + private readonly BingMapsOptions _options; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public BingGeocodingService( + IOptions options, + IMemoryCache cache, + ILogger logger) + { + _options = options.Value; + _cache = cache; + _logger = logger; + } + + public async Task ValidateAsync( + string location, + LocationType locationType, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + _logger.LogWarning( + "Bing Maps API key is not configured. Returning invalid result for {Location}/{LocationType}", + location, locationType); + return Invalid(locationType); + } + + var cacheKey = $"geocode:{location.Trim().ToLowerInvariant()}:{locationType}"; + + if (_cache.TryGetValue(cacheKey, out GeocodeResult? cached) && cached is not null) + { + _logger.LogDebug("Cache hit for {Location}/{LocationType}", location, locationType); + return cached; + } + + var result = await CallBingAsync(location, locationType, cancellationToken); + + _cache.Set(cacheKey, result, CacheTtl); + return result; + } + + private async Task CallBingAsync( + string location, + LocationType locationType, + CancellationToken cancellationToken) + { + try + { + var request = new GeocodeRequest + { + Query = location, + IncludeIso2 = true, + MaxResults = 1, + BingMapsKey = _options.ApiKey + }; + + var response = await request.Execute().WaitAsync(RequestTimeout, cancellationToken); + + if (!IsValidResponse(response)) + { + _logger.LogInformation( + "Bing Maps returned no results for {Location}/{LocationType}", location, locationType); + return Invalid(locationType); + } + + var match = response.ResourceSets[0].Resources[0] as Location; + + if (!LocationMatchesType(match, locationType)) + { + _logger.LogInformation( + "Bing match for {Location} entity type '{EntityType}' does not satisfy {LocationType}", + location, match?.EntityType, locationType); + return Invalid(locationType); + } + + var coordinates = $"{match!.Point.Coordinates[0]},{match.Point.Coordinates[1]}"; + + _logger.LogInformation( + "Geocode success for {Location}/{LocationType} at {Coordinates}", location, locationType, coordinates); + + return new GeocodeResult + { + LocationType = locationType, + Points = ScoringRules.ValidPoints, + Coordinates = coordinates + }; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning("Bing Maps request timed out for {Location}/{LocationType}", location, locationType); + return Invalid(locationType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Bing Maps request failed for {Location}/{LocationType}", location, locationType); + return Invalid(locationType); + } + } + + private static GeocodeResult Invalid(LocationType locationType) => + new() { LocationType = locationType, Points = ScoringRules.InvalidPoints }; + + private static bool IsValidResponse(Response? response) => + response?.ResourceSets is { Length: > 0 } && + response.ResourceSets[0].Resources is { Length: > 0 }; + + private static bool LocationMatchesType(Location? location, LocationType locationType) + { + if (location is null) return false; + + return locationType switch + { + LocationType.City => location.EntityType.Contains("PopulatedPlace"), + LocationType.Village => location.EntityType.Contains("PopulatedPlace"), + LocationType.Country => location.EntityType.Contains("CountryRegion") || + location.EntityType.Contains("AdminDivision1"), + LocationType.Mountain => location.EntityType.Contains("Mountain") || + location.EntityType.Contains("MountainRange"), + LocationType.River => location.EntityType.Contains("River"), + _ => false + }; + } +} diff --git a/src/FastGeography.Server/Services/IGeocodingService.cs b/src/FastGeography.Server/Services/IGeocodingService.cs new file mode 100644 index 0000000..5636a12 --- /dev/null +++ b/src/FastGeography.Server/Services/IGeocodingService.cs @@ -0,0 +1,18 @@ +namespace FastGeography.Server.Services; + +using FastGeography.Shared; + +/// +/// Validates a player's geography answer against an external geocoding provider. +/// +public interface IGeocodingService +{ + /// + /// Returns a with the awarded points and, when valid, + /// the geographic coordinates of the matched place. + /// + Task ValidateAsync( + string location, + LocationType locationType, + CancellationToken cancellationToken = default); +} diff --git a/src/FastGeography.Server/appsettings.Development.json b/src/FastGeography.Server/appsettings.Development.json index 0c208ae..4324ab9 100644 --- a/src/FastGeography.Server/appsettings.Development.json +++ b/src/FastGeography.Server/appsettings.Development.json @@ -4,5 +4,9 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "AllowedHosts": "*", + "BingMaps": { + "ApiKey": "" } } diff --git a/src/FastGeography.Server/appsettings.json b/src/FastGeography.Server/appsettings.json index 10f68b8..db37b72 100644 --- a/src/FastGeography.Server/appsettings.json +++ b/src/FastGeography.Server/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "localhost;127.0.0.1", + "BingMaps": { + "ApiKey": "" + } } diff --git a/src/FastGeography.ServiceDefaults/FastGeography.ServiceDefaults.csproj b/src/FastGeography.ServiceDefaults/FastGeography.ServiceDefaults.csproj index 6c036a1..3943b8a 100644 --- a/src/FastGeography.ServiceDefaults/FastGeography.ServiceDefaults.csproj +++ b/src/FastGeography.ServiceDefaults/FastGeography.ServiceDefaults.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/FastGeography.Shared/BadgeCalculator.cs b/src/FastGeography.Shared/BadgeCalculator.cs new file mode 100644 index 0000000..510ca67 --- /dev/null +++ b/src/FastGeography.Shared/BadgeCalculator.cs @@ -0,0 +1,39 @@ +namespace FastGeography.Shared; + +/// +/// Determines the explorer badge for a cumulative point total. +/// Extracted from GameTable so the logic can be unit-tested and reused. +/// +public static class BadgeCalculator +{ + private static readonly (int MaxInclusive, Badge Badge)[] Tiers = + [ + (100, Badge.Junior), + (200, Badge.Cadet), + (300, Badge.Explorer), + (400, Badge.Traveller), + (500, Badge.Jumper), + (600, Badge.EarthSurfer), + (700, Badge.EarthConqueror), + (800, Badge.SolarSpectre), + (900, Badge.GalacticSurfer), + (1000, Badge.GalacticConqueror), + ]; + + /// + /// Returns the badge for . + /// Any score above 1000 stays at GalacticConqueror; negative scores show Junior. + /// + public static Badge Calculate(int totalPoints) + { + if (totalPoints <= 0) return Badge.Junior; + + foreach (var (max, badge) in Tiers) + { + if (totalPoints <= max) + return badge; + } + + return Badge.GalacticConqueror; + } +} diff --git a/src/FastGeography.Shared/Game.cs b/src/FastGeography.Shared/Game.cs index fe01043..d9eac0f 100644 --- a/src/FastGeography.Shared/Game.cs +++ b/src/FastGeography.Shared/Game.cs @@ -1,28 +1,34 @@ -namespace FastGeography.Shared -{ - using System.Collections.Generic; +namespace FastGeography.Shared; + +using System.Collections.Generic; - public class Game - { - public Guid Id { get; set; } - public DateTime DatePlayed { get; set; } - public bool IsFinished { get; set; } - public char Letter { get; set; } - public GameLocation? City { get; set; } - public GameLocation? Village { get; set; } - public GameLocation? Country { get; set; } - public GameLocation? Mountain { get; set; } - public GameLocation? River { get; set; } +public class Game +{ + public Guid Id { get; set; } + public DateTime DatePlayed { get; set; } + public bool IsFinished { get; set; } + public char Letter { get; set; } + public GameLocation? City { get; set; } + public GameLocation? Village { get; set; } + public GameLocation? Country { get; set; } + public GameLocation? Mountain { get; set; } + public GameLocation? River { get; set; } - public int SecondsPlayed { get; set; } + public int SecondsPlayed { get; set; } - public Dictionary PointsPerTerm { get; set; } = new Dictionary(); + public Dictionary PointsPerTerm { get; set; } = []; - public int TotalPoints => City.Points + Village.Points + Country.Points + Mountain.Points + River.Points; + /// + /// Sum of points across all five location categories. + /// Null-safe: a missing location contributes 0 points. + /// + public int TotalPoints => + (City?.Points ?? 0) + + (Village?.Points ?? 0) + + (Country?.Points ?? 0) + + (Mountain?.Points ?? 0) + + (River?.Points ?? 0); - public string SetCssClass(int points) - { - return points == 0 ? "table-light" : points > 0 ? "table-success" : "table-danger"; - } - } + /// Returns the Bootstrap row-colour CSS class for a given point value. + public string SetCssClass(int points) => ScoringRules.CssRowClass(points); } diff --git a/src/FastGeography.Shared/GeocodeResult.cs b/src/FastGeography.Shared/GeocodeResult.cs new file mode 100644 index 0000000..6b1d346 --- /dev/null +++ b/src/FastGeography.Shared/GeocodeResult.cs @@ -0,0 +1,8 @@ +namespace FastGeography.Shared; + +public sealed class GeocodeResult +{ + public LocationType LocationType { get; init; } + public int Points { get; init; } + public string? Coordinates { get; init; } +} diff --git a/src/FastGeography.Shared/ScoringRules.cs b/src/FastGeography.Shared/ScoringRules.cs new file mode 100644 index 0000000..a898f3c --- /dev/null +++ b/src/FastGeography.Shared/ScoringRules.cs @@ -0,0 +1,33 @@ +namespace FastGeography.Shared; + +/// +/// Single source of truth for all point values and game configuration constants. +/// Both client-side pre-validation and server-side geocode results use these values. +/// +public static class ScoringRules +{ + /// Points awarded when an answer matches the expected location type via Bing Maps. + public const int ValidPoints = 20; + + /// Points deducted when Bing Maps cannot find a matching location of the requested type. + public const int InvalidPoints = -5; + + /// Points deducted when the answer does not start with the required letter. + public const int WrongLetterPoints = -10; + + /// Points for a blank answer (no penalty, no reward). + public const int EmptyPoints = 0; + + /// Default countdown duration in seconds. + public const int DefaultTimerSeconds = 60; + + /// Maximum allowed length for a location answer to guard against abuse. + public const int MaxAnswerLength = 100; + + /// Score threshold above which the achievement banner is shown. + public const int AchievementThreshold = 0; + + /// Returns the CSS row-colour class for a given point value. + public static string CssRowClass(int points) => + points == 0 ? "table-light" : points > 0 ? "table-success" : "table-danger"; +} diff --git a/tst/FastGeography.Tests.E2E/GameWorkflowTests.cs b/tst/FastGeography.Tests.E2E/GameWorkflowTests.cs index 3690491..52f2006 100644 --- a/tst/FastGeography.Tests.E2E/GameWorkflowTests.cs +++ b/tst/FastGeography.Tests.E2E/GameWorkflowTests.cs @@ -1,71 +1,61 @@ -using System.Threading.Tasks; using Microsoft.Playwright; -using FastGeography.Server; -using Xunit; -using Microsoft.AspNetCore.Mvc.Testing; -namespace FastGeography.Tests.E2E +namespace FastGeography.Tests.E2E; + +/// +/// Playwright end-to-end tests for the Fast Geography game workflow. +/// +/// These tests require the application to be running at . +/// Run the server first with: dotnet run --project src/FastGeography.Server +/// +/// They are skipped in CI because the hosted Blazor WASM cannot be served +/// through WebApplicationFactory alone (it needs the published static files). +/// To execute locally, remove the [Fact(Skip=...)] attribute and start the server. +/// +public class GameWorkflowTests { - public class GameWorkflowTests : IClassFixture> - { - private readonly WebApplicationFactory _factory; - - public GameWorkflowTests(WebApplicationFactory factory) - { - _factory = factory; - } - - [Fact] - public async Task StartGame_ShouldInitializeCorrectly() - { - // Arrange: Start the application and get the base address - var client = _factory.CreateClient(); - var baseAddress = client.BaseAddress?.ToString() ?? "http://localhost:5000"; - - - // Act: Use Playwright to navigate to the application - using var playwright = await Playwright.CreateAsync(); - var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); - var page = await browser.NewPageAsync(); + private const string AppBaseUrl = "https://localhost:7002"; + private const string StartButtonText = "Start New Adventure!"; - await page.GotoAsync(baseAddress); - - // Assert: Verify the page loads correctly - var title = await page.TitleAsync(); - Assert.Contains("Fast Geography", title); + [Fact(Skip = "Requires a running dev server at https://localhost:7002")] + public async Task PageTitle_ShouldContainFastGeography() + { + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync( + new BrowserTypeLaunchOptions { Headless = true }); - // Act: Start a new game - await page.ClickAsync("button:has-text('New game')"); + var page = await browser.NewPageAsync(); + await page.GotoAsync(AppBaseUrl); - // Assert: Verify the game table is displayed - var table = await page.QuerySelectorAsync("table"); - Assert.NotNull(table); + var title = await page.TitleAsync(); + Assert.Contains("FastGeography", title); + } - // Assert: Verify the timer starts - var timer = await page.TextContentAsync("#timer"); - Assert.NotNull(timer); - } + [Fact(Skip = "Requires a running dev server at https://localhost:7002")] + public async Task StartGame_ShouldShowGameTableAndTimer() + { + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync( + new BrowserTypeLaunchOptions { Headless = true }); - [Fact] - public async Task StartGame_ShouldInitializeCorrectly2() - { - using var playwright = await Playwright.CreateAsync(); - var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); - var page = await browser.NewPageAsync(); + var page = await browser.NewPageAsync(); + await page.GotoAsync($"{AppBaseUrl}/fastgeography"); - // Navigate to the app - await page.GotoAsync("http://localhost:7002"); + // Wait for Blazor to initialise + await page.WaitForSelectorAsync($"button:has-text('{StartButtonText}')"); - // Start a new game - await page.ClickAsync("button:has-text('New game')"); + // Start a game + await page.ClickAsync($"button:has-text('{StartButtonText}')"); - // Verify the game table is displayed - var table = await page.QuerySelectorAsync("table"); - Assert.NotNull(table); + // Game table should appear + var table = await page.QuerySelectorAsync("table[aria-label='Geography game answers']"); + Assert.NotNull(table); - // Verify the timer starts - var timer = await page.TextContentAsync("Countdown"); - Assert.NotNull(timer); - } + // Timer should be visible and running + var timer = await page.QuerySelectorAsync(".timer-text"); + Assert.NotNull(timer); + var timerText = await timer.TextContentAsync(); + Assert.NotNull(timerText); + Assert.Matches(@"\d{2}:\d{2}", timerText); } -} \ No newline at end of file +} diff --git a/tst/FastGeography.Tests.Integration/BingMapsControllerTests.cs b/tst/FastGeography.Tests.Integration/BingMapsControllerTests.cs deleted file mode 100644 index b4479eb..0000000 --- a/tst/FastGeography.Tests.Integration/BingMapsControllerTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Testing; -using Xunit; -using FastGeography.Server; - -namespace FastGeography.IntegrationTests -{ - public class BingMapsControllerTests : IClassFixture> - { - private readonly HttpClient _client; - - public BingMapsControllerTests(WebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task GetLocationType_ShouldReturnCorrectPoints() - { - // Act - var response = await _client.GetAsync("/bingmaps/London/City"); - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadAsStringAsync(); - - // Assert - Assert.Contains("City:20", result); - } - } -} - - diff --git a/tst/FastGeography.Tests.Integration/FastGeography.Tests.Integration.csproj b/tst/FastGeography.Tests.Integration/FastGeography.Tests.Integration.csproj index eeecb2c..831599a 100644 --- a/tst/FastGeography.Tests.Integration/FastGeography.Tests.Integration.csproj +++ b/tst/FastGeography.Tests.Integration/FastGeography.Tests.Integration.csproj @@ -7,15 +7,24 @@ false true + + + true - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + @@ -23,6 +32,7 @@ + diff --git a/tst/FastGeography.Tests.Integration/Features/BingMapsValidation.feature b/tst/FastGeography.Tests.Integration/Features/BingMapsValidation.feature new file mode 100644 index 0000000..49d5ec1 --- /dev/null +++ b/tst/FastGeography.Tests.Integration/Features/BingMapsValidation.feature @@ -0,0 +1,37 @@ +Feature: Geography Answer Validation + In order to receive fair points for my geography answers + As a game player + I want the server to validate my answers and return a typed result + + Scenario: Known city earns full points + When I submit "London" as location type "City" + Then the response is successful + And the awarded points are 20 + And the response includes coordinates + + Scenario: Unrecognised location loses points + When I submit "XYZNOTEXIST" as location type "City" + Then the response is successful + And the awarded points are -5 + And the response has no coordinates + + Scenario: Answer longer than 100 characters is rejected + Given a location name 101 characters long + When I submit that overlong location as location type "City" + Then the request is rejected with status code 400 + + Scenario: Unrecognised location type is rejected + When I submit "London" as location type "NotAType" + Then the request is rejected with status code 400 + + Scenario Outline: Multiple known places earn full points + When I submit "" as location type "" + Then the response is successful + And the awarded points are 20 + + Examples: + | location | locationType | + | Paris | City | + | Berlin | City | + | Sofia | Country | + | Tokyo | City | diff --git a/tst/FastGeography.Tests.Integration/StepDefinitions/GeographyValidationSteps.cs b/tst/FastGeography.Tests.Integration/StepDefinitions/GeographyValidationSteps.cs new file mode 100644 index 0000000..0031b81 --- /dev/null +++ b/tst/FastGeography.Tests.Integration/StepDefinitions/GeographyValidationSteps.cs @@ -0,0 +1,83 @@ +namespace FastGeography.IntegrationTests.StepDefinitions; + +using System.Net; + +using FastGeography.IntegrationTests.Support; +using FastGeography.Shared; + +/// +/// Step definitions for Features/BingMapsValidation.feature. +/// is injected per-scenario by Reqnroll's context injection. +/// +[Binding] +public sealed class GeographyValidationSteps +{ + private readonly GameApiContext _ctx; + + public GeographyValidationSteps(GameApiContext ctx) => _ctx = ctx; + + // ------------------------------------------------------------------------- + // Given + // ------------------------------------------------------------------------- + + [Given("a location name {int} characters long")] + public void GivenALocationNameOfLength(int length) + { + _ctx.OverlongLocation = new string('A', length); + } + + // ------------------------------------------------------------------------- + // When + // ------------------------------------------------------------------------- + + [When("I submit {string} as location type {string}")] + public async Task WhenISubmitLocationAsType(string location, string locationType) + { + await _ctx.ValidateAsync(location, locationType); + } + + [When("I submit that overlong location as location type {string}")] + public async Task WhenISubmitOverlongLocation(string locationType) + { + Assert.NotNull(_ctx.OverlongLocation); + await _ctx.ValidateAsync(_ctx.OverlongLocation, locationType); + } + + // ------------------------------------------------------------------------- + // Then + // ------------------------------------------------------------------------- + + [Then("the response is successful")] + public void ThenResponseIsSuccessful() + { + Assert.NotNull(_ctx.LastResponse); + _ctx.LastResponse.EnsureSuccessStatusCode(); + Assert.NotNull(_ctx.LastGeocodeResult); + } + + [Then("the awarded points are {int}")] + public void ThenAwardedPointsAre(int expectedPoints) + { + Assert.NotNull(_ctx.LastGeocodeResult); + Assert.Equal(expectedPoints, _ctx.LastGeocodeResult.Points); + } + + [Then("the response includes coordinates")] + public void ThenResponseIncludesCoordinates() + { + Assert.NotNull(_ctx.LastGeocodeResult?.Coordinates); + } + + [Then("the response has no coordinates")] + public void ThenResponseHasNoCoordinates() + { + Assert.Null(_ctx.LastGeocodeResult?.Coordinates); + } + + [Then("the request is rejected with status code {int}")] + public void ThenRequestIsRejectedWithStatus(int statusCode) + { + Assert.NotNull(_ctx.LastResponse); + Assert.Equal((HttpStatusCode)statusCode, _ctx.LastResponse.StatusCode); + } +} diff --git a/tst/FastGeography.Tests.Integration/Support/FakeGeocodingService.cs b/tst/FastGeography.Tests.Integration/Support/FakeGeocodingService.cs new file mode 100644 index 0000000..54b9da8 --- /dev/null +++ b/tst/FastGeography.Tests.Integration/Support/FakeGeocodingService.cs @@ -0,0 +1,32 @@ +namespace FastGeography.IntegrationTests.Support; + +using FastGeography.Server.Services; +using FastGeography.Shared; + +/// +/// Deterministic, network-free substitute for . +/// Returns for a fixed set of known places +/// and for everything else. +/// +internal sealed class FakeGeocodingService : IGeocodingService +{ + private static readonly HashSet KnownPlaces = new(StringComparer.OrdinalIgnoreCase) + { + "london", "paris", "berlin", "sofia", "tokyo" + }; + + public Task ValidateAsync( + string location, + LocationType locationType, + CancellationToken cancellationToken = default) + { + var known = KnownPlaces.Contains(location); + + return Task.FromResult(new GeocodeResult + { + LocationType = locationType, + Points = known ? ScoringRules.ValidPoints : ScoringRules.InvalidPoints, + Coordinates = known ? "51.5074,-0.1278" : null + }); + } +} diff --git a/tst/FastGeography.Tests.Integration/Support/GameApiContext.cs b/tst/FastGeography.Tests.Integration/Support/GameApiContext.cs new file mode 100644 index 0000000..3bbf59b --- /dev/null +++ b/tst/FastGeography.Tests.Integration/Support/GameApiContext.cs @@ -0,0 +1,54 @@ +namespace FastGeography.IntegrationTests.Support; + +using System.Net.Http.Json; + +using FastGeography.Server.Services; +using FastGeography.Shared; + +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +/// +/// Per-scenario test context injected into step definitions by Reqnroll. +/// Creates a with a fake geocoding +/// service so scenarios run without any network calls to Bing Maps. +/// Implements so Reqnroll disposes it after each scenario. +/// +public sealed class GameApiContext : IDisposable +{ + private readonly WebApplicationFactory _factory; + + public HttpClient Client { get; } + + // Mutable scenario state written by When/Given steps and read by Then steps + public HttpResponseMessage? LastResponse { get; set; } + public GeocodeResult? LastGeocodeResult { get; set; } + public string? OverlongLocation { get; set; } + + public GameApiContext() + { + _factory = new WebApplicationFactory() + .WithWebHostBuilder(b => b.ConfigureServices(s => + s.AddSingleton())); + + Client = _factory.CreateClient(); + } + + /// + /// Sends GET /bingmaps/{location}/{locationType} and stores the response. + /// Deserialises the body into on success. + /// + public async Task ValidateAsync(string location, string locationType) + { + LastResponse = await Client.GetAsync($"/bingmaps/{location}/{locationType}"); + + if (LastResponse.IsSuccessStatusCode) + LastGeocodeResult = await LastResponse.Content.ReadFromJsonAsync(); + } + + public void Dispose() + { + Client.Dispose(); + _factory.Dispose(); + } +} diff --git a/tst/FastGeography.Tests.Unit/GameTests.cs b/tst/FastGeography.Tests.Unit/GameTests.cs index 36cfae9..1248596 100644 --- a/tst/FastGeography.Tests.Unit/GameTests.cs +++ b/tst/FastGeography.Tests.Unit/GameTests.cs @@ -1,19 +1,92 @@ -namespace FastGeography.Tests +namespace FastGeography.Tests; + +using FastGeography.Shared; + +public class GameTests { - using FastGeography.Shared; + [Theory] + [InlineData(0, "table-light")] + [InlineData(10, "table-success")] + [InlineData(-5, "table-danger")] + public void SetCssClass_ReturnsCorrectBootstrapClass(int points, string expected) + { + var game = new Game(); + Assert.Equal(expected, game.SetCssClass(points)); + } + + [Fact] + public void TotalPoints_IsNullSafe_WhenLocationsAreNull() + { + var game = new Game(); // all location properties null + Assert.Equal(0, game.TotalPoints); + } - public class GameTests + [Fact] + public void TotalPoints_SumsAllLocationPoints() { - [Fact] - public void SetCssClass_ShouldReturnCorrectClass() + var game = new Game { - // Arrange - var game = new Game(); - - // Act & Assert - Assert.Equal("table-light", game.SetCssClass(0)); - Assert.Equal("table-success", game.SetCssClass(10)); - Assert.Equal("table-danger", game.SetCssClass(-5)); - } + City = new GameLocation { Points = 20 }, + Village = new GameLocation { Points = -5 }, + Country = new GameLocation { Points = 20 }, + River = new GameLocation { Points = 0 }, + Mountain = new GameLocation { Points = -10 } + }; + + Assert.Equal(25, game.TotalPoints); + } +} + +public class ScoringRulesTests +{ + [Fact] + public void Constants_HaveExpectedValues() + { + Assert.Equal(20, ScoringRules.ValidPoints); + Assert.Equal(-5, ScoringRules.InvalidPoints); + Assert.Equal(-10, ScoringRules.WrongLetterPoints); + Assert.Equal(0, ScoringRules.EmptyPoints); + Assert.Equal(60, ScoringRules.DefaultTimerSeconds); + } + + [Theory] + [InlineData(0, "table-light")] + [InlineData(20, "table-success")] + [InlineData(-5, "table-danger")] + public void CssRowClass_ReturnsCorrectClass(int points, string expected) + { + Assert.Equal(expected, ScoringRules.CssRowClass(points)); + } +} + +public class BadgeCalculatorTests +{ + [Theory] + [InlineData(-100, Badge.Junior)] + [InlineData(0, Badge.Junior)] + [InlineData(1, Badge.Junior)] + [InlineData(100, Badge.Junior)] + [InlineData(101, Badge.Cadet)] + [InlineData(200, Badge.Cadet)] + [InlineData(201, Badge.Explorer)] + [InlineData(300, Badge.Explorer)] + [InlineData(301, Badge.Traveller)] + [InlineData(400, Badge.Traveller)] + [InlineData(401, Badge.Jumper)] + [InlineData(500, Badge.Jumper)] + [InlineData(501, Badge.EarthSurfer)] + [InlineData(600, Badge.EarthSurfer)] + [InlineData(601, Badge.EarthConqueror)] + [InlineData(700, Badge.EarthConqueror)] + [InlineData(701, Badge.SolarSpectre)] + [InlineData(800, Badge.SolarSpectre)] + [InlineData(801, Badge.GalacticSurfer)] + [InlineData(900, Badge.GalacticSurfer)] + [InlineData(901, Badge.GalacticConqueror)] + [InlineData(1000, Badge.GalacticConqueror)] + [InlineData(9999, Badge.GalacticConqueror)] + public void Calculate_ReturnsCorrectBadge(int totalPoints, Badge expected) + { + Assert.Equal(expected, BadgeCalculator.Calculate(totalPoints)); } -} \ No newline at end of file +} From 2d53f04c54687b0530287486e5e3455dfd975525 Mon Sep 17 00:00:00 2001 From: Vane Spasov Date: Thu, 27 Aug 2026 23:30:00 +0200 Subject: [PATCH 02/15] Wire Aspire AppHost for hosted Blazor WASM and secret Bing Maps key Orchestrate only FastGeography.Server so the dashboard opens a single origin that serves both the WASM UI and the API. Pass BingMaps__ApiKey from an AppHost secret parameter instead of appsettings. Co-authored-by: Cursor --- .../FastGeography.AppHost.csproj | 1 - src/FastGeography.AppHost/Program.cs | 11 ++++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/FastGeography.AppHost/FastGeography.AppHost.csproj b/src/FastGeography.AppHost/FastGeography.AppHost.csproj index 5b346f8..685d4eb 100644 --- a/src/FastGeography.AppHost/FastGeography.AppHost.csproj +++ b/src/FastGeography.AppHost/FastGeography.AppHost.csproj @@ -16,7 +16,6 @@ - diff --git a/src/FastGeography.AppHost/Program.cs b/src/FastGeography.AppHost/Program.cs index 9714981..11ffae1 100644 --- a/src/FastGeography.AppHost/Program.cs +++ b/src/FastGeography.AppHost/Program.cs @@ -1,7 +1,12 @@ var builder = DistributedApplication.CreateBuilder(args); -// FastGeography.Client is a Blazor WASM app hosted by the Server project. -// Only the Server needs to be registered with Aspire. -builder.AddProject("fastgeography-server"); +// Hosted Blazor WASM: the Server serves both the UI and the API on one origin. +// Do not AddProject the Client — that would start a second WASM host and break same-origin HttpClient. +var bingMapsKey = builder.AddParameter("bingmaps-apikey", secret: true); + +builder.AddProject("fastgeography-server") + .WithExternalHttpEndpoints() + .WithHttpHealthCheck("/alive") + .WithEnvironment("BingMaps__ApiKey", bingMapsKey); builder.Build().Run(); From 4aae39d815e8fe2ae4a6648daf0d9d16a7580d0d Mon Sep 17 00:00:00 2001 From: Vane Spasov Date: Fri, 28 Aug 2026 18:03:52 +0200 Subject: [PATCH 03/15] Added single player and multiplayer options. Posibility for creating room and joining to it. --- .../FastGeography.AppHost.csproj | 1 + src/FastGeography.AppHost/Program.cs | 9 +- src/FastGeography.AppHost/appsettings.json | 3 + src/FastGeography.Client/App.razor | 36 ++- .../Auth/CookieAuthenticationStateProvider.cs | 92 ++++++ .../Auth/CookieHandler.cs | 14 + .../FastGeography.Client.csproj | 2 + .../Pages/GameTable.razor | 3 +- src/FastGeography.Client/Pages/Index.razor | 6 - src/FastGeography.Client/Pages/Login.razor | 84 ++++++ .../Pages/MultiplayerGame.razor | 254 ++++++++++++++++ .../Pages/MultiplayerLobby.razor | 70 +++++ .../Pages/RankedGame.razor | 179 ++++++++++++ src/FastGeography.Client/Pages/Register.razor | 81 ++++++ .../Pages/Scoreboard.razor | 86 ++++++ src/FastGeography.Client/Program.cs | 22 +- .../Shared/MainLayout.razor | 3 + src/FastGeography.Client/Shared/NavMenu.razor | 64 ++-- .../Shared/RedirectToLogin.razor | 9 + src/FastGeography.Client/_Imports.razor | 6 + src/FastGeography.Client/wwwroot/css/app.css | 23 +- .../Controllers/AuthController.cs | 68 +++++ .../Controllers/GamesController.cs | 137 +++++++++ .../Controllers/LeaderboardController.cs | 99 +++++++ .../Controllers/RoomsController.cs | 36 +++ .../Data/ApplicationDbContext.cs | 47 +++ .../Data/ApplicationUser.cs | 12 + .../Data/Entities/GameRound.cs | 17 ++ .../Data/Entities/PlayerProfile.cs | 13 + .../Data/Entities/RoundSubmission.cs | 33 +++ .../FastGeography.Server.csproj | 7 + src/FastGeography.Server/Hubs/GameHub.cs | 273 ++++++++++++++++++ src/FastGeography.Server/Program.cs | 103 ++++++- .../ApplicationUserClaimsPrincipalFactory.cs | 32 ++ .../Services/AuthService.cs | 59 ++++ .../Services/IAuthService.cs | 10 + .../Services/RoomService.cs | 116 ++++++++ src/FastGeography.Shared/Dtos/AuthDtos.cs | 7 + .../Dtos/LeaderboardDtos.cs | 23 ++ .../Dtos/MultiplayerDtos.cs | 21 ++ src/FastGeography.Shared/Dtos/SoloGameDtos.cs | 16 + src/FastGeography.Shared/GameMode.cs | 7 + .../AuthTests.cs | 76 +++++ .../FastGeography.Tests.Integration.csproj | 2 + .../GameHubTests.cs | 242 ++++++++++++++++ .../LeaderboardTests.cs | 70 +++++ .../RankedSoloTests.cs | 91 ++++++ .../Support/GameApiContext.cs | 39 ++- .../Support/TestAppFixture.cs | 55 ++++ 49 files changed, 2696 insertions(+), 62 deletions(-) create mode 100644 src/FastGeography.Client/Auth/CookieAuthenticationStateProvider.cs create mode 100644 src/FastGeography.Client/Auth/CookieHandler.cs delete mode 100644 src/FastGeography.Client/Pages/Index.razor create mode 100644 src/FastGeography.Client/Pages/Login.razor create mode 100644 src/FastGeography.Client/Pages/MultiplayerGame.razor create mode 100644 src/FastGeography.Client/Pages/MultiplayerLobby.razor create mode 100644 src/FastGeography.Client/Pages/RankedGame.razor create mode 100644 src/FastGeography.Client/Pages/Register.razor create mode 100644 src/FastGeography.Client/Pages/Scoreboard.razor create mode 100644 src/FastGeography.Client/Shared/RedirectToLogin.razor create mode 100644 src/FastGeography.Server/Controllers/AuthController.cs create mode 100644 src/FastGeography.Server/Controllers/GamesController.cs create mode 100644 src/FastGeography.Server/Controllers/LeaderboardController.cs create mode 100644 src/FastGeography.Server/Controllers/RoomsController.cs create mode 100644 src/FastGeography.Server/Data/ApplicationDbContext.cs create mode 100644 src/FastGeography.Server/Data/ApplicationUser.cs create mode 100644 src/FastGeography.Server/Data/Entities/GameRound.cs create mode 100644 src/FastGeography.Server/Data/Entities/PlayerProfile.cs create mode 100644 src/FastGeography.Server/Data/Entities/RoundSubmission.cs create mode 100644 src/FastGeography.Server/Hubs/GameHub.cs create mode 100644 src/FastGeography.Server/Services/ApplicationUserClaimsPrincipalFactory.cs create mode 100644 src/FastGeography.Server/Services/AuthService.cs create mode 100644 src/FastGeography.Server/Services/IAuthService.cs create mode 100644 src/FastGeography.Server/Services/RoomService.cs create mode 100644 src/FastGeography.Shared/Dtos/AuthDtos.cs create mode 100644 src/FastGeography.Shared/Dtos/LeaderboardDtos.cs create mode 100644 src/FastGeography.Shared/Dtos/MultiplayerDtos.cs create mode 100644 src/FastGeography.Shared/Dtos/SoloGameDtos.cs create mode 100644 src/FastGeography.Shared/GameMode.cs create mode 100644 tst/FastGeography.Tests.Integration/AuthTests.cs create mode 100644 tst/FastGeography.Tests.Integration/GameHubTests.cs create mode 100644 tst/FastGeography.Tests.Integration/LeaderboardTests.cs create mode 100644 tst/FastGeography.Tests.Integration/RankedSoloTests.cs create mode 100644 tst/FastGeography.Tests.Integration/Support/TestAppFixture.cs diff --git a/src/FastGeography.AppHost/FastGeography.AppHost.csproj b/src/FastGeography.AppHost/FastGeography.AppHost.csproj index 685d4eb..d9c5d15 100644 --- a/src/FastGeography.AppHost/FastGeography.AppHost.csproj +++ b/src/FastGeography.AppHost/FastGeography.AppHost.csproj @@ -13,6 +13,7 @@ + diff --git a/src/FastGeography.AppHost/Program.cs b/src/FastGeography.AppHost/Program.cs index 11ffae1..8f0032b 100644 --- a/src/FastGeography.AppHost/Program.cs +++ b/src/FastGeography.AppHost/Program.cs @@ -4,9 +4,16 @@ // Do not AddProject the Client — that would start a second WASM host and break same-origin HttpClient. var bingMapsKey = builder.AddParameter("bingmaps-apikey", secret: true); +var postgres = builder.AddPostgres("postgres") + .WithPgAdmin(); + +var db = postgres.AddDatabase("fastgeography-db"); + builder.AddProject("fastgeography-server") .WithExternalHttpEndpoints() .WithHttpHealthCheck("/alive") - .WithEnvironment("BingMaps__ApiKey", bingMapsKey); + .WithEnvironment("BingMaps__ApiKey", bingMapsKey) + .WithReference(db) + .WaitFor(db); builder.Build().Run(); diff --git a/src/FastGeography.AppHost/appsettings.json b/src/FastGeography.AppHost/appsettings.json index 31c092a..a36b948 100644 --- a/src/FastGeography.AppHost/appsettings.json +++ b/src/FastGeography.AppHost/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } + }, + "Parameters": { + "bingmaps-apikey": "" } } diff --git a/src/FastGeography.Client/App.razor b/src/FastGeography.Client/App.razor index 6e4e127..7c22065 100644 --- a/src/FastGeography.Client/App.razor +++ b/src/FastGeography.Client/App.razor @@ -1,13 +1,25 @@ - - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
+ + + + + + @* Show nothing while the async auth check is in flight so we don't + prematurely redirect authenticated users to the login page. *@ + + + + + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
+
diff --git a/src/FastGeography.Client/Auth/CookieAuthenticationStateProvider.cs b/src/FastGeography.Client/Auth/CookieAuthenticationStateProvider.cs new file mode 100644 index 0000000..91ec276 --- /dev/null +++ b/src/FastGeography.Client/Auth/CookieAuthenticationStateProvider.cs @@ -0,0 +1,92 @@ +namespace FastGeography.Client.Auth; + +using System.Net.Http.Json; +using System.Security.Claims; + +using FastGeography.Shared.Dtos; + +using Microsoft.AspNetCore.Components.Authorization; + +public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvider +{ + private static readonly AuthenticationState Anonymous = + new(new ClaimsPrincipal(new ClaimsIdentity())); + + private readonly HttpClient _http; + private UserInfoResponse? _cachedUser; + + public CookieAuthenticationStateProvider(HttpClient http) => _http = http; + + public override async Task GetAuthenticationStateAsync() + { + // Return cached state when available to avoid an HTTP round-trip on every + // component render and to prevent the auth state from appearing as + // "anonymous" while an async check is in flight. + if (_cachedUser is not null) + return BuildState(_cachedUser); + + try + { + _cachedUser = await _http.GetFromJsonAsync("api/auth/userinfo"); + } + catch + { + _cachedUser = null; + } + + return _cachedUser is null ? Anonymous : BuildState(_cachedUser); + } + + public async Task LoginAsync(string email, string password) + { + var response = await _http.PostAsJsonAsync( + "api/auth/login", new LoginRequest(email, password)); + + if (!response.IsSuccessStatusCode) return false; + + // Clear the cache so GetAuthenticationStateAsync fetches fresh data. + _cachedUser = null; + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + return true; + } + + public async Task<(bool Success, string? Error)> RegisterAsync( + string email, string password, string displayName) + { + var response = await _http.PostAsJsonAsync( + "api/auth/register", new RegisterRequest(email, password, displayName)); + + if (response.IsSuccessStatusCode) + { + // Auto-login after registration by calling login + await LoginAsync(email, password); + return (true, null); + } + + var body = await response.Content.ReadFromJsonAsync(); + return (false, string.Join("; ", body?.Errors ?? ["Registration failed."])); + } + + public async Task LogoutAsync() + { + await _http.PostAsync("api/auth/logout", null); + _cachedUser = null; + NotifyAuthenticationStateChanged(Task.FromResult(Anonymous)); + } + + public UserInfoResponse? CurrentUser => _cachedUser; + + private static AuthenticationState BuildState(UserInfoResponse user) + { + var identity = new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, user.UserId), + new Claim(ClaimTypes.Name, user.DisplayName), + new Claim(ClaimTypes.Email, user.Email), + ], "cookie"); + + return new AuthenticationState(new ClaimsPrincipal(identity)); + } + + private sealed record ErrorBody(List? Errors); +} diff --git a/src/FastGeography.Client/Auth/CookieHandler.cs b/src/FastGeography.Client/Auth/CookieHandler.cs new file mode 100644 index 0000000..15bd929 --- /dev/null +++ b/src/FastGeography.Client/Auth/CookieHandler.cs @@ -0,0 +1,14 @@ +namespace FastGeography.Client.Auth; + +using Microsoft.AspNetCore.Components.WebAssembly.Http; + +public sealed class CookieHandler : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); + request.Headers.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest"); + return base.SendAsync(request, cancellationToken); + } +} diff --git a/src/FastGeography.Client/FastGeography.Client.csproj b/src/FastGeography.Client/FastGeography.Client.csproj index 3e6267b..6b0ba39 100644 --- a/src/FastGeography.Client/FastGeography.Client.csproj +++ b/src/FastGeography.Client/FastGeography.Client.csproj @@ -8,8 +8,10 @@ + + diff --git a/src/FastGeography.Client/Pages/GameTable.razor b/src/FastGeography.Client/Pages/GameTable.razor index 2a63e1c..95771f9 100644 --- a/src/FastGeography.Client/Pages/GameTable.razor +++ b/src/FastGeography.Client/Pages/GameTable.razor @@ -1,4 +1,5 @@ -@page "/fastgeography" +@page "/" +@page "/fastgeography" @using System.Net.Http.Json @using System.Linq @using FastGeography.Shared diff --git a/src/FastGeography.Client/Pages/Index.razor b/src/FastGeography.Client/Pages/Index.razor deleted file mode 100644 index cef0fea..0000000 --- a/src/FastGeography.Client/Pages/Index.razor +++ /dev/null @@ -1,6 +0,0 @@ -@page "/" -@inject NavigationManager Nav - -@code { - protected override void OnInitialized() => Nav.NavigateTo("/fastgeography"); -} diff --git a/src/FastGeography.Client/Pages/Login.razor b/src/FastGeography.Client/Pages/Login.razor new file mode 100644 index 0000000..225d6ca --- /dev/null +++ b/src/FastGeography.Client/Pages/Login.razor @@ -0,0 +1,84 @@ +@page "/login" +@inject CookieAuthenticationStateProvider Auth +@inject NavigationManager Nav + +Login – FastGeography + +
+
+

Sign In

+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + + + + +
+ + + +
+ +
+ + + +
+ + +
+ +

+ Don't have an account? Register here +

+
+
+ +@code { + private readonly LoginModel _model = new(); + private string? _error; + private bool _busy; + private string? _returnUrl; + + protected override void OnInitialized() + { + var query = new Uri(Nav.Uri).Query; + foreach (var part in query.TrimStart('?').Split('&')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0] == "returnUrl") + { + _returnUrl = Uri.UnescapeDataString(kv[1]); + break; + } + } + } + + private async Task DoLogin() + { + _busy = true; + _error = null; + var ok = await Auth.LoginAsync(_model.Email, _model.Password); + _busy = false; + + if (ok) Nav.NavigateTo(string.IsNullOrWhiteSpace(_returnUrl) ? "/" : _returnUrl); + else _error = "Invalid email or password."; + } + + private sealed class LoginModel + { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Email { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + public string Password { get; set; } = string.Empty; + } +} diff --git a/src/FastGeography.Client/Pages/MultiplayerGame.razor b/src/FastGeography.Client/Pages/MultiplayerGame.razor new file mode 100644 index 0000000..fa8d6a8 --- /dev/null +++ b/src/FastGeography.Client/Pages/MultiplayerGame.razor @@ -0,0 +1,254 @@ +@page "/multiplayer/{RoomCode}" +@attribute [Authorize] +@implements IAsyncDisposable +@inject HttpClient Http +@inject CookieAuthenticationStateProvider Auth +@inject NavigationManager Nav +@inject ILogger Logger + +Room @RoomCode – FastGeography + +
+
+

Room: @RoomCode

+ @if (_roundActive) + { +
+ +
+ } +
+ + @if (!string.IsNullOrEmpty(_error)) + { +
+ @_error + +
+ } + +
+
+
+
+
PLAYERS
+ @foreach (var p in _players) + { +
+ @if (p == _hostName) { 👑 } + @p + @if (_submittedNames.Contains(p)) { } +
+ } +
+
+
+ + @if (_isHost && !_roundActive && _connected) + { +
+ +
+ } + + @if (!_connected && !_connecting) + { +
+ Disconnected. +
+ } +
+ + @if (_roundActive && !_submitted) + { + + + + + + + + + + + + + + + + + + +
LetterCityVillageCountryRiverMountain
@_letter + +
+ } + + @if (_submitted && _roundActive) + { +
Answers submitted! Waiting for other players…
+ } + + @if (_results is not null) + { +
+
Round Results
+ + + + + + + + @foreach (var r in _results.Results) + { + + + + + @foreach (var d in r.Details) + { + + } + + } + +
#PlayerPointsCityVillageCountryRiverMountain
@r.Rank@r.PlayerName@r.TotalPoints + @(string.IsNullOrEmpty(d.Answer) ? "–" : d.Answer) + @d.Points pts +
+
+ } +
+ +@code { + [Parameter] public string RoomCode { get; set; } = string.Empty; + + private HubConnection? _hub; + private bool _connected, _connecting; + private bool _isHost; + private string _hostName = string.Empty; + private List _players = []; + private HashSet _submittedNames = []; + + private bool _roundActive, _submitted; + private char _letter; + private Countdown _countdown = null!; + + private string? _city, _village, _country, _river, _mountain; + private RoundResultsMessage? _results; + private string? _error; + + protected override async Task OnInitializedAsync() => await Connect(); + + private async Task Connect() + { + _connecting = true; + _hub = new HubConnectionBuilder() + .WithUrl(Nav.ToAbsoluteUri("/hubs/game")) + .WithAutomaticReconnect() + .Build(); + + _hub.On("RoomJoined", state => + { + _players = state.Players; + _hostName = state.HostName; + _roundActive = state.RoundActive; + var user = Auth.CurrentUser; + _isHost = user is not null && state.HostName == user.DisplayName; + InvokeAsync(StateHasChanged); + }); + + _hub.On("PlayerJoined", name => + { + if (!_players.Contains(name)) _players.Add(name); + InvokeAsync(StateHasChanged); + }); + + _hub.On("PlayerLeft", name => + { + _players.Remove(name); + InvokeAsync(StateHasChanged); + }); + + _hub.On("RoundStarted", msg => + { + _letter = msg.Letter; + _city = _village = _country = _river = _mountain = string.Empty; + _roundActive = true; + _submitted = false; + _submittedNames.Clear(); + _results = null; + var seconds = Math.Max(1, (int)(msg.EndsAt - DateTime.UtcNow).TotalSeconds); + InvokeAsync(() => { _countdown.Start(seconds); StateHasChanged(); }); + }); + + _hub.On("AnswersAccepted", () => + { + _submitted = true; + InvokeAsync(StateHasChanged); + }); + + _hub.On("RoundResults", results => + { + _results = results; + _roundActive = false; + _submitted = false; + _submittedNames = results.Results.Select(r => r.PlayerName).ToHashSet(); + InvokeAsync(() => { _countdown.Stop(); StateHasChanged(); }); + }); + + _hub.On("Error", msg => + { + _error = msg; + InvokeAsync(StateHasChanged); + }); + + _hub.Reconnected += _ => { InvokeAsync(async () => { await _hub.SendAsync("JoinRoom", RoomCode); }); return Task.CompletedTask; }; + + try + { + await _hub.StartAsync(); + _connected = true; + await _hub.SendAsync("JoinRoom", RoomCode); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to connect to game hub"); + _error = "Could not connect to the game server."; + } + finally + { + _connecting = false; + StateHasChanged(); + } + } + + private async Task StartRound() + { + if (_hub is null) return; + _results = null; + await _hub.SendAsync("StartRound", RoomCode); + } + + private async Task OnTimerExpired() => await SubmitAnswers(); + + private async Task SubmitAnswers() + { + if (_hub is null || !_roundActive || _submitted) return; + + var req = new SubmitAnswersRequest(_city, _village, _country, _river, _mountain); + await _hub.SendAsync("SubmitAnswers", RoomCode, req); + } + + public async ValueTask DisposeAsync() + { + if (_hub is not null) + { + await _hub.DisposeAsync(); + } + } +} diff --git a/src/FastGeography.Client/Pages/MultiplayerLobby.razor b/src/FastGeography.Client/Pages/MultiplayerLobby.razor new file mode 100644 index 0000000..f23fb2e --- /dev/null +++ b/src/FastGeography.Client/Pages/MultiplayerLobby.razor @@ -0,0 +1,70 @@ +@page "/multiplayer" +@attribute [Authorize] +@inject HttpClient Http +@inject NavigationManager Nav + +Multiplayer – FastGeography + +
+

Multiplayer

+

Play live against friends – everyone gets the same letter and 60 seconds.

+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + +
+
+
Create a New Room
+

Get a join code and share it with friends.

+ +
+
+ +
+
+
Join a Room
+
+ + +
+
+
+
+ +@code { + private string _joinCode = string.Empty; + private bool _busy; + private string? _error; + + private async Task CreateRoom() + { + _busy = true; + _error = null; + var resp = await Http.PostAsync("api/rooms", null); + _busy = false; + + if (resp.IsSuccessStatusCode) + { + var result = await resp.Content.ReadFromJsonAsync(); + if (result is not null) + Nav.NavigateTo($"/multiplayer/{result.RoomCode}"); + } + else + { + _error = "Could not create a room. Please try again."; + } + } + + private void JoinRoom() + { + if (string.IsNullOrWhiteSpace(_joinCode)) { _error = "Enter a room code."; return; } + Nav.NavigateTo($"/multiplayer/{_joinCode.Trim().ToUpperInvariant()}"); + } +} diff --git a/src/FastGeography.Client/Pages/RankedGame.razor b/src/FastGeography.Client/Pages/RankedGame.razor new file mode 100644 index 0000000..0da947a --- /dev/null +++ b/src/FastGeography.Client/Pages/RankedGame.razor @@ -0,0 +1,179 @@ +@page "/ranked" +@attribute [Authorize] +@inject HttpClient Http +@inject CookieAuthenticationStateProvider Auth +@inject ILogger Logger + +Ranked Play – FastGeography + +
+
+
+

Ranked Mode

+ Points are saved to the global scoreboard. +
+ + @if (_roundActive) + { +
+ +
+ } +
+ + @if (!string.IsNullOrEmpty(_error)) + { +
+ @_error + +
+ } + + @if (_checking) + { +
+
+ Submitting to server… +
+ } + + @if (_result is not null) + { +
+ Round complete! You scored @_result.TotalPoints points. + Badge: @_result.Badge + View Scoreboard → +
+ + + + @foreach (var d in _result.Details) + { + + + + + + } + +
CategoryAnswerPoints
@d.Type@(string.IsNullOrEmpty(d.Answer) ? "–" : d.Answer)@d.Points
+ } + + @if (_roundActive && _roundId.HasValue) + { + + + + + + + + + + + + + + + + + + + + + + + +
LetterCityVillageCountryRiverMountain
@_letter + +
+ } +
+ +@code { + private Countdown _countdown = null!; + private Guid? _roundId; + private char _letter; + private DateTime _endsAt; + private bool _roundActive; + private bool _checking; + private string? _error; + private SoloSubmitResponse? _result; + + private string? _city, _village, _country, _river, _mountain; + + private async Task StartRound() + { + _error = null; + _result = null; + + var response = await Http.PostAsync("api/games/solo/start", null); + if (!response.IsSuccessStatusCode) + { + _error = "Failed to start round. Please try again."; + return; + } + + var start = await response.Content.ReadFromJsonAsync(); + if (start is null) { _error = "Invalid server response."; return; } + + _roundId = start.RoundId; + _letter = start.Letter; + _endsAt = start.EndsAt; + _city = _village = _country = _river = _mountain = string.Empty; + _roundActive = true; + + var secondsLeft = Math.Max(1, (int)(_endsAt - DateTime.UtcNow).TotalSeconds); + _countdown.Start(secondsLeft); + } + + private async Task OnTimerExpired() => await Submit(); + + private async Task Submit() + { + if (!_roundActive || !_roundId.HasValue) return; + + _countdown.Stop(); + _roundActive = false; + _checking = true; + _error = null; + + try + { + var req = new SubmitAnswersRequest(_city, _village, _country, _river, _mountain); + var resp = await Http.PostAsJsonAsync($"api/games/solo/{_roundId}/submit", req); + + if (resp.IsSuccessStatusCode) + { + _result = await resp.Content.ReadFromJsonAsync(); + } + else if (resp.StatusCode == System.Net.HttpStatusCode.Conflict) + { + _error = "Round already submitted."; + } + else if (resp.StatusCode == System.Net.HttpStatusCode.BadRequest) + { + _error = "Submission rejected: time may have expired."; + } + else + { + _error = "Submission failed. Please try again."; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Error submitting ranked answers"); + _error = "Submission error. Please try again."; + } + finally + { + _checking = false; + } + } +} diff --git a/src/FastGeography.Client/Pages/Register.razor b/src/FastGeography.Client/Pages/Register.razor new file mode 100644 index 0000000..42c5a9e --- /dev/null +++ b/src/FastGeography.Client/Pages/Register.razor @@ -0,0 +1,81 @@ +@page "/register" +@inject CookieAuthenticationStateProvider Auth +@inject NavigationManager Nav + +Register – FastGeography + +
+
+

Create Account

+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } + + + + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+ +

+ Already have an account? Sign in +

+
+
+ +@code { + private readonly RegisterModel _model = new(); + private string? _error; + private bool _busy; + + private async Task DoRegister() + { + _busy = true; + _error = null; + var (ok, err) = await Auth.RegisterAsync(_model.Email, _model.Password, _model.DisplayName); + _busy = false; + + if (ok) Nav.NavigateTo("/"); + else _error = err ?? "Registration failed."; + } + + private sealed class RegisterModel + { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.StringLength(50, MinimumLength = 2)] + public string DisplayName { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.EmailAddress] + public string Email { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.MinLength(6)] + public string Password { get; set; } = string.Empty; + } +} diff --git a/src/FastGeography.Client/Pages/Scoreboard.razor b/src/FastGeography.Client/Pages/Scoreboard.razor new file mode 100644 index 0000000..9b270c4 --- /dev/null +++ b/src/FastGeography.Client/Pages/Scoreboard.razor @@ -0,0 +1,86 @@ +@page "/scoreboard" +@inject HttpClient Http + +Scoreboard – FastGeography + +
+

Global Scoreboard

+ +
+ + +
+ + + +
+ My Stats +
+
+
+ + @if (_loading) + { +
+
+ Loading… +
+ } + else if (_entries is null || !_entries.Any()) + { +

No scores yet. Be the first to play a ranked round!

+ } + else + { +
+ + + + + + + + + + + + @foreach (var e in _entries) + { + + + + + + + + } + +
#ExplorerBadgePointsGames
+ @if (e.Rank == 1) { 🥇 } + else if (e.Rank == 2) { 🥈 } + else if (e.Rank == 3) { 🥉 } + else { @e.Rank } + @e.DisplayName@e.Badge@e.CareerPoints@e.GamesPlayed
+
+ } +
+ +@code { + private List? _entries; + private bool _loading = true; + private string _filter = "alltime"; + + protected override async Task OnInitializedAsync() => await LoadLeaderboard("alltime"); + + private async Task LoadLeaderboard(string filter) + { + _filter = filter; + _loading = true; + _entries = null; + StateHasChanged(); + _entries = await Http.GetFromJsonAsync>($"api/leaderboard?filter={filter}"); + _loading = false; + } +} diff --git a/src/FastGeography.Client/Program.cs b/src/FastGeography.Client/Program.cs index 51d78a6..4d92e77 100644 --- a/src/FastGeography.Client/Program.cs +++ b/src/FastGeography.Client/Program.cs @@ -1,19 +1,29 @@ using BlazorApplicationInsights; using FastGeography.Client; +using FastGeography.Client.Auth; +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; var builder = WebAssemblyHostBuilder.CreateDefault(args); -// Add root components builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); -// Add services to the container -builder.Services.AddScoped(sp => - new HttpClient +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => +{ + var handler = sp.GetRequiredService(); + handler.InnerHandler = new HttpClientHandler(); + return new HttpClient(handler) { - BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) - }); + BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) + }; +}); + +builder.Services.AddAuthorizationCore(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + sp.GetRequiredService()); builder.Services.AddBlazorApplicationInsights(); diff --git a/src/FastGeography.Client/Shared/MainLayout.razor b/src/FastGeography.Client/Shared/MainLayout.razor index f170e38..f54ad51 100644 --- a/src/FastGeography.Client/Shared/MainLayout.razor +++ b/src/FastGeography.Client/Shared/MainLayout.razor @@ -1,6 +1,9 @@ @inherits LayoutComponentBase
+
@Body diff --git a/src/FastGeography.Client/Shared/NavMenu.razor b/src/FastGeography.Client/Shared/NavMenu.razor index 093bf87..d5f9e65 100644 --- a/src/FastGeography.Client/Shared/NavMenu.razor +++ b/src/FastGeography.Client/Shared/NavMenu.razor @@ -1,4 +1,7 @@ - @@ -108,7 +107,7 @@ private bool isChecking = false; private Countdown countdown = null!; private string _langCode = "en"; - private List? _storyPlaces; + private List _storyPlaces = []; protected override async Task OnInitializedAsync() { @@ -190,7 +189,7 @@ totalPoints += game.TotalPoints; encourageMessage = GetEncouragingMessage(game.TotalPoints); - _storyPlaces = BuildStoryRequests(game); + _storyPlaces = MergeStoryPlaces(_storyPlaces, BuildStoryRequests(game)); games.Remove(game.Id); games.Add(game.Id, game); @@ -225,6 +224,21 @@ } } + private static List MergeStoryPlaces(List existing, List newPlaces) + { + var merged = new List(existing); + foreach (var place in newPlaces) + { + if (!merged.Any(p => + p.Name.Equals(place.Name, StringComparison.OrdinalIgnoreCase) && p.Type == place.Type)) + { + merged.Add(place); + } + } + + return merged; + } + private List BuildStoryRequests(Game game) { var locations = new[] diff --git a/src/FastGeography.Client/Pages/MultiplayerGame.razor b/src/FastGeography.Client/Pages/MultiplayerGame.razor index 22bda14..cb986b5 100644 --- a/src/FastGeography.Client/Pages/MultiplayerGame.razor +++ b/src/FastGeography.Client/Pages/MultiplayerGame.razor @@ -4,6 +4,7 @@ @inject HttpClient Http @inject CookieAuthenticationStateProvider Auth @inject NavigationManager Nav +@inject ActiveMultiplayerRoomState ActiveRoom @inject ILogger Logger @inject IStringLocalizer L @@ -59,10 +60,11 @@
@L["MpGame_Players"]
@foreach (var p in _players) { -
- @if (p == _hostName) { 👑 } - @p - @if (_submittedNames.Contains(p)) { } +
+ + @if (p.DisplayName == _hostName) { 👑 } + @p.DisplayName + @if (_submittedNames.Contains(p.DisplayName)) { }
}
@@ -91,64 +93,61 @@ @* ── Personal answers table (casual-play style) ── *@ @if (_myRows.Count > 0 || _roundActive) { - - - - - - - - - - - - - - @foreach (var row in _myRows) - { - var g = ToGame(row); - - } - @if (_roundActive && !_submitted) - { - - - - - - - - - - } - @if (_roundActive && _submitted) - { + +
@L["Col_Letter"]@L["Col_City"]@L["Col_Village"]@L["Col_Country"]@L["Col_River"]@L["Col_Mountain"]
@_letter - -
+ - - - + + + + + + + - } - -
@_letter@L["MpGame_WaitingPlayers"]@L["Col_Letter"]@L["Col_City"]@L["Col_Village"]@L["Col_Country"]@L["Col_River"]@L["Col_Mountain"]@L["Col_Check"]
- } - - @if (_storyPlaces is { Count: > 0 }) - { - + + + @foreach (var row in _myRows) + { + var g = ToGame(row); + + } + @if (_roundActive && !_submitted) + { + + @_letter + + + + + + + + + + } + @if (_roundActive && _submitted) + { + + @_letter + @L["MpGame_WaitingPlayers"] + + + } + + + } @if (_setComplete && _myRows.Count > 0) @@ -176,7 +175,12 @@ { @r.Rank - @r.PlayerName + +
+ + @r.PlayerName +
+ @r.TotalPoints @foreach (var d in r.Details) { @@ -200,7 +204,7 @@ private bool _connected, _connecting; private bool _isHost; private string _hostName = string.Empty; - private List _players = []; + private List _players = []; private HashSet _submittedNames = []; private bool _roundActive, _submitted; @@ -213,7 +217,7 @@ private string? _city, _village, _country, _river, _mountain; private RoundResultsMessage? _results; private string? _error; - private List? _storyPlaces; + private List _storyPlaces = []; private readonly List _myRows = []; private string _myDisplayName = string.Empty; @@ -245,18 +249,23 @@ _myRows.Clear(); _myRows.AddRange(state.MyCompletedRounds); _isHost = !string.IsNullOrEmpty(_myDisplayName) && state.HostName == _myDisplayName; - InvokeAsync(StateHasChanged); + InvokeAsync(async () => + { + await ActiveRoom.SetAsync(RoomCode); + StateHasChanged(); + }); }); - _hub.On("PlayerJoined", name => + _hub.On("PlayerJoined", player => { - if (!_players.Contains(name)) _players.Add(name); + if (!_players.Any(p => p.UserId == player.UserId)) + _players.Add(player); InvokeAsync(StateHasChanged); }); _hub.On("PlayerLeft", name => { - _players.Remove(name); + _players.RemoveAll(p => p.DisplayName == name); InvokeAsync(StateHasChanged); }); @@ -269,7 +278,11 @@ _hub.On("LeftRoom", () => { - InvokeAsync(() => Nav.NavigateTo("/multiplayer")); + InvokeAsync(async () => + { + await ActiveRoom.ClearAsync(); + Nav.NavigateTo("/multiplayer"); + }); }); _hub.On("RoundStarted", msg => @@ -307,10 +320,11 @@ if (myResult is not null) { _myRows.Add(new CompletedRoundRow(_letter, myResult.Details)); - _storyPlaces = myResult.Details + var newPlaces = myResult.Details .Where(d => d.Points == ScoringRules.ValidPoints && !string.IsNullOrWhiteSpace(d.Answer)) .Select(d => new StoryRequest(d.Answer!, d.Type, d.Coordinates, _languageCode)) .ToList(); + _storyPlaces = MergeStoryPlaces(_storyPlaces, newPlaces); } InvokeAsync(() => { _countdown?.Stop(); StateHasChanged(); }); @@ -319,7 +333,7 @@ _hub.On("NewSetStarted", () => { _myRows.Clear(); - _storyPlaces = null; + _storyPlaces = []; _roundsCompleted = 0; _setComplete = false; _results = null; @@ -330,7 +344,18 @@ _hub.On("Error", msg => { _error = msg; - InvokeAsync(StateHasChanged); + if (msg.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + InvokeAsync(async () => + { + await ActiveRoom.ClearAsync(); + StateHasChanged(); + }); + } + else + { + InvokeAsync(StateHasChanged); + } }); _hub.Reconnected += _ => @@ -408,6 +433,21 @@ }; } + private static List MergeStoryPlaces(List existing, List newPlaces) + { + var merged = new List(existing); + foreach (var place in newPlaces) + { + if (!merged.Any(p => + p.Name.Equals(place.Name, StringComparison.OrdinalIgnoreCase) && p.Type == place.Type)) + { + merged.Add(place); + } + } + + return merged; + } + public async ValueTask DisposeAsync() { if (_hub is not null) diff --git a/src/FastGeography.Client/Pages/MultiplayerLobby.razor b/src/FastGeography.Client/Pages/MultiplayerLobby.razor index cb24c69..8cf1b1c 100644 --- a/src/FastGeography.Client/Pages/MultiplayerLobby.razor +++ b/src/FastGeography.Client/Pages/MultiplayerLobby.razor @@ -1,7 +1,9 @@ @page "/multiplayer" @attribute [Authorize] +@implements IDisposable @inject HttpClient Http @inject NavigationManager Nav +@inject ActiveMultiplayerRoomState ActiveRoom @inject IStringLocalizer L @L["Mp_PageTitle"] @@ -10,6 +12,24 @@

@L["Mp_Heading"]

@L["Mp_Description"]

+ @if (_returnRoomCode is not null) + { +
+
+ @string.Format(L["Mp_ReturnToRoom"]!, _returnRoomCode) +
@string.Format(L["Mp_ReturnToRoomDesc"]!, _returnRoomCode)
+
+ +
+ } + @if (!string.IsNullOrEmpty(_error)) {
@_error
@@ -51,6 +71,28 @@ private string _createLang = "en"; private bool _busy; private string? _error; + private string? _returnRoomCode; + + protected override async Task OnInitializedAsync() + { + await ActiveRoom.EnsureLoadedAsync(); + _returnRoomCode = ActiveRoom.RoomCode; + ActiveRoom.Changed += OnActiveRoomChanged; + } + + private void OnActiveRoomChanged() + { + _returnRoomCode = ActiveRoom.RoomCode; + InvokeAsync(StateHasChanged); + } + + private async Task DismissReturnRoom() + { + await ActiveRoom.ClearAsync(); + _returnRoomCode = null; + } + + public void Dispose() => ActiveRoom.Changed -= OnActiveRoomChanged; private async Task CreateRoom() { diff --git a/src/FastGeography.Client/Pages/RankedGame.razor b/src/FastGeography.Client/Pages/RankedGame.razor index 5e614a4..4e99889 100644 --- a/src/FastGeography.Client/Pages/RankedGame.razor +++ b/src/FastGeography.Client/Pages/RankedGame.razor @@ -10,7 +10,7 @@
-
+

@L["Ranked_Heading"]

@L["Ranked_Subtitle"]
@@ -19,10 +19,14 @@ disabled="@(_roundActive || _checking)"> @L["Ranked_StartRound"] - @if (_roundActive) + + @if (_checking) { -
- +
+ + @L["Ranked_Submitting"]
}
@@ -35,14 +39,6 @@
} - @if (_checking) - { -
-
- @L["Ranked_Submitting"] -
- } - @if (_result is not null) {
@@ -50,23 +46,28 @@ @L["Ranked_Badge"] @L[$"Badge_{_result.Badge}"] @L["Ranked_ViewScoreboard"]
- - - - @foreach (var d in _result.Details) - { - - - - - - } - -
@L["Col_Category"]@L["Col_Answer"]@L["Col_Points"]
@L[$"Location_{d.Type}"]@(string.IsNullOrEmpty(d.Answer) ? "–" : d.Answer)@d.Points
- @if (_storyPlaces is { Count: > 0 }) - { - - } + + + + + @foreach (var d in _result.Details) + { + var location = ToGameLocation(d); + + + + + + } + +
@L["Col_Category"]@L["Col_Answer"]@L["Col_Points"]
@L[$"Location_{d.Type}"] + @(string.IsNullOrEmpty(d.Answer) ? "–" : d.Answer) + @if (!string.IsNullOrEmpty(d.Answer)) + { + + } + @d.Points
+
} @if (_roundActive && _roundId.HasValue) @@ -114,13 +115,13 @@ private SoloSubmitResponse? _result; private string? _city, _village, _country, _river, _mountain; - private List? _storyPlaces; + private List _storyPlaces = []; private async Task StartRound() { _error = null; _result = null; - _storyPlaces = null; + _storyPlaces = []; await Language.EnsureLoadedAsync(); _languageCode = Language.Code; @@ -168,7 +169,7 @@ _storyPlaces = _result?.Details .Where(d => d.Points == ScoringRules.ValidPoints && !string.IsNullOrWhiteSpace(d.Answer)) .Select(d => new StoryRequest(d.Answer!, d.Type, d.Coordinates, _languageCode)) - .ToList(); + .ToList() ?? []; } else if (resp.StatusCode == System.Net.HttpStatusCode.Conflict) { @@ -193,4 +194,12 @@ _checking = false; } } + + private static GameLocation ToGameLocation(LocationResult detail) => new() + { + LocationType = detail.Type, + Answer = detail.Answer, + Points = detail.Points, + Coordinates = detail.Coordinates + }; } diff --git a/src/FastGeography.Client/Pages/Scoreboard.razor b/src/FastGeography.Client/Pages/Scoreboard.razor index 1d74088..ef81eda 100644 --- a/src/FastGeography.Client/Pages/Scoreboard.razor +++ b/src/FastGeography.Client/Pages/Scoreboard.razor @@ -63,7 +63,12 @@ else if (e.Rank == 3) { 🥉 } else { @e.Rank } - @e.DisplayName + +
+ + @e.DisplayName +
+ @L[$"Badge_{e.Badge}"] @e.CareerPoints @e.GamesPlayed diff --git a/src/FastGeography.Client/Pages/ScoreboardMe.razor b/src/FastGeography.Client/Pages/ScoreboardMe.razor new file mode 100644 index 0000000..6c57d84 --- /dev/null +++ b/src/FastGeography.Client/Pages/ScoreboardMe.razor @@ -0,0 +1,153 @@ +@page "/scoreboard/me" +@attribute [Authorize] +@inject HttpClient Http +@inject IStringLocalizer L + +@L["Sb_Me_PageTitle"] + +
+
+

@L["Sb_Me_Heading"]

+ @L["Sb_Me_BackToScoreboard"] +
+ + @if (_error is not null) + { + + } + else if (_loading) + { +
+
+ @L["Sb_Loading"] +
+ } + else if (_stats is not null) + { +
+
+
+
+
@L["Sb_ColExplorer"]
+
+ +
@_stats.DisplayName
+
+
+
+
+
+
+
+
@L["Sb_Me_GlobalRank"]
+
#@_stats.Rank
+
+
+
+
+
+
+
@L["Sb_ColPoints"]
+
@_stats.CareerPoints
+
+
+
+
+
+
+
@L["Sb_ColBadge"]
+
@L[$"Badge_{_stats.Badge}"]
+
+
+
+
+ +

+ @string.Format(L["Sb_Me_GamesSummary"]!, _stats.GamesPlayed) +

+ +
@L["Sb_Me_RecentRounds"]
+ + @if (_stats.RecentRounds.Count == 0) + { +

@L["Sb_Me_NoRounds"]

+ } + else + { +
+ + + + + + + + + + + @foreach (var round in _stats.RecentRounds) + { + + + + + + + } + +
@L["Sb_Me_ColPlayedAt"]@L["Sb_Me_ColMode"]@L["Col_Letter"]@L["Sb_ColPoints"]
@round.PlayedAt.ToLocalTime().ToString("g")@FormatMode(round.Mode)@round.Letter@round.Points
+
+ } + } +
+ +@code { + private PlayerStats? _stats; + private bool _loading = true; + private string? _error; + + protected override async Task OnInitializedAsync() => await LoadStatsAsync(); + + private async Task LoadStatsAsync() + { + _loading = true; + _error = null; + _stats = null; + StateHasChanged(); + + try + { + var response = await Http.GetAsync("api/leaderboard/me"); + if (response.IsSuccessStatusCode) + { + _stats = await response.Content.ReadFromJsonAsync(); + } + else if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _error = L["Sb_Me_ProfileNotFound"]; + } + else + { + _error = response.ReasonPhrase ?? response.StatusCode.ToString(); + } + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loading = false; + } + } + + private string FormatMode(string mode) => + mode.Equals("Multiplayer", StringComparison.OrdinalIgnoreCase) + ? L["Sb_Me_ModeMultiplayer"] + : L["Sb_Me_ModeRanked"]; + + private static string PointsClass(int points) => + points > 0 ? "text-success" : points < 0 ? "text-danger" : "text-muted"; +} diff --git a/src/FastGeography.Client/Program.cs b/src/FastGeography.Client/Program.cs index 6d4bab3..dbb9e19 100644 --- a/src/FastGeography.Client/Program.cs +++ b/src/FastGeography.Client/Program.cs @@ -32,6 +32,7 @@ sp.GetRequiredService()); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddLocalization(); builder.Services.AddBlazorApplicationInsights(); diff --git a/src/FastGeography.Client/Resources/UiStrings.mk.resx b/src/FastGeography.Client/Resources/UiStrings.mk.resx index 84d2265..2f9a639 100644 --- a/src/FastGeography.Client/Resources/UiStrings.mk.resx +++ b/src/FastGeography.Client/Resources/UiStrings.mk.resx @@ -36,6 +36,7 @@ Слободна игра Рангирана Повеќеиграчи + Врати се во соба {0} Табела Најава Регистрација @@ -67,6 +68,7 @@ Држава Река Планина + Провери Време (с) Акција Категорија @@ -80,7 +82,9 @@ Прикажи {0} на карта Прикажи на карта Провери + Проверено ({0}с) Провери одговори за буквата {0} + Проверени одговори за буквата {0} за {1} секунди Рангирана игра – FastGeography @@ -117,6 +121,9 @@ Приклучи Не можеше да се создаде соба. Обидете се повторно. Внесете код на собата. + Врати се во соба {0} + Ја напуштивте собата {0}. Приклучете се повторно за да продолжите со пријателите. + Отфрли Соба: @@ -154,6 +161,18 @@ Значка Поени Игри + Мои статистики – FastGeography + Мои статистики + Назад на табелата + Глобален ранг + {0} рангирани игри. + Неодамнешни рунди + Нема рангирани рунди. Играјте рангирана рунда за да ја видите историјата. + Играно + Режим + Рангирано + Мултиплеер + Профилот на играчот не е пронајден. Најава – FastGeography @@ -180,9 +199,13 @@ Најавете се Регистрацијата не успеа. - - Ги отклучивте приказните за дестинации! + Вчитување патни приказни… + Прочитај патничка приказна за {0} + Затвори приказна + Фотографија од {0} + {0}: {1} + преку {0} Жал ни е, на оваа адреса нема ништо. diff --git a/src/FastGeography.Client/Resources/UiStrings.resx b/src/FastGeography.Client/Resources/UiStrings.resx index 8b3cd85..99c1f4c 100644 --- a/src/FastGeography.Client/Resources/UiStrings.resx +++ b/src/FastGeography.Client/Resources/UiStrings.resx @@ -36,6 +36,7 @@ Casual Play Ranked Multiplayer + Return to room {0} Scoreboard Sign In Register @@ -67,6 +68,7 @@ Country River Mountain + Check Time (s) Action Category @@ -80,7 +82,9 @@ View {0} on map View on map Check + Checked ({0}s) Check answers for letter {0} + Checked answers for letter {0} in {1} seconds Ranked Play – FastGeography @@ -117,6 +121,9 @@ Join Could not create a room. Please try again. Enter a room code. + Return to room {0} + You left room {0}. Rejoin to continue with your friends. + Dismiss Room: @@ -154,6 +161,18 @@ Badge Points Games + My Stats – FastGeography + My Stats + Back to scoreboard + Global rank + {0} ranked games played. + Recent rounds + No ranked rounds yet. Play a ranked round to see your history here. + Played + Mode + Ranked + Multiplayer + Player profile not found. Login – FastGeography @@ -180,9 +199,13 @@ Sign in Registration failed. - - You unlocked destination stories! + Loading travel stories… + Read travel story for {0} + Close story + Photo of {0} + {0}: {1} + via {0} Sorry, there's nothing at this address. diff --git a/src/FastGeography.Client/Services/ActiveMultiplayerRoomState.cs b/src/FastGeography.Client/Services/ActiveMultiplayerRoomState.cs new file mode 100644 index 0000000..216bd34 --- /dev/null +++ b/src/FastGeography.Client/Services/ActiveMultiplayerRoomState.cs @@ -0,0 +1,82 @@ +namespace FastGeography.Client.Services; + +using System.Text.Json; + +using Microsoft.JSInterop; + +/// +/// Remembers the last multiplayer room the player joined so they can return +/// after accidental navigation away. Cleared on explicit leave or expiry. +/// +public sealed class ActiveMultiplayerRoomState +{ + private const string StorageKey = "fg_mp_room"; + private static readonly TimeSpan MaxAge = TimeSpan.FromHours(2); + + private readonly IJSRuntime _js; + private bool _loaded; + + public ActiveMultiplayerRoomState(IJSRuntime js) => _js = js; + + public string? RoomCode { get; private set; } + + public event Action? Changed; + + public async Task EnsureLoadedAsync() + { + if (_loaded) return; + + var json = await _js.InvokeAsync("localStorage.getItem", StorageKey); + if (!TryParse(json, out var code)) + { + RoomCode = null; + _loaded = true; + return; + } + + RoomCode = code; + _loaded = true; + } + + public async Task SetAsync(string roomCode) + { + var normalized = roomCode.Trim().ToUpperInvariant(); + if (string.IsNullOrEmpty(normalized)) return; + + var payload = JsonSerializer.Serialize(new StoredRoom(normalized, DateTime.UtcNow)); + await _js.InvokeVoidAsync("localStorage.setItem", StorageKey, payload); + RoomCode = normalized; + _loaded = true; + Changed?.Invoke(); + } + + public async Task ClearAsync() + { + await _js.InvokeVoidAsync("localStorage.removeItem", StorageKey); + RoomCode = null; + _loaded = true; + Changed?.Invoke(); + } + + private static bool TryParse(string? json, out string code) + { + code = string.Empty; + if (string.IsNullOrWhiteSpace(json)) return false; + + try + { + var stored = JsonSerializer.Deserialize(json); + if (stored is null || string.IsNullOrWhiteSpace(stored.Code)) return false; + if (DateTime.UtcNow - stored.JoinedAtUtc > MaxAge) return false; + + code = stored.Code.Trim().ToUpperInvariant(); + return code.Length > 0; + } + catch (JsonException) + { + return false; + } + } + + private sealed record StoredRoom(string Code, DateTime JoinedAtUtc); +} diff --git a/src/FastGeography.Client/Shared/AnswerActions.razor b/src/FastGeography.Client/Shared/AnswerActions.razor new file mode 100644 index 0000000..1d72804 --- /dev/null +++ b/src/FastGeography.Client/Shared/AnswerActions.razor @@ -0,0 +1,131 @@ +@inject IStringLocalizer L + +
+ @if (MapsUri is not null) + { + + @L[ + + } + + @if (_showStorySlot) + { + @if (_storyPending) + { + + + + } + else if (_story is not null) + { + @_stampAlt + +
+ + +
+ } + } + + @if (_pinned) + { + + } +
+ +@code { + [Parameter] public Uri? MapsUri { get; set; } + [Parameter] public string? Answer { get; set; } + [Parameter] public GameLocation? Location { get; set; } + [Parameter] public bool PopoverLeft { get; set; } + + [CascadingParameter] private DestinationStoriesContext? Stories { get; set; } + + private bool _pinned; + private DestinationStoryEntry? _story; + private bool _storyPending; + private bool _showStorySlot; + private bool _imageBroken; + private string _stampSrc = ""; + private string _stampAlt = ""; + private string _categoryIcon = ""; + private bool _showPopoverImage; + private bool _showFallbackArt; + + protected override void OnParametersSet() + { + _story = Stories?.GetStory(Location); + _storyPending = Stories?.IsStoryPending(Location) == true; + _showStorySlot = Stories?.ShouldShowStorySlot(Location) == true; + _imageBroken = false; + + _categoryIcon = Location is not null + ? PlaceCategoryIcon.CategoryIconPath(Location.LocationType) + : PlaceCategoryIcon.CategoryIconPath(LocationType.City); + + _stampAlt = Location is not null + ? string.Format(L["Stories_PlaceStamp"]!, L[$"Location_{Location.LocationType}"].Value, Answer) + : Answer ?? ""; + + if (_story is not null) + { + _showPopoverImage = !string.IsNullOrWhiteSpace(_story.ImageUrl); + _showFallbackArt = !_showPopoverImage; + _stampSrc = _showPopoverImage ? _story.ImageUrl! : _categoryIcon; + } + + if (!_showStorySlot || _story is null) + _pinned = false; + } + + private void TogglePin() => _pinned = !_pinned; + + private void ClosePin() => _pinned = false; + + private void OnImageError() + { + _imageBroken = true; + _showPopoverImage = false; + _showFallbackArt = true; + _stampSrc = _categoryIcon; + } + + private void OnKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + ClosePin(); + } +} diff --git a/src/FastGeography.Client/Shared/Countdown.razor b/src/FastGeography.Client/Shared/Countdown.razor index a967ea9..e9372b3 100644 --- a/src/FastGeography.Client/Shared/Countdown.razor +++ b/src/FastGeography.Client/Shared/Countdown.razor @@ -4,9 +4,9 @@ role="timer" aria-live="off" aria-label="Time remaining: @Time"> -
+
+ -
diff --git a/src/FastGeography.Client/Shared/DestinationStories.razor b/src/FastGeography.Client/Shared/DestinationStories.razor index 5a97e15..6a76346 100644 --- a/src/FastGeography.Client/Shared/DestinationStories.razor +++ b/src/FastGeography.Client/Shared/DestinationStories.razor @@ -1,48 +1,21 @@ @inject HttpClient Http @inject GameLanguageState Language -@inject IStringLocalizer L @inject ILogger Logger @implements IDisposable -@if (_loading) -{ -
-
- - @L["Stories_Loading"] -
-
-} -else if (_stories is { Count: > 0 }) -{ -
-
- 🌍@L["Stories_Heading"] -
-
- @foreach (var s in _stories) - { -
-
-
-
@s.Name
- @L[$"Location_{s.Type}"] -

@s.Story

-
-
-
- } -
-
-} + + @ChildContent + @code { - [Parameter, EditorRequired] - public List Places { get; set; } = []; + [Parameter] public RenderFragment? ChildContent { get; set; } - private bool _loading; - private List? _stories; + [Parameter] + public List? Places { get; set; } + + private DestinationStoriesContext _context = new(null); private int _loadId; + private readonly Dictionary<(string Name, LocationType Type), DestinationStoryEntry> _stories = new(); protected override async Task OnInitializedAsync() { @@ -52,35 +25,69 @@ else if (_stories is { Count: > 0 }) protected override async Task OnParametersSetAsync() => await LoadStoriesAsync(); - private void OnLanguageChanged() => _ = InvokeAsync(LoadStoriesAsync); + private void OnLanguageChanged() + { + _stories.Clear(); + _ = InvokeAsync(LoadStoriesAsync); + } private async Task LoadStoriesAsync() { if (Places is null || Places.Count == 0) { - _stories = null; + _stories.Clear(); + _context = new DestinationStoriesContext(null); return; } - var loadId = ++_loadId; await Language.EnsureLoadedAsync(); var lang = Language.Code; - _loading = true; - _stories = null; - StateHasChanged(); + var places = Places.Select(p => p with { Lang = lang }).ToList(); + var pending = places + .Where(p => !_stories.ContainsKey(StoryKey(p))) + .ToList(); - try + if (pending.Count == 0) { - var places = Places.Select(p => p with { Lang = lang }).ToList(); - var body = new DestinationStoriesRequest(places); - var resp = await Http.PostAsJsonAsync("api/destination-stories", body); - if (loadId != _loadId) - return; + PublishContext(); + return; + } + + const int maxBatch = 10; + var loadId = ++_loadId; + var pendingKeys = pending.Select(p => (p.Name, p.Type)).ToList(); + PublishContext(pendingKeys); + await InvokeAsync(StateHasChanged); - if (resp.IsSuccessStatusCode) + try + { + foreach (var batch in pending.Chunk(maxBatch)) { + if (loadId != _loadId) + return; + + var body = new DestinationStoriesRequest(batch.ToList()); + var resp = await Http.PostAsJsonAsync("api/destination-stories", body); + if (loadId != _loadId) + return; + + if (!resp.IsSuccessStatusCode) + { + Logger.LogWarning( + "Destination stories request failed with {StatusCode}", + (int)resp.StatusCode); + continue; + } + var data = await resp.Content.ReadFromJsonAsync(); - _stories = data?.Stories ?? []; + var results = data?.Stories ?? []; + foreach (var result in results) + { + _stories[StoryKey(result.Name, result.Type)] = new DestinationStoryEntry( + result.Story, + result.ImageUrl, + result.ImageAttribution); + } } } catch (Exception ex) @@ -88,14 +95,28 @@ else if (_stories is { Count: > 0 }) if (loadId != _loadId) return; Logger.LogWarning(ex, "Could not load destination stories"); - _stories = null; - } - finally - { - if (loadId == _loadId) - _loading = false; } + + if (loadId != _loadId) + return; + + PublishContext(); + await InvokeAsync(StateHasChanged); } + private void PublishContext(IReadOnlyList<(string Name, LocationType Type)>? pendingKeys = null) + { + var stories = _stories + .Select(kv => (kv.Key.Name, kv.Key.Type, kv.Value.Story, kv.Value.ImageUrl, kv.Value.ImageAttribution)) + .ToList(); + _context = new DestinationStoriesContext(stories, pendingKeys); + } + + private static (string Name, LocationType Type) StoryKey(StoryRequest place) => + StoryKey(place.Name, place.Type); + + private static (string Name, LocationType Type) StoryKey(string name, LocationType type) => + (name.Trim().ToLowerInvariant(), type); + public void Dispose() => Language.Changed -= OnLanguageChanged; } diff --git a/src/FastGeography.Client/Shared/DestinationStoriesContext.cs b/src/FastGeography.Client/Shared/DestinationStoriesContext.cs new file mode 100644 index 0000000..8e05540 --- /dev/null +++ b/src/FastGeography.Client/Shared/DestinationStoriesContext.cs @@ -0,0 +1,72 @@ +namespace FastGeography.Client.Shared; + +using FastGeography.Shared; + +/// +/// A loaded destination story with optional place image. +/// +public sealed record DestinationStoryEntry( + string Story, + string? ImageUrl, + string? ImageAttribution); + +/// +/// Cascading context for destination story lookup inside a table. +/// +public sealed class DestinationStoriesContext +{ + private readonly Dictionary<(string NormalizedName, LocationType Type), DestinationStoryEntry> _stories; + private readonly HashSet<(string NormalizedName, LocationType Type)> _pendingKeys; + + public DestinationStoriesContext( + IReadOnlyList<(string Name, LocationType Type, string Story, string? ImageUrl, string? ImageAttribution)>? stories, + IReadOnlyList<(string Name, LocationType Type)>? pendingKeys = null) + { + _stories = new Dictionary<(string, LocationType), DestinationStoryEntry>(); + _pendingKeys = new HashSet<(string, LocationType)>(); + + if (stories is not null) + { + foreach (var (name, type, story, imageUrl, imageAttribution) in stories) + { + _stories[NormalizeKey(name, type)] = new DestinationStoryEntry( + story, imageUrl, imageAttribution); + } + } + + if (pendingKeys is not null) + { + foreach (var (name, type) in pendingKeys) + _pendingKeys.Add(NormalizeKey(name, type)); + } + } + + public bool IsEligible(GameLocation? location) => + location is not null + && location.Points == ScoringRules.ValidPoints + && !string.IsNullOrWhiteSpace(location.Answer); + + public bool IsStoryPending(GameLocation? location) + { + if (!IsEligible(location)) + return false; + + var key = NormalizeKey(location!.Answer!, location.LocationType); + return _pendingKeys.Contains(key) && !_stories.ContainsKey(key); + } + + public DestinationStoryEntry? GetStory(GameLocation? location) + { + if (!IsEligible(location)) + return null; + + var key = NormalizeKey(location!.Answer!, location.LocationType); + return _stories.TryGetValue(key, out var story) ? story : null; + } + + public bool ShouldShowStorySlot(GameLocation? location) => + IsStoryPending(location) || GetStory(location) is not null; + + private static (string NormalizedName, LocationType Type) NormalizeKey(string name, LocationType type) => + (name.Trim().ToLowerInvariant(), type); +} diff --git a/src/FastGeography.Client/Shared/GameRow.razor b/src/FastGeography.Client/Shared/GameRow.razor index ccc7737..54df5e2 100644 --- a/src/FastGeography.Client/Shared/GameRow.razor +++ b/src/FastGeography.Client/Shared/GameRow.razor @@ -6,68 +6,46 @@ - - + - @if (game.City.MapsUri != null) - { - - @L[ - - } + - - + - @if (game.Village.MapsUri != null) - { - - @L[ - - } + - - + - @if (game.Country.MapsUri != null) - { - - @L[ - - } + - - + - @if (game.River.MapsUri != null) - { - - @L[ - - } + - - + - @if (game.Mountain.MapsUri != null) - { - - @L[ - - } + - @game.SecondsPlayed - + diff --git a/src/FastGeography.Client/Shared/NavMenu.razor b/src/FastGeography.Client/Shared/NavMenu.razor index b6c9db0..b57654b 100644 --- a/src/FastGeography.Client/Shared/NavMenu.razor +++ b/src/FastGeography.Client/Shared/NavMenu.razor @@ -1,7 +1,9 @@ -@inject CookieAuthenticationStateProvider Auth +@implements IDisposable +@inject CookieAuthenticationStateProvider Auth @inject NavigationManager Nav @inject HttpClient Http @inject GameLanguageState Language +@inject ActiveMultiplayerRoomState ActiveRoom @inject IStringLocalizer L + @if (_returnRoomCode is not null) + { + + } } + @if (_showLangMismatchBanner) + { +
+ @string.Format(L["MpGame_LangMismatch"]!, RoomLanguageName(_languageCode), UiLanguageName(Language.Code)) + +
+ } + @* ── Players + controls ── *@
@@ -222,10 +231,12 @@ private readonly List _myRows = []; private string _myDisplayName = string.Empty; private string _languageCode = "en"; + private bool _showLangMismatchBanner; private int _mySetPoints => _myRows.Sum(r => r.Details.Sum(d => d.Points)); protected override async Task OnInitializedAsync() { + await Language.EnsureLoadedAsync(); _myDisplayName = Auth.CurrentUser?.DisplayName ?? string.Empty; await Connect(); } @@ -246,6 +257,8 @@ _roundsCompleted = state.RoundsCompletedInSet; _setComplete = state.SetComplete; _languageCode = state.LanguageCode; + _showLangMismatchBanner = !string.Equals( + Language.Code, state.LanguageCode, StringComparison.OrdinalIgnoreCase); _myRows.Clear(); _myRows.AddRange(state.MyCompletedRounds); _isHost = !string.IsNullOrEmpty(_myDisplayName) && state.HostName == _myDisplayName; @@ -448,6 +461,11 @@ return merged; } + private string RoomLanguageName(string languageCode) => + languageCode == "mk" ? L["MpGame_LangMk"].Value : L["MpGame_LangEn"].Value; + + private string UiLanguageName(string languageCode) => RoomLanguageName(languageCode); + public async ValueTask DisposeAsync() { if (_hub is not null) diff --git a/src/FastGeography.Client/Pages/MultiplayerLobby.razor b/src/FastGeography.Client/Pages/MultiplayerLobby.razor index 8cf1b1c..22f02f6 100644 --- a/src/FastGeography.Client/Pages/MultiplayerLobby.razor +++ b/src/FastGeography.Client/Pages/MultiplayerLobby.razor @@ -1,9 +1,12 @@ @page "/multiplayer" @attribute [Authorize] @implements IDisposable +@using FastGeography.Shared +@using FastGeography.Shared.Dtos @inject HttpClient Http @inject NavigationManager Nav @inject ActiveMultiplayerRoomState ActiveRoom +@inject GameLanguageState Language @inject IStringLocalizer L @L["Mp_PageTitle"] @@ -11,13 +14,23 @@

@L["Mp_Heading"]

@L["Mp_Description"]

+

+ @L["Mp_HowToPlayLink"] +

@if (_returnRoomCode is not null) {
@string.Format(L["Mp_ReturnToRoom"]!, _returnRoomCode) -
@string.Format(L["Mp_ReturnToRoomDesc"]!, _returnRoomCode)
+ @if (_returnPreview is not null) + { +
@FormatRoomLanguageLine(_returnPreview.LanguageCode)
+ } + else + { +
@string.Format(L["Mp_ReturnToRoomDesc"]!, _returnRoomCode)
+ }
@@ -72,28 +106,76 @@ private bool _busy; private string? _error; private string? _returnRoomCode; + private RoomPreviewResponse? _joinPreview; + private RoomPreviewResponse? _returnPreview; + private bool _joinPreviewLoading; protected override async Task OnInitializedAsync() { + await Language.EnsureLoadedAsync(); await ActiveRoom.EnsureLoadedAsync(); _returnRoomCode = ActiveRoom.RoomCode; ActiveRoom.Changed += OnActiveRoomChanged; + + if (_returnRoomCode is not null) + _returnPreview = await FetchRoomPreviewAsync(_returnRoomCode); } private void OnActiveRoomChanged() { _returnRoomCode = ActiveRoom.RoomCode; - InvokeAsync(StateHasChanged); + InvokeAsync(async () => + { + _returnPreview = _returnRoomCode is null + ? null + : await FetchRoomPreviewAsync(_returnRoomCode); + StateHasChanged(); + }); } private async Task DismissReturnRoom() { await ActiveRoom.ClearAsync(); _returnRoomCode = null; + _returnPreview = null; } public void Dispose() => ActiveRoom.Changed -= OnActiveRoomChanged; + private async Task OnJoinCodeInput(ChangeEventArgs e) + { + _joinCode = (e.Value?.ToString() ?? string.Empty).Trim().ToUpperInvariant(); + _error = null; + _joinPreview = null; + + if (_joinCode.Length != 6) + { + _joinPreviewLoading = false; + return; + } + + _joinPreviewLoading = true; + StateHasChanged(); + + _joinPreview = await FetchRoomPreviewAsync(_joinCode); + _joinPreviewLoading = false; + + if (_joinPreview is null && _joinCode.Length == 6) + _error = L["Mp_RoomNotFound"]; + } + + private async Task FetchRoomPreviewAsync(string code) + { + try + { + return await Http.GetFromJsonAsync($"api/rooms/{code.Trim().ToUpperInvariant()}"); + } + catch + { + return null; + } + } + private async Task CreateRoom() { _busy = true; @@ -113,9 +195,36 @@ } } - private void JoinRoom() + private async Task JoinRoom() { - if (string.IsNullOrWhiteSpace(_joinCode)) { _error = L["Mp_JoinCodeEmpty"]; return; } - Nav.NavigateTo($"/multiplayer/{_joinCode.Trim().ToUpperInvariant()}"); + if (string.IsNullOrWhiteSpace(_joinCode)) + { + _error = L["Mp_JoinCodeEmpty"]; + return; + } + + var code = _joinCode.Trim().ToUpperInvariant(); + _joinPreview = await FetchRoomPreviewAsync(code); + if (_joinPreview is null) + { + _error = L["Mp_RoomNotFound"]; + return; + } + + Nav.NavigateTo($"/multiplayer/{code}"); } + + private string FormatRoomLanguageLine(string languageCode) => + string.Format(L["Mp_RoomPreviewGameLang"]!, RoomLanguageName(languageCode)); + + private string RoomLanguageName(string languageCode) => + languageCode == "mk" ? L["MpGame_LangMk"].Value : L["MpGame_LangEn"].Value; + + private string UiLanguageName(string languageCode) => RoomLanguageName(languageCode); + + private string RoomAlphabetHint(string languageCode) => + languageCode == "mk" ? L["Mp_RoomPreviewMkHint"].Value : L["Mp_RoomPreviewEnHint"].Value; + + private bool UiLanguageDiffers(string roomLanguageCode) => + !string.Equals(Language.Code, roomLanguageCode, StringComparison.OrdinalIgnoreCase); } diff --git a/src/FastGeography.Client/Pages/RankedGame.razor b/src/FastGeography.Client/Pages/RankedGame.razor index 4e99889..7eab7ba 100644 --- a/src/FastGeography.Client/Pages/RankedGame.razor +++ b/src/FastGeography.Client/Pages/RankedGame.razor @@ -13,6 +13,9 @@ + + } +
+
+ +@code { + private readonly ResetModel _model = new(); + private string? _email; + private string? _token; + private bool _busy; + private bool _success; + private string? _error; + + protected override void OnInitialized() + { + var query = new Uri(Nav.Uri).Query; + foreach (var part in query.TrimStart('?').Split('&')) + { + var kv = part.Split('=', 2); + if (kv.Length != 2) continue; + var key = kv[0]; + var value = Uri.UnescapeDataString(kv[1]); + if (key == "email") _email = value; + else if (key == "token") _token = value; + } + } + + private async Task DoSubmit() + { + if (string.IsNullOrEmpty(_email) || string.IsNullOrEmpty(_token)) return; + + if (_model.Password != _model.ConfirmPassword) + { + _error = L["Reset_PasswordMismatch"]; + return; + } + + _busy = true; + _error = null; + + var (ok, err) = await Auth.ResetPasswordAsync(_email, _token, _model.Password); + _busy = false; + + if (ok) _success = true; + else _error = err ?? L["Reset_Error"]; + } + + private sealed class ResetModel + { + [System.ComponentModel.DataAnnotations.Required] + [System.ComponentModel.DataAnnotations.MinLength(6)] + public string Password { get; set; } = string.Empty; + + [System.ComponentModel.DataAnnotations.Required] + public string ConfirmPassword { get; set; } = string.Empty; + } +} diff --git a/src/FastGeography.Client/Resources/UiStrings.mk.resx b/src/FastGeography.Client/Resources/UiStrings.mk.resx index 2f9a639..18f23b8 100644 --- a/src/FastGeography.Client/Resources/UiStrings.mk.resx +++ b/src/FastGeography.Client/Resources/UiStrings.mk.resx @@ -38,6 +38,7 @@ Повеќеиграчи Врати се во соба {0} Табела + Како се игра Најава Регистрација Одјава @@ -51,6 +52,7 @@ Затвори Се проверуваат одговорите… Нема започната игра. Ве молам започнете. + Како се игра Вкупно поени: {0} Неверојатен истражувач! Ја освоивте значката {0}! Одговори за географија @@ -90,6 +92,7 @@ Рангирана игра – FastGeography Рангиран режим Поените се зачувуваат на глобалната табела. + Како работи рангираната игра Започни рангирана рунда Се испраќа на серверот… Рундата е завршена! @@ -109,6 +112,7 @@ Повеќеиграчи – FastGeography Повеќеиграчи Играјте живо против пријатели – сите добиваат иста буква и 60 секунди. + Како работи повеќеиграчкиот режим Создај нова соба Добијте код за приклучување и споделете го со пријателите. Јазик на играта @@ -121,6 +125,12 @@ Приклучи Не можеше да се создаде соба. Обидете се повторно. Внесете код на собата. + Не постои соба со тој код. + Соба {0} + Јазик на играта: {0} + Латинска азбука (A–Z). Одговорите мора да почнуваат со латинска буква. + Кирилична азбука (А–Ш). Одговорите мора да почнуваат со кирилична буква. + Вашата апликација е на {0}, но собата се игра на {1}. Врати се во соба {0} Ја напуштивте собата {0}. Приклучете се повторно за да продолжите со пријателите. Отфрли @@ -129,6 +139,7 @@ Соба: Англиски Македонски + Оваа соба користи {0}. Менито останува на {1}, но буквите и валидните одговори следат јазикот на собата. Рунда {0} / {1} Вкупно: {0} поени Напушти соба @@ -184,6 +195,30 @@ Немате сметка? Регистрирајте се тука Погрешна е-пошта или лозинка. + Ја заборавивте лозинката? + + + Ресетирање лозинка – FastGeography + Ресетирајте ја лозинката + Внесете ја е-поштата на вашата сметка. Ако постои, ќе ви испратиме линк за нова лозинка. + Испрати линк + Се испраќа… + Ако постои сметка за таа е-пошта, испративме линк за ресетирање. Проверете ја поштата. + Назад на најава + Не можеше да се испрати линкот. Обидете се повторно. + + + Нова лозинка – FastGeography + Изберете нова лозинка + Нова лозинка + Потврди лозинка + Ажурирај лозинка + Се ажурира… + Лозинката е ажурирана. Сега можете да се најавите. + Најави се + Линкот е неважечки или нецелосен. Побарајте нов. + Лозинките не се совпаѓаат. + Не можеше да се ресетира лозинката. Линкот може да е истечен. Регистрација – FastGeography @@ -228,4 +263,30 @@ Сончев спектар Галактички серфер Галактички освојувач + + + Како се игра – FastGeography + Како се игра + FastGeography е географска игра со зборови. Секоја рунда дава случајна буква и одбројување — пополнете вистински места за секоја категорија пред да истече времето. + Како работи една рунда + Имате {0} секунди да внесете град, село, држава, река и планина што почнуваат со буквата на рундата. Притиснете Провери (или испратете кога тајмерот истече) за да добиете поени. + Поени + Резултат + Поени + Валидно место од точниот тип + Празно + Не е соодветно место (погрешен тип или непознато) + Не почнува со буквата на рундата + Валидните одговори можат да отклучат кратки приказни за дестинации со фотографии. + Режими на игра + Слободна игра + Вежбајте сами без сметка. Поените остануваат на вашиот уред и не се зачувуваат на глобалната табела. Одлично за учење на правилата и пробање нови букви. + Играј слободно + Рангирана + Најавете се и играјте соло рунди што се бројат кон вашите кариерни поени. Вкупниот резултат се појавува на глобалната табела и добивате значки додека напредувате. + Играј рангирано + Повеќеиграчи + Најавете се, создајте или приклучете се на соба со 6-цифрен код и играјте живо со пријатели. Домаќинот започнува секоја рунда; сите добиваат иста буква и тајмер. Серијата е {0} рунди — споредете поени по секоја рунда и започнете нова серија кога завршите. Ако случајно ја напуштите собата, користете Врати се во соба од менито. + Играј повеќеиграчи + Најавете се за да играте diff --git a/src/FastGeography.Client/Resources/UiStrings.resx b/src/FastGeography.Client/Resources/UiStrings.resx index 99c1f4c..249dfe1 100644 --- a/src/FastGeography.Client/Resources/UiStrings.resx +++ b/src/FastGeography.Client/Resources/UiStrings.resx @@ -38,6 +38,7 @@ Multiplayer Return to room {0} Scoreboard + How to Play Sign In Register Sign Out @@ -51,6 +52,7 @@ Dismiss Checking your answers… No game started. Please start one. + How to play Total points: {0} Amazing Explorer! You earned the {0} badge! Geography game answers @@ -90,6 +92,7 @@ Ranked Play – FastGeography Ranked Mode Points are saved to the global scoreboard. + How ranked mode works Start Ranked Round Submitting to server… Round complete! @@ -109,6 +112,7 @@ Multiplayer – FastGeography Multiplayer Play live against friends – everyone gets the same letter and 60 seconds. + How multiplayer works Create a New Room Get a join code and share it with friends. Game language @@ -121,6 +125,12 @@ Join Could not create a room. Please try again. Enter a room code. + No room found with that code. + Room {0} + Game language: {0} + Latin alphabet (A–Z). Answers must start with a Latin letter. + Cyrillic alphabet (А–Ш). Answers must start with a Cyrillic letter. + Your app is in {0}, but this room plays in {1}. Return to room {0} You left room {0}. Rejoin to continue with your friends. Dismiss @@ -129,6 +139,7 @@ Room: English Македонски + This room uses {0}. Your menus stay in {1}, but round letters and valid answers follow the room language. Round {0} / {1} Set total: {0} pts Leave Room @@ -184,6 +195,30 @@ Don't have an account? Register here Invalid email or password. + Forgot your password? + + + Reset Password – FastGeography + Reset your password + Enter the email address for your account. If it exists, we will send you a link to choose a new password. + Send reset link + Sending… + If an account exists for that email, we sent a password reset link. Check your inbox. + Back to sign in + Could not send reset link. Please try again. + + + Choose New Password – FastGeography + Choose a new password + New password + Confirm password + Update password + Updating… + Your password has been updated. You can sign in now. + Sign in + This reset link is invalid or incomplete. Request a new one. + Passwords do not match. + Could not reset password. The link may have expired. Register – FastGeography @@ -228,4 +263,30 @@ Solar Spectre Galactic Surfer Galactic Conqueror + + + How to Play – FastGeography + How to Play + FastGeography is a geography word game. Every round gives you a random letter and a countdown — fill in real places for each category before time runs out. + How a round works + You have {0} seconds to enter a city, village, country, river, and mountain that all start with the round letter. Tap Check (or submit when the timer ends) to score your answers. + Scoring + Result + Points + Valid place of the correct type + Left blank + Not a matching place (wrong type or unknown) + Does not start with the round letter + Valid answers can unlock short destination stories with photos. + Game modes + Casual Play + Practice on your own with no account required. Scores stay on your device and are not saved to the global scoreboard. Great for learning the rules and trying new letters. + Play Casual + Ranked + Sign in and play solo rounds that count toward your career points. Your total appears on the global scoreboard and earns explorer badges as you climb. + Play Ranked + Multiplayer + Sign in, create or join a room with a 6-character code, and play live with friends. The host starts each round; everyone gets the same letter and timer. Sets are {0} rounds — compare scores after each round and start a new set when finished. If you leave by accident, use Return to room from the menu. + Play Multiplayer + Sign in to play diff --git a/src/FastGeography.Client/Shared/NavMenu.razor b/src/FastGeography.Client/Shared/NavMenu.razor index b57654b..07aec65 100644 --- a/src/FastGeography.Client/Shared/NavMenu.razor +++ b/src/FastGeography.Client/Shared/NavMenu.razor @@ -46,6 +46,11 @@ @L["Nav_Scoreboard"]
+
@* ── Language picker ── *@ diff --git a/src/FastGeography.Server/Controllers/AuthController.cs b/src/FastGeography.Server/Controllers/AuthController.cs index 1598ca8..3466cb5 100644 --- a/src/FastGeography.Server/Controllers/AuthController.cs +++ b/src/FastGeography.Server/Controllers/AuthController.cs @@ -45,6 +45,23 @@ public async Task Login([FromBody] LoginRequest request) return Ok(); } + [HttpPost("forgot-password")] + public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) + { + await _authService.ForgotPasswordAsync(request.Email); + return Ok(); + } + + [HttpPost("reset-password")] + public async Task ResetPassword([FromBody] ResetPasswordRequest request) + { + var result = await _authService.ResetPasswordAsync(request.Email, request.Token, request.NewPassword); + if (!result.Succeeded) + return BadRequest(new { errors = result.Errors.Select(e => e.Description) }); + + return Ok(); + } + [Authorize] [HttpPost("logout")] public async Task Logout() diff --git a/src/FastGeography.Server/Controllers/RoomsController.cs b/src/FastGeography.Server/Controllers/RoomsController.cs index 2b26eb2..54fbbfa 100644 --- a/src/FastGeography.Server/Controllers/RoomsController.cs +++ b/src/FastGeography.Server/Controllers/RoomsController.cs @@ -28,6 +28,14 @@ public IActionResult CreateRoom([FromQuery] string? lang) return Ok(new CreateRoomResponse(room.Code, room.LanguageCode)); } + [HttpGet("{code}")] + public IActionResult GetRoom(string code) + { + var room = _rooms.GetRoom(code); + if (room is null) return NotFound(); + return Ok(new RoomPreviewResponse(room.Code, room.LanguageCode)); + } + [HttpGet("{code}/exists")] [AllowAnonymous] public IActionResult RoomExists(string code) diff --git a/src/FastGeography.Server/Options/SmtpOptions.cs b/src/FastGeography.Server/Options/SmtpOptions.cs new file mode 100644 index 0000000..2c9e898 --- /dev/null +++ b/src/FastGeography.Server/Options/SmtpOptions.cs @@ -0,0 +1,13 @@ +namespace FastGeography.Server.Options; + +public sealed class SmtpOptions +{ + public const string Section = "Smtp"; + + public string Host { get; set; } = string.Empty; + public int Port { get; set; } = 587; + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string From { get; set; } = string.Empty; + public bool EnableSsl { get; set; } = true; +} diff --git a/src/FastGeography.Server/Program.cs b/src/FastGeography.Server/Program.cs index 7f1b9e2..ed023b2 100644 --- a/src/FastGeography.Server/Program.cs +++ b/src/FastGeography.Server/Program.cs @@ -47,6 +47,11 @@ public static async Task Main(string[] args) .AddDefaultTokenProviders() .AddClaimsPrincipalFactory(); + builder.Services.Configure(options => + { + options.TokenLifespan = TimeSpan.FromHours(2); + }); + builder.Services.ConfigureApplicationCookie(options => { options.Cookie.HttpOnly = true; @@ -132,6 +137,17 @@ public static async Task Main(string[] args) // Unkeyed → CatalogGeocodingService decorator: checks DB first, falls back to // "active" provider, and persists confirmed results for future lookups. builder.Services.AddSingleton(); + builder.Services.AddHttpContextAccessor(); + builder.Services.Configure(builder.Configuration.GetSection(SmtpOptions.Section)); + builder.Services.AddSingleton(sp => + { + var smtp = sp.GetRequiredService>().Value; + return string.IsNullOrWhiteSpace(smtp.Host) + ? sp.GetRequiredService() + : sp.GetRequiredService(); + }); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/FastGeography.Server/Services/AuthService.cs b/src/FastGeography.Server/Services/AuthService.cs index 9a96b62..1890691 100644 --- a/src/FastGeography.Server/Services/AuthService.cs +++ b/src/FastGeography.Server/Services/AuthService.cs @@ -4,21 +4,28 @@ namespace FastGeography.Server.Services; using FastGeography.Server.Data.Entities; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; public sealed class AuthService : IAuthService { private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly ApplicationDbContext _db; + private readonly IEmailSender _emailSender; + private readonly IHttpContextAccessor _httpContextAccessor; public AuthService( UserManager userManager, SignInManager signInManager, - ApplicationDbContext db) + ApplicationDbContext db, + IEmailSender emailSender, + IHttpContextAccessor httpContextAccessor) { _userManager = userManager; _signInManager = signInManager; _db = db; + _emailSender = emailSender; + _httpContextAccessor = httpContextAccessor; } public async Task RegisterAsync(string email, string password, string displayName) @@ -56,4 +63,45 @@ public async Task LogoutAsync() { await _signInManager.SignOutAsync(); } + + public async Task ForgotPasswordAsync(string email) + { + var user = await _userManager.FindByEmailAsync(email); + if (user is null) return; + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var encodedToken = WebEncoders.Base64UrlEncode(System.Text.Encoding.UTF8.GetBytes(token)); + + var request = _httpContextAccessor.HttpContext?.Request; + var baseUrl = request is null + ? "https://localhost" + : $"{request.Scheme}://{request.Host}"; + + var resetLink = + $"{baseUrl}/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(encodedToken)}"; + + await _emailSender.SendAsync( + email, + "Reset your FastGeography password", + $"Use this link to reset your password (valid for 2 hours):\n\n{resetLink}\n\nIf you did not request this, you can ignore this email."); + } + + public async Task ResetPasswordAsync(string email, string token, string newPassword) + { + var user = await _userManager.FindByEmailAsync(email); + if (user is null) + return IdentityResult.Failed(new IdentityError { Description = "Invalid reset request." }); + + string decodedToken; + try + { + decodedToken = System.Text.Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); + } + catch (FormatException) + { + return IdentityResult.Failed(new IdentityError { Description = "Invalid reset token." }); + } + + return await _userManager.ResetPasswordAsync(user, decodedToken, newPassword); + } } diff --git a/src/FastGeography.Server/Services/IAuthService.cs b/src/FastGeography.Server/Services/IAuthService.cs index 68f1b0b..685923e 100644 --- a/src/FastGeography.Server/Services/IAuthService.cs +++ b/src/FastGeography.Server/Services/IAuthService.cs @@ -7,4 +7,6 @@ public interface IAuthService Task RegisterAsync(string email, string password, string displayName); Task LoginAsync(string email, string password); Task LogoutAsync(); + Task ForgotPasswordAsync(string email); + Task ResetPasswordAsync(string email, string token, string newPassword); } diff --git a/src/FastGeography.Server/Services/IEmailSender.cs b/src/FastGeography.Server/Services/IEmailSender.cs new file mode 100644 index 0000000..fb6790d --- /dev/null +++ b/src/FastGeography.Server/Services/IEmailSender.cs @@ -0,0 +1,6 @@ +namespace FastGeography.Server.Services; + +public interface IEmailSender +{ + Task SendAsync(string to, string subject, string body, CancellationToken cancellationToken = default); +} diff --git a/src/FastGeography.Server/Services/LoggingEmailSender.cs b/src/FastGeography.Server/Services/LoggingEmailSender.cs new file mode 100644 index 0000000..32b081d --- /dev/null +++ b/src/FastGeography.Server/Services/LoggingEmailSender.cs @@ -0,0 +1,12 @@ +namespace FastGeography.Server.Services; + +public sealed class LoggingEmailSender(ILogger logger) : IEmailSender +{ + public Task SendAsync(string to, string subject, string body, CancellationToken cancellationToken = default) + { + logger.LogInformation( + "Email (not sent — SMTP not configured). To: {To}, Subject: {Subject}, Body: {Body}", + to, subject, body); + return Task.CompletedTask; + } +} diff --git a/src/FastGeography.Server/Services/SmtpEmailSender.cs b/src/FastGeography.Server/Services/SmtpEmailSender.cs new file mode 100644 index 0000000..bf6e405 --- /dev/null +++ b/src/FastGeography.Server/Services/SmtpEmailSender.cs @@ -0,0 +1,30 @@ +namespace FastGeography.Server.Services; + +using System.Net; +using System.Net.Mail; + +using FastGeography.Server.Options; + +using Microsoft.Extensions.Options; + +public sealed class SmtpEmailSender(IOptions options, ILogger logger) : IEmailSender +{ + private readonly SmtpOptions _options = options.Value; + + public async Task SendAsync(string to, string subject, string body, CancellationToken cancellationToken = default) + { + using var client = new SmtpClient(_options.Host, _options.Port) + { + EnableSsl = _options.EnableSsl, + Credentials = string.IsNullOrWhiteSpace(_options.UserName) + ? null + : new NetworkCredential(_options.UserName, _options.Password), + }; + + var from = string.IsNullOrWhiteSpace(_options.From) ? _options.UserName : _options.From; + using var message = new MailMessage(from, to, subject, body) { IsBodyHtml = false }; + + await client.SendMailAsync(message, cancellationToken); + logger.LogInformation("Password reset email sent to {To}", to); + } +} diff --git a/src/FastGeography.Server/appsettings.json b/src/FastGeography.Server/appsettings.json index b21a9f4..f8b3ec5 100644 --- a/src/FastGeography.Server/appsettings.json +++ b/src/FastGeography.Server/appsettings.json @@ -27,5 +27,13 @@ "BingMaps": { "ApiKey": "" } + }, + "Smtp": { + "Host": "", + "Port": 587, + "UserName": "", + "Password": "", + "From": "", + "EnableSsl": true } } diff --git a/src/FastGeography.Shared/Dtos/AuthDtos.cs b/src/FastGeography.Shared/Dtos/AuthDtos.cs index a0600b9..d79e682 100644 --- a/src/FastGeography.Shared/Dtos/AuthDtos.cs +++ b/src/FastGeography.Shared/Dtos/AuthDtos.cs @@ -4,6 +4,10 @@ public record RegisterRequest(string Email, string Password, string DisplayName) public record LoginRequest(string Email, string Password); +public record ForgotPasswordRequest(string Email); + +public record ResetPasswordRequest(string Email, string Token, string NewPassword); + public record UserInfoResponse(string UserId, string Email, string DisplayName, string PreferredLanguage = "en"); public record SetLanguageRequest(string LanguageCode); diff --git a/src/FastGeography.Shared/Dtos/MultiplayerDtos.cs b/src/FastGeography.Shared/Dtos/MultiplayerDtos.cs index 80e324d..b40f5ce 100644 --- a/src/FastGeography.Shared/Dtos/MultiplayerDtos.cs +++ b/src/FastGeography.Shared/Dtos/MultiplayerDtos.cs @@ -4,6 +4,8 @@ namespace FastGeography.Shared.Dtos; public record CreateRoomResponse(string RoomCode, string LanguageCode); +public record RoomPreviewResponse(string RoomCode, string LanguageCode); + public record RoomPlayerDto(string UserId, string DisplayName); /// One completed round row belonging to a single player (letter + their scored answers). diff --git a/tst/FastGeography.Tests.Integration/AuthTests.cs b/tst/FastGeography.Tests.Integration/AuthTests.cs index 9e51259..a5f4f2b 100644 --- a/tst/FastGeography.Tests.Integration/AuthTests.cs +++ b/tst/FastGeography.Tests.Integration/AuthTests.cs @@ -2,6 +2,7 @@ namespace FastGeography.IntegrationTests; using System.Net; using System.Net.Http.Json; +using System.Text.RegularExpressions; using FastGeography.IntegrationTests.Support; using FastGeography.Shared.Dtos; @@ -73,4 +74,87 @@ await client.PostAsJsonAsync("/api/auth/register", var resp = await client.GetAsync("/api/auth/userinfo"); Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); } + + [Fact] + public async Task ForgotPassword_UnknownEmail_Returns200_AndSendsNoEmail() + { + _fixture.EmailSender.Clear(); + var client = _fixture.NewClient(); + + var resp = await client.PostAsJsonAsync("/api/auth/forgot-password", + new ForgotPasswordRequest($"missing-{Guid.NewGuid():N}@test.com")); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Empty(_fixture.EmailSender.Sent); + } + + [Fact] + public async Task ForgotPassword_KnownEmail_Returns200_AndSendsResetLink() + { + _fixture.EmailSender.Clear(); + var client = _fixture.NewClient(); + var email = $"reset-{Guid.NewGuid():N}@test.com"; + + await client.PostAsJsonAsync("/api/auth/register", + new RegisterRequest(email, "Pass123", "ResetUser")); + + var resp = await client.PostAsJsonAsync("/api/auth/forgot-password", + new ForgotPasswordRequest(email)); + + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + Assert.Single(_fixture.EmailSender.Sent); + Assert.Equal(email, _fixture.EmailSender.Sent[0].To); + Assert.Contains("reset-password", _fixture.EmailSender.Sent[0].Body); + } + + [Fact] + public async Task ResetPassword_WithValidToken_AllowsLoginWithNewPassword() + { + _fixture.EmailSender.Clear(); + var client = _fixture.NewClient(); + var email = $"resetflow-{Guid.NewGuid():N}@test.com"; + const string oldPassword = "Pass123"; + const string newPassword = "NewPass456"; + + await client.PostAsJsonAsync("/api/auth/register", + new RegisterRequest(email, oldPassword, "ResetFlow")); + + await client.PostAsJsonAsync("/api/auth/forgot-password", new ForgotPasswordRequest(email)); + + var token = ExtractTokenFromEmail(_fixture.EmailSender.Sent.Single().Body); + Assert.NotNull(token); + + var resetResp = await client.PostAsJsonAsync("/api/auth/reset-password", + new ResetPasswordRequest(email, token, newPassword)); + Assert.Equal(HttpStatusCode.OK, resetResp.StatusCode); + + var oldLogin = await client.PostAsJsonAsync("/api/auth/login", + new LoginRequest(email, oldPassword)); + Assert.Equal(HttpStatusCode.Unauthorized, oldLogin.StatusCode); + + var newLogin = await client.PostAsJsonAsync("/api/auth/login", + new LoginRequest(email, newPassword)); + Assert.Equal(HttpStatusCode.OK, newLogin.StatusCode); + } + + [Fact] + public async Task ResetPassword_WithInvalidToken_Returns400() + { + var client = _fixture.NewClient(); + var email = $"badtoken-{Guid.NewGuid():N}@test.com"; + + await client.PostAsJsonAsync("/api/auth/register", + new RegisterRequest(email, "Pass123", "BadToken")); + + var resp = await client.PostAsJsonAsync("/api/auth/reset-password", + new ResetPasswordRequest(email, "not-a-valid-token", "NewPass456")); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + private static string? ExtractTokenFromEmail(string body) + { + var match = Regex.Match(body, @"token=([^\s&]+)"); + return match.Success ? Uri.UnescapeDataString(match.Groups[1].Value) : null; + } } diff --git a/tst/FastGeography.Tests.Integration/GameHubTests.cs b/tst/FastGeography.Tests.Integration/GameHubTests.cs index 317aca1..02f7c99 100644 --- a/tst/FastGeography.Tests.Integration/GameHubTests.cs +++ b/tst/FastGeography.Tests.Integration/GameHubTests.cs @@ -67,6 +67,11 @@ public async Task CreateRoom_ViaRestApi_ReturnsRoomCode() Assert.NotNull(room); Assert.Equal(6, room!.RoomCode.Length); + var preview = await restClient.GetFromJsonAsync($"/api/rooms/{room.RoomCode}"); + Assert.NotNull(preview); + Assert.Equal(room.RoomCode, preview!.RoomCode); + Assert.Equal("en", preview.LanguageCode); + // Verify the room exists var existsResp = await restClient.GetAsync($"/api/rooms/{room.RoomCode}/exists"); Assert.Equal(HttpStatusCode.OK, existsResp.StatusCode); diff --git a/tst/FastGeography.Tests.Integration/Support/FakeEmailSender.cs b/tst/FastGeography.Tests.Integration/Support/FakeEmailSender.cs new file mode 100644 index 0000000..071069d --- /dev/null +++ b/tst/FastGeography.Tests.Integration/Support/FakeEmailSender.cs @@ -0,0 +1,16 @@ +namespace FastGeography.IntegrationTests.Support; + +using FastGeography.Server.Services; + +public sealed class FakeEmailSender : IEmailSender +{ + public List<(string To, string Subject, string Body)> Sent { get; } = []; + + public Task SendAsync(string to, string subject, string body, CancellationToken cancellationToken = default) + { + Sent.Add((to, subject, body)); + return Task.CompletedTask; + } + + public void Clear() => Sent.Clear(); +} diff --git a/tst/FastGeography.Tests.Integration/Support/TestAppFixture.cs b/tst/FastGeography.Tests.Integration/Support/TestAppFixture.cs index c8cfd05..d1fe4a3 100644 --- a/tst/FastGeography.Tests.Integration/Support/TestAppFixture.cs +++ b/tst/FastGeography.Tests.Integration/Support/TestAppFixture.cs @@ -17,6 +17,7 @@ namespace FastGeography.IntegrationTests.Support; public sealed class TestAppFixture : IDisposable { public WebApplicationFactory Factory { get; } + public FakeEmailSender EmailSender { get; } = new(); public TestAppFixture() { @@ -31,6 +32,7 @@ public TestAppFixture() s.AddSingleton(); s.AddSingleton(); s.AddSingleton(); + s.AddSingleton(EmailSender); var toRemove = s .Where(d => d.ServiceType == typeof(DbContextOptions)