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

View File

@@ -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
};
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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<JsonElement>("/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();

View File

@@ -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
{

View File

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

View File

@@ -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");

View File

@@ -10,12 +10,13 @@ Help a small Discord group (48 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

View File

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

View File

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

View File

@@ -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.",

View File

@@ -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;