merge: rebase canonical reveal flow onto main
This commit is contained in:
@@ -24,6 +24,15 @@ def lobby_i18n_error_messages() -> dict:
|
||||
return shared_i18n_catalog().get("backend", {}).get("errors", {})
|
||||
|
||||
|
||||
def resolve_error_key(code: str) -> str:
|
||||
resolved = lobby_i18n_errors().get(code)
|
||||
if isinstance(resolved, str) and resolved:
|
||||
return resolved
|
||||
|
||||
LOGGER.warning("i18n error code missing in shared catalog", extra={"code": code})
|
||||
return code
|
||||
|
||||
|
||||
def _quality_value(language_candidate: str) -> float | None:
|
||||
for parameter in language_candidate.split(";")[1:]:
|
||||
key, separator, value = parameter.partition("=")
|
||||
@@ -78,12 +87,13 @@ def resolve_error_message(*, key: str, locale: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
def api_error(request: HttpRequest, *, key: str, status: int) -> JsonResponse:
|
||||
def api_error(request: HttpRequest, *, code: str, status: int) -> JsonResponse:
|
||||
locale = resolve_locale(request)
|
||||
key = resolve_error_key(code)
|
||||
return JsonResponse(
|
||||
{
|
||||
"error": resolve_error_message(key=key, locale=locale),
|
||||
"error_code": key,
|
||||
"error_code": code,
|
||||
"locale": locale,
|
||||
},
|
||||
status=status,
|
||||
|
||||
@@ -55,8 +55,12 @@
|
||||
<p id="hostCriticalPlayers">Spillere: afventer</p>
|
||||
<p id="hostCriticalRound">Aktiv round question: afventer</p>
|
||||
</section>
|
||||
<pre id="out">Klar.</pre>
|
||||
<pre id="out">Ready.</pre>
|
||||
{{ lobby_i18n|json_script:"wppHostI18n" }}
|
||||
<script>
|
||||
var WPP_HOST_LOCALE="{{ shell_locale|default:'en'|escapejs }}";
|
||||
var WPP_HOST_I18N=JSON.parse(document.getElementById("wppHostI18n").textContent||"{}");
|
||||
function hostCopy(path,fallback){var node=WPP_HOST_I18N;var parts=(path||"").split(".");for(var i=0;i<parts.length;i++){if(!node||typeof node!=="object"){return fallback||path;}node=node[parts[i]];}if(node&&typeof node==="object"){if(node[WPP_HOST_LOCALE]){return node[WPP_HOST_LOCALE];}if(node.en){return node.en;}}return typeof node==="string"?node:(fallback||path);}
|
||||
var currentSessionStatus="";
|
||||
var autoRefreshEnabled=false;
|
||||
var autoRefreshTimer=null;
|
||||
@@ -65,7 +69,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;
|
||||
@@ -85,7 +89,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"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 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)+".";}
|
||||
@@ -93,18 +97,18 @@ function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggl
|
||||
function stopAutoRefresh(reason){autoRefreshEnabled=false;if(autoRefreshTimer){clearInterval(autoRefreshTimer);autoRefreshTimer=null;}if(reason){var hint=document.getElementById("autoRefreshHint");if(hint){hint.textContent=reason;}}updateAutoRefreshUi();saveHostContext();}
|
||||
function startAutoRefresh(){if(!code()){updateAutoRefreshUi();return;}autoRefreshEnabled=true;if(autoRefreshTimer){clearInterval(autoRefreshTimer);}autoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updateAutoRefreshUi();saveHostContext();}
|
||||
function toggleAutoRefresh(){if(hostActionInFlight||sessionDetailInFlight||!code()){updateAutoRefreshUi();return;}if(autoRefreshEnabled){stopAutoRefresh();return;}startAutoRefresh();}
|
||||
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
|
||||
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString(WPP_HOST_LOCALE,{hour12:false});}
|
||||
function markSessionRefresh(status){if(status>=200&&status<300){lastRefreshAtLabel=formatTimeLabel(new Date());lastRefreshFailed=false;}else{lastRefreshFailed=true;}updateLastRefreshStatus();}
|
||||
function updateLastRefreshStatus(){var el=document.getElementById("lastRefreshStatus");if(!el){return;}if(!lastRefreshAtLabel){el.textContent=lastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(lastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+lastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+lastRefreshAtLabel+".";}
|
||||
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
|
||||
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
|
||||
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(WPP_HOST_I18N&&WPP_HOST_I18N.backend&&WPP_HOST_I18N.backend.errors&&WPP_HOST_I18N.backend.errors[key]){return hostCopy("backend.errors."+key,"Action failed. Refresh state and retry.");}if(key.indexOf("session")!==-1){return hostCopy("backend.errors.session_not_found_or_closed","Session code is invalid, or session no longer exists.");}return hostCopy("backend.errors.generic_action_failed_retry","Action failed. Refresh state and retry.");}
|
||||
function updateErrorHint(status,data){var el=document.getElementById("hostErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
||||
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updateHostShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updateHostShellErrorBoundary();}
|
||||
|
||||
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();});}
|
||||
|
||||
@@ -69,8 +69,12 @@
|
||||
<p id="playerCriticalRound">Round question: afventer</p>
|
||||
<p id="playerCriticalJoin">Join-status: afventer</p>
|
||||
</section>
|
||||
<pre id="out">Klar.</pre>
|
||||
<pre id="out">Ready.</pre>
|
||||
{{ lobby_i18n|json_script:"wppPlayerI18n" }}
|
||||
<script>
|
||||
var WPP_PLAYER_LOCALE="{{ shell_locale|default:'en'|escapejs }}";
|
||||
var WPP_PLAYER_I18N=JSON.parse(document.getElementById("wppPlayerI18n").textContent||"{}");
|
||||
function playerCopy(path,fallback){var node=WPP_PLAYER_I18N;var parts=(path||"").split(".");for(var i=0;i<parts.length;i++){if(!node||typeof node!=="object"){return fallback||path;}node=node[parts[i]];}if(node&&typeof node==="object"){if(node[WPP_PLAYER_LOCALE]){return node[WPP_PLAYER_LOCALE];}if(node.en){return node.en;}}return typeof node==="string"?node:(fallback||path);}
|
||||
var availableAnswers=[];
|
||||
var guessSubmitted=false;
|
||||
var lieSubmitted=false;
|
||||
@@ -105,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 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"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 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;}}
|
||||
@@ -118,8 +122,8 @@ function resetRoundContextForManualChange(){document.getElementById("roundQuesti
|
||||
function updateContextLockState(){var locked=isPlayerContextLocked()||joinInFlight;var codeField=document.getElementById("code");var nicknameField=document.getElementById("nickname");var playerIdField=document.getElementById("playerId");var tokenField=document.getElementById("sessionToken");if(codeField){codeField.readOnly=locked;}if(nicknameField){nicknameField.readOnly=locked;}if(playerIdField){playerIdField.readOnly=locked;}if(tokenField){tokenField.readOnly=true;}var hint=document.getElementById("contextLockHint");if(!hint){return;}if(joinInFlight){hint.textContent="Låser kontekst…";return;}if(locked){hint.textContent="Spillerkontekst er låst efter join.";return;}hint.textContent="Kontekst er ikke låst endnu.";}
|
||||
function canAttemptJoin(){return !!(code()&&document.getElementById("nickname").value.trim());}
|
||||
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
|
||||
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
|
||||
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
|
||||
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(WPP_PLAYER_I18N&&WPP_PLAYER_I18N.backend&&WPP_PLAYER_I18N.backend.errors&&WPP_PLAYER_I18N.backend.errors[key]){return playerCopy("backend.errors."+key,"Action failed. Refresh state and retry.");}if(key.indexOf("session")!==-1){return playerCopy("backend.errors.session_not_found_or_closed","Session code is invalid, or session no longer exists.");}return playerCopy("backend.errors.generic_action_failed_retry","Action failed. Refresh state and retry.");}
|
||||
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString(WPP_PLAYER_LOCALE,{hour12:false});}
|
||||
function markPlayerSessionRefresh(status){if(status>=200&&status<300){playerLastRefreshAtLabel=formatTimeLabel(new Date());playerLastRefreshFailed=false;}else{playerLastRefreshFailed=true;}updatePlayerLastRefreshStatus();}
|
||||
function updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
|
||||
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";}
|
||||
|
||||
223
lobby/tests.py
223
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)
|
||||
|
||||
@@ -360,9 +375,9 @@ class LieSubmissionTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "lie_submission_window_closed")
|
||||
self.assertEqual(response.json()["error_code"], "lie_submission_closed")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Lie submission window has closed.")
|
||||
self.assertEqual(response.json()["error"], "Lie submission window has closed")
|
||||
|
||||
def test_submit_lie_rejects_duplicate_submission(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
@@ -385,7 +400,7 @@ class LieSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error_code"], "lie_already_submitted")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Lie has already been submitted for this player.")
|
||||
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
|
||||
|
||||
def test_submit_lie_requires_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
@@ -407,7 +422,7 @@ class LieSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "session_token_required")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Session token is required.")
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
|
||||
def test_submit_lie_rejects_invalid_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
@@ -429,7 +444,30 @@ class LieSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Player session token is invalid.")
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
|
||||
def test_submit_lie_uses_danish_locale_payload_from_accept_language(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.1",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
||||
self.assertEqual(response.json()["locale"], "da")
|
||||
self.assertEqual(response.json()["error"], "Ugyldigt spiller-session-token")
|
||||
|
||||
class MixAnswersTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -592,7 +630,7 @@ class GuessSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "guess_submission_invalid_phase")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase.")
|
||||
self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase")
|
||||
|
||||
def test_submit_guess_rejects_unknown_answer(self):
|
||||
response = self.client.post(
|
||||
@@ -606,7 +644,8 @@ class GuessSubmissionTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "selected_answer_invalid")
|
||||
self.assertEqual(response.json()["error"], "Selected answer is not part of this round.")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Selected answer is not part of this round")
|
||||
|
||||
def test_submit_guess_rejects_duplicate_submission(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True)
|
||||
@@ -622,7 +661,8 @@ class GuessSubmissionTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error_code"], "guess_already_submitted")
|
||||
self.assertEqual(response.json()["error"], "Guess has already been submitted for this player.")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Guess already submitted for this player")
|
||||
|
||||
def test_submit_guess_rejects_after_deadline(self):
|
||||
self.round_question.shown_at = timezone.now() - timedelta(seconds=76)
|
||||
@@ -638,8 +678,9 @@ class GuessSubmissionTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "guess_submission_window_closed")
|
||||
self.assertEqual(response.json()["error"], "Guess submission window has closed.")
|
||||
self.assertEqual(response.json()["error_code"], "guess_submission_closed")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Guess submission window has closed")
|
||||
|
||||
|
||||
|
||||
@@ -654,7 +695,9 @@ class GuessSubmissionTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Session token is required.")
|
||||
self.assertEqual(response.json()["error_code"], "session_token_required")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
|
||||
def test_submit_guess_rejects_invalid_session_token(self):
|
||||
response = self.client.post(
|
||||
@@ -668,7 +711,8 @@ class GuessSubmissionTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
||||
self.assertEqual(response.json()["error"], "Player session token is invalid.")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
|
||||
|
||||
class ScoreCalculationTests(TestCase):
|
||||
@@ -795,7 +839,9 @@ class ScoreCalculationTests(TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only the host can calculate scores.")
|
||||
self.assertEqual(response.json()["error_code"], "host_only_calculate_scores")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Only host can calculate scores")
|
||||
|
||||
def test_calculate_scores_rejects_duplicate_calculation(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
|
||||
@@ -816,18 +862,21 @@ class ScoreCalculationTests(TestCase):
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 409)
|
||||
self.assertEqual(second.json()["error"], "Scores have already been calculated for this round question.")
|
||||
self.assertEqual(second.json()["error_code"], "scores_already_calculated")
|
||||
self.assertEqual(second.json()["locale"], "en")
|
||||
self.assertEqual(second.json()["error"], "Scores already calculated for this round question")
|
||||
|
||||
|
||||
class RevealRoundFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_reveal", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other_reveal", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.SCOREBOARD)
|
||||
self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL)
|
||||
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(
|
||||
@@ -841,6 +890,20 @@ 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)
|
||||
|
||||
def test_reveal_scoreboard_requires_host(self):
|
||||
self.client.login(username="other_reveal", password="secret123")
|
||||
@@ -849,15 +912,32 @@ 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 the host can view the scoreboard.")
|
||||
self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
||||
|
||||
def test_host_can_finish_game_from_scoreboard(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, 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(
|
||||
reverse(
|
||||
"lobby:finish_game",
|
||||
@@ -881,11 +961,14 @@ 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 the host can finish the game.")
|
||||
self.assertEqual(response.json()["error_code"], "host_only_finish_game")
|
||||
self.assertEqual(response.json()["locale"], "da")
|
||||
self.assertEqual(response.json()["error"], "Kun værten kan afslutte spillet")
|
||||
|
||||
def test_finish_game_rejects_wrong_phase(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
@@ -896,14 +979,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_code"], "finish_game_invalid_phase")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase")
|
||||
|
||||
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}))
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
@@ -921,6 +1009,50 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
|
||||
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):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
@@ -930,11 +1062,30 @@ 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_code"], "next_round_invalid_phase")
|
||||
self.assertEqual(response.json()["locale"], "da")
|
||||
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):
|
||||
self.client.login(username="other_reveal", password="secret123")
|
||||
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"lobby:reveal_scoreboard",
|
||||
kwargs={"code": self.session.code},
|
||||
),
|
||||
HTTP_ACCEPT_LANGUAGE="fr-FR,fr;q=0.9",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
||||
|
||||
class UiScreenTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -1150,6 +1301,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")
|
||||
@@ -1496,6 +1658,15 @@ class I18nResolverTests(TestCase):
|
||||
self.assertEqual(result, "missing_key")
|
||||
self.assertTrue(any("i18n key missing in shared catalog" in entry for entry in logs.output))
|
||||
|
||||
def test_missing_backend_error_code_is_logged_with_context(self):
|
||||
from lobby.i18n import resolve_error_key
|
||||
|
||||
with self.assertLogs("lobby.i18n", level="WARNING") as logs:
|
||||
result = resolve_error_key("missing_code")
|
||||
|
||||
self.assertEqual(result, "missing_code")
|
||||
self.assertTrue(any("i18n error code missing in shared catalog" in entry for entry in logs.output))
|
||||
|
||||
def test_missing_locale_translation_falls_back_to_default_locale(self):
|
||||
with patch(
|
||||
"lobby.i18n.lobby_i18n_error_messages",
|
||||
|
||||
@@ -40,6 +40,7 @@ def host_screen(request, spa_path=None):
|
||||
{
|
||||
"categories": categories,
|
||||
"lobby_i18n": lobby_i18n_catalog(),
|
||||
"shell_locale": resolve_locale(request),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -48,4 +49,8 @@ def player_screen(request):
|
||||
if use_spa_ui():
|
||||
return _render_spa_shell(request, "/player", "player")
|
||||
|
||||
return render(request, "lobby/player_screen.html", {"lobby_i18n": lobby_i18n_catalog()})
|
||||
return render(
|
||||
request,
|
||||
"lobby/player_screen.html",
|
||||
{"lobby_i18n": lobby_i18n_catalog(), "shell_locale": resolve_locale(request)},
|
||||
)
|
||||
|
||||
282
lobby/views.py
282
lobby/views.py
@@ -19,8 +19,9 @@ from fupogfakta.models import (
|
||||
RoundQuestion,
|
||||
ScoreEvent,
|
||||
)
|
||||
from realtime.broadcast import sync_broadcast_phase_event
|
||||
|
||||
from .i18n import api_error, lobby_i18n_errors
|
||||
from .i18n import api_error
|
||||
|
||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
SESSION_CODE_LENGTH = 6
|
||||
@@ -30,16 +31,10 @@ JOINABLE_STATUSES = {
|
||||
GameSession.Status.LIE,
|
||||
GameSession.Status.GUESS,
|
||||
GameSession.Status.REVEAL,
|
||||
GameSession.Status.SCOREBOARD,
|
||||
}
|
||||
ERROR_CODES = lobby_i18n_errors()
|
||||
|
||||
|
||||
def _error_key(code: str) -> str:
|
||||
return ERROR_CODES.get(code, code)
|
||||
|
||||
|
||||
def _api_error(request: HttpRequest, *, code: str, status: int) -> JsonResponse:
|
||||
return api_error(request, key=_error_key(code), status=status)
|
||||
|
||||
|
||||
def _json_body(request: HttpRequest) -> dict:
|
||||
@@ -184,21 +179,41 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
nickname = str(payload.get("nickname", "")).strip()
|
||||
|
||||
if not code:
|
||||
return _api_error(request, code="session_code_required", status=400)
|
||||
return api_error(
|
||||
request,
|
||||
code="session_code_required",
|
||||
status=400,
|
||||
)
|
||||
|
||||
if len(nickname) < 2 or len(nickname) > 40:
|
||||
return _api_error(request, code="nickname_invalid", status=400)
|
||||
return api_error(
|
||||
request,
|
||||
code="nickname_invalid",
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(
|
||||
request,
|
||||
code="session_not_found",
|
||||
status=404,
|
||||
)
|
||||
|
||||
if session.status not in JOINABLE_STATUSES:
|
||||
return _api_error(request, code="session_not_joinable", status=400)
|
||||
return api_error(
|
||||
request,
|
||||
code="session_not_joinable",
|
||||
status=400,
|
||||
)
|
||||
|
||||
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
||||
return _api_error(request, code="nickname_taken", status=409)
|
||||
return api_error(
|
||||
request,
|
||||
code="nickname_taken",
|
||||
status=409,
|
||||
)
|
||||
|
||||
player = Player.objects.create(session=session, nickname=nickname)
|
||||
|
||||
@@ -226,7 +241,11 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(
|
||||
request,
|
||||
code="session_not_found",
|
||||
status=404,
|
||||
)
|
||||
|
||||
players = list(
|
||||
session.players.order_by("nickname").values(
|
||||
@@ -286,26 +305,34 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
category_slug = str(payload.get("category_slug", "")).strip()
|
||||
|
||||
if not category_slug:
|
||||
return _api_error(request, code="category_slug_required", status=400)
|
||||
return api_error(
|
||||
request,
|
||||
code="category_slug_required",
|
||||
status=400,
|
||||
)
|
||||
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(
|
||||
request,
|
||||
code="session_not_found",
|
||||
status=404,
|
||||
)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("host_only_start_round", "host_only_start_round"),
|
||||
code="host_only_start_round",
|
||||
status=403,
|
||||
)
|
||||
|
||||
if session.status != GameSession.Status.LOBBY:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
||||
code="round_start_invalid_phase",
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -314,14 +341,14 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
except Category.DoesNotExist:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("category_not_found", "category_not_found"),
|
||||
code="category_not_found",
|
||||
status=404,
|
||||
)
|
||||
|
||||
if not Question.objects.filter(category=category, is_active=True).exists():
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("category_has_no_questions", "category_has_no_questions"),
|
||||
code="category_has_no_questions",
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -330,7 +357,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if session.status != GameSession.Status.LOBBY:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
|
||||
code="round_start_invalid_phase",
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -342,13 +369,23 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if not created:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("round_already_configured", "round_already_configured"),
|
||||
code="round_already_configured",
|
||||
status=409,
|
||||
)
|
||||
|
||||
session.status = GameSession.Status.LIE
|
||||
session.save(update_fields=["status"])
|
||||
|
||||
sync_broadcast_phase_event(
|
||||
session.code,
|
||||
"phase.lie_started",
|
||||
{
|
||||
"round_number": session.current_round,
|
||||
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
@@ -376,19 +413,23 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(
|
||||
request,
|
||||
code="session_not_found",
|
||||
status=404,
|
||||
)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("host_only_show_question", "host_only_show_question"),
|
||||
code="host_only_show_question",
|
||||
status=403,
|
||||
)
|
||||
|
||||
if session.status != GameSession.Status.LIE:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("show_question_invalid_phase", "show_question_invalid_phase"),
|
||||
code="show_question_invalid_phase",
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -397,14 +438,14 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
except RoundConfig.DoesNotExist:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("round_config_missing", "round_config_missing"),
|
||||
code="round_config_missing",
|
||||
status=400,
|
||||
)
|
||||
|
||||
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("question_already_shown", "question_already_shown"),
|
||||
code="question_already_shown",
|
||||
status=409,
|
||||
)
|
||||
|
||||
@@ -417,7 +458,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if not available_questions.exists():
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("no_available_questions", "no_available_questions"),
|
||||
code="no_available_questions",
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -431,6 +472,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
|
||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||
|
||||
sync_broadcast_phase_event(
|
||||
session.code,
|
||||
"phase.question_shown",
|
||||
{
|
||||
"round_question_id": round_question.id,
|
||||
"prompt": question.prompt,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"round_question": {
|
||||
@@ -458,29 +511,29 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
||||
lie_text = str(payload.get("text", "")).strip()
|
||||
|
||||
if not player_id:
|
||||
return _api_error(request, code="player_id_required", status=400)
|
||||
return api_error(request, code="player_id_required", status=400)
|
||||
|
||||
if not session_token:
|
||||
return _api_error(request, code="session_token_required", status=400)
|
||||
return api_error(request, code="session_token_required", status=400)
|
||||
|
||||
if not lie_text or len(lie_text) > 255:
|
||||
return _api_error(request, code="lie_text_invalid", status=400)
|
||||
return api_error(request, code="lie_text_invalid", status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(request, code="session_not_found", status=404)
|
||||
|
||||
if session.status != GameSession.Status.LIE:
|
||||
return _api_error(request, code="lie_submission_invalid_phase", status=400)
|
||||
return api_error(request, code="lie_submission_invalid_phase", status=400)
|
||||
|
||||
try:
|
||||
player = Player.objects.get(pk=player_id, session=session)
|
||||
except Player.DoesNotExist:
|
||||
return _api_error(request, code="player_not_found_in_session", status=404)
|
||||
return api_error(request, code="player_not_found_in_session", status=404)
|
||||
|
||||
if player.session_token != session_token:
|
||||
return _api_error(request, code="invalid_player_session_token", status=403)
|
||||
return api_error(request, code="invalid_player_session_token", status=403)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
@@ -489,21 +542,21 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return _api_error(request, code="round_question_not_found", status=404)
|
||||
return api_error(request, code="round_question_not_found", status=404)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return _api_error(request, code="round_config_missing", status=400)
|
||||
return api_error(request, code="round_config_missing", status=400)
|
||||
|
||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||
if timezone.now() > lie_deadline_at:
|
||||
return _api_error(request, code="lie_submission_window_closed", status=400)
|
||||
return api_error(request, code="lie_submission_closed", status=400)
|
||||
|
||||
try:
|
||||
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
|
||||
except IntegrityError:
|
||||
return _api_error(request, code="lie_already_submitted", status=409)
|
||||
return api_error(request, code="lie_already_submitted", status=409)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
@@ -529,13 +582,25 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(
|
||||
request,
|
||||
code="session_not_found",
|
||||
status=404,
|
||||
)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return _api_error(request, code="host_only_mix_answers", status=403)
|
||||
return api_error(
|
||||
request,
|
||||
code="host_only_mix_answers",
|
||||
status=403,
|
||||
)
|
||||
|
||||
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
|
||||
return _api_error(request, code="mix_answers_invalid_phase", status=400)
|
||||
return api_error(
|
||||
request,
|
||||
code="mix_answers_invalid_phase",
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
@@ -544,14 +609,18 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return _api_error(request, code="round_question_not_found", status=404)
|
||||
return api_error(
|
||||
request,
|
||||
code="round_question_not_found",
|
||||
status=404,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
|
||||
return api_error(
|
||||
request,
|
||||
key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"),
|
||||
code="mix_answers_invalid_phase",
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -569,7 +638,11 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
|
||||
deduped_answers.append(text.strip())
|
||||
|
||||
if len(deduped_answers) < 2:
|
||||
return _api_error(request, code="not_enough_answers_to_mix", status=400)
|
||||
return api_error(
|
||||
request,
|
||||
code="not_enough_answers_to_mix",
|
||||
status=400,
|
||||
)
|
||||
|
||||
random.shuffle(deduped_answers)
|
||||
locked_round_question.mixed_answers = deduped_answers
|
||||
@@ -579,6 +652,22 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
|
||||
locked_session.status = GameSession.Status.GUESS
|
||||
locked_session.save(update_fields=["status"])
|
||||
|
||||
try:
|
||||
_guess_config = RoundConfig.objects.get(session=session, number=session.current_round)
|
||||
_guess_seconds = _guess_config.guess_seconds
|
||||
except RoundConfig.DoesNotExist:
|
||||
_guess_seconds = None
|
||||
|
||||
sync_broadcast_phase_event(
|
||||
session.code,
|
||||
"phase.guess_started",
|
||||
{
|
||||
"round_question_id": round_question.id,
|
||||
"answers": [{"text": t} for t in deduped_answers],
|
||||
"guess_seconds": _guess_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
@@ -605,29 +694,29 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
||||
selected_text = str(payload.get("selected_text", "")).strip()
|
||||
|
||||
if not player_id:
|
||||
return _api_error(request, code="player_id_required", status=400)
|
||||
return api_error(request, code="player_id_required", status=400)
|
||||
|
||||
if not session_token:
|
||||
return _api_error(request, code="session_token_required", status=400)
|
||||
return api_error(request, code="session_token_required", status=400)
|
||||
|
||||
if not selected_text or len(selected_text) > 255:
|
||||
return _api_error(request, code="selected_text_invalid", status=400)
|
||||
return api_error(request, code="selected_text_invalid", status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(request, code="session_not_found", status=404)
|
||||
|
||||
if session.status != GameSession.Status.GUESS:
|
||||
return _api_error(request, code="guess_submission_invalid_phase", status=400)
|
||||
return api_error(request, code="guess_submission_invalid_phase", status=400)
|
||||
|
||||
try:
|
||||
player = Player.objects.get(pk=player_id, session=session)
|
||||
except Player.DoesNotExist:
|
||||
return _api_error(request, code="player_not_found_in_session", status=404)
|
||||
return api_error(request, code="player_not_found_in_session", status=404)
|
||||
|
||||
if player.session_token != session_token:
|
||||
return _api_error(request, code="invalid_player_session_token", status=403)
|
||||
return api_error(request, code="invalid_player_session_token", status=403)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
@@ -636,18 +725,18 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return _api_error(request, code="round_question_not_found", status=404)
|
||||
return api_error(request, code="round_question_not_found", status=404)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return _api_error(request, code="round_config_missing", status=400)
|
||||
return api_error(request, code="round_config_missing", status=400)
|
||||
|
||||
guess_deadline_at = round_question.shown_at + timedelta(
|
||||
seconds=round_config.lie_seconds + round_config.guess_seconds
|
||||
)
|
||||
if timezone.now() > guess_deadline_at:
|
||||
return _api_error(request, code="guess_submission_window_closed", status=400)
|
||||
return api_error(request, code="guess_submission_closed", status=400)
|
||||
|
||||
allowed_answers = {
|
||||
round_question.correct_answer.strip().casefold(),
|
||||
@@ -660,7 +749,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
||||
|
||||
selected_normalized = selected_text.casefold()
|
||||
if selected_normalized not in allowed_answers:
|
||||
return _api_error(request, code="selected_answer_invalid", status=400)
|
||||
return api_error(request, code="selected_answer_invalid", status=400)
|
||||
|
||||
correct_normalized = round_question.correct_answer.strip().casefold()
|
||||
fooled_player_id = None
|
||||
@@ -678,7 +767,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
||||
fooled_player_id=fooled_player_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
return _api_error(request, code="guess_already_submitted", status=409)
|
||||
return api_error(request, code="guess_already_submitted", status=409)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
@@ -709,13 +798,21 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(request, code="session_not_found", status=404)
|
||||
|
||||
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)
|
||||
|
||||
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 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"])
|
||||
|
||||
if session.status != GameSession.Status.SCOREBOARD:
|
||||
return _api_error(request, code="scoreboard_invalid_phase", status=400)
|
||||
|
||||
leaderboard = list(
|
||||
Player.objects.filter(session=session)
|
||||
@@ -723,12 +820,20 @@ 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": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"current_round": session.current_round,
|
||||
"status": locked_session.status,
|
||||
"current_round": locked_session.current_round,
|
||||
},
|
||||
"leaderboard": leaderboard,
|
||||
}
|
||||
@@ -743,15 +848,16 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(request, code="session_not_found", status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return _api_error(request, code="host_only_start_next_round", status=403)
|
||||
return api_error(request, code="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 _api_error(request, code="start_next_round_invalid_phase", status=400)
|
||||
return api_error(request, code="next_round_invalid_phase", status=400)
|
||||
|
||||
|
||||
locked_session.current_round += 1
|
||||
locked_session.status = GameSession.Status.LOBBY
|
||||
@@ -775,15 +881,16 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(request, code="session_not_found", status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return _api_error(request, code="host_only_finish_game", status=403)
|
||||
return api_error(request, code="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 _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.save(update_fields=["status"])
|
||||
@@ -796,6 +903,12 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
|
||||
|
||||
winner = leaderboard[0] if leaderboard else None
|
||||
|
||||
sync_broadcast_phase_event(
|
||||
session.code,
|
||||
"phase.game_over",
|
||||
{"winner": winner, "leaderboard": list(leaderboard)},
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
@@ -817,20 +930,20 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return _api_error(request, code="session_not_found", status=404)
|
||||
return api_error(request, code="session_not_found", status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return _api_error(request, code="host_only_calculate_scores", status=403)
|
||||
return api_error(request, code="host_only_calculate_scores", status=403)
|
||||
|
||||
already_calculated = ScoreEvent.objects.filter(
|
||||
session=session,
|
||||
meta__round_question_id=round_question_id,
|
||||
).exists()
|
||||
if already_calculated:
|
||||
return _api_error(request, code="scores_already_calculated", status=409)
|
||||
return api_error(request, code="scores_already_calculated", status=409)
|
||||
|
||||
if session.status != GameSession.Status.GUESS:
|
||||
return _api_error(request, code="calculate_scores_invalid_phase", status=400)
|
||||
return api_error(request, code="calculate_scores_invalid_phase", status=400)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
@@ -839,16 +952,16 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return _api_error(request, code="round_question_not_found", status=404)
|
||||
return api_error(request, code="round_question_not_found", status=404)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return _api_error(request, code="round_config_missing", status=400)
|
||||
return api_error(request, code="round_config_missing", status=400)
|
||||
|
||||
guesses = list(round_question.guesses.select_related("player"))
|
||||
if not guesses:
|
||||
return _api_error(request, code="no_guesses_submitted", status=400)
|
||||
return api_error(request, code="no_guesses_submitted", status=400)
|
||||
|
||||
bluff_counts = {}
|
||||
for guess in guesses:
|
||||
@@ -858,7 +971,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status != GameSession.Status.GUESS:
|
||||
return _api_error(request, code="calculate_scores_invalid_phase", status=400)
|
||||
return api_error(request, code="calculate_scores_invalid_phase", status=400)
|
||||
|
||||
score_events = []
|
||||
|
||||
@@ -902,6 +1015,21 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
|
||||
.values("id", "nickname", "score")
|
||||
)
|
||||
|
||||
score_deltas = [
|
||||
{"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason}
|
||||
for ev in score_events
|
||||
]
|
||||
|
||||
sync_broadcast_phase_event(
|
||||
session.code,
|
||||
"phase.scores_calculated",
|
||||
{
|
||||
"round_question_id": round_question.id,
|
||||
"score_deltas": score_deltas,
|
||||
"leaderboard": list(leaderboard),
|
||||
},
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
|
||||
Reference in New Issue
Block a user