Require suggestion before entering vote phase

This commit is contained in:
2026-02-07 13:18:55 +01:00
parent c3951b95ac
commit 9d3947714a
13 changed files with 77 additions and 18 deletions

2
API.md
View File

@@ -13,7 +13,7 @@ GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, u
GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal
## Player (requires auth) ## 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) POST /api/me/phase/prev — admin-only move caller backward (Results→Vote→Suggest)
## Suggestions (requires auth + phase gating) ## Suggestions (requires auth + phase gating)

View File

@@ -61,6 +61,17 @@ public static class StateEndpoints
var reconciled = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); var reconciled = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
var next = NextPhase(player.CurrentPhase); 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 (next == Phase.Results && !appState.ResultsOpen)
{ {
if (reconciled) if (reconciled)
@@ -108,4 +119,3 @@ public static class StateEndpoints
_ => Phase.Suggest _ => Phase.Suggest
}; };
} }

View File

@@ -15,13 +15,13 @@ public class AdminTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); 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(); var p1 = factory.CreateClientWithCookies();
await p1.RegisterAsync("alice"); await p1.RegisterAsync("alice");
var p2 = factory.CreateClientWithCookies(); var p2 = factory.CreateClientWithCookies();
await p2.RegisterAsync("bob"); await p2.RegisterAsync("bob");
await p2.PostAsJsonAsync("/api/me/phase/next", new { }); await p2.AdvanceToVoteAsync("Bob seed");
var s1 = await p1.CreateSuggestionAsync("A"); var s1 = await p1.CreateSuggestionAsync("A");
await p1.PostAsJsonAsync("/api/me/phase/next", new { }); await p1.PostAsJsonAsync("/api/me/phase/next", new { });
@@ -111,7 +111,7 @@ public class AdminTests
var b = await player.CreateSuggestionAsync("Game B"); var b = await player.CreateSuggestionAsync("Game B");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); 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 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 a = await player.CreateSuggestionAsync("Game A");
var b = await player.CreateSuggestionAsync("Game B"); var b = await player.CreateSuggestionAsync("Game B");
await player.PostAsJsonAsync("/api/me/phase/next", new { }); 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 await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
{ {
SourceSuggestionId = a, SourceSuggestionId = a,
@@ -269,7 +269,7 @@ public class AdminTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); 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(); var p1 = factory.CreateClientWithCookies();
await p1.RegisterAsync("alice"); await p1.RegisterAsync("alice");
@@ -277,7 +277,7 @@ public class AdminTests
await p2.RegisterAsync("bob"); await p2.RegisterAsync("bob");
var s = await p1.CreateSuggestionAsync("Game"); var s = await p1.CreateSuggestionAsync("Game");
await p1.PostAsJsonAsync("/api/me/phase/next", new { }); 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 await p1.PostAsJsonAsync("/api/votes", new
{ {
SuggestionId = s, SuggestionId = s,
@@ -300,7 +300,7 @@ public class AdminTests
var p = factory.CreateClientWithCookies(); var p = factory.CreateClientWithCookies();
await p.RegisterAsync("player"); 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 }); await p.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
var give = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = (await p.GetProfileIdAsync()) }); 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); 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/me/phase/next", new { });
await player.PostAsJsonAsync("/api/votes", new await player.PostAsJsonAsync("/api/votes", new
@@ -373,9 +373,9 @@ public class AdminTests
var a = await p1.CreateSuggestionAsync("A"); var a = await p1.CreateSuggestionAsync("A");
var b = await p1.CreateSuggestionAsync("B"); 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 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 p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
await p2.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(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); 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 }); var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = 9999 });
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();

View File

@@ -43,7 +43,7 @@ public class ResultsTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("user"); 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"); var resp = await client.GetAsync("/api/results");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode); Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode);
} }

View File

@@ -91,6 +91,7 @@ public class StateTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("advance"); await client.RegisterAsync("advance");
await client.CreateSuggestionAsync("Advance game");
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
@@ -121,6 +122,7 @@ public class StateTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.CreateSuggestionAsync("Admin game");
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
@@ -143,6 +145,7 @@ public class StateTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("player"); await client.RegisterAsync("player");
await client.CreateSuggestionAsync("Player game");
var toVote = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { })); var toVote = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { }));
toVote.EnsureSuccessStatusCode(); toVote.EnsureSuccessStatusCode();
@@ -152,6 +155,20 @@ public class StateTests
Assert.Equal(HttpStatusCode.BadRequest, toResults.StatusCode); 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<JsonElement>("/api/me");
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
}
[Fact] [Fact]
public async Task Admin_opening_results_moves_players_to_results_phase() public async Task Admin_opening_results_moves_players_to_results_phase()
{ {
@@ -199,6 +216,7 @@ public class StateTests
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.CreateSuggestionAsync("Admin phase game");
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote
var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { }); var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { });
back.EnsureSuccessStatusCode(); back.EnsureSuccessStatusCode();

View File

@@ -602,6 +602,7 @@ public class SuggestionTests
}); });
await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote 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/me/phase/next", new { });
await other.PostAsJsonAsync("/api/votes", new await other.PostAsJsonAsync("/api/votes", new
{ {

View File

@@ -49,4 +49,11 @@ internal static class TestClientExtensions
var me = await client.GetFromJsonAsync<JsonElement>("/api/me"); var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
return Guid.Parse(me.GetProperty("id").GetString()!); 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();
}
} }

View File

@@ -78,7 +78,7 @@ public class VoteTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies(); var client = factory.CreateClientWithCookies();
await client.RegisterAsync("invalid"); 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 var resp = await client.PostAsJsonAsync("/api/votes", new
{ {
@@ -152,7 +152,7 @@ public class VoteTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin link seed");
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("linker"); await player.RegisterAsync("linker");
@@ -189,7 +189,7 @@ public class VoteTests
await using var factory = new TestWebApplicationFactory(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); await admin.AdvanceToVoteAsync("Admin chain seed");
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("chain"); await player.RegisterAsync("chain");

View File

@@ -10,12 +10,13 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Single shared instance - Single shared instance
- Username/password login (cookie auth) - Username/password login (cookie auth)
- Admins flagged via admin key at registration - 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 ## Suggest Phase
- Up to **5 suggestions** per player - Up to **5 suggestions** per player
- Name required; optional genre, description, screenshot URL, YouTube URL, external game link, min/max players - Name required; optional genre, description, screenshot URL, YouTube URL, external game link, min/max players
- Players see only their own suggestions until voting - 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 - Screenshots validated as reachable images
## Vote Phase ## Vote Phase

View File

@@ -24,6 +24,7 @@ Jeder Spieler durchläuft die Phasen unabhängig voneinander:
**Vorschlagen → Abstimmen → Ergebnisse** **Vorschlagen → Abstimmen → Ergebnisse**
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen. 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 ## Spiele vorschlagen
@@ -175,6 +176,10 @@ Stelle sicher:
Warte auf die Abstimmungsphase und bitte bei Bedarf um einen Joker. 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." ### „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. Registriere dich erneut mit dem korrekten Schlüssel vom Host oder lasse das Feld leer, um ein normales Konto zu erstellen.

View File

@@ -25,6 +25,7 @@ Each player progresses independently through the phases:
**Suggest → Vote → Results** **Suggest → Vote → Results**
Click **"Next"** to move forward. Admins can move themselves backward if needed. 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 ## Suggesting Games
@@ -179,6 +180,10 @@ Make sure:
Wait for the Vote phase and request a joker if needed. 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." ### "Invalid admin key."
Register again using the correct key from the host or leave it blank to create a regular account. Register again using the correct key from the host or leave it blank to create a regular account.

View File

@@ -26,6 +26,7 @@
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}", "counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
"nav.prev": "Back", "nav.prev": "Back",
"nav.next": "Next", "nav.next": "Next",
"nav.addSuggestionFirst": "Add a game first",
"nav.waitingForResults": "Waiting…", "nav.waitingForResults": "Waiting…",
"nav.freezeTitle": "Ready to reveal?", "nav.freezeTitle": "Ready to reveal?",
"nav.freezeHint": "Moving forward will freeze your suggestions. Game names become locked; only extra details stay editable.", "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}", "counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
"nav.prev": "Zurück", "nav.prev": "Zurück",
"nav.next": "Weiter", "nav.next": "Weiter",
"nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen",
"nav.waitingForResults": "Warten…", "nav.waitingForResults": "Warten…",
"nav.freezeTitle": "Bereit zum Aufdecken?", "nav.freezeTitle": "Bereit zum Aufdecken?",
"nav.freezeHint": "Beim Weitergehen werden deine Vorschläge eingefroren. Spielnamen werden gesperrt; nur Zusatzinfos bleiben bearbeitbar.", "nav.freezeHint": "Beim Weitergehen werden deine Vorschläge eingefroren. Spielnamen werden gesperrt; nur Zusatzinfos bleiben bearbeitbar.",

View File

@@ -248,6 +248,16 @@ export function updatePhaseNav() {
if (btn) btn.classList.toggle("hidden", !isAdmin); 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"); const voteNext = $("nav-vote-next");
if (voteNext) { if (voteNext) {
const locked = !state.resultsOpen && !isAdmin; const locked = !state.resultsOpen && !isAdmin;