From 9d3947714a91e582e02c89df5d76b357cf7edc8f Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 7 Feb 2026 13:18:55 +0100 Subject: [PATCH] Require suggestion before entering vote phase --- API.md | 2 +- Endpoints/StateEndpoints.cs | 12 +++++++++- GameList.Tests/AdminTests.cs | 22 +++++++++---------- GameList.Tests/ResultsTests.cs | 2 +- GameList.Tests/StateTests.cs | 18 +++++++++++++++ GameList.Tests/SuggestionTests.cs | 1 + .../Support/TestClientExtensions.cs | 7 ++++++ GameList.Tests/VoteTests.cs | 6 ++--- SPEC.md | 3 ++- wwwroot/data/i18n/faq/de.md | 5 +++++ wwwroot/data/i18n/faq/en.md | 5 +++++ wwwroot/data/i18n/translations.json | 2 ++ wwwroot/js/votes-ui.js | 10 +++++++++ 13 files changed, 77 insertions(+), 18 deletions(-) diff --git a/API.md b/API.md index 424be51..242d5a4 100644 --- a/API.md +++ b/API.md @@ -13,7 +13,7 @@ GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, u GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal ## Player (requires auth) -POST /api/me/phase/next — advance caller to next phase (Suggest→Vote→Results; Results gated by resultsOpen) +POST /api/me/phase/next — advance caller to next phase (Suggest→Vote requires at least one own suggestion; Vote→Results is gated by resultsOpen) POST /api/me/phase/prev — admin-only move caller backward (Results→Vote→Suggest) ## Suggestions (requires auth + phase gating) diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 1f91b47..2319927 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -61,6 +61,17 @@ public static class StateEndpoints var reconciled = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); var next = NextPhase(player.CurrentPhase); + if (next == Phase.Vote) + { + var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id); + if (!hasSuggestions) + { + if (reconciled) + await db.SaveChangesAsync(); + return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase."); + } + } + if (next == Phase.Results && !appState.ResultsOpen) { if (reconciled) @@ -108,4 +119,3 @@ public static class StateEndpoints _ => Phase.Suggest }; } - diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index 22a187d..22f88bf 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -15,13 +15,13 @@ public class AdminTests await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // move to Vote + await admin.AdvanceToVoteAsync("Admin seed"); // move to Vote var p1 = factory.CreateClientWithCookies(); await p1.RegisterAsync("alice"); var p2 = factory.CreateClientWithCookies(); await p2.RegisterAsync("bob"); - await p2.PostAsJsonAsync("/api/me/phase/next", new { }); + await p2.AdvanceToVoteAsync("Bob seed"); var s1 = await p1.CreateSuggestionAsync("A"); await p1.PostAsJsonAsync("/api/me/phase/next", new { }); @@ -111,7 +111,7 @@ public class AdminTests var b = await player.CreateSuggestionAsync("Game B"); await player.PostAsJsonAsync("/api/me/phase/next", new { }); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await admin.AdvanceToVoteAsync("Admin link seed"); var same = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { @@ -147,7 +147,7 @@ public class AdminTests var a = await player.CreateSuggestionAsync("Game A"); var b = await player.CreateSuggestionAsync("Game B"); await player.PostAsJsonAsync("/api/me/phase/next", new { }); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await admin.AdvanceToVoteAsync("Admin unlink seed"); await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, @@ -269,7 +269,7 @@ public class AdminTests await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await admin.AdvanceToVoteAsync("Admin vote status seed"); var p1 = factory.CreateClientWithCookies(); await p1.RegisterAsync("alice"); @@ -277,7 +277,7 @@ public class AdminTests await p2.RegisterAsync("bob"); var s = await p1.CreateSuggestionAsync("Game"); await p1.PostAsJsonAsync("/api/me/phase/next", new { }); - await p2.PostAsJsonAsync("/api/me/phase/next", new { }); + await p2.AdvanceToVoteAsync("Bob vote seed"); await p1.PostAsJsonAsync("/api/votes", new { SuggestionId = s, @@ -300,7 +300,7 @@ public class AdminTests var p = factory.CreateClientWithCookies(); await p.RegisterAsync("player"); - await p.PostAsJsonAsync("/api/me/phase/next", new { }); + await p.AdvanceToVoteAsync("Player joker seed"); await p.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); var give = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = (await p.GetProfileIdAsync()) }); @@ -333,7 +333,7 @@ public class AdminTests }); Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await admin.AdvanceToVoteAsync("Admin link-phase seed"); await player.PostAsJsonAsync("/api/me/phase/next", new { }); await player.PostAsJsonAsync("/api/votes", new @@ -373,9 +373,9 @@ public class AdminTests var a = await p1.CreateSuggestionAsync("A"); var b = await p1.CreateSuggestionAsync("B"); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await admin.AdvanceToVoteAsync("Admin unfinalize seed"); await p1.PostAsJsonAsync("/api/me/phase/next", new { }); - await p2.PostAsJsonAsync("/api/me/phase/next", new { }); + await p2.AdvanceToVoteAsync("P2 unfinalize seed"); await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); await p2.PostAsJsonAsync("/api/votes/finalize", new { Final = true }); @@ -400,7 +400,7 @@ public class AdminTests await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await admin.AdvanceToVoteAsync("Admin unlink not-found seed"); var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = 9999 }); resp.EnsureSuccessStatusCode(); diff --git a/GameList.Tests/ResultsTests.cs b/GameList.Tests/ResultsTests.cs index 2576aee..10140c3 100644 --- a/GameList.Tests/ResultsTests.cs +++ b/GameList.Tests/ResultsTests.cs @@ -43,7 +43,7 @@ public class ResultsTests await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("user"); - await client.PostAsJsonAsync("/api/me/phase/next", new { }); + await client.AdvanceToVoteAsync("Results locked seed"); var resp = await client.GetAsync("/api/results"); Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode); } diff --git a/GameList.Tests/StateTests.cs b/GameList.Tests/StateTests.cs index 5e70c77..8206b78 100644 --- a/GameList.Tests/StateTests.cs +++ b/GameList.Tests/StateTests.cs @@ -91,6 +91,7 @@ public class StateTests await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("advance"); + await client.CreateSuggestionAsync("Advance game"); await factory.WithDbContextAsync(async db => { @@ -121,6 +122,7 @@ public class StateTests await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); + await admin.CreateSuggestionAsync("Admin game"); await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote await factory.WithDbContextAsync(async db => @@ -143,6 +145,7 @@ public class StateTests await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("player"); + await client.CreateSuggestionAsync("Player game"); var toVote = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { })); toVote.EnsureSuccessStatusCode(); @@ -152,6 +155,20 @@ public class StateTests Assert.Equal(HttpStatusCode.BadRequest, toResults.StatusCode); } + [Fact] + public async Task Phase_next_from_suggest_requires_at_least_one_suggestion() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("nosuggest"); + + var response = await client.PostAsJsonAsync("/api/me/phase/next", new { }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var me = await client.GetFromJsonAsync("/api/me"); + Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString()); + } + [Fact] public async Task Admin_opening_results_moves_players_to_results_phase() { @@ -199,6 +216,7 @@ public class StateTests var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); + await admin.CreateSuggestionAsync("Admin phase game"); await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { }); back.EnsureSuccessStatusCode(); diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs index 7f7737c..3a95a2c 100644 --- a/GameList.Tests/SuggestionTests.cs +++ b/GameList.Tests/SuggestionTests.cs @@ -602,6 +602,7 @@ public class SuggestionTests }); await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote + await other.CreateSuggestionAsync("Other vote seed"); await other.PostAsJsonAsync("/api/me/phase/next", new { }); await other.PostAsJsonAsync("/api/votes", new { diff --git a/GameList.Tests/Support/TestClientExtensions.cs b/GameList.Tests/Support/TestClientExtensions.cs index 276b65c..9d0aa51 100644 --- a/GameList.Tests/Support/TestClientExtensions.cs +++ b/GameList.Tests/Support/TestClientExtensions.cs @@ -49,4 +49,11 @@ internal static class TestClientExtensions var me = await client.GetFromJsonAsync("/api/me"); return Guid.Parse(me.GetProperty("id").GetString()!); } + + public static async Task AdvanceToVoteAsync(this HttpClient client, string suggestionName = "Seed game") + { + await client.CreateSuggestionAsync(suggestionName); + var response = await client.PostAsJsonAsync("/api/me/phase/next", new { }); + response.EnsureSuccessStatusCode(); + } } diff --git a/GameList.Tests/VoteTests.cs b/GameList.Tests/VoteTests.cs index e0517df..c5ed476 100644 --- a/GameList.Tests/VoteTests.cs +++ b/GameList.Tests/VoteTests.cs @@ -78,7 +78,7 @@ public class VoteTests await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("invalid"); - await client.PostAsJsonAsync("/api/me/phase/next", new { }); + await client.AdvanceToVoteAsync("Invalid seed"); var resp = await client.PostAsJsonAsync("/api/votes", new { @@ -152,7 +152,7 @@ public class VoteTests await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await admin.AdvanceToVoteAsync("Admin link seed"); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("linker"); @@ -189,7 +189,7 @@ public class VoteTests await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); - await admin.PostAsJsonAsync("/api/me/phase/next", new { }); + await admin.AdvanceToVoteAsync("Admin chain seed"); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("chain"); diff --git a/SPEC.md b/SPEC.md index e62b806..9c33856 100644 --- a/SPEC.md +++ b/SPEC.md @@ -10,12 +10,13 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow: - Single shared instance - Username/password login (cookie auth) - Admins flagged via admin key at registration -- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle) +- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion) ## Suggest Phase - Up to **5 suggestions** per player - Name required; optional genre, description, screenshot URL, YouTube URL, external game link, min/max players - Players see only their own suggestions until voting +- A player can enter Vote only after submitting at least one own suggestion - Screenshots validated as reachable images ## Vote Phase diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index df3191b..f8f2cca 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -24,6 +24,7 @@ Jeder Spieler durchläuft die Phasen unabhängig voneinander: **Vorschlagen → Abstimmen → Ergebnisse** Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen. +In der **Vorschlagsphase** bleibt **„Weiter"** deaktiviert, bis dein Konto mindestens einen eigenen Spielvorschlag hat. ## Spiele vorschlagen @@ -175,6 +176,10 @@ Stelle sicher: Warte auf die Abstimmungsphase und bitte bei Bedarf um einen Joker. +### „Füge mindestens einen Vorschlag hinzu, bevor du in die Abstimmungsphase wechselst." + +Füge mit deinem aktuellen Konto mindestens einen Spielvorschlag hinzu. Erst dann kannst du von der Vorschlagsphase in die Abstimmungsphase wechseln. + ### „Ungültiger Admin-Schlüssel." Registriere dich erneut mit dem korrekten Schlüssel vom Host ‒ oder lasse das Feld leer, um ein normales Konto zu erstellen. diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index 1e6a98e..49a12e7 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -25,6 +25,7 @@ Each player progresses independently through the phases: **Suggest → Vote → Results** Click **"Next"** to move forward. Admins can move themselves backward if needed. +In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion. ## Suggesting Games @@ -179,6 +180,10 @@ Make sure: Wait for the Vote phase and request a joker if needed. +### "Add at least one suggestion before entering the Vote phase." + +Add at least one game suggestion with your current account. Only then can you move from Suggest to Vote. + ### "Invalid admin key." Register again using the correct key from the host ‒ or leave it blank to create a regular account. diff --git a/wwwroot/data/i18n/translations.json b/wwwroot/data/i18n/translations.json index ae80914..d4bdef7 100644 --- a/wwwroot/data/i18n/translations.json +++ b/wwwroot/data/i18n/translations.json @@ -26,6 +26,7 @@ "counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}", "nav.prev": "Back", "nav.next": "Next", + "nav.addSuggestionFirst": "Add a game first", "nav.waitingForResults": "Waiting…", "nav.freezeTitle": "Ready to reveal?", "nav.freezeHint": "Moving forward will freeze your suggestions. Game names become locked; only extra details stay editable.", @@ -185,6 +186,7 @@ "counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}", "nav.prev": "Zurück", "nav.next": "Weiter", + "nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen", "nav.waitingForResults": "Warten…", "nav.freezeTitle": "Bereit zum Aufdecken?", "nav.freezeHint": "Beim Weitergehen werden deine Vorschläge eingefroren. Spielnamen werden gesperrt; nur Zusatzinfos bleiben bearbeitbar.", diff --git a/wwwroot/js/votes-ui.js b/wwwroot/js/votes-ui.js index 7e3f275..6d404f2 100644 --- a/wwwroot/js/votes-ui.js +++ b/wwwroot/js/votes-ui.js @@ -248,6 +248,16 @@ export function updatePhaseNav() { if (btn) btn.classList.toggle("hidden", !isAdmin); }); + const suggestNext = $("nav-suggest-next"); + if (suggestNext) { + const hasSuggestions = (state.mySuggestions?.length ?? 0) > 0; + const canAdvance = phase !== "Suggest" || hasSuggestions; + suggestNext.disabled = !canAdvance; + suggestNext.textContent = canAdvance + ? t("nav.next") + : t("nav.addSuggestionFirst"); + } + const voteNext = $("nav-vote-next"); if (voteNext) { const locked = !state.resultsOpen && !isAdmin;