Require suggestion before entering vote phase
This commit is contained in:
2
API.md
2
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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
3
SPEC.md
3
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user