feat(lobby): canonical backend round flow for issue #287 #298
22
docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md
Normal file
22
docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Issue #287 — Canonical round-flow backend artifact
|
||||
|
||||
## State-transition matrix
|
||||
|
||||
| Trigger | From | To | Server-owned effect |
|
||||
|---|---|---|---|
|
||||
| `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar |
|
||||
| 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}/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)
|
||||
|
||||
1. Host starter runde med kategori.
|
||||
2. Server vælger straks spørgsmål og går i `lie`.
|
||||
3. Spillere sender løgne; sidste submission auto-advancer til `guess`.
|
||||
4. Spillere sender gæt; sidste submission auto-advancer til `reveal` og scorer runden.
|
||||
5. Næste canonical state-read promoverer resolved reveal til `scoreboard`; state findes uden separat debug-knap.
|
||||
6. Host kan nu kun vælge `next round` eller `finish game`.
|
||||
7. `next round` starter næste runde direkte i `lie` med nyt konkret spørgsmål; ingen mellem-hop tilbage til `lobby`.
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
|
||||
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
|
||||
<button *ngIf="canStartRound" (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
|
||||
<button *ngIf="canShowQuestion" (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button>
|
||||
<button *ngIf="canMixAnswers" (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button>
|
||||
<button *ngIf="canCalculateScores" (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button>
|
||||
<button *ngIf="canRevealScoreboard || scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy(scoreboardError ? 'host.retry_scoreboard' : 'host.load_scoreboard') }}</button>
|
||||
<button *ngIf="canStartNextRound || nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy(nextRoundError ? 'host.retry_next_round' : 'host.start_next_round') }}</button>
|
||||
<button *ngIf="canFinishGame || finishError" (click)="finishGame()" [disabled]="loading">{{ copy(finishError ? 'host.retry_finish' : 'host.finish_game') }}</button>
|
||||
</div>
|
||||
|
||||
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
|
||||
<p *ngIf="error" class="error">{{ error }}</p>
|
||||
<p *ngIf="scoreboardError" class="error">{{ scoreboardError }}</p>
|
||||
<p *ngIf="nextRoundError" class="error">{{ nextRoundError }}</p>
|
||||
<p *ngIf="finishError" class="error">{{ finishError }}</p>
|
||||
|
||||
@@ -67,7 +62,6 @@ type LeaderboardResponse = FinishGameResponse;
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
|
||||
<div *ngIf="finalLeaderboard.length">
|
||||
<h3>{{ copy('host.final_leaderboard') }}</h3>
|
||||
<p *ngIf="finalWinner"><strong>{{ copy('host.winner') }}:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})</p>
|
||||
@@ -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(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.scoreboardError = '';
|
||||
this.nextRoundError = '';
|
||||
this.finishError = '';
|
||||
try {
|
||||
@@ -226,54 +201,11 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
||||
this.sessionCode = this.session.session.code;
|
||||
this.persistSessionCode(this.sessionCode);
|
||||
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
|
||||
this.scoreboardPayload = '';
|
||||
this.resetFinalLeaderboard();
|
||||
this.syncRouteFromSession();
|
||||
});
|
||||
}
|
||||
|
||||
async showQuestion(): Promise<void> {
|
||||
await this.runAction(async () => {
|
||||
const code = this.normalizeCode(this.sessionCode);
|
||||
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {});
|
||||
await this.refreshSession();
|
||||
});
|
||||
}
|
||||
|
||||
async mixAnswers(): Promise<void> {
|
||||
await this.runAction(async () => {
|
||||
const code = this.normalizeCode(this.sessionCode);
|
||||
const roundQuestionId = this.roundQuestionId.trim();
|
||||
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {});
|
||||
await this.refreshSession();
|
||||
});
|
||||
}
|
||||
|
||||
async calculateScores(): Promise<void> {
|
||||
await this.runAction(async () => {
|
||||
const code = this.normalizeCode(this.sessionCode);
|
||||
const roundQuestionId = this.roundQuestionId.trim();
|
||||
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {});
|
||||
await this.refreshSession();
|
||||
});
|
||||
}
|
||||
|
||||
async loadScoreboard(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.scoreboardError = '';
|
||||
this.error = '';
|
||||
try {
|
||||
const code = this.normalizeCode(this.sessionCode);
|
||||
const payload = await this.request<unknown>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
|
||||
this.scoreboardPayload = JSON.stringify(payload, null, 2);
|
||||
await this.refreshSession();
|
||||
} catch (error) {
|
||||
this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async startNextRound(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.nextRoundError = '';
|
||||
@@ -284,7 +216,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
||||
throw new Error(this.copy('host.session_code_required'));
|
||||
}
|
||||
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {});
|
||||
this.scoreboardPayload = '';
|
||||
this.resetFinalLeaderboard();
|
||||
await this.refreshSession();
|
||||
} catch (error) {
|
||||
|
||||
@@ -634,7 +634,7 @@ describe('createAngularApiClient', () => {
|
||||
}
|
||||
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') {
|
||||
expect(body).toEqual({});
|
||||
|
||||
@@ -70,12 +70,9 @@ class Command(BaseCommand):
|
||||
if start_res.status_code != 201:
|
||||
raise CommandError(f"start_round failed: {start_res.status_code}")
|
||||
|
||||
show_res = host_client.post(f"/lobby/sessions/{code}/questions/show", content_type="application/json")
|
||||
if show_res.status_code != 201:
|
||||
raise CommandError(f"show_question failed: {show_res.status_code}")
|
||||
|
||||
round_question_id = show_res.json()["round_question"]["id"]
|
||||
round_question_id = start_res.json()["round_question"]["id"]
|
||||
|
||||
answers = []
|
||||
for player in players:
|
||||
nick = player["nickname"]
|
||||
lie_res = Client().post(
|
||||
@@ -91,17 +88,17 @@ class Command(BaseCommand):
|
||||
)
|
||||
if lie_res.status_code != 201:
|
||||
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
|
||||
if lie_res.json().get("answers"):
|
||||
answers = lie_res.json()["answers"]
|
||||
|
||||
mix_res = host_client.post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/answers/mix",
|
||||
content_type="application/json",
|
||||
)
|
||||
if mix_res.status_code != 200:
|
||||
raise CommandError(f"mix_answers failed: {mix_res.status_code}")
|
||||
|
||||
answers = mix_res.json().get("answers", [])
|
||||
if not answers:
|
||||
raise CommandError("mix_answers returned empty answers")
|
||||
detail_res = host_client.get(f"/lobby/sessions/{code}")
|
||||
if detail_res.status_code != 200:
|
||||
raise CommandError(f"session_detail after lies failed: {detail_res.status_code}")
|
||||
answers = detail_res.json().get("round_question", {}).get("answers", [])
|
||||
|
||||
if not answers:
|
||||
raise CommandError("canonical lie->guess transition returned empty answers")
|
||||
|
||||
for player in players:
|
||||
nick = player["nickname"]
|
||||
@@ -120,16 +117,11 @@ class Command(BaseCommand):
|
||||
if guess_res.status_code != 201:
|
||||
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
|
||||
|
||||
calc_res = host_client.post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/scores/calculate",
|
||||
content_type="application/json",
|
||||
)
|
||||
if calc_res.status_code != 200:
|
||||
raise CommandError(f"calculate_scores failed: {calc_res.status_code}")
|
||||
|
||||
board_res = host_client.get(f"/lobby/sessions/{code}/scoreboard")
|
||||
if board_res.status_code != 200:
|
||||
raise CommandError(f"reveal_scoreboard failed: {board_res.status_code}")
|
||||
detail_res = host_client.get(f"/lobby/sessions/{code}")
|
||||
if detail_res.status_code != 200:
|
||||
raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}")
|
||||
if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD:
|
||||
raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard")
|
||||
|
||||
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
|
||||
if finish_res.status_code != 200:
|
||||
@@ -148,12 +140,10 @@ class Command(BaseCommand):
|
||||
"create_session",
|
||||
"join_players",
|
||||
"start_round",
|
||||
"show_question",
|
||||
"submit_lies",
|
||||
"mix_answers",
|
||||
"auto_guess_transition",
|
||||
"submit_guesses",
|
||||
"calculate_scores",
|
||||
"reveal_scoreboard",
|
||||
"auto_reveal_to_scoreboard",
|
||||
"finish_game",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -24,14 +24,10 @@
|
||||
<p id="categoryGuardHint">Kategori er kun redigérbar i lobby-fasen.</p>
|
||||
|
||||
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
|
||||
<button id="showQuestionBtn" onclick="showQuestion()" disabled>3) Vis spørgsmål</button>
|
||||
<input id="roundQuestionId" placeholder="Round question id">
|
||||
<p id="roundQuestionGuardHint">Round question-id kan kun redigeres i lie/guess/reveal-faser.</p>
|
||||
<button id="mixAnswersBtn" onclick="mixAnswers()" disabled>4) Mix svar</button>
|
||||
<button id="calcScoresBtn" onclick="calcScores()" disabled>5) Beregn score</button>
|
||||
<button id="showScoreboardBtn" onclick="showScoreboard()" disabled>6) Scoreboard</button>
|
||||
<button id="nextRoundBtn" onclick="nextRound()" disabled>7) Næste runde</button>
|
||||
<button id="finishGameBtn" onclick="finishGame()" disabled>8) Afslut spil</button>
|
||||
<p id="roundQuestionStatus">Aktiv round question: afventer session-status.</p>
|
||||
<p id="roundQuestionGuardHint">Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.</p>
|
||||
<button id="nextRoundBtn" onclick="nextRound()" disabled>3) Næste runde</button>
|
||||
<button id="finishGameBtn" onclick="finishGame()" disabled>4) Afslut spil</button>
|
||||
<p id="hostActionHint">Angiv sessionkode for at aktivere host-actions.</p>
|
||||
<p id="hostErrorHint">Ingen fejl.</p>
|
||||
<button id="sessionDetailBtn" onclick="sessionDetail()">Session-status</button>
|
||||
@@ -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);});
|
||||
|
||||
337
lobby/tests.py
337
lobby/tests.py
@@ -19,6 +19,7 @@ from fupogfakta.models import (
|
||||
Question,
|
||||
RoundConfig,
|
||||
RoundQuestion,
|
||||
ScoreEvent,
|
||||
)
|
||||
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
|
||||
|
||||
@@ -204,11 +205,14 @@ class StartRoundTests(TestCase):
|
||||
self.assertEqual(body["session"]["status"], GameSession.Status.LIE)
|
||||
self.assertEqual(body["round"]["number"], 1)
|
||||
self.assertEqual(body["round"]["category"]["slug"], self.category.slug)
|
||||
self.assertEqual(body["round_question"]["prompt"], "Hvilket år faldt muren?")
|
||||
self.assertIn("lie_deadline_at", body["round_question"])
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
||||
self.assertEqual(round_config.category, self.category)
|
||||
self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
|
||||
|
||||
def test_host_start_round_uses_normalized_session_code_from_path(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
@@ -304,6 +308,23 @@ class StartRoundTests(TestCase):
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Only host can start round")
|
||||
|
||||
@patch("lobby.views._select_round_question", side_effect=ValueError("no_available_questions"))
|
||||
def test_start_round_does_not_persist_round_config_when_question_selection_fails(self, _mock_select_round_question):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "no_available_questions")
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
|
||||
self.assertFalse(RoundConfig.objects.filter(session=self.session, number=1).exists())
|
||||
self.assertFalse(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
|
||||
|
||||
|
||||
class LieSubmissionTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -715,6 +736,220 @@ class GuessSubmissionTests(TestCase):
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
|
||||
|
||||
class CanonicalRoundFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_canonical", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="CN2871")
|
||||
self.category = Category.objects.create(name="Kanon", slug="kanon", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvem skrev Hamlet?",
|
||||
correct_answer="Shakespeare",
|
||||
is_active=True,
|
||||
)
|
||||
self.players = [
|
||||
Player.objects.create(session=self.session, nickname="Luna"),
|
||||
Player.objects.create(session=self.session, nickname="Mads"),
|
||||
Player.objects.create(session=self.session, nickname="Nora"),
|
||||
]
|
||||
|
||||
def test_canonical_round_flow_auto_advances_from_start_to_scoreboard(self):
|
||||
self.client.login(username="host_canonical", password="secret123")
|
||||
|
||||
start_response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(start_response.status_code, 201)
|
||||
round_question_id = start_response.json()["round_question"]["id"]
|
||||
self.assertEqual(start_response.json()["session"]["status"], GameSession.Status.LIE)
|
||||
|
||||
lie_responses = []
|
||||
for index, player in enumerate(self.players, start=1):
|
||||
lie_responses.append(
|
||||
self.client.post(
|
||||
reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question_id}),
|
||||
data={"player_id": player.id, "session_token": player.session_token, "text": f"Løgn {index}"},
|
||||
content_type="application/json",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(all(response.status_code == 201 for response in lie_responses))
|
||||
self.assertEqual(lie_responses[-1].json()["session"]["status"], GameSession.Status.GUESS)
|
||||
self.assertTrue(lie_responses[-1].json()["phase_transition"]["auto_advanced"])
|
||||
self.assertGreaterEqual(len(lie_responses[-1].json()["answers"]), 2)
|
||||
|
||||
guess_targets = ["Shakespeare", "Løgn 1", "Shakespeare"]
|
||||
guess_responses = []
|
||||
for player, selected_text in zip(self.players, guess_targets, strict=True):
|
||||
guess_responses.append(
|
||||
self.client.post(
|
||||
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question_id}),
|
||||
data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text},
|
||||
content_type="application/json",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(all(response.status_code == 201 for response in guess_responses))
|
||||
self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL)
|
||||
self.assertTrue(guess_responses[-1].json()["phase_transition"]["auto_advanced"])
|
||||
self.assertIsNotNone(guess_responses[-1].json()["reveal"])
|
||||
|
||||
detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||
self.assertEqual(detail_response.status_code, 200)
|
||||
payload = detail_response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
||||
self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD)
|
||||
self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
||||
self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"])
|
||||
self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare")
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._resolve_scores")
|
||||
def test_session_detail_promotes_zero_score_event_reveal_to_scoreboard(self, mock_resolve_scores, mock_sync_broadcast):
|
||||
self.client.login(username="host_canonical", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
round_config = RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=1,
|
||||
category=self.category,
|
||||
points_correct=5,
|
||||
points_bluff=2,
|
||||
)
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer="Shakespeare",
|
||||
)
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe")
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.players[1], text="Chaucer")
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.players[2], text="Austen")
|
||||
|
||||
mock_resolve_scores.return_value = ([], [
|
||||
{"id": player.id, "nickname": player.nickname, "score": player.score}
|
||||
for player in sorted(self.players, key=lambda player: player.nickname)
|
||||
])
|
||||
|
||||
guess_targets = ["Shakespeare", "Shakespeare", "Shakespeare"]
|
||||
guess_responses = []
|
||||
for player, selected_text in zip(self.players, guess_targets, strict=True):
|
||||
guess_responses.append(
|
||||
self.client.post(
|
||||
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}),
|
||||
data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text},
|
||||
content_type="application/json",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(all(response.status_code == 201 for response in guess_responses))
|
||||
self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL)
|
||||
self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 0)
|
||||
|
||||
detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(detail_response.status_code, 200)
|
||||
payload = detail_response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
||||
self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD)
|
||||
self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
||||
self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare")
|
||||
mock_resolve_scores.assert_called_once_with(self.session, round_question, round_config)
|
||||
mock_sync_broadcast.assert_any_call(
|
||||
self.session.code,
|
||||
"phase.scoreboard",
|
||||
{
|
||||
"leaderboard": payload["scoreboard"],
|
||||
"current_round": self.session.current_round,
|
||||
},
|
||||
)
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._resolve_scores")
|
||||
@patch("lobby.views.GameSession.objects.get")
|
||||
def test_submit_guess_skips_rescore_when_locked_session_is_already_revealing(
|
||||
self,
|
||||
mock_session_get,
|
||||
mock_resolve_scores,
|
||||
mock_sync_broadcast,
|
||||
):
|
||||
round_config = RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=1,
|
||||
category=self.category,
|
||||
points_correct=5,
|
||||
points_bluff=2,
|
||||
)
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer="Shakespeare",
|
||||
)
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.players[0], text="Marlowe")
|
||||
Guess.objects.create(
|
||||
round_question=round_question,
|
||||
player=self.players[0],
|
||||
selected_text="Shakespeare",
|
||||
is_correct=True,
|
||||
)
|
||||
Guess.objects.create(
|
||||
round_question=round_question,
|
||||
player=self.players[1],
|
||||
selected_text="Marlowe",
|
||||
is_correct=False,
|
||||
fooled_player=self.players[0],
|
||||
)
|
||||
self.players[0].score = round_config.points_correct + round_config.points_bluff
|
||||
self.players[0].save(update_fields=["score"])
|
||||
ScoreEvent.objects.create(
|
||||
session=self.session,
|
||||
player=self.players[0],
|
||||
delta=round_config.points_correct,
|
||||
reason="guess_correct",
|
||||
meta={"round_question_id": round_question.id, "guess_id": 1},
|
||||
)
|
||||
ScoreEvent.objects.create(
|
||||
session=self.session,
|
||||
player=self.players[0],
|
||||
delta=round_config.points_bluff,
|
||||
reason="bluff_success",
|
||||
meta={"round_question_id": round_question.id, "fooled_count": 1},
|
||||
)
|
||||
self.session.status = GameSession.Status.REVEAL
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
stale_session = GameSession(
|
||||
pk=self.session.pk,
|
||||
host=self.host,
|
||||
code=self.session.code,
|
||||
status=GameSession.Status.GUESS,
|
||||
current_round=self.session.current_round,
|
||||
)
|
||||
mock_session_get.return_value = stale_session
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question.id}),
|
||||
data={
|
||||
"player_id": self.players[2].id,
|
||||
"session_token": self.players[2].session_token,
|
||||
"selected_text": "Shakespeare",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()["session"]["status"], GameSession.Status.REVEAL)
|
||||
self.assertTrue(response.json()["phase_transition"]["auto_advanced"])
|
||||
self.assertIsNotNone(response.json()["reveal"])
|
||||
mock_resolve_scores.assert_not_called()
|
||||
mock_sync_broadcast.assert_not_called()
|
||||
self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2)
|
||||
|
||||
|
||||
class ScoreCalculationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_score", password="secret123")
|
||||
@@ -874,6 +1109,33 @@ class RevealRoundFlowTests(TestCase):
|
||||
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)
|
||||
self.category = Category.objects.create(name="Reveal", slug="reveal", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvad er Danmarks hovedstad?",
|
||||
correct_answer="København",
|
||||
is_active=True,
|
||||
)
|
||||
self.next_question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvad er Sveriges hovedstad?",
|
||||
correct_answer="Stockholm",
|
||||
is_active=True,
|
||||
)
|
||||
self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category)
|
||||
self.round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
ScoreEvent.objects.create(
|
||||
session=self.session,
|
||||
player=self.player_one,
|
||||
delta=5,
|
||||
reason="guess_correct",
|
||||
meta={"round_question_id": self.round_question.id},
|
||||
)
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event):
|
||||
@@ -989,9 +1251,10 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase")
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
def test_host_can_start_next_round_from_scoreboard(self, _mock_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}))
|
||||
mock_sync_broadcast_phase_event.reset_mock()
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
@@ -1002,12 +1265,24 @@ class RevealRoundFlowTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.LOBBY)
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.LIE)
|
||||
self.assertEqual(payload["session"]["current_round"], 2)
|
||||
self.assertEqual(payload["round"]["category"]["slug"], self.category.slug)
|
||||
self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt)
|
||||
self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds)
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
|
||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||
self.assertEqual(self.session.current_round, 2)
|
||||
self.assertTrue(
|
||||
RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
|
||||
)
|
||||
mock_sync_broadcast_phase_event.assert_called_once()
|
||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code)
|
||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
||||
|
||||
def test_start_next_round_requires_host(self):
|
||||
self.session.status = GameSession.Status.SCOREBOARD
|
||||
@@ -1071,6 +1346,26 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(response.json()["locale"], "da")
|
||||
self.assertEqual(response.json()["error"], "Næste runde kan kun starte fra scoreboard-fasen")
|
||||
|
||||
def test_start_next_round_does_not_persist_round_config_when_question_selection_fails(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||
self.next_question.delete()
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:start_next_round",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "no_available_questions")
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.SCOREBOARD)
|
||||
self.assertEqual(self.session.current_round, 1)
|
||||
self.assertFalse(RoundConfig.objects.filter(session=self.session, number=2).exists())
|
||||
self.assertFalse(RoundQuestion.objects.filter(session=self.session, round_number=2).exists())
|
||||
|
||||
def test_reveal_scoreboard_unsupported_locale_falls_back_to_en_deterministically(self):
|
||||
self.client.login(username="other_reveal", password="secret123")
|
||||
|
||||
@@ -1104,8 +1399,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\"")
|
||||
|
||||
@@ -1117,8 +1411,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")
|
||||
@@ -1147,7 +1441,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.")
|
||||
@@ -1502,8 +1798,8 @@ class SessionDetailRoundQuestionTests(TestCase):
|
||||
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
|
||||
self.assertEqual(reveal_payload["reveal"], scoreboard_payload["reveal"])
|
||||
self.assertTrue(reveal_payload["phase_view_model"]["host"]["can_reveal_scoreboard"])
|
||||
self.assertFalse(scoreboard_payload["phase_view_model"]["host"]["can_reveal_scoreboard"])
|
||||
self.assertTrue(reveal_payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
||||
self.assertTrue(scoreboard_payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
||||
self.assertFalse(reveal_payload["phase_view_model"]["host"]["can_start_next_round"])
|
||||
self.assertTrue(scoreboard_payload["phase_view_model"]["host"]["can_start_next_round"])
|
||||
|
||||
@@ -1530,7 +1826,10 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
self.assertTrue(phase["constraints"]["min_players_reached"])
|
||||
self.assertTrue(phase["constraints"]["max_players_allowed"])
|
||||
self.assertTrue(phase["host"]["can_start_round"])
|
||||
self.assertEqual(phase["current_phase"], GameSession.Status.LOBBY)
|
||||
self.assertFalse(phase["host"]["can_show_question"])
|
||||
self.assertFalse(phase["readiness"]["question_ready"])
|
||||
self.assertFalse(phase["readiness"]["scoreboard_ready"])
|
||||
self.assertTrue(phase["player"]["can_join"])
|
||||
self.assertFalse(phase["player"]["can_submit_lie"])
|
||||
self.assertFalse(phase["player"]["can_submit_guess"])
|
||||
@@ -1555,7 +1854,8 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
lie_phase = lie_payload["phase_view_model"]
|
||||
self.assertFalse(lie_phase["host"]["can_show_question"])
|
||||
self.assertTrue(lie_phase["host"]["can_mix_answers"])
|
||||
self.assertFalse(lie_phase["host"]["can_mix_answers"])
|
||||
self.assertTrue(lie_phase["readiness"]["question_ready"])
|
||||
self.assertTrue(lie_phase["player"]["can_submit_lie"])
|
||||
self.assertFalse(lie_phase["player"]["can_submit_guess"])
|
||||
|
||||
@@ -1563,8 +1863,9 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
self.session.save(update_fields=["status"])
|
||||
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
guess_phase = guess_payload["phase_view_model"]
|
||||
self.assertTrue(guess_phase["host"]["can_mix_answers"])
|
||||
self.assertTrue(guess_phase["host"]["can_calculate_scores"])
|
||||
self.assertFalse(guess_phase["host"]["can_mix_answers"])
|
||||
self.assertFalse(guess_phase["host"]["can_calculate_scores"])
|
||||
self.assertFalse(guess_phase["readiness"]["scoreboard_ready"])
|
||||
self.assertFalse(guess_phase["player"]["can_submit_lie"])
|
||||
self.assertTrue(guess_phase["player"]["can_submit_guess"])
|
||||
|
||||
@@ -1573,7 +1874,8 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
self.session.save(update_fields=["status"])
|
||||
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
reveal_phase = reveal_payload["phase_view_model"]
|
||||
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
|
||||
self.assertFalse(reveal_phase["host"]["can_reveal_scoreboard"])
|
||||
self.assertTrue(reveal_phase["readiness"]["scoreboard_ready"])
|
||||
self.assertFalse(reveal_phase["host"]["can_start_next_round"])
|
||||
self.assertFalse(reveal_phase["host"]["can_finish_game"])
|
||||
self.assertFalse(reveal_phase["player"]["can_view_final_result"])
|
||||
@@ -1583,6 +1885,7 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
scoreboard_phase = scoreboard_payload["phase_view_model"]
|
||||
self.assertFalse(scoreboard_phase["host"]["can_reveal_scoreboard"])
|
||||
self.assertTrue(scoreboard_phase["readiness"]["scoreboard_ready"])
|
||||
self.assertTrue(scoreboard_phase["host"]["can_start_next_round"])
|
||||
self.assertTrue(scoreboard_phase["host"]["can_finish_game"])
|
||||
self.assertFalse(scoreboard_phase["player"]["can_view_final_result"])
|
||||
@@ -1620,12 +1923,10 @@ class SmokeStagingCommandTests(TestCase):
|
||||
"create_session",
|
||||
"join_players",
|
||||
"start_round",
|
||||
"show_question",
|
||||
"submit_lies",
|
||||
"mix_answers",
|
||||
"auto_guess_transition",
|
||||
"submit_guesses",
|
||||
"calculate_scores",
|
||||
"reveal_scoreboard",
|
||||
"auto_reveal_to_scoreboard",
|
||||
"finish_game",
|
||||
],
|
||||
)
|
||||
|
||||
489
lobby/views.py
489
lobby/views.py
@@ -112,12 +112,174 @@ def _build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
|
||||
|
||||
|
||||
|
||||
def _build_leaderboard(session: GameSession) -> list[dict]:
|
||||
return list(
|
||||
Player.objects.filter(session=session)
|
||||
.order_by("-score", "nickname")
|
||||
.values("id", "nickname", "score")
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _get_current_round_question(session: GameSession) -> RoundQuestion | None:
|
||||
return (
|
||||
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
|
||||
.select_related("question")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion:
|
||||
existing_round_question = _get_current_round_question(session)
|
||||
if existing_round_question is not None:
|
||||
return existing_round_question
|
||||
|
||||
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
|
||||
available_questions = Question.objects.filter(
|
||||
category=round_config.category,
|
||||
is_active=True,
|
||||
).exclude(pk__in=used_question_ids)
|
||||
|
||||
if not available_questions.exists():
|
||||
raise ValueError("no_available_questions")
|
||||
|
||||
question = random.choice(list(available_questions))
|
||||
return RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
|
||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||
return {
|
||||
"round_number": session.current_round,
|
||||
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
|
||||
"round_question_id": round_question.id,
|
||||
"prompt": round_question.question.prompt,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
|
||||
deduped_answers = list(round_question.mixed_answers or [])
|
||||
if deduped_answers:
|
||||
return deduped_answers
|
||||
|
||||
lie_texts = list(round_question.lies.values_list("text", flat=True))
|
||||
seen = set()
|
||||
for text in [round_question.correct_answer, *lie_texts]:
|
||||
normalized = text.strip().casefold()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
deduped_answers.append(text.strip())
|
||||
|
||||
if len(deduped_answers) < 2:
|
||||
raise ValueError("not_enough_answers_to_mix")
|
||||
|
||||
random.shuffle(deduped_answers)
|
||||
round_question.mixed_answers = deduped_answers
|
||||
round_question.save(update_fields=["mixed_answers"])
|
||||
return deduped_answers
|
||||
|
||||
|
||||
|
||||
def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]:
|
||||
guesses = list(round_question.guesses.select_related("player"))
|
||||
if not guesses:
|
||||
raise ValueError("no_guesses_submitted")
|
||||
|
||||
bluff_counts: dict[int, int] = {}
|
||||
for guess in guesses:
|
||||
if guess.fooled_player_id:
|
||||
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
|
||||
|
||||
score_events = []
|
||||
|
||||
for guess in guesses:
|
||||
if guess.is_correct:
|
||||
guess.player.score += round_config.points_correct
|
||||
guess.player.save(update_fields=["score"])
|
||||
score_events.append(
|
||||
ScoreEvent(
|
||||
session=session,
|
||||
player=guess.player,
|
||||
delta=round_config.points_correct,
|
||||
reason="guess_correct",
|
||||
meta={"round_question_id": round_question.id, "guess_id": guess.id},
|
||||
)
|
||||
)
|
||||
|
||||
for player_id, fooled_count in bluff_counts.items():
|
||||
delta = fooled_count * round_config.points_bluff
|
||||
player = Player.objects.get(pk=player_id, session=session)
|
||||
player.score += delta
|
||||
player.save(update_fields=["score"])
|
||||
score_events.append(
|
||||
ScoreEvent(
|
||||
session=session,
|
||||
player=player,
|
||||
delta=delta,
|
||||
reason="bluff_success",
|
||||
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
|
||||
)
|
||||
)
|
||||
|
||||
ScoreEvent.objects.bulk_create(score_events)
|
||||
return score_events, _build_leaderboard(session)
|
||||
|
||||
|
||||
|
||||
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
||||
if session.status != GameSession.Status.REVEAL:
|
||||
return session
|
||||
|
||||
current_round_question = _get_current_round_question(session)
|
||||
if current_round_question is None:
|
||||
return session
|
||||
|
||||
players_count = Player.objects.filter(session=session).count()
|
||||
guess_count = Guess.objects.filter(round_question=current_round_question).count()
|
||||
has_score_events = ScoreEvent.objects.filter(
|
||||
session=session,
|
||||
meta__round_question_id=current_round_question.id,
|
||||
).exists()
|
||||
reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count)
|
||||
if not reveal_is_resolved:
|
||||
return session
|
||||
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status != GameSession.Status.REVEAL:
|
||||
return locked_session
|
||||
locked_session.status = GameSession.Status.SCOREBOARD
|
||||
locked_session.save(update_fields=["status"])
|
||||
|
||||
leaderboard = _build_leaderboard(session)
|
||||
sync_broadcast_phase_event(
|
||||
session.code,
|
||||
"phase.scoreboard",
|
||||
{"leaderboard": list(leaderboard), "current_round": session.current_round},
|
||||
)
|
||||
session.refresh_from_db(fields=["status"])
|
||||
return session
|
||||
|
||||
|
||||
|
||||
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
|
||||
status = session.status
|
||||
in_lobby = status == GameSession.Status.LOBBY
|
||||
in_lie = status == GameSession.Status.LIE
|
||||
in_guess = status == GameSession.Status.GUESS
|
||||
in_reveal = status == GameSession.Status.REVEAL
|
||||
in_scoreboard = status == GameSession.Status.SCOREBOARD
|
||||
in_finished = status == GameSession.Status.FINISHED
|
||||
|
||||
@@ -126,6 +288,7 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"current_phase": status,
|
||||
"round_number": session.current_round,
|
||||
"players_count": players_count,
|
||||
"constraints": {
|
||||
@@ -134,12 +297,17 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou
|
||||
"min_players_reached": min_players_reached,
|
||||
"max_players_allowed": max_players_allowed,
|
||||
},
|
||||
"readiness": {
|
||||
"question_ready": has_round_question,
|
||||
"scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED},
|
||||
"can_advance_to_next_round": in_scoreboard,
|
||||
},
|
||||
"host": {
|
||||
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
|
||||
"can_show_question": in_lie and not has_round_question,
|
||||
"can_mix_answers": in_lie or in_guess,
|
||||
"can_calculate_scores": in_guess,
|
||||
"can_reveal_scoreboard": in_reveal,
|
||||
"can_show_question": False,
|
||||
"can_mix_answers": False,
|
||||
"can_calculate_scores": False,
|
||||
"can_reveal_scoreboard": False,
|
||||
"can_start_next_round": in_scoreboard,
|
||||
"can_finish_game": in_scoreboard,
|
||||
},
|
||||
@@ -256,12 +424,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
)
|
||||
)
|
||||
|
||||
current_round_question = (
|
||||
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
|
||||
.select_related("question")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
session = _maybe_promote_reveal_to_scoreboard(session)
|
||||
current_round_question = _get_current_round_question(session)
|
||||
|
||||
round_question_payload = None
|
||||
if current_round_question:
|
||||
@@ -293,6 +457,9 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
"reveal": _build_reveal_payload(current_round_question)
|
||||
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
|
||||
else None,
|
||||
"scoreboard": _build_leaderboard(session)
|
||||
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
|
||||
else None,
|
||||
"phase_view_model": phase_view_model,
|
||||
}
|
||||
)
|
||||
@@ -361,29 +528,34 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
status=400,
|
||||
)
|
||||
|
||||
round_config, created = RoundConfig.objects.get_or_create(
|
||||
session=session,
|
||||
number=session.current_round,
|
||||
defaults={"category": category},
|
||||
)
|
||||
if not created:
|
||||
if RoundConfig.objects.filter(session=session, number=session.current_round).exists():
|
||||
return api_error(
|
||||
request,
|
||||
code="round_already_configured",
|
||||
status=409,
|
||||
)
|
||||
|
||||
round_config = RoundConfig(
|
||||
session=session,
|
||||
number=session.current_round,
|
||||
category=category,
|
||||
)
|
||||
|
||||
try:
|
||||
round_question = _select_round_question(session, round_config)
|
||||
except ValueError as exc:
|
||||
return api_error(request, code=str(exc), status=400)
|
||||
|
||||
round_config.save()
|
||||
session.status = GameSession.Status.LIE
|
||||
session.save(update_fields=["status"])
|
||||
|
||||
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
|
||||
|
||||
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,
|
||||
},
|
||||
lie_started_payload,
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
@@ -400,6 +572,16 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
"name": round_config.category.name,
|
||||
},
|
||||
},
|
||||
"round_question": {
|
||||
"id": round_question.id,
|
||||
"prompt": round_question.question.prompt,
|
||||
"round_number": round_question.round_number,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
|
||||
},
|
||||
"config": {
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
@@ -442,33 +624,14 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
status=400,
|
||||
)
|
||||
|
||||
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
|
||||
return api_error(
|
||||
request,
|
||||
code="question_already_shown",
|
||||
status=409,
|
||||
)
|
||||
|
||||
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
|
||||
available_questions = Question.objects.filter(
|
||||
category=round_config.category,
|
||||
is_active=True,
|
||||
).exclude(pk__in=used_question_ids)
|
||||
|
||||
if not available_questions.exists():
|
||||
return api_error(
|
||||
request,
|
||||
code="no_available_questions",
|
||||
status=400,
|
||||
)
|
||||
|
||||
question = random.choice(list(available_questions))
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
existing_round_question = _get_current_round_question(session)
|
||||
if existing_round_question is not None:
|
||||
round_question = existing_round_question
|
||||
else:
|
||||
try:
|
||||
round_question = _select_round_question(session, round_config)
|
||||
except ValueError as exc:
|
||||
return api_error(request, code=str(exc), status=400)
|
||||
|
||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||
|
||||
@@ -477,7 +640,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
"phase.question_shown",
|
||||
{
|
||||
"round_question_id": round_question.id,
|
||||
"prompt": question.prompt,
|
||||
"prompt": round_question.question.prompt,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
@@ -488,7 +651,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
{
|
||||
"round_question": {
|
||||
"id": round_question.id,
|
||||
"prompt": question.prompt,
|
||||
"prompt": round_question.question.prompt,
|
||||
"round_number": round_question.round_number,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
@@ -558,6 +721,31 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
||||
except IntegrityError:
|
||||
return api_error(request, code="lie_already_submitted", status=409)
|
||||
|
||||
players_count = Player.objects.filter(session=session).count()
|
||||
lie_count = LieAnswer.objects.filter(round_question=round_question).count()
|
||||
session_status = session.status
|
||||
mixed_answers_payload = None
|
||||
|
||||
if players_count > 0 and lie_count >= players_count:
|
||||
try:
|
||||
mixed_answers = _prepare_mixed_answers(round_question)
|
||||
except ValueError as exc:
|
||||
return api_error(request, code=str(exc), status=400)
|
||||
|
||||
session.status = GameSession.Status.GUESS
|
||||
session.save(update_fields=["status"])
|
||||
session_status = session.status
|
||||
mixed_answers_payload = [{"text": text} for text in mixed_answers]
|
||||
sync_broadcast_phase_event(
|
||||
session.code,
|
||||
"phase.guess_started",
|
||||
{
|
||||
"round_question_id": round_question.id,
|
||||
"answers": mixed_answers_payload,
|
||||
"guess_seconds": round_config.guess_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"lie": {
|
||||
@@ -570,6 +758,18 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
||||
"window": {
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
},
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session_status,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"phase_transition": {
|
||||
"current_phase": session_status,
|
||||
"lies_submitted": lie_count,
|
||||
"players_expected": players_count,
|
||||
"auto_advanced": session_status == GameSession.Status.GUESS,
|
||||
},
|
||||
"answers": mixed_answers_payload,
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
@@ -626,27 +826,10 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
|
||||
|
||||
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
|
||||
|
||||
deduped_answers = list(locked_round_question.mixed_answers or [])
|
||||
if not deduped_answers:
|
||||
lie_texts = list(locked_round_question.lies.values_list("text", flat=True))
|
||||
seen = set()
|
||||
for text in [locked_round_question.correct_answer, *lie_texts]:
|
||||
normalized = text.strip().casefold()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
deduped_answers.append(text.strip())
|
||||
|
||||
if len(deduped_answers) < 2:
|
||||
return api_error(
|
||||
request,
|
||||
code="not_enough_answers_to_mix",
|
||||
status=400,
|
||||
)
|
||||
|
||||
random.shuffle(deduped_answers)
|
||||
locked_round_question.mixed_answers = deduped_answers
|
||||
locked_round_question.save(update_fields=["mixed_answers"])
|
||||
try:
|
||||
deduped_answers = _prepare_mixed_answers(locked_round_question)
|
||||
except ValueError as exc:
|
||||
return api_error(request, code=str(exc), status=400)
|
||||
|
||||
if locked_session.status == GameSession.Status.LIE:
|
||||
locked_session.status = GameSession.Status.GUESS
|
||||
@@ -769,6 +952,67 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
||||
except IntegrityError:
|
||||
return api_error(request, code="guess_already_submitted", status=409)
|
||||
|
||||
players_count = Player.objects.filter(session=session).count()
|
||||
guess_count = Guess.objects.filter(round_question=round_question).count()
|
||||
session_status = session.status
|
||||
reveal_payload = None
|
||||
leaderboard = None
|
||||
|
||||
if players_count > 0 and guess_count >= players_count:
|
||||
score_events = []
|
||||
should_broadcast_scores = False
|
||||
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
|
||||
if locked_session.status == GameSession.Status.GUESS:
|
||||
already_calculated = ScoreEvent.objects.filter(
|
||||
session=locked_session,
|
||||
meta__round_question_id=round_question.id,
|
||||
).exists()
|
||||
if not already_calculated:
|
||||
score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config)
|
||||
should_broadcast_scores = True
|
||||
else:
|
||||
score_events = list(
|
||||
ScoreEvent.objects.filter(
|
||||
session=locked_session,
|
||||
meta__round_question_id=round_question.id,
|
||||
).select_related("player")
|
||||
)
|
||||
leaderboard = _build_leaderboard(locked_session)
|
||||
|
||||
locked_session.status = GameSession.Status.REVEAL
|
||||
locked_session.save(update_fields=["status"])
|
||||
|
||||
elif locked_session.status == GameSession.Status.REVEAL:
|
||||
score_events = list(
|
||||
ScoreEvent.objects.filter(
|
||||
session=locked_session,
|
||||
meta__round_question_id=round_question.id,
|
||||
).select_related("player")
|
||||
)
|
||||
leaderboard = _build_leaderboard(locked_session)
|
||||
|
||||
session_status = locked_session.status
|
||||
|
||||
reveal_payload = _build_reveal_payload(round_question)
|
||||
|
||||
if should_broadcast_scores:
|
||||
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(
|
||||
{
|
||||
"guess": {
|
||||
@@ -783,6 +1027,19 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
||||
"window": {
|
||||
"guess_deadline_at": guess_deadline_at.isoformat(),
|
||||
},
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session_status,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"phase_transition": {
|
||||
"current_phase": session_status,
|
||||
"guesses_submitted": guess_count,
|
||||
"players_expected": players_count,
|
||||
"auto_advanced": session_status == GameSession.Status.REVEAL,
|
||||
},
|
||||
"reveal": reveal_payload,
|
||||
"leaderboard": leaderboard,
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
@@ -803,37 +1060,18 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if session.host_id != request.user.id:
|
||||
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"])
|
||||
|
||||
|
||||
leaderboard = list(
|
||||
Player.objects.filter(session=session)
|
||||
.order_by("-score", "nickname")
|
||||
.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},
|
||||
)
|
||||
session = _maybe_promote_reveal_to_scoreboard(session)
|
||||
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
|
||||
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
||||
|
||||
leaderboard = _build_leaderboard(session)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": locked_session.status,
|
||||
"current_round": locked_session.current_round,
|
||||
"status": session.status,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"leaderboard": leaderboard,
|
||||
}
|
||||
@@ -858,18 +1096,65 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if locked_session.status != GameSession.Status.SCOREBOARD:
|
||||
return api_error(request, code="next_round_invalid_phase", status=400)
|
||||
|
||||
previous_round_config = RoundConfig.objects.filter(
|
||||
session=locked_session,
|
||||
number=locked_session.current_round,
|
||||
).select_related("category").first()
|
||||
if previous_round_config is None:
|
||||
return api_error(request, code="round_config_missing", status=400)
|
||||
|
||||
locked_session.current_round += 1
|
||||
locked_session.status = GameSession.Status.LOBBY
|
||||
next_round_number = locked_session.current_round + 1
|
||||
next_round_config = RoundConfig(
|
||||
session=locked_session,
|
||||
number=next_round_number,
|
||||
category=previous_round_config.category,
|
||||
lie_seconds=previous_round_config.lie_seconds,
|
||||
guess_seconds=previous_round_config.guess_seconds,
|
||||
points_correct=previous_round_config.points_correct,
|
||||
points_bluff=previous_round_config.points_bluff,
|
||||
)
|
||||
locked_session.current_round = next_round_number
|
||||
|
||||
try:
|
||||
round_question = _select_round_question(locked_session, next_round_config)
|
||||
except ValueError as exc:
|
||||
return api_error(request, code=str(exc), status=400)
|
||||
|
||||
next_round_config.save()
|
||||
locked_session.status = GameSession.Status.LIE
|
||||
locked_session.save(update_fields=["current_round", "status"])
|
||||
|
||||
lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question)
|
||||
sync_broadcast_phase_event(
|
||||
locked_session.code,
|
||||
"phase.lie_started",
|
||||
lie_started_payload,
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": GameSession.Status.LOBBY,
|
||||
"code": locked_session.code,
|
||||
"status": locked_session.status,
|
||||
"current_round": locked_session.current_round,
|
||||
}
|
||||
},
|
||||
"round": {
|
||||
"number": next_round_config.number,
|
||||
"category": {
|
||||
"slug": next_round_config.category.slug,
|
||||
"name": next_round_config.category.name,
|
||||
},
|
||||
},
|
||||
"round_question": {
|
||||
"id": round_question.id,
|
||||
"prompt": round_question.question.prompt,
|
||||
"round_number": round_question.round_number,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
|
||||
},
|
||||
"config": {
|
||||
"lie_seconds": next_round_config.lie_seconds,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user