diff --git a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md index 9aec970..2aec3b7 100644 --- a/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md +++ b/docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md @@ -8,7 +8,7 @@ | Sidste gyldige `submit_lie` for aktivt spørgsmål | `lie` | `guess` | Dedupe/shuffle `correct_answer + lies`, persisterer `mixed_answers`, broadcaster `phase.guess_started` | | Sidste gyldige `submit_guess` for aktivt spørgsmål | `guess` | `reveal` | Beregner score deterministisk, persisterer `ScoreEvent` + opdaterede `Player.score`, returnerer canonical reveal payload | | Første canonical state-read efter resolved reveal (`session_detail`, og idempotent `GET /scoreboard` hvis state allerede er resolved) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness | -| `POST /lobby/sessions/{code}/next` | `scoreboard` | `lie` | Increment round counter, kopierer seneste `RoundConfig`, vælger/låser næste spørgsmål i samme kategori og broadcaster `phase.lie_started` | +| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard` | `lie` | Increment round counter, kopierer seneste `RoundConfig`, vælger/låser næste spørgsmål i samme kategori og broadcaster `phase.lie_started` | | `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard | ## Flow-log (happy path) diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 63b72b5..c496c94 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -140,7 +140,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { if (url === '/lobby/sessions/ABCD12/rounds/next') { expect(body).toEqual({}); - return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T; + return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T; } if (url === '/lobby/sessions/ABCD12/finish') { diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 82fb35f..8fe8674 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -85,10 +85,10 @@ function sessionDetailPayload( }, host: { can_start_round: status === 'lobby', - can_show_question: status === 'lie', - can_mix_answers: status === 'lie', - can_calculate_scores: status === 'guess', - can_reveal_scoreboard: status === 'reveal', + can_show_question: false, + can_mix_answers: false, + can_calculate_scores: false, + can_reveal_scoreboard: false, can_start_next_round: status === 'scoreboard', can_finish_game: status === 'scoreboard', }, @@ -179,80 +179,16 @@ describe('HostShellComponent gameplay wiring', () => { }); }); - it('captures scoreboard error for retry path', async () => { - const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' })); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = 'ABCD12'; - - await component.loadScoreboard(); - - expect(fetchMock).toHaveBeenCalledWith('/lobby/sessions/ABCD12/scoreboard', expect.objectContaining({ method: 'GET' })); - expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable'); - expect(component.loading).toBe(false); - }); - - it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => { + it('runs next-round transition into canonical lie phase and clears prior final leaderboard state', async () => { const fetchMock: FetchMock = vi .fn() - .mockResolvedValueOnce( - jsonResponse(200, { - round_question: { - id: 77, - round_number: 1, - prompt: 'Q?', - shown_at: '2026-01-01T00:00:00Z', - lie_deadline_at: '2026-01-01T00:00:45Z', - }, - config: { lie_seconds: 45 }, - }) - ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 77 }))) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, - round_question: { id: 77, round_number: 1 }, - answers: [{ text: 'A' }], - }) - ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, - round_question: { id: 77, round_number: 1 }, - events_created: 2, - leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }], - }) - ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }))); + .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } })) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))); vi.stubGlobal('fetch', fetchMock); const component = new HostShellComponent(); component.sessionCode = ' abcd12 '; - component.roundQuestionId = ' 77 '; - - await component.showQuestion(); - await component.mixAnswers(); - await component.calculateScores(); - - expect(component.error).toBe(''); - expect(component.loading).toBe(false); - }); - - it('runs next-round transition without reload and clears scoreboard payload', async () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); - - vi.stubGlobal('fetch', fetchMock); - - const component = new HostShellComponent(); - component.sessionCode = ' abcd12 '; - component.scoreboardPayload = '{"leaderboard":[]}'; component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; @@ -264,8 +200,8 @@ describe('HostShellComponent gameplay wiring', () => { expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) ); expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' })); - expect(component.session?.session.status).toBe('lobby'); - expect(component.scoreboardPayload).toBe(''); + expect(component.session?.session.status).toBe('lie'); + expect(component.roundQuestionId).toBe('99'); expect(component.finalLeaderboardPayload).toBe(''); expect(component.finalLeaderboard).toEqual([]); expect(component.nextRoundError).toBe(''); @@ -341,25 +277,24 @@ describe('HostShellComponent gameplay wiring', () => { expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12'); expect(component.canStartRound).toBe(false); - expect(component.canShowQuestion).toBe(false); - expect(component.canMixAnswers).toBe(false); - expect(component.canCalculateScores).toBe(true); - }); - - it('uses phase_view_model to keep host action surface phase-specific', async () => { - const component = new HostShellComponent(); - - expect(component.canStartRound).toBe(true); - expect(component.canShowQuestion).toBe(false); - - component.session = sessionDetailPayload('lie') as any; - expect(component.canStartRound).toBe(false); - expect(component.canShowQuestion).toBe(true); - expect(component.canMixAnswers).toBe(true); - - component.session = sessionDetailPayload('reveal') as any; - expect(component.canRevealScoreboard).toBe(true); expect(component.canStartNextRound).toBe(false); expect(component.canFinishGame).toBe(false); }); + + it('uses phase_view_model to keep host action surface bound to round boundaries only', async () => { + const component = new HostShellComponent(); + + expect(component.canStartRound).toBe(true); + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); + + component.session = sessionDetailPayload('lie') as any; + expect(component.canStartRound).toBe(false); + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); + + component.session = sessionDetailPayload('scoreboard') as any; + expect(component.canStartNextRound).toBe(true); + expect(component.canFinishGame).toBe(true); + }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 89c6ac9..83d9165 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -3,13 +3,13 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; -import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types'; +import type { FinishGameResponse, SessionDetailResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; type SessionDetail = SessionDetailResponse; -type LeaderboardEntry = ScoreboardResponse['leaderboard'][number]; +type LeaderboardEntry = FinishGameResponse['leaderboard'][number]; type LeaderboardResponse = FinishGameResponse; @Component({ @@ -24,17 +24,12 @@ type LeaderboardResponse = FinishGameResponse; - - - -
{{ copy('host.audio_locale_hint') }}: {{ locale }}
{{ error }}
-{{ scoreboardError }}
{{ nextRoundError }}
{{ finishError }}
@@ -67,7 +62,6 @@ type LeaderboardResponse = FinishGameResponse; -{{ scoreboardPayload }}
{{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})
@@ -88,10 +82,8 @@ export class HostShellComponent implements OnInit, OnDestroy { roundQuestionId = ''; loading = false; error = ''; - scoreboardError = ''; nextRoundError = ''; finishError = ''; - scoreboardPayload = ''; finalLeaderboardPayload = ''; finalLeaderboard: LeaderboardEntry[] = []; finalWinner: LeaderboardEntry | null = null; @@ -133,22 +125,6 @@ export class HostShellComponent implements OnInit, OnDestroy { return Boolean(this.session?.phase_view_model?.host?.can_start_round ?? !this.session); } - get canShowQuestion(): boolean { - return Boolean(this.session?.phase_view_model?.host?.can_show_question); - } - - get canMixAnswers(): boolean { - return Boolean(this.session?.phase_view_model?.host?.can_mix_answers); - } - - get canCalculateScores(): boolean { - return Boolean(this.session?.phase_view_model?.host?.can_calculate_scores); - } - - get canRevealScoreboard(): boolean { - return Boolean(this.session?.phase_view_model?.host?.can_reveal_scoreboard); - } - get canStartNextRound(): boolean { return Boolean(this.session?.phase_view_model?.host?.can_start_next_round); } @@ -193,7 +169,6 @@ export class HostShellComponent implements OnInit, OnDestroy { async refreshSession(): PromiseKategori er kun redigérbar i lobby-fasen.
Fase: ukendt (opdatér session-status).
- - -Round question-id kan kun redigeres i lie/guess/reveal-faser.
- - - - - +Aktiv round question: afventer session-status.
+Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.
+ +Angiv sessionkode for at aktivere host-actions.
Ingen fejl.
@@ -74,9 +70,10 @@ var hostShellFatalError=false; var hostShellRecoverInFlight=false; var hostCriticalHydrated=false; function setHostCriticalLoading(isLoading){var skeleton=document.getElementById("hostCriticalSkeleton");var view=document.getElementById("hostCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";} -function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");} +function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");var roundStatus=document.getElementById("roundQuestionStatus");var roundQuestionId=(data&&data.round_question&&data.round_question.id)?String(data.round_question.id):"";if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");} if(playersEl){playersEl.textContent="Spillere: "+(typeof session.players_count==="number"?session.players_count:"ukendt");} -if(roundEl){roundEl.textContent="Aktiv round question: "+(rq()||"ikke valgt");} +if(roundEl){roundEl.textContent="Aktiv round question: "+(roundQuestionId||"ikke valgt");} +if(roundStatus){roundStatus.textContent="Aktiv round question: "+(roundQuestionId||"afventer session-status.");} hostCriticalHydrated=true; setHostCriticalLoading(false); } @@ -86,9 +83,8 @@ function setHostShellFatalError(detail){hostShellFatalError=true;var out=documen function clearHostShellFatalError(){hostShellFatalError=false;hostShellRecoverInFlight=false;updateHostShellErrorBoundary();} function recoverHostShell(mode){if(hostShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}hostShellRecoverInFlight=true;updateHostShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearHostShellFatalError();return result;}).catch(function(err){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();throw err;});} 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 saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),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.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==="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]||"";} @@ -108,20 +104,16 @@ function updateCreateSessionState(){var btn=document.getElementById("createSessi function updatePhaseStatus(){var el=document.getElementById("phaseStatus");syncHostShellRoute();if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";} function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";} -function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="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 updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var phase=currentSessionStatus||"";var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}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 se aktiv round question.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-kontekst.";}else{roundQuestionGuardHint.textContent="Round question-id styres server-side i canonical flow og er read-only 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==="scoreboard"){hint.textContent="Host-actions er klar: vælg næste runde eller afslut spillet.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i 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.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 createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});} function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});} function startRound(){if(document.getElementById("startRoundBtn").disabled){return Promise.resolve({error:"not_enough_players_client_guard"});}return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/start","POST",{category_slug:document.getElementById("category").value});});} -function showQuestion(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/show","POST",{});});} -function mixAnswers(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/answers/mix","POST",{});});} -function calcScores(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/scores/calculate","POST",{});});} -function showScoreboard(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/scoreboard","GET",null);});} function nextRound(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/next","POST",{});});} function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});} -["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});}); +["code"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});}); window.addEventListener("error",function(event){setHostShellFatalError((event&&event.message)||"Ukendt runtime-fejl");}); window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setHostShellFatalError(detail);}); diff --git a/lobby/tests.py b/lobby/tests.py index f59689e..bb6f6d5 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -1218,8 +1218,7 @@ class UiScreenTests(TestCase): self.assertContains(response, "id=\"createSessionHint\"") self.assertContains(response, "saveHostContext") self.assertContains(response, "restoreHostContext") - self.assertContains(response, "id=\"showQuestionBtn\"") - self.assertContains(response, "id=\"mixAnswersBtn\"") + self.assertContains(response, "id=\"roundQuestionStatus\"") self.assertContains(response, "id=\"hostActionHint\"") self.assertContains(response, "id=\"categoryGuardHint\"") @@ -1231,8 +1230,8 @@ class UiScreenTests(TestCase): self.assertContains(response, "Kategori er kun redigérbar i lobby-fasen.") self.assertContains(response, "Kræver 3-5 spillere i lobbyen.") self.assertContains(response, "For mange spillere: maks 5 i MVP før runde-start.") - self.assertContains(response, "Round question-id kan kun redigeres i lie/guess/reveal-faser.") - self.assertContains(response, "roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion") + self.assertContains(response, "Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.") + self.assertContains(response, "Round question-id styres server-side i canonical flow og er read-only i fase:") self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==\"lobby\"") self.assertContains(response, "hostActionInFlight") self.assertContains(response, "withHostActionLock") @@ -1261,7 +1260,9 @@ class UiScreenTests(TestCase): self.assertContains(response, "markSessionRefresh") self.assertContains(response, "updateLastRefreshStatus") self.assertContains(response, "isSessionDetailRead") - self.assertContains(response, "showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==") + self.assertContains(response, "Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i fase:") + self.assertContains(response, "Host-actions er klar: vælg næste runde eller afslut spillet.") + self.assertContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}') self.assertContains(response, "Host-actions er låst mens session-opdatering kører.") self.assertContains(response, "Round question-id er låst mens session-opdatering kører.") self.assertContains(response, "Kategori er låst mens session-opdatering kører.")