fix(gameplay): explicit scoreboard phase after reveal (#288) #291
@@ -31,17 +31,17 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
|||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
can_start_round: true,
|
can_start_round: true,
|
||||||
can_show_question: true,
|
can_show_question: false,
|
||||||
can_mix_answers: true,
|
can_mix_answers: false,
|
||||||
can_calculate_scores: true,
|
can_calculate_scores: false,
|
||||||
can_reveal_scoreboard: true,
|
can_reveal_scoreboard: false,
|
||||||
can_start_next_round: true,
|
can_start_next_round: false,
|
||||||
can_finish_game: true
|
can_finish_game: false
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
can_join: true,
|
can_join: true,
|
||||||
can_submit_lie: true,
|
can_submit_lie: false,
|
||||||
can_submit_guess: true,
|
can_submit_guess: false,
|
||||||
can_view_final_result: 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') {
|
if (url === '/lobby/sessions/ABCD12/scoreboard') {
|
||||||
return {
|
return {
|
||||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
|
||||||
leaderboard: [
|
leaderboard: [
|
||||||
{ id: 9, nickname: 'Maja', score: 200 },
|
{ id: 9, nickname: 'Maja', score: 200 },
|
||||||
{ id: 10, nickname: 'Bo', score: 150 }
|
{ id: 10, nickname: 'Bo', score: 150 }
|
||||||
@@ -107,6 +107,12 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
|||||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||||
round_question: { id: 77, round_number: 1 },
|
round_question: { id: 77, round_number: 1 },
|
||||||
events_created: 2,
|
events_created: 2,
|
||||||
|
reveal: {
|
||||||
|
round_question_id: 77,
|
||||||
|
correct_answer: 'A',
|
||||||
|
lies: [],
|
||||||
|
guesses: []
|
||||||
|
},
|
||||||
leaderboard: [
|
leaderboard: [
|
||||||
{ id: 9, nickname: 'Maja', score: 200 },
|
{ id: 9, nickname: 'Maja', score: 200 },
|
||||||
{ id: 10, nickname: 'Bo', score: 150 }
|
{ id: 10, nickname: 'Bo', score: 150 }
|
||||||
@@ -116,7 +122,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
|||||||
|
|
||||||
if (url === '/lobby/sessions/ABCD12/rounds/next') {
|
if (url === '/lobby/sessions/ABCD12/rounds/next') {
|
||||||
expect(body).toEqual({});
|
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') {
|
if (url === '/lobby/sessions/ABCD12/finish') {
|
||||||
@@ -170,8 +176,8 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
|||||||
expect(session.ok).toBe(true);
|
expect(session.ok).toBe(true);
|
||||||
if (session.ok) {
|
if (session.ok) {
|
||||||
expect(session.data.session.code).toBe('ABCD12');
|
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.host.can_start_next_round).toBe(false);
|
||||||
expect(session.data.phase_view_model.player.can_submit_guess).toBe(true);
|
expect(session.data.phase_view_model.player.can_submit_guess).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);
|
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ describe('createAngularApiClient', () => {
|
|||||||
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
|
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
|
||||||
if (url === '/lobby/sessions/ABCD12/scoreboard') {
|
if (url === '/lobby/sessions/ABCD12/scoreboard') {
|
||||||
return {
|
return {
|
||||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
|
||||||
leaderboard: [
|
leaderboard: [
|
||||||
{ id: 2, nickname: 'Maja', score: 11 },
|
{ id: 2, nickname: 'Maja', score: 11 },
|
||||||
{ id: 3, nickname: 'Bo', score: 7 }
|
{ id: 3, nickname: 'Bo', score: 7 }
|
||||||
|
|||||||
18
fupogfakta/migrations/0005_alter_gamesession_status.py
Normal file
18
fupogfakta/migrations/0005_alter_gamesession_status.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -42,6 +42,7 @@ class GameSession(models.Model):
|
|||||||
LIE = "lie", "Løgnfase"
|
LIE = "lie", "Løgnfase"
|
||||||
GUESS = "guess", "Gættefase"
|
GUESS = "guess", "Gættefase"
|
||||||
REVEAL = "reveal", "Reveal"
|
REVEAL = "reveal", "Reveal"
|
||||||
|
SCOREBOARD = "scoreboard", "Scoreboard"
|
||||||
FINISHED = "finished", "Afsluttet"
|
FINISHED = "finished", "Afsluttet"
|
||||||
|
|
||||||
host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions")
|
host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions")
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ var lastRefreshAtLabel="";
|
|||||||
var lastRefreshFailed=false;
|
var lastRefreshFailed=false;
|
||||||
var sessionDetailInFlight=false;
|
var sessionDetailInFlight=false;
|
||||||
var hostShellRouteHint="";
|
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 hostShellFatalError=false;
|
||||||
var hostShellRecoverInFlight=false;
|
var hostShellRecoverInFlight=false;
|
||||||
var hostCriticalHydrated=false;
|
var hostCriticalHydrated=false;
|
||||||
@@ -89,7 +89,7 @@ function code(){return document.getElementById("code").value.trim().toUpperCase(
|
|||||||
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
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 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 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"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Finished";}return"Unknown";}
|
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Finished";}return"Unknown";}
|
||||||
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 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 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)+".";}
|
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)+".";}
|
||||||
@@ -108,7 +108,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 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 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;}
|
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();});}
|
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();});}
|
||||||
|
|||||||
@@ -109,7 +109,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 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 pid(){return document.getElementById("playerId").value.trim();}
|
||||||
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
||||||
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Finished";}return"Unknown";}
|
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Finished";}return"Unknown";}
|
||||||
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 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 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;}}
|
function loadPlayerContext(){try{var raw=localStorage.getItem(PLAYER_CONTEXT_KEY);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
|
||||||
|
|||||||
144
lobby/tests.py
144
lobby/tests.py
@@ -100,6 +100,21 @@ class LobbyFlowTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 409)
|
self.assertEqual(response.status_code, 409)
|
||||||
self.assertEqual(response.json()["error"], "Nickname already taken")
|
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):
|
def test_join_rejects_non_joinable_session(self):
|
||||||
GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED)
|
GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED)
|
||||||
|
|
||||||
@@ -814,7 +829,8 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9)
|
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)
|
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")
|
self.client.login(username="host_reveal", password="secret123")
|
||||||
|
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
@@ -826,8 +842,22 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
payload = response.json()
|
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.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)
|
||||||
|
|
||||||
def test_reveal_scoreboard_requires_host(self):
|
def test_reveal_scoreboard_requires_host(self):
|
||||||
self.client.login(username="other_reveal", password="secret123")
|
self.client.login(username="other_reveal", password="secret123")
|
||||||
@@ -836,7 +866,8 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
reverse(
|
reverse(
|
||||||
"lobby:reveal_scoreboard",
|
"lobby:reveal_scoreboard",
|
||||||
kwargs={"code": self.session.code},
|
kwargs={"code": self.session.code},
|
||||||
)
|
),
|
||||||
|
HTTP_ACCEPT_LANGUAGE="fr",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
@@ -844,9 +875,23 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
self.assertEqual(response.json()["locale"], "en")
|
self.assertEqual(response.json()["locale"], "en")
|
||||||
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
||||||
|
|
||||||
def test_host_can_finish_game_from_reveal(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")
|
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, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
||||||
|
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
||||||
|
|
||||||
|
@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}))
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
"lobby:finish_game",
|
"lobby:finish_game",
|
||||||
@@ -870,13 +915,14 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
reverse(
|
reverse(
|
||||||
"lobby:finish_game",
|
"lobby:finish_game",
|
||||||
kwargs={"code": self.session.code},
|
kwargs={"code": self.session.code},
|
||||||
)
|
),
|
||||||
|
HTTP_ACCEPT_LANGUAGE="da",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
self.assertEqual(response.json()["error_code"], "host_only_finish_game")
|
self.assertEqual(response.json()["error_code"], "host_only_finish_game")
|
||||||
self.assertEqual(response.json()["locale"], "en")
|
self.assertEqual(response.json()["locale"], "da")
|
||||||
self.assertEqual(response.json()["error"], "Only host can finish game")
|
self.assertEqual(response.json()["error"], "Kun værten kan afslutte spillet")
|
||||||
|
|
||||||
def test_finish_game_rejects_wrong_phase(self):
|
def test_finish_game_rejects_wrong_phase(self):
|
||||||
self.client.login(username="host_reveal", password="secret123")
|
self.client.login(username="host_reveal", password="secret123")
|
||||||
@@ -887,16 +933,19 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
reverse(
|
reverse(
|
||||||
"lobby:finish_game",
|
"lobby:finish_game",
|
||||||
kwargs={"code": self.session.code},
|
kwargs={"code": self.session.code},
|
||||||
)
|
),
|
||||||
|
HTTP_ACCEPT_LANGUAGE="fr",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertEqual(response.json()["error_code"], "finish_game_invalid_phase")
|
self.assertEqual(response.json()["error_code"], "finish_game_invalid_phase")
|
||||||
self.assertEqual(response.json()["locale"], "en")
|
self.assertEqual(response.json()["locale"], "en")
|
||||||
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):
|
@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.login(username="host_reveal", password="secret123")
|
||||||
|
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
@@ -914,6 +963,50 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
|
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
|
||||||
self.assertEqual(self.session.current_round, 2)
|
self.assertEqual(self.session.current_round, 2)
|
||||||
|
|
||||||
|
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(
|
||||||
|
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(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):
|
def test_start_next_round_rejects_wrong_phase(self):
|
||||||
self.client.login(username="host_reveal", password="secret123")
|
self.client.login(username="host_reveal", password="secret123")
|
||||||
self.session.status = GameSession.Status.GUESS
|
self.session.status = GameSession.Status.GUESS
|
||||||
@@ -923,13 +1016,14 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
reverse(
|
reverse(
|
||||||
"lobby:start_next_round",
|
"lobby:start_next_round",
|
||||||
kwargs={"code": self.session.code},
|
kwargs={"code": self.session.code},
|
||||||
)
|
),
|
||||||
|
HTTP_ACCEPT_LANGUAGE="da",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertEqual(response.json()["error_code"], "next_round_invalid_phase")
|
self.assertEqual(response.json()["error_code"], "next_round_invalid_phase")
|
||||||
self.assertEqual(response.json()["locale"], "en")
|
self.assertEqual(response.json()["locale"], "da")
|
||||||
self.assertEqual(response.json()["error"], "Next round can only start from reveal phase")
|
self.assertEqual(response.json()["error"], "Næste runde kan kun starte fra scoreboard-fasen")
|
||||||
|
|
||||||
def test_reveal_scoreboard_unsupported_locale_falls_back_to_en_deterministically(self):
|
def test_reveal_scoreboard_unsupported_locale_falls_back_to_en_deterministically(self):
|
||||||
self.client.login(username="other_reveal", password="secret123")
|
self.client.login(username="other_reveal", password="secret123")
|
||||||
@@ -1161,6 +1255,17 @@ class UiScreenTests(TestCase):
|
|||||||
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
|
self.assertContains(response, "data-wpp-shell-route=\"/host/guess/round-1\"")
|
||||||
self.assertContains(response, "data-wpp-shell-kind=\"host\"")
|
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)
|
@override_settings(USE_SPA_UI=True)
|
||||||
def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self):
|
def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self):
|
||||||
self.client.login(username="host_ui", password="secret123")
|
self.client.login(username="host_ui", password="secret123")
|
||||||
@@ -1289,10 +1394,19 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
|||||||
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
reveal_phase = reveal_payload["phase_view_model"]
|
reveal_phase = reveal_payload["phase_view_model"]
|
||||||
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
|
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
|
||||||
self.assertTrue(reveal_phase["host"]["can_start_next_round"])
|
self.assertFalse(reveal_phase["host"]["can_start_next_round"])
|
||||||
self.assertTrue(reveal_phase["host"]["can_finish_game"])
|
self.assertFalse(reveal_phase["host"]["can_finish_game"])
|
||||||
self.assertFalse(reveal_phase["player"]["can_view_final_result"])
|
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.status = GameSession.Status.FINISHED
|
||||||
self.session.save(update_fields=["status"])
|
self.session.save(update_fields=["status"])
|
||||||
finished_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
finished_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from fupogfakta.models import (
|
|||||||
RoundQuestion,
|
RoundQuestion,
|
||||||
ScoreEvent,
|
ScoreEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
from realtime.broadcast import sync_broadcast_phase_event
|
from realtime.broadcast import sync_broadcast_phase_event
|
||||||
|
|
||||||
from .i18n import api_error
|
from .i18n import api_error
|
||||||
@@ -32,6 +31,7 @@ JOINABLE_STATUSES = {
|
|||||||
GameSession.Status.LIE,
|
GameSession.Status.LIE,
|
||||||
GameSession.Status.GUESS,
|
GameSession.Status.GUESS,
|
||||||
GameSession.Status.REVEAL,
|
GameSession.Status.REVEAL,
|
||||||
|
GameSession.Status.SCOREBOARD,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou
|
|||||||
in_lie = status == GameSession.Status.LIE
|
in_lie = status == GameSession.Status.LIE
|
||||||
in_guess = status == GameSession.Status.GUESS
|
in_guess = status == GameSession.Status.GUESS
|
||||||
in_reveal = status == GameSession.Status.REVEAL
|
in_reveal = status == GameSession.Status.REVEAL
|
||||||
|
in_scoreboard = status == GameSession.Status.SCOREBOARD
|
||||||
in_finished = status == GameSession.Status.FINISHED
|
in_finished = status == GameSession.Status.FINISHED
|
||||||
|
|
||||||
min_players_reached = players_count >= 3
|
min_players_reached = players_count >= 3
|
||||||
@@ -90,8 +91,8 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou
|
|||||||
"can_mix_answers": in_lie or in_guess,
|
"can_mix_answers": in_lie or in_guess,
|
||||||
"can_calculate_scores": in_guess,
|
"can_calculate_scores": in_guess,
|
||||||
"can_reveal_scoreboard": in_reveal,
|
"can_reveal_scoreboard": in_reveal,
|
||||||
"can_start_next_round": in_reveal,
|
"can_start_next_round": in_scoreboard,
|
||||||
"can_finish_game": in_reveal,
|
"can_finish_game": in_scoreboard,
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"can_join": status in JOINABLE_STATUSES,
|
"can_join": status in JOINABLE_STATUSES,
|
||||||
@@ -750,8 +751,16 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
if session.host_id != request.user.id:
|
if session.host_id != request.user.id:
|
||||||
return api_error(request, code="host_only_view_scoreboard", status=403)
|
return api_error(request, code="host_only_view_scoreboard", status=403)
|
||||||
|
|
||||||
if session.status != GameSession.Status.REVEAL:
|
with transaction.atomic():
|
||||||
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
|
if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}:
|
||||||
|
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
|
||||||
leaderboard = list(
|
leaderboard = list(
|
||||||
Player.objects.filter(session=session)
|
Player.objects.filter(session=session)
|
||||||
@@ -759,18 +768,20 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
.values("id", "nickname", "score")
|
.values("id", "nickname", "score")
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
if promoted_to_scoreboard:
|
||||||
session.code,
|
sync_broadcast_phase_event(
|
||||||
"phase.scoreboard",
|
session.code,
|
||||||
{"leaderboard": list(leaderboard), "current_round": session.current_round},
|
"phase.scoreboard",
|
||||||
)
|
{"leaderboard": list(leaderboard), "current_round": locked_session.current_round},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"session": {
|
"session": {
|
||||||
"code": session.code,
|
"code": session.code,
|
||||||
"status": session.status,
|
"status": locked_session.status,
|
||||||
"current_round": session.current_round,
|
"current_round": locked_session.current_round,
|
||||||
},
|
},
|
||||||
"leaderboard": leaderboard,
|
"leaderboard": leaderboard,
|
||||||
}
|
}
|
||||||
@@ -792,9 +803,10 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
if locked_session.status != GameSession.Status.REVEAL:
|
if locked_session.status != GameSession.Status.SCOREBOARD:
|
||||||
return api_error(request, code="next_round_invalid_phase", status=400)
|
return api_error(request, code="next_round_invalid_phase", status=400)
|
||||||
|
|
||||||
|
|
||||||
locked_session.current_round += 1
|
locked_session.current_round += 1
|
||||||
locked_session.status = GameSession.Status.LOBBY
|
locked_session.status = GameSession.Status.LOBBY
|
||||||
locked_session.save(update_fields=["current_round", "status"])
|
locked_session.save(update_fields=["current_round", "status"])
|
||||||
@@ -824,9 +836,10 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
if locked_session.status != GameSession.Status.REVEAL:
|
if locked_session.status != GameSession.Status.SCOREBOARD:
|
||||||
return api_error(request, code="finish_game_invalid_phase", status=400)
|
return api_error(request, code="finish_game_invalid_phase", status=400)
|
||||||
|
|
||||||
|
|
||||||
locked_session.status = GameSession.Status.FINISHED
|
locked_session.status = GameSession.Status.FINISHED
|
||||||
locked_session.save(update_fields=["status"])
|
locked_session.save(update_fields=["status"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.exceptions import InvalidChannelLayerError
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
|
try:
|
||||||
|
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||||
|
except Exception: # pragma: no cover - optional dependency in local/test runtimes
|
||||||
|
RedisConnectionError = RuntimeError
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None:
|
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."""
|
"""Send a phase event to all WebSocket clients connected to a game session."""
|
||||||
channel_layer = get_channel_layer()
|
try:
|
||||||
group_name = f"game_{session_code.upper()}"
|
channel_layer = get_channel_layer()
|
||||||
await channel_layer.group_send(
|
if channel_layer is None:
|
||||||
group_name,
|
return
|
||||||
{
|
group_name = f"game_{session_code.upper()}"
|
||||||
"type": "phase_event",
|
await channel_layer.group_send(
|
||||||
"payload": {"type": event_type, **payload},
|
group_name,
|
||||||
},
|
{
|
||||||
)
|
"type": "phase.event",
|
||||||
|
"event_type": event_type,
|
||||||
|
"payload": payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (InvalidChannelLayerError, RedisConnectionError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None:
|
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."""
|
"""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
|
||||||
|
|||||||
@@ -58,4 +58,6 @@ class GameConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
async def phase_event(self, event):
|
async def phase_event(self, event):
|
||||||
"""Forward any phase_event broadcast to the WebSocket client."""
|
"""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)
|
||||||
|
|||||||
@@ -1,13 +1,65 @@
|
|||||||
from channels.testing import WebsocketCommunicator
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
from channels.exceptions import InvalidChannelLayerError
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import SimpleTestCase, TestCase
|
||||||
|
|
||||||
|
try:
|
||||||
|
from channels.testing import WebsocketCommunicator
|
||||||
|
except Exception: # pragma: no cover - optional test dependency
|
||||||
|
WebsocketCommunicator = None
|
||||||
|
|
||||||
from fupogfakta.models import GameSession, Player
|
from fupogfakta.models import GameSession, Player
|
||||||
from partyhub.asgi import application
|
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()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
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"})
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class GameConsumerConnectTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="host", password="pw")
|
self.user = User.objects.create_user(username="host", password="pw")
|
||||||
@@ -62,7 +114,6 @@ class GameConsumerConnectTest(TestCase):
|
|||||||
connected, _ = await communicator.connect()
|
connected, _ = await communicator.connect()
|
||||||
self.assertTrue(connected)
|
self.assertTrue(connected)
|
||||||
|
|
||||||
from realtime.broadcast import broadcast_phase_event
|
|
||||||
await broadcast_phase_event("AABBCC", "phase.test_event", {"hello": "world"})
|
await broadcast_phase_event("AABBCC", "phase.test_event", {"hello": "world"})
|
||||||
|
|
||||||
message = await communicator.receive_json_from(timeout=2)
|
message = await communicator.receive_json_from(timeout=2)
|
||||||
|
|||||||
@@ -435,24 +435,24 @@
|
|||||||
"da": "Kun værten kan se scoreboard"
|
"da": "Kun værten kan se scoreboard"
|
||||||
},
|
},
|
||||||
"scoreboard_invalid_phase": {
|
"scoreboard_invalid_phase": {
|
||||||
"en": "Scoreboard is only available in reveal phase",
|
"en": "Scoreboard is only available in reveal/scoreboard phase",
|
||||||
"da": "Scoreboard er kun tilgængeligt i reveal-fasen"
|
"da": "Scoreboard er kun tilgængeligt i reveal-/scoreboard-fasen"
|
||||||
},
|
},
|
||||||
"host_only_start_next_round": {
|
"host_only_start_next_round": {
|
||||||
"en": "Only host can start next round",
|
"en": "Only host can start next round",
|
||||||
"da": "Kun værten kan starte næste runde"
|
"da": "Kun værten kan starte næste runde"
|
||||||
},
|
},
|
||||||
"next_round_invalid_phase": {
|
"next_round_invalid_phase": {
|
||||||
"en": "Next round can only start from reveal phase",
|
"en": "Next round can only start from scoreboard phase",
|
||||||
"da": "Næste runde kan kun startes fra reveal-fasen"
|
"da": "Næste runde kan kun starte fra scoreboard-fasen"
|
||||||
},
|
},
|
||||||
"host_only_finish_game": {
|
"host_only_finish_game": {
|
||||||
"en": "Only host can finish game",
|
"en": "Only host can finish game",
|
||||||
"da": "Kun værten kan afslutte spillet"
|
"da": "Kun værten kan afslutte spillet"
|
||||||
},
|
},
|
||||||
"finish_game_invalid_phase": {
|
"finish_game_invalid_phase": {
|
||||||
"en": "Game can only be finished from reveal phase",
|
"en": "Game can only be finished from scoreboard phase",
|
||||||
"da": "Spillet kan kun afsluttes fra reveal-fasen"
|
"da": "Spillet kan kun afsluttes fra scoreboard-fasen"
|
||||||
},
|
},
|
||||||
"host_only_calculate_scores": {
|
"host_only_calculate_scores": {
|
||||||
"en": "Only host can calculate scores",
|
"en": "Only host can calculate scores",
|
||||||
|
|||||||
Reference in New Issue
Block a user