[READY][Gameplay] #310 Host transition idempotency and error catalog for scoreboard -> next round / finish #320
@@ -38,7 +38,10 @@ jobs:
|
|||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install SPA dependencies
|
- name: Install SPA dependencies
|
||||||
run: npm ci --prefix frontend/angular
|
run: |
|
||||||
|
npm ci --prefix frontend/angular
|
||||||
|
node -e "require('./frontend/angular/node_modules/rollup/dist/native.js')" \
|
||||||
|
|| npm install --prefix frontend/angular
|
||||||
|
|
||||||
- name: SPA Angular smoke tests
|
- name: SPA Angular smoke tests
|
||||||
run: npm --prefix frontend/angular test
|
run: npm --prefix frontend/angular test
|
||||||
|
|||||||
52
docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md
Normal file
52
docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Issue #301 Artifact — Client action gating from canonical phase state
|
||||||
|
|
||||||
|
Refs: #287, #301
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Frontend host/player shells now prefer the canonical phase exposed by `phase_view_model.current_phase` when deciding:
|
||||||
|
|
||||||
|
- which gameplay actions are enabled
|
||||||
|
- whether reveal data should still be shown
|
||||||
|
- which SPA hash-route should represent the active game state
|
||||||
|
|
||||||
|
This tightens the #301 slice so the client stays aligned with backend canonicalisation even when `session.status` lags during reveal/scoreboard promotion.
|
||||||
|
|
||||||
|
## Gated UI actions by phase
|
||||||
|
|
||||||
|
### Lobby
|
||||||
|
- **Host:** `startRound`
|
||||||
|
- **Player:** `join`
|
||||||
|
|
||||||
|
### Bluff / lie
|
||||||
|
- **Host:** `showQuestion`
|
||||||
|
- **Player:** `submitLie`
|
||||||
|
- **Blocked:** guess submission, scoreboard load, next round, finish game
|
||||||
|
|
||||||
|
### Guess
|
||||||
|
- **Host:** `mixAnswers`, `calculateScores`
|
||||||
|
- **Player:** `submitGuess`
|
||||||
|
- **Blocked:** lie submission, scoreboard load, next round, finish game
|
||||||
|
|
||||||
|
### Reveal
|
||||||
|
- **Host:** `loadScoreboard`
|
||||||
|
- **Player:** display-only reveal state
|
||||||
|
- **Blocked:** start next round, finish game, guess/lie submission
|
||||||
|
|
||||||
|
### Scoreboard
|
||||||
|
- **Host:** `startNextRound`, `finishGame`
|
||||||
|
- **Player:** display-only reveal/scoreboard state
|
||||||
|
- **Blocked:** scoreboard reload, guess/lie submission
|
||||||
|
|
||||||
|
## Test evidence
|
||||||
|
|
||||||
|
Targeted tests added/updated for:
|
||||||
|
|
||||||
|
- host shell canonical gating and route sync when `current_phase` differs from `session.status`
|
||||||
|
- player shell canonical gating and route sync when `current_phase` differs from `session.status`
|
||||||
|
- shared gameplay phase machine gating from canonical permissions
|
||||||
|
- shared API mapper contract coverage, including reveal/scoreboard payload stability
|
||||||
|
|
||||||
|
## Contract note
|
||||||
|
|
||||||
|
No backend protocol redesign was introduced. This follow-up only preserves and consumes the existing canonical phase/action contract more strictly on the client side.
|
||||||
55
docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md
Normal file
55
docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Issue #302 Evidence — canonical bluff → guess → reveal → scoreboard regression
|
||||||
|
|
||||||
|
## Runnable command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py smoke_staging --artifact docs/artifacts/issue-302-canonical-loop-smoke.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate` is the normal local bootstrap precondition when the database has not been initialized yet; the regression evidence itself is produced by `smoke_staging`.
|
||||||
|
|
||||||
|
## What the regression proves
|
||||||
|
|
||||||
|
`smoke_staging` now exercises one full canonical round and fails fast with step-specific diagnostics if any of these break:
|
||||||
|
|
||||||
|
1. `start_round` lands the session in `lie` and returns a concrete `round_question_id`.
|
||||||
|
2. Final `submit_lie` auto-advances the session to `guess` and exposes mixed answers containing both the correct answer and player bluffs.
|
||||||
|
3. Final `submit_guess` auto-advances the session to `reveal` and returns the canonical reveal payload.
|
||||||
|
4. The reveal payload includes:
|
||||||
|
- correct answer
|
||||||
|
- all lies
|
||||||
|
- all guesses
|
||||||
|
- fooled-player references for bluff hits
|
||||||
|
5. The first canonical state read after reveal promotes the session to `scoreboard`.
|
||||||
|
6. Scoreboard promotion preserves the same reveal payload and exposes a leaderboard with `scoreboard_ready=true`.
|
||||||
|
|
||||||
|
## Artifact shape
|
||||||
|
|
||||||
|
When `--artifact` is provided, the JSON file records:
|
||||||
|
|
||||||
|
- the exact smoke command
|
||||||
|
- session code and round question id
|
||||||
|
- deterministic guess plan used to produce both bluff hits and one correct guess
|
||||||
|
- per-step evidence for:
|
||||||
|
- `create_session`
|
||||||
|
- `join_players`
|
||||||
|
- `start_round`
|
||||||
|
- `auto_guess_transition`
|
||||||
|
- `submit_guesses`
|
||||||
|
- `auto_reveal_transition`
|
||||||
|
- `auto_scoreboard_transition`
|
||||||
|
- `finish_game`
|
||||||
|
- reveal summary (`correct_answer`, lie/guess counts, fooled-player ids, correct guess player ids)
|
||||||
|
- promoted scoreboard leaderboard payload
|
||||||
|
|
||||||
|
## Targeted test coverage
|
||||||
|
|
||||||
|
Backend regression coverage lives in `lobby/tests.py`:
|
||||||
|
|
||||||
|
- `test_smoke_staging_command_runs_full_flow`
|
||||||
|
- `test_smoke_staging_writes_phase_evidence_artifact_when_requested`
|
||||||
|
|
||||||
|
Together they ensure the command stays runnable in normal workflow and that the evidence artifact contains phase-by-phase proof instead of only a generic pass/fail.
|
||||||
|
|
||||||
|
Refs #287 #302
|
||||||
110
docs/artifacts/issue-302-canonical-loop-smoke.json
Normal file
110
docs/artifacts/issue-302-canonical-loop-smoke.json
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"command": "python manage.py smoke_staging --artifact <path>",
|
||||||
|
"generated_at": "2026-03-16T15:19:30.105231+00:00",
|
||||||
|
"question": {
|
||||||
|
"prompt": "Smoke prompt?",
|
||||||
|
"correct_answer": "Correct"
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"step": "create_session",
|
||||||
|
"session_status": "lobby"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "join_players",
|
||||||
|
"players_count": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "start_round",
|
||||||
|
"session_status": "lie",
|
||||||
|
"round_question_id": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "auto_guess_transition",
|
||||||
|
"session_status": "guess",
|
||||||
|
"answers": [
|
||||||
|
"Lie from P3",
|
||||||
|
"Lie from P1",
|
||||||
|
"Lie from P2",
|
||||||
|
"Correct"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "submit_guesses",
|
||||||
|
"guess_results": [
|
||||||
|
{
|
||||||
|
"player_id": 1,
|
||||||
|
"selected_text": "Lie from P2",
|
||||||
|
"is_correct": false,
|
||||||
|
"fooled_player_id": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player_id": 2,
|
||||||
|
"selected_text": "Correct",
|
||||||
|
"is_correct": true,
|
||||||
|
"fooled_player_id": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player_id": 3,
|
||||||
|
"selected_text": "Lie from P1",
|
||||||
|
"is_correct": false,
|
||||||
|
"fooled_player_id": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "auto_reveal_transition",
|
||||||
|
"session_status": "reveal",
|
||||||
|
"reveal": {
|
||||||
|
"correct_answer": "Correct",
|
||||||
|
"lies_count": 3,
|
||||||
|
"guesses_count": 3,
|
||||||
|
"fooled_player_ids": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"correct_guess_player_ids": [
|
||||||
|
2
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "auto_scoreboard_transition",
|
||||||
|
"session_status": "scoreboard",
|
||||||
|
"leaderboard": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"nickname": "P2",
|
||||||
|
"score": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nickname": "P1",
|
||||||
|
"score": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"nickname": "P3",
|
||||||
|
"score": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "finish_game",
|
||||||
|
"session_status": "finished"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"session_code": "7YV59E",
|
||||||
|
"players": [
|
||||||
|
"P1",
|
||||||
|
"P2",
|
||||||
|
"P3"
|
||||||
|
],
|
||||||
|
"round_question_id": 1,
|
||||||
|
"guess_plan": {
|
||||||
|
"P1": "Lie from P2",
|
||||||
|
"P2": "Correct",
|
||||||
|
"P3": "Lie from P1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
|
|||||||
function sessionDetailPayload(
|
function sessionDetailPayload(
|
||||||
status: string,
|
status: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
currentPhase?: string;
|
||||||
roundQuestionId?: number | null;
|
roundQuestionId?: number | null;
|
||||||
reveal?: {
|
reveal?: {
|
||||||
correct_answer: string;
|
correct_answer: string;
|
||||||
@@ -81,6 +82,7 @@ function sessionDetailPayload(
|
|||||||
},
|
},
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status,
|
status,
|
||||||
|
current_phase: options?.currentPhase ?? status,
|
||||||
round_number: 1,
|
round_number: 1,
|
||||||
players_count: 2,
|
players_count: 2,
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -89,14 +91,18 @@ function sessionDetailPayload(
|
|||||||
min_players_reached: true,
|
min_players_reached: true,
|
||||||
max_players_allowed: true,
|
max_players_allowed: true,
|
||||||
},
|
},
|
||||||
|
readiness: {
|
||||||
|
question_ready: (options?.currentPhase ?? status) !== 'lobby',
|
||||||
|
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
|
||||||
|
},
|
||||||
host: {
|
host: {
|
||||||
can_start_round: status === 'lobby',
|
can_start_round: (options?.currentPhase ?? status) === 'lobby',
|
||||||
can_show_question: status === 'lie',
|
can_show_question: (options?.currentPhase ?? status) === 'lie',
|
||||||
can_mix_answers: status === 'lie' || status === 'guess',
|
can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess',
|
||||||
can_calculate_scores: status === 'guess',
|
can_calculate_scores: (options?.currentPhase ?? status) === 'guess',
|
||||||
can_reveal_scoreboard: status === 'reveal',
|
can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal',
|
||||||
can_start_next_round: status === 'reveal',
|
can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard',
|
||||||
can_finish_game: status === 'reveal',
|
can_finish_game: (options?.currentPhase ?? status) === 'scoreboard',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
can_join: status === 'lobby',
|
can_join: status === 'lobby',
|
||||||
@@ -259,7 +265,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
component.scoreboardPayload = '{"leaderboard":[]}';
|
component.scoreboardPayload = '{"leaderboard":[]}';
|
||||||
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
|
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
|
||||||
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
|
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
|
||||||
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||||
|
|
||||||
await component.startNextRound();
|
await component.startNextRound();
|
||||||
|
|
||||||
@@ -296,7 +302,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
|
|
||||||
const component = new HostShellComponent();
|
const component = new HostShellComponent();
|
||||||
component.sessionCode = 'ABCD12';
|
component.sessionCode = 'ABCD12';
|
||||||
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||||
|
|
||||||
await component.finishGame();
|
await component.finishGame();
|
||||||
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
|
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
|
||||||
@@ -320,7 +326,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
|
|
||||||
const component = new HostShellComponent();
|
const component = new HostShellComponent();
|
||||||
component.sessionCode = ' ';
|
component.sessionCode = ' ';
|
||||||
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||||
|
|
||||||
await component.startNextRound();
|
await component.startNextRound();
|
||||||
await component.finishGame();
|
await component.finishGame();
|
||||||
@@ -351,6 +357,10 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
|
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
|
||||||
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||||
await component.loadScoreboard();
|
await component.loadScoreboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const status of ['lie', 'guess', 'reveal'] as const) {
|
||||||
|
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||||
await component.startNextRound();
|
await component.startNextRound();
|
||||||
await component.finishGame();
|
await component.finishGame();
|
||||||
}
|
}
|
||||||
@@ -361,16 +371,42 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
|
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
|
||||||
expect(component.canCalculateScores).toBe(false);
|
expect(component.canCalculateScores).toBe(false);
|
||||||
expect(component.canLoadScoreboard).toBe(true);
|
expect(component.canLoadScoreboard).toBe(true);
|
||||||
expect(component.canStartNextRound).toBe(true);
|
expect(component.canStartNextRound).toBe(false);
|
||||||
expect(component.canFinishGame).toBe(true);
|
expect(component.canFinishGame).toBe(false);
|
||||||
|
|
||||||
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||||
expect(component.canLoadScoreboard).toBe(false);
|
expect(component.canLoadScoreboard).toBe(false);
|
||||||
expect(component.canStartNextRound).toBe(false);
|
expect(component.canStartNextRound).toBe(true);
|
||||||
expect(component.canFinishGame).toBe(false);
|
expect(component.canFinishGame).toBe(true);
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers canonical current_phase for reveal panel and host routing when status lags behind', async () => {
|
||||||
|
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 77, reveal: { correct_answer: 'Mercury' } }))
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const replaceState = vi.fn();
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
location: { hash: '#/host/reveal/ABCD12' },
|
||||||
|
history: { state: null, replaceState },
|
||||||
|
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const component = new HostShellComponent();
|
||||||
|
component.sessionCode = 'ABCD12';
|
||||||
|
|
||||||
|
await component.refreshSession();
|
||||||
|
|
||||||
|
expect(component.gameplayPhase).toBe('scoreboard');
|
||||||
|
expect(component.showRevealPanel).toBe(true);
|
||||||
|
expect(component.canLoadScoreboard).toBe(false);
|
||||||
|
expect(component.canStartNextRound).toBe(true);
|
||||||
|
expect(component.canFinishGame).toBe(true);
|
||||||
|
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/scoreboard/ABCD12');
|
||||||
|
});
|
||||||
|
|
||||||
it('syncs host hash-route with latest phase after refresh without page reload', async () => {
|
it('syncs host hash-route with latest phase after refresh without page reload', async () => {
|
||||||
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
|
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
@@ -408,12 +444,12 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
|
|
||||||
component.session = sessionDetailPayload('reveal') as any;
|
component.session = sessionDetailPayload('reveal') as any;
|
||||||
expect(component.canLoadScoreboard).toBe(true);
|
expect(component.canLoadScoreboard).toBe(true);
|
||||||
expect(component.canStartNextRound).toBe(true);
|
expect(component.canStartNextRound).toBe(false);
|
||||||
expect(component.canFinishGame).toBe(true);
|
expect(component.canFinishGame).toBe(false);
|
||||||
|
|
||||||
component.session = sessionDetailPayload('scoreboard') as any;
|
component.session = sessionDetailPayload('scoreboard') as any;
|
||||||
expect(component.canLoadScoreboard).toBe(false);
|
expect(component.canLoadScoreboard).toBe(false);
|
||||||
expect(component.canStartNextRound).toBe(false);
|
expect(component.canStartNextRound).toBe(true);
|
||||||
expect(component.canFinishGame).toBe(false);
|
expect(component.canFinishGame).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ type LeaderboardResponse = FinishGameResponse;
|
|||||||
<ul>
|
<ul>
|
||||||
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
|
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
|
<div class="panel" *ngIf="showRevealPanel">
|
||||||
<h3>Reveal</h3>
|
<h3>Reveal</h3>
|
||||||
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
||||||
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
||||||
@@ -163,6 +163,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
return isHostGameplayActionAllowed(this.session as any, 'finishGame');
|
return isHostGameplayActionAllowed(this.session as any, 'finishGame');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showRevealPanel(): boolean {
|
||||||
|
return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard'));
|
||||||
|
}
|
||||||
|
|
||||||
copy(key: string): string {
|
copy(key: string): string {
|
||||||
return t(key, this.locale);
|
return t(key, this.locale);
|
||||||
}
|
}
|
||||||
@@ -364,7 +368,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = this.session.session.status || 'lobby';
|
const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
|
||||||
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
|
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function jsonResponse(status: number, body: unknown) {
|
|||||||
function sessionDetailPayload(
|
function sessionDetailPayload(
|
||||||
status: string,
|
status: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
currentPhase?: string;
|
||||||
answers?: string[];
|
answers?: string[];
|
||||||
players?: Array<{ id: number; nickname: string; score: number }>;
|
players?: Array<{ id: number; nickname: string; score: number }>;
|
||||||
roundQuestionId?: number | null;
|
roundQuestionId?: number | null;
|
||||||
@@ -79,6 +80,7 @@ function sessionDetailPayload(
|
|||||||
},
|
},
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status,
|
status,
|
||||||
|
current_phase: options?.currentPhase ?? status,
|
||||||
round_number: 1,
|
round_number: 1,
|
||||||
players_count: (options?.players ?? []).length,
|
players_count: (options?.players ?? []).length,
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -87,6 +89,10 @@ function sessionDetailPayload(
|
|||||||
min_players_reached: true,
|
min_players_reached: true,
|
||||||
max_players_allowed: true,
|
max_players_allowed: true,
|
||||||
},
|
},
|
||||||
|
readiness: {
|
||||||
|
question_ready: (options?.currentPhase ?? status) !== 'lobby',
|
||||||
|
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
|
||||||
|
},
|
||||||
host: {
|
host: {
|
||||||
can_start_round: false,
|
can_start_round: false,
|
||||||
can_show_question: false,
|
can_show_question: false,
|
||||||
@@ -97,10 +103,10 @@ function sessionDetailPayload(
|
|||||||
can_finish_game: false,
|
can_finish_game: false,
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
can_join: status === 'lobby',
|
can_join: (options?.currentPhase ?? status) === 'lobby',
|
||||||
can_submit_lie: status === 'lie',
|
can_submit_lie: (options?.currentPhase ?? status) === 'lie',
|
||||||
can_submit_guess: status === 'guess',
|
can_submit_guess: (options?.currentPhase ?? status) === 'guess',
|
||||||
can_view_final_result: status === 'finished',
|
can_view_final_result: (options?.currentPhase ?? status) === 'finished',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -437,6 +443,34 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
expect(values.get('wpp.session-context')).toBeUndefined();
|
expect(values.get('wpp.session-context')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers canonical current_phase for player reveal panel and routing when status lags behind', async () => {
|
||||||
|
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 11, reveal: { correct_answer: 'A' } }))
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const replaceState = vi.fn();
|
||||||
|
const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() };
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
location: { hash: '#/player/reveal/ABCD12' },
|
||||||
|
history: { state: null, replaceState },
|
||||||
|
localStorage,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const component = new PlayerShellComponent();
|
||||||
|
component.sessionCode = 'ABCD12';
|
||||||
|
|
||||||
|
await component.refreshSession();
|
||||||
|
|
||||||
|
expect(component.gameplayPhase).toBe('scoreboard');
|
||||||
|
expect(component.showRevealPanel).toBe(true);
|
||||||
|
expect(component.showGuessControls).toBe(false);
|
||||||
|
expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/scoreboard/ABCD12');
|
||||||
|
});
|
||||||
|
|
||||||
it('syncs player hash-route with latest phase during periodic state sync', async () => {
|
it('syncs player hash-route with latest phase during periodic state sync', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function resolveLocalStorage(): Storage | undefined {
|
|||||||
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading || !canSubmitGuess">{{ copy('player.retry_guess_submit') }}</button>
|
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading || !canSubmitGuess">{{ copy('player.retry_guess_submit') }}</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
|
<div class="panel" *ngIf="showRevealPanel">
|
||||||
<h3>Reveal</h3>
|
<h3>Reveal</h3>
|
||||||
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
||||||
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
||||||
@@ -221,6 +221,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
return isPlayerGameplayActionAllowed(this.session as any, 'submitGuess');
|
return isPlayerGameplayActionAllowed(this.session as any, 'submitGuess');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showRevealPanel(): boolean {
|
||||||
|
return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard'));
|
||||||
|
}
|
||||||
|
|
||||||
private readonly handleOnline = (): void => {
|
private readonly handleOnline = (): void => {
|
||||||
this.connectionState = 'reconnecting';
|
this.connectionState = 'reconnecting';
|
||||||
void this.retryReconnect();
|
void this.retryReconnect();
|
||||||
@@ -469,7 +473,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const phase = this.session.session.status || 'lobby';
|
const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
|
||||||
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
|
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
|||||||
reveal,
|
reveal,
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status: readString(phase, 'status', 'session_detail.phase_view_model'),
|
status: readString(phase, 'status', 'session_detail.phase_view_model'),
|
||||||
|
current_phase: typeof phase.current_phase === 'string' ? phase.current_phase : undefined,
|
||||||
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
|
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
|
||||||
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
|
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -203,6 +204,19 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
|||||||
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
|
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
|
||||||
max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints')
|
max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints')
|
||||||
},
|
},
|
||||||
|
readiness:
|
||||||
|
phase.readiness && typeof phase.readiness === 'object'
|
||||||
|
? {
|
||||||
|
question_ready:
|
||||||
|
typeof (phase.readiness as Record<string, unknown>).question_ready === 'boolean'
|
||||||
|
? ((phase.readiness as Record<string, unknown>).question_ready as boolean)
|
||||||
|
: undefined,
|
||||||
|
scoreboard_ready:
|
||||||
|
typeof (phase.readiness as Record<string, unknown>).scoreboard_ready === 'boolean'
|
||||||
|
? ((phase.readiness as Record<string, unknown>).scoreboard_ready as boolean)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
host: {
|
host: {
|
||||||
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
|
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
|
||||||
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
|
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface SessionRoundQuestion {
|
|||||||
|
|
||||||
export interface PhaseViewModel {
|
export interface PhaseViewModel {
|
||||||
status: string;
|
status: string;
|
||||||
|
current_phase?: string;
|
||||||
round_number: number;
|
round_number: number;
|
||||||
players_count: number;
|
players_count: number;
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -40,6 +41,10 @@ export interface PhaseViewModel {
|
|||||||
min_players_reached: boolean;
|
min_players_reached: boolean;
|
||||||
max_players_allowed: boolean;
|
max_players_allowed: boolean;
|
||||||
};
|
};
|
||||||
|
readiness?: {
|
||||||
|
question_ready?: boolean;
|
||||||
|
scoreboard_ready?: boolean;
|
||||||
|
};
|
||||||
host: {
|
host: {
|
||||||
can_start_round: boolean;
|
can_start_round: boolean;
|
||||||
can_show_question: boolean;
|
can_show_question: boolean;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Run minimal staging smoke flow for lobby gameplay"
|
help = "Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -18,6 +18,26 @@ class Command(BaseCommand):
|
|||||||
help="Optional path to write smoke result artifact as JSON",
|
help="Optional path to write smoke result artifact as JSON",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _fail(self, step: str, detail: str, payload=None):
|
||||||
|
message = f"{step} failed: {detail}"
|
||||||
|
if payload is not None:
|
||||||
|
message += f" | payload={json.dumps(payload, sort_keys=True)}"
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _expect_status(self, response, expected_status: int, step: str):
|
||||||
|
if response.status_code != expected_status:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
payload = {"raw": response.content.decode("utf-8", errors="replace")}
|
||||||
|
self._fail(step, f"expected HTTP {expected_status}, got {response.status_code}", payload)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
|
||||||
|
actual_status = payload.get("session", {}).get("status")
|
||||||
|
if actual_status != expected_status:
|
||||||
|
self._fail(step, f"expected session.status={expected_status}, got {actual_status}", payload)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
GameSession.objects.all().delete()
|
GameSession.objects.all().delete()
|
||||||
Player.objects.all().delete()
|
Player.objects.all().delete()
|
||||||
@@ -30,11 +50,14 @@ class Command(BaseCommand):
|
|||||||
category.is_active = True
|
category.is_active = True
|
||||||
category.save(update_fields=["is_active"])
|
category.save(update_fields=["is_active"])
|
||||||
|
|
||||||
Question.objects.get_or_create(
|
question, _ = Question.objects.get_or_create(
|
||||||
category=category,
|
category=category,
|
||||||
prompt="Smoke prompt?",
|
prompt="Smoke prompt?",
|
||||||
defaults={"correct_answer": "Correct", "is_active": True},
|
defaults={"correct_answer": "Correct", "is_active": True},
|
||||||
)
|
)
|
||||||
|
if not question.is_active:
|
||||||
|
question.is_active = True
|
||||||
|
question.save(update_fields=["is_active"])
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
host, _ = User.objects.get_or_create(username="smoke-host")
|
host, _ = User.objects.get_or_create(username="smoke-host")
|
||||||
@@ -42,111 +65,254 @@ class Command(BaseCommand):
|
|||||||
host.is_staff = True
|
host.is_staff = True
|
||||||
host.save()
|
host.save()
|
||||||
|
|
||||||
|
artifact = {
|
||||||
|
"ok": True,
|
||||||
|
"command": "python manage.py smoke_staging --artifact <path>",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"question": {
|
||||||
|
"prompt": question.prompt,
|
||||||
|
"correct_answer": question.correct_answer,
|
||||||
|
},
|
||||||
|
"steps": [],
|
||||||
|
}
|
||||||
|
|
||||||
host_client = Client()
|
host_client = Client()
|
||||||
host_client.force_login(host)
|
host_client.force_login(host)
|
||||||
|
|
||||||
create_res = host_client.post("/lobby/sessions/create", content_type="application/json")
|
create_payload = self._expect_status(
|
||||||
if create_res.status_code != 201:
|
host_client.post("/lobby/sessions/create", content_type="application/json"),
|
||||||
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}")
|
201,
|
||||||
|
"create_session",
|
||||||
code = create_res.json()["session"]["code"]
|
)
|
||||||
|
code = create_payload["session"]["code"]
|
||||||
|
artifact["session_code"] = code
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "create_session",
|
||||||
|
"session_status": create_payload["session"]["status"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
players = []
|
players = []
|
||||||
for nickname in ["P1", "P2", "P3"]:
|
for nickname in ["P1", "P2", "P3"]:
|
||||||
join_res = Client().post(
|
join_payload = self._expect_status(
|
||||||
"/lobby/sessions/join",
|
Client().post(
|
||||||
data=json.dumps({"code": code, "nickname": nickname}),
|
"/lobby/sessions/join",
|
||||||
content_type="application/json",
|
data=json.dumps({"code": code, "nickname": nickname}),
|
||||||
|
content_type="application/json",
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
f"join_session[{nickname}]",
|
||||||
)
|
)
|
||||||
if join_res.status_code != 201:
|
players.append(join_payload["player"])
|
||||||
raise CommandError(f"join_session failed for {nickname}: {join_res.status_code}")
|
artifact["players"] = [player["nickname"] for player in players]
|
||||||
players.append(join_res.json()["player"])
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
start_res = host_client.post(
|
"step": "join_players",
|
||||||
f"/lobby/sessions/{code}/rounds/start",
|
"players_count": len(players),
|
||||||
data=json.dumps({"category_slug": category.slug}),
|
}
|
||||||
content_type="application/json",
|
|
||||||
)
|
)
|
||||||
if start_res.status_code != 201:
|
|
||||||
raise CommandError(f"start_round failed: {start_res.status_code}")
|
|
||||||
|
|
||||||
round_question_id = start_res.json()["round_question"]["id"]
|
start_payload = self._expect_status(
|
||||||
|
host_client.post(
|
||||||
|
f"/lobby/sessions/{code}/rounds/start",
|
||||||
|
data=json.dumps({"category_slug": category.slug}),
|
||||||
|
content_type="application/json",
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
"start_round",
|
||||||
|
)
|
||||||
|
self._expect_session_status(start_payload, GameSession.Status.LIE, "start_round")
|
||||||
|
|
||||||
|
round_question_id = start_payload["round_question"]["id"]
|
||||||
|
artifact["round_question_id"] = round_question_id
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "start_round",
|
||||||
|
"session_status": start_payload["session"]["status"],
|
||||||
|
"round_question_id": round_question_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
answers = []
|
answers = []
|
||||||
|
lie_transition_payload = None
|
||||||
for player in players:
|
for player in players:
|
||||||
nick = player["nickname"]
|
nickname = player["nickname"]
|
||||||
lie_res = Client().post(
|
lie_payload = self._expect_status(
|
||||||
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
|
Client().post(
|
||||||
data=json.dumps(
|
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
|
||||||
{
|
data=json.dumps(
|
||||||
"player_id": player["id"],
|
{
|
||||||
"session_token": player["session_token"],
|
"player_id": player["id"],
|
||||||
"text": f"Lie from {nick}",
|
"session_token": player["session_token"],
|
||||||
}
|
"text": f"Lie from {nickname}",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
201,
|
||||||
|
f"submit_lie[{nickname}]",
|
||||||
)
|
)
|
||||||
if lie_res.status_code != 201:
|
if lie_payload.get("answers"):
|
||||||
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
|
answers = lie_payload["answers"]
|
||||||
if lie_res.json().get("answers"):
|
lie_transition_payload = lie_payload
|
||||||
answers = lie_res.json()["answers"]
|
|
||||||
|
|
||||||
if not answers:
|
if not answers:
|
||||||
detail_res = host_client.get(f"/lobby/sessions/{code}")
|
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_lies")
|
||||||
if detail_res.status_code != 200:
|
answers = detail_payload.get("round_question", {}).get("answers", [])
|
||||||
raise CommandError(f"session_detail after lies failed: {detail_res.status_code}")
|
self._expect_session_status(detail_payload, GameSession.Status.GUESS, "session_detail_after_lies")
|
||||||
answers = detail_res.json().get("round_question", {}).get("answers", [])
|
lie_transition_payload = detail_payload
|
||||||
|
|
||||||
if not answers:
|
if not answers:
|
||||||
raise CommandError("canonical lie->guess transition returned empty answers")
|
self._fail("auto_guess_transition", "canonical lie->guess transition returned empty answers")
|
||||||
|
|
||||||
for player in players:
|
if not any(answer.get("text") == question.correct_answer for answer in answers):
|
||||||
nick = player["nickname"]
|
self._fail("auto_guess_transition", "mixed answers missing correct answer", {"answers": answers})
|
||||||
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
|
if len(answers) < len(players) + 1:
|
||||||
guess_res = Client().post(
|
self._fail(
|
||||||
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
|
"auto_guess_transition",
|
||||||
data=json.dumps(
|
"mixed answers shorter than expected bluff set",
|
||||||
{
|
{"answers": answers, "players_count": len(players)},
|
||||||
"player_id": player["id"],
|
|
||||||
"session_token": player["session_token"],
|
|
||||||
"selected_text": selected["text"],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
)
|
||||||
if guess_res.status_code != 201:
|
|
||||||
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
|
|
||||||
|
|
||||||
detail_res = host_client.get(f"/lobby/sessions/{code}")
|
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, "auto_guess_transition")
|
||||||
if detail_res.status_code != 200:
|
artifact["steps"].append(
|
||||||
raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}")
|
{
|
||||||
if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD:
|
"step": "auto_guess_transition",
|
||||||
raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard")
|
"session_status": lie_transition_payload["session"]["status"],
|
||||||
|
"answers": [answer["text"] for answer in answers],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
|
answer_texts = {answer["text"] for answer in answers}
|
||||||
if finish_res.status_code != 200:
|
correct_answer = next((answer["text"] for answer in answers if answer.get("text") == question.correct_answer), None)
|
||||||
raise CommandError(f"finish_game failed: {finish_res.status_code}")
|
if correct_answer is None:
|
||||||
|
self._fail("submit_guesses", "could not resolve correct answer from mixed answers", {"answers": answers})
|
||||||
|
|
||||||
|
guess_plan = {
|
||||||
|
players[0]["nickname"]: "Lie from P2",
|
||||||
|
players[1]["nickname"]: correct_answer,
|
||||||
|
players[2]["nickname"]: "Lie from P1",
|
||||||
|
}
|
||||||
|
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
|
||||||
|
if missing_guess_targets:
|
||||||
|
self._fail(
|
||||||
|
"submit_guesses",
|
||||||
|
"expected bluff targets missing from mixed answers",
|
||||||
|
{"answers": answers, "missing_guess_targets": sorted(missing_guess_targets)},
|
||||||
|
)
|
||||||
|
artifact["guess_plan"] = guess_plan
|
||||||
|
|
||||||
|
guess_payloads = []
|
||||||
|
for player in players:
|
||||||
|
nickname = player["nickname"]
|
||||||
|
guess_payload = self._expect_status(
|
||||||
|
Client().post(
|
||||||
|
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"player_id": player["id"],
|
||||||
|
"session_token": player["session_token"],
|
||||||
|
"selected_text": guess_plan[nickname],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
f"submit_guess[{nickname}]",
|
||||||
|
)
|
||||||
|
guess_payloads.append(guess_payload)
|
||||||
|
|
||||||
|
reveal_payload = guess_payloads[-1]
|
||||||
|
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, "auto_reveal_transition")
|
||||||
|
if not reveal_payload.get("phase_transition", {}).get("auto_advanced"):
|
||||||
|
self._fail("auto_reveal_transition", "expected auto_advanced=true on final guess", reveal_payload)
|
||||||
|
reveal = reveal_payload.get("reveal")
|
||||||
|
if not reveal:
|
||||||
|
self._fail("auto_reveal_transition", "missing canonical reveal payload", reveal_payload)
|
||||||
|
if reveal.get("correct_answer") != question.correct_answer:
|
||||||
|
self._fail(
|
||||||
|
"auto_reveal_transition",
|
||||||
|
"reveal payload returned wrong correct answer",
|
||||||
|
{"expected": question.correct_answer, "reveal": reveal},
|
||||||
|
)
|
||||||
|
if len(reveal.get("lies", [])) != len(players):
|
||||||
|
self._fail("auto_reveal_transition", "unexpected lie count in reveal payload", reveal)
|
||||||
|
if len(reveal.get("guesses", [])) != len(players):
|
||||||
|
self._fail("auto_reveal_transition", "unexpected guess count in reveal payload", reveal)
|
||||||
|
|
||||||
|
fooled_guesses = [guess for guess in reveal["guesses"] if not guess.get("is_correct")]
|
||||||
|
correct_guesses = [guess for guess in reveal["guesses"] if guess.get("is_correct")]
|
||||||
|
if len(fooled_guesses) != 2:
|
||||||
|
self._fail("auto_reveal_transition", "expected exactly two bluff guesses", reveal)
|
||||||
|
if len(correct_guesses) != 1:
|
||||||
|
self._fail("auto_reveal_transition", "expected exactly one correct guess", reveal)
|
||||||
|
if any(guess.get("fooled_player_id") is None for guess in fooled_guesses):
|
||||||
|
self._fail("auto_reveal_transition", "bluff guesses missing fooled_player_id", reveal)
|
||||||
|
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "submit_guesses",
|
||||||
|
"guess_results": [
|
||||||
|
{
|
||||||
|
"player_id": payload["guess"]["player_id"],
|
||||||
|
"selected_text": payload["guess"]["selected_text"],
|
||||||
|
"is_correct": payload["guess"]["is_correct"],
|
||||||
|
"fooled_player_id": payload["guess"].get("fooled_player_id"),
|
||||||
|
}
|
||||||
|
for payload in guess_payloads
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "auto_reveal_transition",
|
||||||
|
"session_status": reveal_payload["session"]["status"],
|
||||||
|
"reveal": {
|
||||||
|
"correct_answer": reveal["correct_answer"],
|
||||||
|
"lies_count": len(reveal["lies"]),
|
||||||
|
"guesses_count": len(reveal["guesses"]),
|
||||||
|
"fooled_player_ids": sorted(guess["fooled_player_id"] for guess in fooled_guesses),
|
||||||
|
"correct_guess_player_ids": sorted(guess["player_id"] for guess in correct_guesses),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_guesses")
|
||||||
|
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, "auto_scoreboard_transition")
|
||||||
|
if detail_payload.get("reveal") != reveal:
|
||||||
|
self._fail("auto_scoreboard_transition", "scoreboard promotion changed canonical reveal payload", detail_payload)
|
||||||
|
scoreboard = detail_payload.get("scoreboard")
|
||||||
|
if not scoreboard:
|
||||||
|
self._fail("auto_scoreboard_transition", "missing scoreboard payload after promotion", detail_payload)
|
||||||
|
if len(scoreboard) != len(players):
|
||||||
|
self._fail("auto_scoreboard_transition", "unexpected scoreboard length", detail_payload)
|
||||||
|
if not detail_payload.get("phase_view_model", {}).get("readiness", {}).get("scoreboard_ready"):
|
||||||
|
self._fail("auto_scoreboard_transition", "scoreboard_ready=false after promotion", detail_payload)
|
||||||
|
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "auto_scoreboard_transition",
|
||||||
|
"session_status": detail_payload["session"]["status"],
|
||||||
|
"leaderboard": scoreboard,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
finish_payload = self._expect_status(
|
||||||
|
host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json"),
|
||||||
|
200,
|
||||||
|
"finish_game",
|
||||||
|
)
|
||||||
|
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, "finish_game")
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "finish_game",
|
||||||
|
"session_status": finish_payload["session"]["status"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
artifact_path = options.get("artifact")
|
artifact_path = options.get("artifact")
|
||||||
if artifact_path:
|
if artifact_path:
|
||||||
artifact = {
|
|
||||||
"ok": True,
|
|
||||||
"command": "smoke_staging",
|
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"session_code": code,
|
|
||||||
"players": [player["nickname"] for player in players],
|
|
||||||
"round_question_id": round_question_id,
|
|
||||||
"steps": [
|
|
||||||
"create_session",
|
|
||||||
"join_players",
|
|
||||||
"start_round",
|
|
||||||
"submit_lies",
|
|
||||||
"auto_guess_transition",
|
|
||||||
"submit_guesses",
|
|
||||||
"auto_reveal_to_scoreboard",
|
|
||||||
"finish_game",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
output_path = Path(artifact_path)
|
output_path = Path(artifact_path)
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
|
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|||||||
138
lobby/tests.py
138
lobby/tests.py
@@ -867,6 +867,79 @@ class CanonicalRoundFlowTests(TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_canonical_round_flow_bootstraps_second_round_without_first_round_carry_over(self):
|
||||||
|
self.client.login(username="host_canonical", password="secret123")
|
||||||
|
extra_question = Question.objects.create(
|
||||||
|
category=self.category,
|
||||||
|
prompt="Hvem malede Mona Lisa?",
|
||||||
|
correct_answer="Da Vinci",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
first_round_question_id = start_response.json()["round_question"]["id"]
|
||||||
|
first_round_prompt = start_response.json()["round_question"]["prompt"]
|
||||||
|
first_round_correct_answer = RoundQuestion.objects.get(pk=first_round_question_id).correct_answer
|
||||||
|
second_question = extra_question if first_round_prompt == self.question.prompt else self.question
|
||||||
|
|
||||||
|
final_lie_response = None
|
||||||
|
for index, player in enumerate(self.players, start=1):
|
||||||
|
lie_response = self.client.post(
|
||||||
|
reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}),
|
||||||
|
data={"player_id": player.id, "session_token": player.session_token, "text": f"Første løgn {index}"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(lie_response.status_code, 201)
|
||||||
|
final_lie_response = lie_response
|
||||||
|
|
||||||
|
self.assertIsNotNone(final_lie_response)
|
||||||
|
|
||||||
|
for player, selected_text in zip(
|
||||||
|
self.players,
|
||||||
|
[first_round_correct_answer, first_round_correct_answer, first_round_correct_answer],
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
guess_response = self.client.post(
|
||||||
|
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}),
|
||||||
|
data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(guess_response.status_code, 201)
|
||||||
|
|
||||||
|
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
self.assertEqual(scoreboard_payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
||||||
|
self.assertEqual(scoreboard_payload["round_question"]["id"], first_round_question_id)
|
||||||
|
self.assertIsNotNone(scoreboard_payload["reveal"])
|
||||||
|
self.assertIsNotNone(scoreboard_payload["scoreboard"])
|
||||||
|
self.assertGreaterEqual(len(scoreboard_payload["reveal"]["guesses"]), 1)
|
||||||
|
|
||||||
|
next_round_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
self.assertEqual(next_round_response.status_code, 200)
|
||||||
|
self.assertEqual(next_round_response.json()["session"]["status"], GameSession.Status.LIE)
|
||||||
|
self.assertEqual(next_round_response.json()["session"]["current_round"], 2)
|
||||||
|
|
||||||
|
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
|
||||||
|
self.assertEqual(detail_payload["session"]["current_round"], 2)
|
||||||
|
self.assertEqual(detail_payload["phase_view_model"]["current_phase"], GameSession.Status.LIE)
|
||||||
|
self.assertIsNone(detail_payload["reveal"])
|
||||||
|
self.assertIsNone(detail_payload["scoreboard"])
|
||||||
|
self.assertEqual(detail_payload["round_question"]["round_number"], 2)
|
||||||
|
self.assertNotEqual(detail_payload["round_question"]["id"], first_round_question_id)
|
||||||
|
self.assertEqual(detail_payload["round_question"]["prompt"], second_question.prompt)
|
||||||
|
self.assertEqual(detail_payload["round_question"]["answers"], [])
|
||||||
|
|
||||||
|
round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2)
|
||||||
|
self.assertEqual(round_two_question.question, second_question)
|
||||||
|
self.assertEqual(round_two_question.lies.count(), 0)
|
||||||
|
self.assertEqual(round_two_question.guesses.count(), 0)
|
||||||
|
self.assertEqual(round_two_question.mixed_answers, [])
|
||||||
|
|
||||||
@patch("lobby.views.sync_broadcast_phase_event")
|
@patch("lobby.views.sync_broadcast_phase_event")
|
||||||
@patch("lobby.views._resolve_scores")
|
@patch("lobby.views._resolve_scores")
|
||||||
@patch("lobby.views.GameSession.objects.get")
|
@patch("lobby.views.GameSession.objects.get")
|
||||||
@@ -1347,6 +1420,45 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1)
|
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1)
|
||||||
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1)
|
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1)
|
||||||
|
|
||||||
|
def test_start_next_round_clears_existing_next_round_bootstrap_state(self):
|
||||||
|
self.client.login(username="host_reveal", password="secret123")
|
||||||
|
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
stale_round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=self.next_question,
|
||||||
|
correct_answer=self.next_question.correct_answer,
|
||||||
|
mixed_answers=["Stale truth", "Stale lie"],
|
||||||
|
)
|
||||||
|
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie")
|
||||||
|
Guess.objects.create(
|
||||||
|
round_question=stale_round_question,
|
||||||
|
player=self.player_two,
|
||||||
|
selected_text="Stale truth",
|
||||||
|
is_correct=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
stale_round_question.refresh_from_db()
|
||||||
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
|
self.assertEqual(self.session.current_round, 2)
|
||||||
|
self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id)
|
||||||
|
self.assertEqual(stale_round_question.mixed_answers, [])
|
||||||
|
self.assertEqual(stale_round_question.lies.count(), 0)
|
||||||
|
self.assertEqual(stale_round_question.guesses.count(), 0)
|
||||||
|
|
||||||
|
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
|
||||||
|
self.assertEqual(detail_payload["session"]["current_round"], 2)
|
||||||
|
self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id)
|
||||||
|
self.assertEqual(detail_payload["round_question"]["answers"], [])
|
||||||
|
self.assertIsNone(detail_payload["reveal"])
|
||||||
|
self.assertIsNone(detail_payload["scoreboard"])
|
||||||
|
|
||||||
def test_start_next_round_requires_host(self):
|
def test_start_next_round_requires_host(self):
|
||||||
self.session.status = GameSession.Status.SCOREBOARD
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
self.session.save(update_fields=["status"])
|
self.session.save(update_fields=["status"])
|
||||||
@@ -1968,7 +2080,7 @@ class SmokeStagingCommandTests(TestCase):
|
|||||||
self.assertEqual(session.status, GameSession.Status.FINISHED)
|
self.assertEqual(session.status, GameSession.Status.FINISHED)
|
||||||
self.assertEqual(Player.objects.filter(session=session).count(), 3)
|
self.assertEqual(Player.objects.filter(session=session).count(), 3)
|
||||||
|
|
||||||
def test_smoke_staging_writes_artifact_when_requested(self):
|
def test_smoke_staging_writes_phase_evidence_artifact_when_requested(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
artifact_path = Path(tmp_dir) / "smoke.json"
|
artifact_path = Path(tmp_dir) / "smoke.json"
|
||||||
call_command("smoke_staging", artifact=str(artifact_path))
|
call_command("smoke_staging", artifact=str(artifact_path))
|
||||||
@@ -1976,24 +2088,40 @@ class SmokeStagingCommandTests(TestCase):
|
|||||||
self.assertTrue(artifact_path.exists())
|
self.assertTrue(artifact_path.exists())
|
||||||
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
||||||
self.assertTrue(payload["ok"])
|
self.assertTrue(payload["ok"])
|
||||||
self.assertEqual(payload["command"], "smoke_staging")
|
self.assertEqual(payload["command"], "python manage.py smoke_staging --artifact <path>")
|
||||||
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
|
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
|
||||||
self.assertIn("generated_at", payload)
|
self.assertIn("generated_at", payload)
|
||||||
self.assertIn("session_code", payload)
|
self.assertIn("session_code", payload)
|
||||||
|
self.assertEqual(payload["question"]["correct_answer"], "Correct")
|
||||||
|
self.assertEqual(payload["guess_plan"]["P2"], "Correct")
|
||||||
|
|
||||||
|
step_names = [step["step"] for step in payload["steps"]]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
payload["steps"],
|
step_names,
|
||||||
[
|
[
|
||||||
"create_session",
|
"create_session",
|
||||||
"join_players",
|
"join_players",
|
||||||
"start_round",
|
"start_round",
|
||||||
"submit_lies",
|
|
||||||
"auto_guess_transition",
|
"auto_guess_transition",
|
||||||
"submit_guesses",
|
"submit_guesses",
|
||||||
"auto_reveal_to_scoreboard",
|
"auto_reveal_transition",
|
||||||
|
"auto_scoreboard_transition",
|
||||||
"finish_game",
|
"finish_game",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
reveal_step = payload["steps"][5]
|
||||||
|
self.assertEqual(reveal_step["session_status"], GameSession.Status.REVEAL)
|
||||||
|
self.assertEqual(reveal_step["reveal"]["correct_answer"], "Correct")
|
||||||
|
self.assertEqual(reveal_step["reveal"]["lies_count"], 3)
|
||||||
|
self.assertEqual(reveal_step["reveal"]["guesses_count"], 3)
|
||||||
|
self.assertEqual(len(reveal_step["reveal"]["fooled_player_ids"]), 2)
|
||||||
|
self.assertEqual(len(reveal_step["reveal"]["correct_guess_player_ids"]), 1)
|
||||||
|
|
||||||
|
scoreboard_step = payload["steps"][6]
|
||||||
|
self.assertEqual(scoreboard_step["session_status"], GameSession.Status.SCOREBOARD)
|
||||||
|
self.assertEqual(len(scoreboard_step["leaderboard"]), 3)
|
||||||
|
|
||||||
|
|
||||||
class I18nResolverTests(TestCase):
|
class I18nResolverTests(TestCase):
|
||||||
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):
|
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):
|
||||||
|
|||||||
130
lobby/views.py
130
lobby/views.py
@@ -116,6 +116,132 @@ def _build_finish_game_response(session: GameSession) -> JsonResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion:
|
||||||
|
Guess.objects.filter(round_question=round_question).delete()
|
||||||
|
LieAnswer.objects.filter(round_question=round_question).delete()
|
||||||
|
if round_question.mixed_answers:
|
||||||
|
round_question.mixed_answers = []
|
||||||
|
round_question.save(update_fields=["mixed_answers"])
|
||||||
|
return round_question
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
||||||
if session.status != GameSession.Status.REVEAL:
|
if session.status != GameSession.Status.REVEAL:
|
||||||
return session
|
return session
|
||||||
@@ -995,7 +1121,9 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
locked_session.current_round = next_round_number
|
locked_session.current_round = next_round_number
|
||||||
|
|
||||||
try:
|
try:
|
||||||
round_question = _select_round_question(locked_session, next_round_config)
|
round_question = _reset_round_question_bootstrap_state(
|
||||||
|
_select_round_question(locked_session, next_round_config)
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return api_error(request, code=str(exc), status=400)
|
return api_error(request, code=str(exc), status=400)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user