From a0277fd8be17a923d992cf92fa8e530cd2c462aa Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Fri, 13 Mar 2026 16:11:06 +0000 Subject: [PATCH 1/9] fix(gameplay): add explicit scoreboard phase (#288) --- .../src/app/api-contract-smoke.spec.ts | 26 +++++----- frontend/tests/angular-api-client.test.ts | 2 +- .../0005_alter_gamesession_status.py | 18 +++++++ fupogfakta/models.py | 1 + lobby/templates/lobby/host_screen.html | 4 +- lobby/templates/lobby/player_screen.html | 2 +- lobby/tests.py | 48 ++++++++++++++++--- lobby/views.py | 27 +++++++---- 8 files changed, 94 insertions(+), 34 deletions(-) create mode 100644 fupogfakta/migrations/0005_alter_gamesession_status.py diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 7be1d6a..429e28c 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -31,17 +31,17 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { }, host: { can_start_round: true, - can_show_question: true, - can_mix_answers: true, - can_calculate_scores: true, - can_reveal_scoreboard: true, - can_start_next_round: true, - can_finish_game: true + can_show_question: false, + can_mix_answers: false, + can_calculate_scores: false, + can_reveal_scoreboard: false, + can_start_next_round: false, + can_finish_game: false }, player: { can_join: true, - can_submit_lie: true, - can_submit_guess: true, + can_submit_lie: false, + can_submit_guess: false, can_view_final_result: false } } @@ -50,7 +50,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/scoreboard') { return { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, leaderboard: [ { id: 9, nickname: 'Maja', score: 200 }, { id: 10, nickname: 'Bo', score: 150 } @@ -104,7 +104,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { expect(body).toEqual({}); return { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, round_question: { id: 77, round_number: 1 }, events_created: 2, leaderboard: [ @@ -116,7 +116,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/rounds/next') { expect(body).toEqual({}); - return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T; + return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T; } if (url === '/lobby/sessions/ABCD12/finish') { @@ -170,8 +170,8 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { expect(session.ok).toBe(true); if (session.ok) { expect(session.data.session.code).toBe('ABCD12'); - expect(session.data.phase_view_model.host.can_start_next_round).toBe(true); - expect(session.data.phase_view_model.player.can_submit_guess).toBe(true); + expect(session.data.phase_view_model.host.can_start_next_round).toBe(false); + expect(session.data.phase_view_model.player.can_submit_guess).toBe(false); } expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true); diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index 82c9336..13a574b 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -210,7 +210,7 @@ describe('createAngularApiClient', () => { const get = vi.fn(async (url: string) => { if (url === '/lobby/sessions/ABCD12/scoreboard') { return { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, + session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, leaderboard: [ { id: 2, nickname: 'Maja', score: 11 }, { id: 3, nickname: 'Bo', score: 7 } diff --git a/fupogfakta/migrations/0005_alter_gamesession_status.py b/fupogfakta/migrations/0005_alter_gamesession_status.py new file mode 100644 index 0000000..202f7be --- /dev/null +++ b/fupogfakta/migrations/0005_alter_gamesession_status.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-03-13 16:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fupogfakta', '0004_player_session_token'), + ] + + operations = [ + migrations.AlterField( + model_name='gamesession', + name='status', + field=models.CharField(choices=[('lobby', 'Lobby'), ('lie', 'Løgnfase'), ('guess', 'Gættefase'), ('reveal', 'Reveal'), ('scoreboard', 'Scoreboard'), ('finished', 'Afsluttet')], default='lobby', max_length=16), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 6a5a39d..8670349 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -42,6 +42,7 @@ class GameSession(models.Model): LIE = "lie", "Løgnfase" GUESS = "guess", "Gættefase" REVEAL = "reveal", "Reveal" + SCOREBOARD = "scoreboard", "Scoreboard" FINISHED = "finished", "Afsluttet" host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions") diff --git a/lobby/templates/lobby/host_screen.html b/lobby/templates/lobby/host_screen.html index 49f9d01..09d3187 100644 --- a/lobby/templates/lobby/host_screen.html +++ b/lobby/templates/lobby/host_screen.html @@ -85,7 +85,7 @@ function code(){return document.getElementById("code").value.trim().toUpperCase( function rq(){return document.getElementById("roundQuestionId").value.trim();} function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}} function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}} -function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";} +function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Afsluttet";}return"Ukendt";} function hostShellRouteFromPath(){var marker="/lobby/ui/host";var path=(window.location.pathname||"").toLowerCase();var idx=path.indexOf(marker);if(idx===-1){return"";}var remainder=path.slice(idx+marker.length).replace(/^\/+|\/+$/g,"");if(!remainder){return"";}var route=remainder.split("/")[0];return HOST_SHELL_ROUTES[route]?route:"";} function expectedHostShellRoute(){return HOST_SHELL_ROUTES[currentSessionStatus]||"";} function syncHostShellRoute(){var currentRoute=hostShellRouteFromPath();var expectedRoute=expectedHostShellRoute();if(!currentRoute||!expectedRoute){hostShellRouteHint="";return;}if(currentRoute===expectedRoute){hostShellRouteHint="";return;}var nextPath="/lobby/ui/host/"+expectedRoute;window.history.replaceState(null,"",nextPath);hostShellRouteHint="Deep-link route guard: omdirigeret fra /"+currentRoute+" til /"+expectedRoute+" for fase "+phaseLabel(currentSessionStatus)+".";} @@ -104,7 +104,7 @@ function updateCreateSessionState(){var btn=document.getElementById("createSessi function updatePhaseStatus(){var el=document.getElementById("phaseStatus");syncHostShellRoute();if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";} function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";} -function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";} +function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";} async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session){hydrateHostCriticalView(d);}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}if(hostShellFatalError){clearHostShellFatalError();}saveHostContext();return d;} function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});} diff --git a/lobby/templates/lobby/player_screen.html b/lobby/templates/lobby/player_screen.html index b0f3fc6..fa7d95b 100644 --- a/lobby/templates/lobby/player_screen.html +++ b/lobby/templates/lobby/player_screen.html @@ -105,7 +105,7 @@ function clearPlayerShellFatalError(){playerShellFatalError=false;playerShellRec function recoverPlayerShell(mode){if(playerShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}playerShellRecoverInFlight=true;updatePlayerShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearPlayerShellFatalError();return result;}).catch(function(err){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();throw err;});} function pid(){return document.getElementById("playerId").value.trim();} function rq(){return document.getElementById("roundQuestionId").value.trim();} -function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";} +function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Afsluttet";}return"Ukendt";} function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";} function savePlayerContext(){try{localStorage.setItem(PLAYER_CONTEXT_KEY,JSON.stringify({code:code(),nickname:document.getElementById("nickname").value.trim(),player_id:pid(),session_token:document.getElementById("sessionToken").value.trim(),round_question_id:rq(),auto_refresh:playerAutoRefreshEnabled}));}catch(_e){}} function loadPlayerContext(){try{var raw=localStorage.getItem(PLAYER_CONTEXT_KEY);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}} diff --git a/lobby/tests.py b/lobby/tests.py index 6b156f3..3938c6f 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -779,9 +779,12 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.status_code, 200) payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) + self.session.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) + def test_reveal_scoreboard_requires_host(self): self.client.login(username="other_reveal", password="secret123") @@ -795,8 +798,9 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error"], "Only host can view scoreboard") - def test_host_can_finish_game_from_reveal(self): + def test_host_can_finish_game_from_scoreboard(self): self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) response = self.client.post( reverse( @@ -840,10 +844,11 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Game can only be finished from reveal phase") + self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase") - def test_host_can_start_next_round_from_reveal(self): + def test_host_can_start_next_round_from_scoreboard(self): self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) response = self.client.post( reverse( @@ -861,6 +866,26 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(self.session.status, GameSession.Status.LOBBY) self.assertEqual(self.session.current_round, 2) + def test_reveal_scoreboard_allows_repeated_reads_from_scoreboard_phase(self): + self.client.login(username="host_reveal", password="secret123") + + first_response = self.client.get( + reverse( + "lobby:reveal_scoreboard", + kwargs={"code": self.session.code}, + ) + ) + second_response = self.client.get( + reverse( + "lobby:reveal_scoreboard", + kwargs={"code": self.session.code}, + ) + ) + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) + def test_start_next_round_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") self.session.status = GameSession.Status.GUESS @@ -874,7 +899,7 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Next round can only start from reveal phase") + self.assertEqual(response.json()["error"], "Next round can only start from scoreboard phase") class UiScreenTests(TestCase): def setUp(self): @@ -1218,10 +1243,19 @@ class SessionDetailPhaseViewModelTests(TestCase): reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() reveal_phase = reveal_payload["phase_view_model"] self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"]) - self.assertTrue(reveal_phase["host"]["can_start_next_round"]) - self.assertTrue(reveal_phase["host"]["can_finish_game"]) + self.assertFalse(reveal_phase["host"]["can_start_next_round"]) + self.assertFalse(reveal_phase["host"]["can_finish_game"]) self.assertFalse(reveal_phase["player"]["can_view_final_result"]) + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() + scoreboard_phase = scoreboard_payload["phase_view_model"] + self.assertFalse(scoreboard_phase["host"]["can_reveal_scoreboard"]) + self.assertTrue(scoreboard_phase["host"]["can_start_next_round"]) + self.assertTrue(scoreboard_phase["host"]["can_finish_game"]) + self.assertFalse(scoreboard_phase["player"]["can_view_final_result"]) + self.session.status = GameSession.Status.FINISHED self.session.save(update_fields=["status"]) finished_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json() diff --git a/lobby/views.py b/lobby/views.py index 0282fb2..8b94fbe 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -67,6 +67,7 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou in_lie = status == GameSession.Status.LIE in_guess = status == GameSession.Status.GUESS in_reveal = status == GameSession.Status.REVEAL + in_scoreboard = status == GameSession.Status.SCOREBOARD in_finished = status == GameSession.Status.FINISHED min_players_reached = players_count >= 3 @@ -88,8 +89,8 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou "can_mix_answers": in_lie or in_guess, "can_calculate_scores": in_guess, "can_reveal_scoreboard": in_reveal, - "can_start_next_round": in_reveal, - "can_finish_game": in_reveal, + "can_start_next_round": in_scoreboard, + "can_finish_game": in_scoreboard, }, "player": { "can_join": status in JOINABLE_STATUSES, @@ -710,8 +711,14 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: if session.host_id != request.user.id: return JsonResponse({"error": "Only host can view scoreboard"}, status=403) - if session.status != GameSession.Status.REVEAL: - return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400) + with transaction.atomic(): + locked_session = GameSession.objects.select_for_update().get(pk=session.pk) + if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}: + return JsonResponse({"error": "Scoreboard is only available in reveal or scoreboard phase"}, status=400) + + if locked_session.status == GameSession.Status.REVEAL: + locked_session.status = GameSession.Status.SCOREBOARD + locked_session.save(update_fields=["status"]) leaderboard = list( Player.objects.filter(session=session) @@ -723,8 +730,8 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: { "session": { "code": session.code, - "status": session.status, - "current_round": session.current_round, + "status": locked_session.status, + "current_round": locked_session.current_round, }, "leaderboard": leaderboard, } @@ -746,8 +753,8 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.REVEAL: - return JsonResponse({"error": "Next round can only start from reveal phase"}, status=400) + if locked_session.status != GameSession.Status.SCOREBOARD: + return JsonResponse({"error": "Next round can only start from scoreboard phase"}, status=400) locked_session.current_round += 1 locked_session.status = GameSession.Status.LOBBY @@ -778,8 +785,8 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.REVEAL: - return JsonResponse({"error": "Game can only be finished from reveal phase"}, status=400) + if locked_session.status != GameSession.Status.SCOREBOARD: + return JsonResponse({"error": "Game can only be finished from scoreboard phase"}, status=400) locked_session.status = GameSession.Status.FINISHED locked_session.save(update_fields=["status"]) -- 2.39.5 From 638c9452d86b72c1944c69611600816eae655118 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Fri, 13 Mar 2026 18:04:41 +0000 Subject: [PATCH 2/9] fix(spa): register scoreboard host shell route --- lobby/templates/lobby/host_screen.html | 2 +- lobby/tests.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lobby/templates/lobby/host_screen.html b/lobby/templates/lobby/host_screen.html index 09d3187..b7a0d42 100644 --- a/lobby/templates/lobby/host_screen.html +++ b/lobby/templates/lobby/host_screen.html @@ -65,7 +65,7 @@ var lastRefreshAtLabel=""; var lastRefreshFailed=false; var sessionDetailInFlight=false; var hostShellRouteHint=""; -var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",finished:"finished"}; +var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",scoreboard:"scoreboard",finished:"finished"}; var hostShellFatalError=false; var hostShellRecoverInFlight=false; var hostCriticalHydrated=false; diff --git a/lobby/tests.py b/lobby/tests.py index 3938c6f..9ef6803 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1115,6 +1115,17 @@ class UiScreenTests(TestCase): self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"") self.assertContains(response, "data-wpp-shell-kind=\"host\"") + def test_host_screen_template_registers_scoreboard_shell_route(self): + self.client.login(username="host_ui", password="secret123") + + response = self.client.get(reverse("lobby:host_screen")) + + self.assertEqual(response.status_code, 200) + self.assertContains( + response, + 'var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",scoreboard:"scoreboard",finished:"finished"};', + ) + @override_settings(USE_SPA_UI=True) def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self): self.client.login(username="host_ui", password="secret123") -- 2.39.5 From dc0c203f7f38b8c025c83fdff1ec185a2b41a692 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 07:43:38 +0000 Subject: [PATCH 3/9] fix(gameplay): align scoreboard API contract --- lobby/tests.py | 21 ++++++++++++++++++--- lobby/views.py | 5 +++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index 9ef6803..2495309 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -100,6 +100,21 @@ class LobbyFlowTests(TestCase): self.assertEqual(response.status_code, 409) self.assertEqual(response.json()["error"], "Nickname already taken") + def test_player_can_join_during_scoreboard_phase(self): + session = GameSession.objects.create(host=self.host, code="ZXCV97", status=GameSession.Status.SCOREBOARD) + + response = self.client.post( + reverse("lobby:join_session"), + data={"code": "ZXCV97", "nickname": "Kai"}, + content_type="application/json", + ) + + self.assertEqual(response.status_code, 201) + body = response.json() + self.assertEqual(body["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(body["player"]["nickname"], "Kai") + self.assertTrue(Player.objects.filter(session=session, nickname="Kai").exists()) + def test_join_rejects_non_joinable_session(self): GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED) @@ -686,7 +701,7 @@ class ScoreCalculationTests(TestCase): self.player_two = Player.objects.create(session=self.session, nickname="Mads") self.player_three = Player.objects.create(session=self.session, nickname="Nora") - def test_host_can_calculate_scores_and_transition_to_reveal(self): + def test_host_can_calculate_scores_and_transition_to_scoreboard(self): Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) Guess.objects.create( round_question=self.round_question, @@ -713,7 +728,7 @@ class ScoreCalculationTests(TestCase): self.assertEqual(response.status_code, 200) payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(payload["events_created"], 2) self.player_one.refresh_from_db() @@ -722,7 +737,7 @@ class ScoreCalculationTests(TestCase): self.assertEqual(self.player_one.score, 5) self.assertEqual(self.player_three.score, 4) - self.assertEqual(self.session.status, GameSession.Status.REVEAL) + self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) def test_calculate_scores_requires_host(self): self.client.login(username="other_score", password="secret123") diff --git a/lobby/views.py b/lobby/views.py index 8b94fbe..73c17ee 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -30,6 +30,7 @@ JOINABLE_STATUSES = { GameSession.Status.LIE, GameSession.Status.GUESS, GameSession.Status.REVEAL, + GameSession.Status.SCOREBOARD, } ERROR_CODES = lobby_i18n_errors() @@ -896,7 +897,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> ScoreEvent.objects.bulk_create(score_events) - locked_session.status = GameSession.Status.REVEAL + locked_session.status = GameSession.Status.SCOREBOARD locked_session.save(update_fields=["status"]) leaderboard = list( @@ -909,7 +910,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> { "session": { "code": session.code, - "status": GameSession.Status.REVEAL, + "status": GameSession.Status.SCOREBOARD, "current_round": session.current_round, }, "round_question": { -- 2.39.5 From 558f8fe2453a6e96d565bf56d6b680f8d05f04ef Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 15 Mar 2026 07:55:48 +0000 Subject: [PATCH 4/9] fix(gameplay): restore reveal before scoreboard --- .../src/app/api-contract-smoke.spec.ts | 8 ++++++- lobby/tests.py | 22 ++++++++++++++----- lobby/views.py | 13 +++++------ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 429e28c..b630734 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -104,9 +104,15 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') { expect(body).toEqual({}); return { - session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 }, + session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, round_question: { id: 77, round_number: 1 }, events_created: 2, + reveal: { + round_question_id: 77, + correct_answer: 'A', + lies: [], + guesses: [] + }, leaderboard: [ { id: 9, nickname: 'Maja', score: 200 }, { id: 10, nickname: 'Bo', score: 150 } diff --git a/lobby/tests.py b/lobby/tests.py index 2495309..d11b541 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -701,7 +701,7 @@ class ScoreCalculationTests(TestCase): self.player_two = Player.objects.create(session=self.session, nickname="Mads") self.player_three = Player.objects.create(session=self.session, nickname="Nora") - def test_host_can_calculate_scores_and_transition_to_scoreboard(self): + def test_host_can_calculate_scores_and_transition_to_reveal(self): Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True) Guess.objects.create( round_question=self.round_question, @@ -728,7 +728,7 @@ class ScoreCalculationTests(TestCase): self.assertEqual(response.status_code, 200) payload = response.json() - self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) self.assertEqual(payload["events_created"], 2) self.player_one.refresh_from_db() @@ -737,7 +737,7 @@ class ScoreCalculationTests(TestCase): self.assertEqual(self.player_one.score, 5) self.assertEqual(self.player_three.score, 4) - self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) + self.assertEqual(self.session.status, GameSession.Status.REVEAL) def test_calculate_scores_requires_host(self): self.client.login(username="other_score", password="secret123") @@ -813,6 +813,16 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error"], "Only host can view scoreboard") + def test_reveal_scoreboard_rejects_scoreboard_phase(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + self.client.login(username="host_reveal", password="secret123") + + response = self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"], "Scoreboard is only available in reveal phase") + def test_host_can_finish_game_from_scoreboard(self): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) @@ -881,7 +891,7 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(self.session.status, GameSession.Status.LOBBY) self.assertEqual(self.session.current_round, 2) - def test_reveal_scoreboard_allows_repeated_reads_from_scoreboard_phase(self): + def test_reveal_scoreboard_rejects_repeated_reads_after_promotion(self): self.client.login(username="host_reveal", password="secret123") first_response = self.client.get( @@ -898,8 +908,8 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(first_response.status_code, 200) - self.assertEqual(second_response.status_code, 200) - self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(second_response.status_code, 400) + self.assertEqual(second_response.json()["error"], "Scoreboard is only available in reveal phase") def test_start_next_round_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") diff --git a/lobby/views.py b/lobby/views.py index 73c17ee..0aa1ea6 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -714,12 +714,11 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}: - return JsonResponse({"error": "Scoreboard is only available in reveal or scoreboard phase"}, status=400) + if locked_session.status != GameSession.Status.REVEAL: + return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400) - if locked_session.status == GameSession.Status.REVEAL: - locked_session.status = GameSession.Status.SCOREBOARD - locked_session.save(update_fields=["status"]) + locked_session.status = GameSession.Status.SCOREBOARD + locked_session.save(update_fields=["status"]) leaderboard = list( Player.objects.filter(session=session) @@ -897,7 +896,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> ScoreEvent.objects.bulk_create(score_events) - locked_session.status = GameSession.Status.SCOREBOARD + locked_session.status = GameSession.Status.REVEAL locked_session.save(update_fields=["status"]) leaderboard = list( @@ -910,7 +909,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> { "session": { "code": session.code, - "status": GameSession.Status.SCOREBOARD, + "status": GameSession.Status.REVEAL, "current_round": session.current_round, }, "round_question": { -- 2.39.5 From 97b366d1e9ee487f776ada96fd9a8f96e9486db4 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 15 Mar 2026 08:05:21 +0000 Subject: [PATCH 5/9] fix(gameplay): make scoreboard reads idempotent --- lobby/tests.py | 16 ++++++++++------ lobby/views.py | 9 +++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/lobby/tests.py b/lobby/tests.py index d11b541..5b01b5d 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -813,15 +813,17 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error"], "Only host can view scoreboard") - def test_reveal_scoreboard_rejects_scoreboard_phase(self): + def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self): self.session.status = GameSession.Status.SCOREBOARD self.session.save(update_fields=["status"]) self.client.login(username="host_reveal", password="secret123") response = self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Scoreboard is only available in reveal phase") + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) def test_host_can_finish_game_from_scoreboard(self): self.client.login(username="host_reveal", password="secret123") @@ -891,7 +893,7 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(self.session.status, GameSession.Status.LOBBY) self.assertEqual(self.session.current_round, 2) - def test_reveal_scoreboard_rejects_repeated_reads_after_promotion(self): + def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self): self.client.login(username="host_reveal", password="secret123") first_response = self.client.get( @@ -908,8 +910,10 @@ class RevealRoundFlowTests(TestCase): ) self.assertEqual(first_response.status_code, 200) - self.assertEqual(second_response.status_code, 400) - self.assertEqual(second_response.json()["error"], "Scoreboard is only available in reveal phase") + self.assertEqual(second_response.status_code, 200) + self.assertEqual(first_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) + self.assertEqual([item["nickname"] for item in second_response.json()["leaderboard"]], ["Luna", "Mads"]) def test_start_next_round_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") diff --git a/lobby/views.py b/lobby/views.py index 0aa1ea6..e8ba0f1 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -714,11 +714,12 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) - if locked_session.status != GameSession.Status.REVEAL: - return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400) + if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}: + return JsonResponse({"error": "Scoreboard is only available in reveal/scoreboard phase"}, status=400) - locked_session.status = GameSession.Status.SCOREBOARD - locked_session.save(update_fields=["status"]) + if locked_session.status == GameSession.Status.REVEAL: + locked_session.status = GameSession.Status.SCOREBOARD + locked_session.save(update_fields=["status"]) leaderboard = list( Player.objects.filter(session=session) -- 2.39.5 From 8fa39adc2b19f1760f48fbdb5206fe5c743b84e1 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 15 Mar 2026 08:52:35 +0000 Subject: [PATCH 6/9] fix(gameplay): restore scoreboard phase error contract --- lobby/tests.py | 80 +++++++++++++++++++++++++++++++++++------- lobby/views.py | 65 ++++++++++++++++++++++++++++------ realtime/broadcast.py | 20 +++++++++++ shared/i18n/lobby.json | 52 +++++++++++++++++++++++++-- 4 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 realtime/broadcast.py diff --git a/lobby/tests.py b/lobby/tests.py index 5b01b5d..7850a30 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -782,7 +782,8 @@ class RevealRoundFlowTests(TestCase): self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9) self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3) - def test_host_can_get_reveal_scoreboard(self): + @patch("lobby.views.sync_broadcast_phase_event") + def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") response = self.client.get( @@ -796,6 +797,17 @@ class RevealRoundFlowTests(TestCase): payload = response.json() self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) + mock_sync_broadcast_phase_event.assert_called_once_with( + self.session.code, + "phase.scoreboard", + { + "leaderboard": [ + {"id": self.player_one.id, "nickname": "Luna", "score": 9}, + {"id": self.player_two.id, "nickname": "Mads", "score": 3}, + ], + "current_round": 1, + }, + ) self.session.refresh_from_db() self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD) @@ -807,11 +819,16 @@ class RevealRoundFlowTests(TestCase): reverse( "lobby:reveal_scoreboard", kwargs={"code": self.session.code}, - ) + ), + HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Only host can view scoreboard") + self.assertEqual(response.json(), { + "error": "Only host can view scoreboard", + "error_code": "host_only_view_scoreboard", + "locale": "en", + }) def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self): self.session.status = GameSession.Status.SCOREBOARD @@ -825,7 +842,8 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"]) - def test_host_can_finish_game_from_scoreboard(self): + @patch("lobby.views.sync_broadcast_phase_event") + def test_host_can_finish_game_from_scoreboard(self, _mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) @@ -852,11 +870,16 @@ class RevealRoundFlowTests(TestCase): reverse( "lobby:finish_game", kwargs={"code": self.session.code}, - ) + ), + HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"], "Only host can finish game") + self.assertEqual(response.json(), { + "error": "Kun værten kan afslutte spillet", + "error_code": "host_only_finish_game", + "locale": "da", + }) def test_finish_game_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") @@ -867,13 +890,19 @@ class RevealRoundFlowTests(TestCase): reverse( "lobby:finish_game", kwargs={"code": self.session.code}, - ) + ), + HTTP_ACCEPT_LANGUAGE="fr", ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase") + self.assertEqual(response.json(), { + "error": "Game can only be finished from scoreboard phase", + "error_code": "finish_game_invalid_phase", + "locale": "en", + }) - def test_host_can_start_next_round_from_scoreboard(self): + @patch("lobby.views.sync_broadcast_phase_event") + def test_host_can_start_next_round_from_scoreboard(self, _mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) @@ -893,7 +922,28 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(self.session.status, GameSession.Status.LOBBY) self.assertEqual(self.session.current_round, 2) - def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self): + def test_start_next_round_requires_host(self): + self.session.status = GameSession.Status.SCOREBOARD + self.session.save(update_fields=["status"]) + self.client.login(username="other_reveal", password="secret123") + + response = self.client.post( + reverse( + "lobby:start_next_round", + kwargs={"code": self.session.code}, + ), + HTTP_ACCEPT_LANGUAGE="fr", + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), { + "error": "Only host can start next round", + "error_code": "host_only_start_next_round", + "locale": "en", + }) + + @patch("lobby.views.sync_broadcast_phase_event") + def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self, mock_sync_broadcast_phase_event): self.client.login(username="host_reveal", password="secret123") first_response = self.client.get( @@ -914,6 +964,7 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(first_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.SCOREBOARD) self.assertEqual([item["nickname"] for item in second_response.json()["leaderboard"]], ["Luna", "Mads"]) + self.assertEqual(mock_sync_broadcast_phase_event.call_count, 1) def test_start_next_round_rejects_wrong_phase(self): self.client.login(username="host_reveal", password="secret123") @@ -924,11 +975,16 @@ class RevealRoundFlowTests(TestCase): reverse( "lobby:start_next_round", kwargs={"code": self.session.code}, - ) + ), + HTTP_ACCEPT_LANGUAGE="da", ) self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()["error"], "Next round can only start from scoreboard phase") + self.assertEqual(response.json(), { + "error": "Næste runde kan kun starte fra scoreboard-fasen", + "error_code": "next_round_invalid_phase", + "locale": "da", + }) class UiScreenTests(TestCase): def setUp(self): diff --git a/lobby/views.py b/lobby/views.py index e8ba0f1..7bce5eb 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -19,6 +19,7 @@ from fupogfakta.models import ( RoundQuestion, ScoreEvent, ) +from realtime.broadcast import sync_broadcast_phase_event from .i18n import api_error, lobby_i18n_errors @@ -707,17 +708,30 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + request, + key=ERROR_CODES.get("session_not_found", "session_not_found"), + status=404, + ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can view scoreboard"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_view_scoreboard", "host_only_view_scoreboard"), + status=403, + ) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}: - return JsonResponse({"error": "Scoreboard is only available in reveal/scoreboard phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("scoreboard_invalid_phase", "scoreboard_invalid_phase"), + status=400, + ) - if locked_session.status == GameSession.Status.REVEAL: + promoted_to_scoreboard = locked_session.status == GameSession.Status.REVEAL + if promoted_to_scoreboard: locked_session.status = GameSession.Status.SCOREBOARD locked_session.save(update_fields=["status"]) @@ -727,6 +741,13 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: .values("id", "nickname", "score") ) + if promoted_to_scoreboard: + sync_broadcast_phase_event( + session.code, + "phase.scoreboard", + {"leaderboard": list(leaderboard), "current_round": locked_session.current_round}, + ) + return JsonResponse( { "session": { @@ -747,15 +768,27 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + request, + key=ERROR_CODES.get("session_not_found", "session_not_found"), + status=404, + ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can start next round"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_start_next_round", "host_only_start_next_round"), + status=403, + ) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.SCOREBOARD: - return JsonResponse({"error": "Next round can only start from scoreboard phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("next_round_invalid_phase", "next_round_invalid_phase"), + status=400, + ) locked_session.current_round += 1 locked_session.status = GameSession.Status.LOBBY @@ -779,15 +812,27 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: - return JsonResponse({"error": "Session not found"}, status=404) + return api_error( + request, + key=ERROR_CODES.get("session_not_found", "session_not_found"), + status=404, + ) if session.host_id != request.user.id: - return JsonResponse({"error": "Only host can finish game"}, status=403) + return api_error( + request, + key=ERROR_CODES.get("host_only_finish_game", "host_only_finish_game"), + status=403, + ) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.SCOREBOARD: - return JsonResponse({"error": "Game can only be finished from scoreboard phase"}, status=400) + return api_error( + request, + key=ERROR_CODES.get("finish_game_invalid_phase", "finish_game_invalid_phase"), + status=400, + ) locked_session.status = GameSession.Status.FINISHED locked_session.save(update_fields=["status"]) diff --git a/realtime/broadcast.py b/realtime/broadcast.py new file mode 100644 index 0000000..95d1a5d --- /dev/null +++ b/realtime/broadcast.py @@ -0,0 +1,20 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + + +async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: + """Send a phase event to all WebSocket clients connected to a game session.""" + channel_layer = get_channel_layer() + group_name = f"game_{session_code.upper()}" + await channel_layer.group_send( + group_name, + { + "type": "phase_event", + "payload": {"type": event_type, **payload}, + }, + ) + + +def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: + """Sync wrapper for calling broadcast_phase_event from synchronous Django views.""" + async_to_sync(broadcast_phase_event)(session_code, event_type, payload) diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index c7cf10f..ab47fb8 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -39,6 +39,18 @@ "unknown": { "en": "Action failed. Refresh status and try again.", "da": "Handlingen fejlede. Opdater status og prøv igen." + }, + "scoreboard_failed": { + "en": "Could not load scoreboard. Refresh the session and try again.", + "da": "Kunne ikke indlæse scoreboardet. Opdater sessionen og prøv igen." + }, + "next_round_failed": { + "en": "Could not start next round. Refresh the session and try again.", + "da": "Kunne ikke starte næste runde. Opdater sessionen og prøv igen." + }, + "finish_game_failed": { + "en": "Could not finish game. Refresh the session and try again.", + "da": "Kunne ikke afslutte spillet. Opdater sessionen og prøv igen." } }, "ui": { @@ -296,7 +308,13 @@ "not_enough_answers_to_mix": "not_enough_answers_to_mix", "host_only_start_round": "host_only_start_round", "host_only_show_question": "host_only_show_question", - "host_only_mix_answers": "host_only_mix_answers" + "host_only_mix_answers": "host_only_mix_answers", + "host_only_view_scoreboard": "host_only_view_scoreboard", + "scoreboard_invalid_phase": "scoreboard_invalid_phase", + "host_only_start_next_round": "host_only_start_next_round", + "next_round_invalid_phase": "next_round_invalid_phase", + "host_only_finish_game": "host_only_finish_game", + "finish_game_invalid_phase": "finish_game_invalid_phase" }, "errors": { "session_code_required": { @@ -378,6 +396,30 @@ "host_only_mix_answers": { "en": "Only host can mix answers", "da": "Kun værten kan blande svar" + }, + "host_only_view_scoreboard": { + "en": "Only host can view scoreboard", + "da": "Kun værten kan se scoreboardet" + }, + "scoreboard_invalid_phase": { + "en": "Scoreboard is only available in reveal/scoreboard phase", + "da": "Scoreboard er kun tilgængeligt i reveal-/scoreboard-fasen" + }, + "host_only_start_next_round": { + "en": "Only host can start next round", + "da": "Kun værten kan starte næste runde" + }, + "next_round_invalid_phase": { + "en": "Next round can only start from scoreboard phase", + "da": "Næste runde kan kun starte fra scoreboard-fasen" + }, + "host_only_finish_game": { + "en": "Only host can finish game", + "da": "Kun værten kan afslutte spillet" + }, + "finish_game_invalid_phase": { + "en": "Game can only be finished from scoreboard phase", + "da": "Spillet kan kun afsluttes fra scoreboard-fasen" } } }, @@ -416,7 +458,13 @@ "no_available_questions": "start_round_failed", "mix_answers_invalid_phase": "start_round_failed", "round_question_not_found": "start_round_failed", - "not_enough_answers_to_mix": "start_round_failed" + "not_enough_answers_to_mix": "start_round_failed", + "host_only_view_scoreboard": "scoreboard_failed", + "scoreboard_invalid_phase": "scoreboard_failed", + "host_only_start_next_round": "next_round_failed", + "next_round_invalid_phase": "next_round_failed", + "host_only_finish_game": "finish_game_failed", + "finish_game_invalid_phase": "finish_game_failed" } } } -- 2.39.5 From be38fe6ac2f00831ef34e9b898ec03e34663b4d6 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 15 Mar 2026 09:08:13 +0000 Subject: [PATCH 7/9] fix(realtime): tolerate missing scoreboard channel layer --- realtime/broadcast.py | 33 ++++++++++++++++++++++++--------- realtime/tests.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/realtime/broadcast.py b/realtime/broadcast.py index 95d1a5d..aa3d11a 100644 --- a/realtime/broadcast.py +++ b/realtime/broadcast.py @@ -1,20 +1,35 @@ from asgiref.sync import async_to_sync +from channels.exceptions import InvalidChannelLayerError from channels.layers import get_channel_layer +from redis.exceptions import ConnectionError as RedisConnectionError async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: """Send a phase event to all WebSocket clients connected to a game session.""" - channel_layer = get_channel_layer() + try: + channel_layer = get_channel_layer() + except InvalidChannelLayerError: + return + + if channel_layer is None: + return + group_name = f"game_{session_code.upper()}" - await channel_layer.group_send( - group_name, - { - "type": "phase_event", - "payload": {"type": event_type, **payload}, - }, - ) + try: + await channel_layer.group_send( + group_name, + { + "type": "phase_event", + "payload": {"type": event_type, **payload}, + }, + ) + except (InvalidChannelLayerError, RedisConnectionError): + return def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: """Sync wrapper for calling broadcast_phase_event from synchronous Django views.""" - async_to_sync(broadcast_phase_event)(session_code, event_type, payload) + try: + async_to_sync(broadcast_phase_event)(session_code, event_type, payload) + except (InvalidChannelLayerError, RedisConnectionError): + return diff --git a/realtime/tests.py b/realtime/tests.py index 7ce503c..41229eb 100644 --- a/realtime/tests.py +++ b/realtime/tests.py @@ -1,3 +1,27 @@ -from django.test import TestCase +from unittest.mock import Mock, patch -# Create your tests here. +from channels.exceptions import InvalidChannelLayerError +from django.test import SimpleTestCase + +from realtime.broadcast import broadcast_phase_event, sync_broadcast_phase_event + + +class BroadcastPhaseEventTests(SimpleTestCase): + @patch("realtime.broadcast.get_channel_layer", return_value=None) + async def test_broadcast_phase_event_noops_without_channel_layer(self, _mock_get_channel_layer): + await broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) + + @patch("realtime.broadcast.async_to_sync") + def test_sync_broadcast_phase_event_noops_when_channel_layer_is_unavailable(self, mock_async_to_sync): + mock_async_to_sync.return_value.side_effect = InvalidChannelLayerError("missing channel layer") + + sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) + + @patch("realtime.broadcast.async_to_sync") + def test_sync_broadcast_phase_event_still_broadcasts_when_channel_layer_exists(self, mock_async_to_sync): + sender = Mock() + mock_async_to_sync.return_value = sender + + sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) + + sender.assert_called_once_with("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) -- 2.39.5 From 62174135b8d6e0cd8e041f28383cbf174a75b26d Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 15 Mar 2026 09:49:55 +0000 Subject: [PATCH 8/9] fix(ci): remove duplicate realtime import --- lobby/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lobby/views.py b/lobby/views.py index 4ecf866..dbfb20a 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -21,8 +21,6 @@ from fupogfakta.models import ( ) from realtime.broadcast import sync_broadcast_phase_event -from realtime.broadcast import sync_broadcast_phase_event - from .i18n import api_error SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" -- 2.39.5 From 5c9d29a3a7c26577fbe7e201b7c0b600bc367fc4 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 15 Mar 2026 10:32:10 +0000 Subject: [PATCH 9/9] fix(realtime): restore websocket phase event type --- realtime/consumers.py | 4 +++- realtime/tests.py | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/realtime/consumers.py b/realtime/consumers.py index b52c088..a3149c7 100644 --- a/realtime/consumers.py +++ b/realtime/consumers.py @@ -58,4 +58,6 @@ class GameConsumer(AsyncJsonWebsocketConsumer): async def phase_event(self, event): """Forward any phase_event broadcast to the WebSocket client.""" - await self.send_json(event["payload"]) + payload = dict(event.get("payload") or {}) + payload["type"] = event["event_type"] + await self.send_json(payload) diff --git a/realtime/tests.py b/realtime/tests.py index 21b3d64..e87677e 100644 --- a/realtime/tests.py +++ b/realtime/tests.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from channels.exceptions import InvalidChannelLayerError from django.contrib.auth import get_user_model @@ -13,6 +13,7 @@ except Exception: # pragma: no cover - optional test dependency from fupogfakta.models import GameSession, Player from partyhub.asgi import application from realtime.broadcast import broadcast_phase_event, sync_broadcast_phase_event +from realtime.consumers import GameConsumer User = get_user_model() @@ -38,6 +39,26 @@ class BroadcastPhaseEventTests(SimpleTestCase): sender.assert_called_once_with("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) +class GameConsumerPhaseEventTests(SimpleTestCase): + async def test_phase_event_restores_external_type_field(self): + consumer = GameConsumer() + consumer.send_json = AsyncMock() + + await consumer.phase_event( + { + "event_type": "phase.test_event", + "payload": {"hello": "world"}, + } + ) + + consumer.send_json.assert_awaited_once_with( + { + "type": "phase.test_event", + "hello": "world", + } + ) + + @unittest.skipIf(WebsocketCommunicator is None, "channels.testing dependencies unavailable") class GameConsumerConnectTest(TestCase): def setUp(self): -- 2.39.5