Compare commits

..

2 Commits

Author SHA1 Message Date
agw 1662d7d197 Add lie phase timer API tests 2026-02-27 14:23:30 +01:00
agw 1c78b856fb Implement lie question timer flow endpoints 2026-02-27 14:23:26 +01:00
80 changed files with 323 additions and 4835 deletions
-33
View File
@@ -1,33 +0,0 @@
name: CI
on:
push:
branches:
- "**"
pull_request:
branches:
- main
jobs:
test-and-quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install ruff
- name: Lint
run: ruff check lobby
- name: Tests
run: python manage.py test lobby -v 1
-1
View File
@@ -22,7 +22,6 @@ media/
.env
.env.*
!.env.test.example
!.env.staging.example
!.env.prod.example
# Editors/OS
+7 -18
View File
@@ -14,7 +14,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- Deployment: Proxmox LXC (ikke Docker)
## Midlertidige defaults (kan finjusteres senere)
- Spillere: min 3, default max 5 (MVP)
- Spillere: min 3, max 12
- Løgntid (X): 45 sek
- Gættetid (Z): 30 sek
- Login: username/password for host/admin
@@ -55,12 +55,12 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
### Fase 3 — Spilflow `Fup og Fakta`
- [x] Lobby: host opretter session, spillere joiner via kode
- [x] Runde starter med kategori
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
- [x] System blander korrekt svar + løgne
- [x] Guessfase: alle gætter inden Z sek
- [x] Pointudregning (konfigurerbar pr. runde)
- [x] Scoreboard + næste spørgsmål/runde
- [x] Slutresultat
- [ ] Spørgsmål vises -> alle skriver løgn inden X sek
- [ ] System blander korrekt svar + løgne
- [ ] Guessfase: alle gætter inden Z sek
- [ ] Pointudregning (konfigurerbar pr. runde)
- [ ] Scoreboard + næste spørgsmål/runde
- [ ] Slutresultat
### Fase 4 — Voice-acting (platformkrav)
- [ ] Definér TTS provider-interface
@@ -103,20 +103,9 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [ ] Migrations + static + health checks
### Backlog — Need-to-have / Nice-to-have
- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh
- [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total)
- [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge.
- [ ] (Need-to-have) Rate limiting på join/submit endpoints
- [ ] (Need-to-have) Session-kode brute-force beskyttelse
- [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip)
- [ ] (Nice-to-have) Runde-tema musik/lyd-cues
- [ ] (Nice-to-have) Hurtig onboarding-skærm for nye spillere
## PO-beslutninger (2026-02-27)
- MVP: Første spil (Fup og Fakta) skal være spilbart end-to-end og stabilt.
- Realtime: WebSocket er krav i MVP (ingen polling).
- Join: kun i LOBBY i MVP (viewers senere).
- Sikkerhed: sanitised inputs + server-side validering + fairness logging er hard requirement.
- Spillere: default max 5, minimum 3 for start (konfigurerbart).
- Se detaljer: coordination/PO_DECISIONS_2026-02-27.md.
-41
View File
@@ -1,41 +0,0 @@
# PO decisions — 2026-02-27
## MVP scope
- MVP = første spil (`Fup og Fakta`) er spilbart end-to-end og fungerer stabilt.
- Platformen skal fra start være modulariseret så flere spil kan tilføjes senere.
## Realtime
- WebSocket er et krav i MVP (ikke polling).
- Redis/RabbitMQ er muligt senere for skalering; Redis kan bruges tidligt til channels layer.
## Join-regel
- Spillere kan kun joine i `LOBBY` i MVP.
- Viewers/observers er post-MVP.
## Sikkerhed / "anti-cheat"
- Fokus i MVP: sikre/sanitized user inputs og robust server-side validering.
- Hard requirements i første spilbare version:
- input sanitation (XSS/angrebsvektorer)
- server-side validering af submit/guess-flow
- fairness logging
## Release-klar kriterier
1. Stabilitet er prioritet #1.
2. Spillerantal er konfigurerbart; default: max 5, minimum 3 for at starte.
3. Spilstruktur skal understøtte parametre:
- X = spørgsmål i kategori (stor pool)
- K = antal spørgsmål udvalgt pr. kategori (K < X)
- Z = antal kategorier i pool (stor pool)
- N = antal runder pr. spil
- M = antal kategorier pr. runde
- K og M holdes ens på tværs af runder i MVP
## Eksempel
- 4 runder, 3 kategorier pr. runde, 2 spørgsmål pr. kategori.
## Prioriteret roadmap
- M1: Lås MVP-scope + acceptkriterier
- M2: End-to-end round engine
- M3: Anti-cheat/sikkerhed enforcement + fairness logging
- M4: Stabilitet/UX + release gates
- M5: UI/high-performance forbedringer
+1 -1
View File
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-02-27T13:38:35Z",
"updatedAt": "2026-02-27T13:15:06Z",
"active": [],
"queue": []
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
-28
View File
@@ -1,28 +0,0 @@
# Release policy (Issue #23)
## Formål
Sikre at release-tags altid repræsenterer faktisk deployet software.
## Hård regel
- **Ingen release-tag før staging deploy er succesfuld.**
- **Ingen release-tag uden changelog-reference.**
- **Ingen deploy hvis tester er i gang med smoke-run.**
## Release-flow
1. Bekræft architect-gate (`issue #17`) er release-approved.
2. Bekræft tester ikke er aktiv.
3. Deploy kandidat til staging (`infra/staging/deploy_staging.sh`).
4. Verificér `/healthz` + smoke-resultat.
5. Tilføj changelog-entry i `CHANGELOG.md`.
6. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
## Minimum release-notes template
```markdown
## Changelog
- Ref: CHANGELOG.md#<sektion>
## Deploy
- Environment: staging
- Status: success
- Healthz: ok
```
-43
View File
@@ -1,43 +0,0 @@
# Staging gameplay smoke artifact (Issue #144)
Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke i #16/#90-sporet uden scope-udvidelse.
## Guardrails (MVP)
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
- Kun verifikation af eksisterende flow; ingen nye features/polish.
## Hvornår bruges artifacten
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
- Resultatet logges i issue/PR-kommentar med denne skabelon.
## Evidence template (kopiér i PR/issue-kommentar)
```markdown
### Staging gameplay smoke evidence
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
- Environment: staging
- Commit/Head SHA: <sha>
- Linked scope: #16 #17 #90 #129 #144
#### Setup
- Host authenticated in Django admin: <yes/no>
- Active category/questions present: <yes/no>
- Participants: host + <N> players
#### Checks (PASS/FAIL)
1. Lobby -> join -> start
- Mixed-case + whitespace session code accepted: <pass/fail>
2. One full round to scoreboard
- submit lie -> mix -> submit guess -> calculate score -> show scoreboard: <pass/fail>
3. Next-round + game-end sanity
- next round transitions: <pass/fail>
- final leaderboard visible: <pass/fail>
#### Evidence pointers
- Command(s): `<exact command(s)>`
- UI notes/screenshots/log refs: <short refs>
- Result: <PASS/FAIL>
- If FAIL: blocker issue link + shortest repro
```
## Anti-stall minimum
Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging.
-18
View File
@@ -1,18 +0,0 @@
# UI smoke (MVP)
## Forudsætning
- Host er logget ind i Django.
- Mindst én aktiv kategori med spørgsmål findes.
## Flow
1. Åbn host-siden på /lobby/ui/host og tryk Opret session.
2. Åbn player-siden i 3 faner/enheder på /lobby/ui/player.
3. Join alle spillere med sessionkode og nickname.
4. Host: vælg kategori, Start runde, Vis spørgsmål.
5. Spillere: brug round_question_id og submit løgn.
6. Host: Mix svar.
7. Spillere: submit gæt.
8. Host: Beregn score og Vis scoreboard.
9. Host: Næste runde eller Afslut spil.
Resultat: En fuld runde kan køres uden rå API-kald fra terminal.
-18
View File
@@ -1,18 +0,0 @@
# SPA cutover feature flag (`USE_SPA_UI`)
## Formål
`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates.
## Miljø-toggle (uden kodeændring)
Sæt env var pr. miljø:
- `USE_SPA_UI=true` -> `/lobby/ui/host` og `/lobby/ui/player` returnerer SPA shell
- `USE_SPA_UI=false` (default) -> legacy template-flow bruges uændret
Backward compatibility under cutover:
- Hvis `USE_SPA_UI` ikke er sat, bruges `WPP_SPA_ENABLED` som fallback.
## Verifikation
- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off`
- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled`
- Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled`
-1
View File
@@ -1 +0,0 @@
node_modules/
-12
View File
@@ -1,12 +0,0 @@
# Frontend API client baseline
Dette er baseline-klientlaget for SPA-sporet.
## Kører checks lokalt
```bash
cd frontend
npm install
npm test
npm run build
```
-1454
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
{
"name": "wpp-frontend-api-client-baseline",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "vitest run",
"build": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
}
}
-62
View File
@@ -1,62 +0,0 @@
import type { ApiFailure, ApiResult, HealthResponse, SessionDetailResponse } from './types';
export interface AngularHttpError {
status?: number;
message?: string;
error?: unknown;
}
export interface AngularHttpClientLike {
get<T>(url: string, options?: { withCredentials?: boolean }): Promise<T>;
}
export interface AngularApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
}
function toFailure(error: unknown): ApiFailure {
const candidate = (error ?? {}) as AngularHttpError;
const status = typeof candidate.status === 'number' ? candidate.status : 0;
const payload = candidate.error;
if (status === 0) {
return {
kind: 'network',
status: 0,
message: candidate.message ?? 'Network error while contacting API'
};
}
return {
kind: 'http',
status,
message: candidate.message ?? `HTTP ${status}`,
...(payload === undefined ? {} : { payload })
};
}
function normalizeCode(code: string): string {
return code.trim().toUpperCase();
}
async function wrapGet<T>(call: () => Promise<T>): Promise<ApiResult<T>> {
try {
const data = await call();
return { ok: true, status: 200, data };
} catch (error: unknown) {
return { ok: false, status: typeof (error as AngularHttpError)?.status === 'number' ? (error as AngularHttpError).status! : 0, error: toFailure(error) };
}
}
export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''): AngularApiClient {
return {
health: () => wrapGet(() => http.get<HealthResponse>(`${baseUrl}/healthz`, { withCredentials: true })),
getSession: (code: string) =>
wrapGet(() =>
http.get<SessionDetailResponse>(`${baseUrl}/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`, {
withCredentials: true
})
)
};
}
-81
View File
@@ -1,81 +0,0 @@
import type {
ApiResult,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
SessionDetailResponse,
StartRoundRequest,
StartRoundResponse
} from './types';
export interface ApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
}
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
async function request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<ApiResult<T>> {
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
});
} catch {
return {
ok: false,
status: 0,
error: { kind: 'network', status: 0, message: 'Network error while contacting API' }
};
}
let responsePayload: unknown;
try {
responsePayload = await response.json();
} catch {
return {
ok: false,
status: response.status,
error: { kind: 'parse', status: response.status, message: 'Invalid JSON response from API' }
};
}
if (!response.ok) {
return {
ok: false,
status: response.status,
error: {
kind: 'http',
status: response.status,
message: `HTTP ${response.status}`,
payload: responsePayload
}
};
}
return { ok: true, status: response.status, data: responsePayload as T };
}
return {
health: () => request<HealthResponse>('/healthz', 'GET'),
getSession: (code: string) =>
request<SessionDetailResponse>(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, 'GET'),
joinSession: (payload: JoinSessionRequest) =>
request<JoinSessionResponse>('/lobby/sessions/join', 'POST', {
code: payload.code.trim().toUpperCase(),
nickname: payload.nickname.trim()
}),
startRound: (code: string, payload: StartRoundRequest) =>
request<StartRoundResponse>(
`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}/rounds/start`,
'POST',
payload
)
};
}
-115
View File
@@ -1,115 +0,0 @@
export interface HealthResponse {
ok: boolean;
service: string;
}
export interface SessionSummary {
code: string;
status: string;
host_id: number | null;
current_round: number;
players_count: number;
}
export interface SessionPlayer {
id: number;
nickname: string;
score: number;
is_connected: boolean;
}
export interface SessionAnswer {
text: string;
}
export interface SessionRoundQuestion {
id: number;
round_number: number;
prompt: string;
shown_at: string;
answers: SessionAnswer[];
}
export interface PhaseViewModel {
status: string;
round_number: number;
players_count: number;
constraints: {
min_players_to_start: number;
max_players_mvp: number;
min_players_reached: boolean;
max_players_allowed: boolean;
};
host: {
can_start_round: boolean;
can_show_question: boolean;
can_mix_answers: boolean;
can_calculate_scores: boolean;
can_reveal_scoreboard: boolean;
can_start_next_round: boolean;
can_finish_game: boolean;
};
player: {
can_join: boolean;
can_submit_lie: boolean;
can_submit_guess: boolean;
can_view_final_result: boolean;
};
}
export interface SessionDetailResponse {
session: SessionSummary;
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
phase_view_model: PhaseViewModel;
}
export interface JoinSessionRequest {
code: string;
nickname: string;
}
export interface JoinSessionResponse {
player: {
id: number;
nickname: string;
session_token: string;
score: number;
};
session: {
code: string;
status: string;
};
}
export interface StartRoundRequest {
category_slug: string;
}
export interface StartRoundResponse {
session: {
code: string;
status: string;
current_round: number;
};
round: {
number: number;
category: {
slug: string;
name: string;
};
};
}
export type ApiErrorKind = 'network' | 'http' | 'parse';
export interface ApiFailure {
kind: ApiErrorKind;
message: string;
status: number;
payload?: unknown;
}
export type ApiResult<T> =
| { ok: true; status: number; data: T }
| { ok: false; status: number; error: ApiFailure };
-87
View File
@@ -1,87 +0,0 @@
import type { ApiClient } from '../api/client';
import type { SessionDetailResponse } from '../api/types';
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
export interface VerticalSliceState {
sessionCode: string;
session: SessionDetailResponse | null;
joinState: AsyncState;
startRoundState: AsyncState;
loadingSession: boolean;
errorMessage: string | null;
}
export interface VerticalSliceController {
getState(): VerticalSliceState;
hydrateLobby(sessionCode: string): Promise<VerticalSliceState>;
joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState>;
startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState>;
}
export function createVerticalSliceController(api: ApiClient): VerticalSliceController {
const state: VerticalSliceState = {
sessionCode: '',
session: null,
joinState: 'idle',
startRoundState: 'idle',
loadingSession: false,
errorMessage: null
};
const normalizeCode = (value: string): string => value.trim().toUpperCase();
async function hydrateLobby(sessionCode: string): Promise<VerticalSliceState> {
state.loadingSession = true;
state.errorMessage = null;
state.sessionCode = normalizeCode(sessionCode);
const result = await api.getSession(state.sessionCode);
state.loadingSession = false;
if (!result.ok) {
state.errorMessage = 'Kunne ikke hente lobby-status.';
return { ...state };
}
state.session = result.data;
return { ...state };
}
async function joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState> {
state.joinState = 'loading';
state.errorMessage = null;
const join = await api.joinSession({ code: sessionCode, nickname });
if (!join.ok) {
state.joinState = 'error';
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.';
return { ...state };
}
state.joinState = 'success';
return hydrateLobby(sessionCode);
}
async function startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState> {
state.startRoundState = 'loading';
state.errorMessage = null;
const start = await api.startRound(sessionCode, { category_slug: categorySlug });
if (!start.ok) {
state.startRoundState = 'error';
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.';
return { ...state };
}
state.startRoundState = 'success';
return hydrateLobby(sessionCode);
}
return {
getState: () => ({ ...state }),
hydrateLobby,
joinLobby,
startRound
};
}
-92
View File
@@ -1,92 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client';
describe('createAngularApiClient', () => {
it('reads health and session detail using Django-compatible endpoints', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/healthz') {
return { ok: true, service: 'partyhub' } as T;
}
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
{ id: 3, nickname: 'Bo', score: 0, is_connected: false }
],
round_question: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const http = { get };
const client = createAngularApiClient(http as AngularHttpClientLike);
const health = await client.health();
expect(health.ok).toBe(true);
if (health.ok) {
expect(health.data.ok).toBe(true);
expect(health.data.service).toBe('partyhub');
}
const session = await client.getSession(' abcd12 ');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.session.host_id).toBe(1);
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
}
expect(get).toHaveBeenNthCalledWith(1, '/healthz', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', { withCredentials: true });
});
it('maps HttpErrorResponse-style failures to ApiResult errors', async () => {
const http = {
get: vi.fn<AngularHttpClientLike['get']>(async () => {
throw { status: 503, message: 'Service unavailable', error: { error: 'maintenance' } };
})
};
const client = createAngularApiClient(http as AngularHttpClientLike);
const result = await client.health();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(503);
expect(result.error.kind).toBe('http');
expect(result.error.payload).toEqual({ error: 'maintenance' });
expect(result.error.message).toContain('Service unavailable');
}
});
});
@@ -1,148 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AddressInfo } from 'node:net';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { createApiClient } from '../src/api/client';
let server: Server;
let baseUrl: string;
beforeAll(async () => {
server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' }));
return;
}
if (req.url === '/lobby/sessions/ABCD12' && req.method === 'GET') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
})
);
return;
}
if (req.url === '/lobby/sessions/join' && req.method === 'POST') {
res.writeHead(201, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 },
session: { code: 'ABCD12', status: 'lobby' }
})
);
return;
}
if (req.url === '/lobby/sessions/ABCD12/rounds/start' && req.method === 'POST') {
res.writeHead(201, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
})
);
return;
}
if (req.url?.startsWith('/lobby/sessions/')) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
return;
}
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unexpected route' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close((err?: Error) => (err ? reject(err) : resolve()))
);
});
describe('createApiClient', () => {
it('reads health + session detail through typed wrappers', async () => {
const client = createApiClient(baseUrl);
const health = await client.health();
expect(health.ok).toBe(true);
const session = await client.getSession('abcd12');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
}
});
it('supports join + start round writes for lobby vertical slice', async () => {
const client = createApiClient(baseUrl);
const join = await client.joinSession({ code: 'abcd12', nickname: 'Maja' });
expect(join.ok).toBe(true);
const start = await client.startRound('abcd12', { category_slug: 'history' });
expect(start.ok).toBe(true);
if (start.ok) {
expect(start.data.session.status).toBe('lie');
}
});
it('returns consistent HTTP error shape for 4xx/5xx', async () => {
const client = createApiClient(baseUrl);
const missing = await client.getSession('missing');
expect(missing.ok).toBe(false);
if (!missing.ok) {
expect(missing.status).toBe(404);
expect(missing.error.kind).toBe('http');
expect(missing.error.payload).toEqual({ error: 'Session not found' });
}
});
it('returns consistent network error shape', async () => {
const client = createApiClient('http://127.0.0.1:9');
const health = await client.health();
expect(health.ok).toBe(false);
if (!health.ok) {
expect(health.error.kind).toBe('network');
expect(health.status).toBe(0);
}
});
});
-115
View File
@@ -1,115 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { createVerticalSliceController } from '../src/spa/vertical-slice';
import type { ApiClient } from '../src/api/client';
function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
const base: ApiClient = {
health: vi.fn(),
getSession: vi.fn().mockResolvedValue({
ok: true,
status: 200,
data: {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
}
}),
joinSession: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: { player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }
}),
startRound: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
}
})
};
return { ...base, ...overrides };
}
describe('vertical slice controller: lobby -> join -> start round', () => {
it('tracks loading and success state for join + start flow', async () => {
const api = makeApiMock();
const controller = createVerticalSliceController(api);
const beforeJoinPromise = controller.joinLobby('abcd12', 'Maja');
expect(controller.getState().joinState).toBe('loading');
await beforeJoinPromise;
const postJoin = controller.getState();
expect(postJoin.joinState).toBe('success');
expect(postJoin.session?.session.code).toBe('ABCD12');
const beforeStartPromise = controller.startRound('abcd12', 'history');
expect(controller.getState().startRoundState).toBe('loading');
await beforeStartPromise;
const postStart = controller.getState();
expect(postStart.startRoundState).toBe('success');
});
it('surfaces a friendly error when join fails', async () => {
const api = makeApiMock({
joinSession: vi.fn().mockResolvedValue({
ok: false,
status: 404,
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found' } }
})
});
const controller = createVerticalSliceController(api);
await controller.joinLobby('missing', 'Maja');
const state = controller.getState();
expect(state.joinState).toBe('error');
expect(state.errorMessage).toContain('Join fejlede');
});
it('surfaces a friendly error when round start fails', async () => {
const api = makeApiMock({
startRound: vi.fn().mockResolvedValue({
ok: false,
status: 400,
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby' } }
})
});
const controller = createVerticalSliceController(api);
await controller.startRound('ABCD12', 'history');
const state = controller.getState();
expect(state.startRoundState).toBe('error');
expect(state.errorMessage).toContain('Kunne ikke starte runden');
});
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM"],
"types": ["vitest/globals", "node"]
},
"include": ["src", "tests"]
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
exclude: ['**/node_modules/**']
}
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,22 @@
# Generated by Django 6.0.2 on 2026-02-27 13:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='roundquestion',
name='lie_deadline_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterUniqueTogether(
name='roundquestion',
unique_together={('session', 'round_number')},
),
]
@@ -1,19 +0,0 @@
# Generated by Django 6.0.2 on 2026-02-27 13:32
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fupogfakta", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="roundquestion",
name="shown_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.2 on 2026-02-27 21:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0002_roundquestion_shown_at'),
]
operations = [
migrations.AddField(
model_name='roundquestion',
name='mixed_answers',
field=models.JSONField(blank=True, default=list),
),
]
@@ -1,19 +0,0 @@
# Generated by Django 6.0.2 on 2026-02-27 22:08
import fupogfakta.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0003_roundquestion_mixed_answers'),
]
operations = [
migrations.AddField(
model_name='player',
name='session_token',
field=models.CharField(db_index=True, default=fupogfakta.models._generate_player_session_token, max_length=64),
),
]
+5 -11
View File
@@ -1,15 +1,8 @@
import secrets
from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.db import models
User = get_user_model()
def _generate_player_session_token() -> str:
return secrets.token_urlsafe(24)
class Category(models.Model):
name = models.CharField(max_length=120, unique=True)
@@ -60,7 +53,6 @@ class GameSession(models.Model):
class Player(models.Model):
session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="players")
nickname = models.CharField(max_length=40)
session_token = models.CharField(max_length=64, db_index=True, default=_generate_player_session_token)
score = models.IntegerField(default=0)
is_connected = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -93,8 +85,10 @@ class RoundQuestion(models.Model):
round_number = models.PositiveIntegerField()
question = models.ForeignKey(Question, on_delete=models.PROTECT)
correct_answer = models.CharField(max_length=255)
shown_at = models.DateTimeField(default=timezone.now)
mixed_answers = models.JSONField(default=list, blank=True)
lie_deadline_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = (("session", "round_number"),)
class LieAnswer(models.Model):
+92 -1
View File
@@ -1,2 +1,93 @@
from datetime import timedelta
# Create your tests here.
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from .models import Category, GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion
User = get_user_model()
class LiePhaseFlowTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvilket år faldt muren?",
correct_answer="1989",
is_active=True,
)
RoundConfig.objects.create(
session=self.session,
number=self.session.current_round,
category=self.category,
lie_seconds=20,
)
self.player = Player.objects.create(session=self.session, nickname="Luna")
def test_host_can_start_lie_question_with_deadline(self):
self.client.login(username="host", password="secret123")
response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 201)
payload = response.json()["question"]
self.assertEqual(payload["round"], 1)
self.assertEqual(payload["prompt"], self.question.prompt)
self.assertEqual(payload["lie_seconds"], 20)
round_question = RoundQuestion.objects.get(session=self.session, round_number=1)
self.assertIsNotNone(round_question.lie_deadline_at)
def test_submit_lie_before_deadline_is_accepted(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
lie_deadline_at=timezone.now() + timedelta(seconds=10),
)
response = self.client.post(
reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}),
data={"player_id": self.player.id, "text": "1991"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(
LieAnswer.objects.filter(round_question=round_question, player=self.player, text="1991").exists()
)
self.assertEqual(response.json()["progress"]["submitted_count"], 1)
def test_submit_lie_after_deadline_is_rejected(self):
RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
lie_deadline_at=timezone.now() - timedelta(seconds=1),
)
response = self.client.post(
reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}),
data={"player_id": self.player.id, "text": "1991"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Lie timer expired")
self.assertEqual(LieAnswer.objects.count(), 0)
def test_non_host_cannot_start_lie_question(self):
User.objects.create_user(username="other", password="secret123")
self.client.login(username="other", password="secret123")
response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only host can start lie question")
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "fupogfakta"
urlpatterns = [
path("sessions/<str:code>/lie-question/start", views.start_lie_question, name="start_lie_question"),
path("sessions/<str:code>/lies", views.submit_lie, name="submit_lie"),
]
+175 -1
View File
@@ -1,2 +1,176 @@
import json
import random
from datetime import timedelta
# Create your views here.
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_POST
from .models import GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion
MAX_LIE_LENGTH = 255
def _json_body(request: HttpRequest) -> dict:
if not request.body:
return {}
try:
return json.loads(request.body)
except json.JSONDecodeError:
return {}
def _emit_realtime_session_event(session_code: str, event: str, payload: dict) -> None:
channel_layer = get_channel_layer()
if not channel_layer:
return
try:
async_to_sync(channel_layer.group_send)(
f"session_{session_code}",
{
"type": "session.event",
"event": event,
"payload": payload,
},
)
except Exception:
# Realtime broadcasting must not fail game flow.
return
@require_POST
@login_required
def start_lie_question(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can start lie question"}, status=403)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
with transaction.atomic():
session = GameSession.objects.select_for_update().get(pk=session.pk)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
return JsonResponse({"error": "Lie question already started for round"}, status=409)
try:
round_config = RoundConfig.objects.select_related("category").get(
session=session,
number=session.current_round,
)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config not found"}, status=404)
question_qs = Question.objects.filter(category=round_config.category, is_active=True)
question_ids = list(question_qs.values_list("id", flat=True))
if not question_ids:
return JsonResponse({"error": "No active question for category"}, status=400)
question = question_qs.get(pk=random.choice(question_ids))
lie_deadline_at = timezone.now() + timedelta(seconds=round_config.lie_seconds)
round_question = RoundQuestion.objects.create(
session=session,
round_number=session.current_round,
question=question,
correct_answer=question.correct_answer,
lie_deadline_at=lie_deadline_at,
)
payload = {
"round": round_question.round_number,
"question_id": round_question.id,
"prompt": round_question.question.prompt,
"lie_deadline_at": round_question.lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
_emit_realtime_session_event(session.code, "lie_question_started", payload)
return JsonResponse({"question": payload}, status=201)
@require_POST
def submit_lie(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
payload = _json_body(request)
player_id = payload.get("player_id")
lie_text = str(payload.get("text", "")).strip()
if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400)
if len(lie_text) < 2 or len(lie_text) > MAX_LIE_LENGTH:
return JsonResponse({"error": "text must be between 2 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
try:
player = Player.objects.get(id=player_id, session=session)
except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404)
try:
round_question = RoundQuestion.objects.get(session=session, round_number=session.current_round)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "No active lie question"}, status=404)
now = timezone.now()
if round_question.lie_deadline_at and now > round_question.lie_deadline_at:
return JsonResponse({"error": "Lie timer expired"}, status=400)
lie_answer, _ = LieAnswer.objects.update_or_create(
round_question=round_question,
player=player,
defaults={"text": lie_text},
)
submitted_count = LieAnswer.objects.filter(round_question=round_question).count()
players_count = session.players.count()
event_payload = {
"round": session.current_round,
"question_id": round_question.id,
"player_id": player.id,
"submitted_count": submitted_count,
"players_count": players_count,
"lie_deadline_at": round_question.lie_deadline_at.isoformat() if round_question.lie_deadline_at else None,
}
_emit_realtime_session_event(session.code, "lie_submitted", event_payload)
return JsonResponse(
{
"lie": {
"id": lie_answer.id,
"player_id": player.id,
"question_id": round_question.id,
},
"progress": {
"submitted_count": submitted_count,
"players_count": players_count,
},
},
status=201,
)
-12
View File
@@ -1,12 +0,0 @@
DJANGO_SECRET_KEY=change-me-staging
DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=staging.party.weircon.dk
DB_ENGINE=django.db.backends.mysql
DB_NAME=wpp_staging
DB_USER=wpp_staging
DB_PASSWORD=change-me
DB_HOST=127.0.0.1
DB_PORT=3306
TEST_DB_NAME=
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6379
-35
View File
@@ -1,35 +0,0 @@
# DB setup runbook (Issue #21)
> Credentials ligger i Secrets-repo, ikke i applikationsrepo.
## Databaser
- `wpp_test`
- `wpp_staging`
- `wpp_prod`
## Brugere
- `wpp_test_user` (least privilege på `wpp_test`)
- `wpp_staging_user` (least privilege på `wpp_staging`)
- `wpp_prod_user` (least privilege på `wpp_prod`)
## Secrets placering
I Secrets-repo:
- `wpp/wpp_test.env`
- `wpp/wpp_staging.env`
- `wpp/wpp_prod.env`
Forventede felter:
- `DB_HOST`
- `DB_PORT`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
## Verifikation (eksempel)
Kør fra staging-CT eller anden tilladt klient:
```bash
mysql -h <DB_HOST> -u <DB_USER> -p<DB_PASSWORD> -e "SELECT 1" <DB_NAME>
```
Alle forbindelser skal returnere `1`.
-50
View File
@@ -1,50 +0,0 @@
# Staging runbook (Issue #20)
## Mål
Staging-miljø for WPP i Proxmox LXC, så release-klar kode kan deployes og smoke-testes sikkert.
## Miljø
- LXC: CT 143 (wpp-staging)
- App path: /opt/wpp-staging/app
- Service: wpp-staging.service
- Health endpoint: GET /healthz
- Database: MySQL (staging må ikke bruge SQLite, issue #133)
## Verifikation
Kør fra devops-shell med Proxmox-adgang:
ssh proxmox-lan "sudo -n pct status 143"
ssh proxmox-lan "sudo -n pct exec 143 -- systemctl is-active wpp-staging.service"
ssh proxmox-lan "sudo -n pct exec 143 -- curl -fsS http://127.0.0.1:8000/healthz"
Forventet:
- CT er running
- service er active
- healthz returnerer JSON med ok=true
Smoke-suite skriver nu et gameplay-artifact som JSON under `/opt/wpp-staging/app/artifacts/smoke/` (kan overrides via `ARTIFACT_DIR`/`ARTIFACT_FILE`).
Efter deploy validerer scriptet, at `DB_ENGINE` ikke er `django.db.backends.sqlite3` før migrations køres.
Deploy-scriptet bruger en release-candidate mappe og promoverer først til `/opt/wpp-staging/app` efter succesfuld `migrate`. Det reducerer schema/code drift ved afbrudte deploys (issue #130) og understøtter release-readiness gate (issue #90).
## Deploy (canonical execution context)
Deploy skal altid køres via Proxmox host over SSH (ikke fra lokal coder-shell med direkte sudo pct).
Officiel kommando:
./infra/staging/deploy_staging.sh [ref]
Scriptet bruger default PROXMOX_HOST=proxmox-lan og kører sudo -n pct exec på hosten.
Eksempler:
./infra/staging/deploy_staging.sh
./infra/staging/deploy_staging.sh v0.3.0
PROXMOX_HOST=proxmox-prod ./infra/staging/deploy_staging.sh main
## Policy-kobling
Før deploy:
1. Bekræft at tester ikke er aktiv (ingen aktiv smoke-run).
2. Deploy til staging skal lykkes.
3. Først derefter må release-tag oprettes (se docs/RELEASE_POLICY.md).
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
CT_ID="${CT_ID:-143}"
REF_NAME="${1:-main}"
ARCHIVE_URL="https://gitea.weircon.dk/wpp/weirsoe-party-protocol/archive/${REF_NAME}.tar.gz"
PROXMOX_HOST="${PROXMOX_HOST:-proxmox-lan}"
echo "[deploy] host=${PROXMOX_HOST} CT_ID=${CT_ID} REF=${REF_NAME}"
echo "[deploy] extracting source + installing deps + migrate + restart"
ssh "${PROXMOX_HOST}" sudo -n /usr/sbin/pct exec "${CT_ID}" -- bash -s -- "${ARCHIVE_URL}" <<'REMOTE'
set -euo pipefail
ARCHIVE_URL="$1"
RELEASES_DIR=/opt/wpp-staging/releases
CANDIDATE_DIR="${RELEASES_DIR}/src"
APP_DIR=/opt/wpp-staging/app
install -d -m 0755 -o wpp -g wpp "${RELEASES_DIR}" "${APP_DIR}"
mkdir -p "${CANDIDATE_DIR}"
cd "${RELEASES_DIR}"
curl -fsSL "${ARCHIVE_URL}" -o app.tar.gz
rm -rf "${CANDIDATE_DIR}" && mkdir -p "${CANDIDATE_DIR}"
tar -xzf app.tar.gz -C "${CANDIDATE_DIR}" --strip-components=1
# Ensure deploy artifact copied as root does not leave candidate tree non-writable for wpp.
chown -R wpp:wpp "${CANDIDATE_DIR}"
# Staging must not run on SQLite (issue #133). Remove bundled sqlite artifact from candidate.
rm -f "${CANDIDATE_DIR}/db.sqlite3"
cd "${CANDIDATE_DIR}"
# Load staging env before any manage.py call (issue #133 follow-up).
ENV_LOADED=0
for ENV_FILE in \
/opt/wpp-staging/.env.staging \
/opt/wpp-staging/.env \
/opt/wpp-staging/env/wpp_staging.env \
/opt/wpp-staging/secrets/wpp_staging.env
do
if [ -f "${ENV_FILE}" ]; then
set -a
. "${ENV_FILE}"
set +a
echo "[deploy] loaded staging env: ${ENV_FILE}"
ENV_LOADED=1
break
fi
done
if [ "${ENV_LOADED}" -ne 1 ]; then
echo "[deploy] ERROR: no staging env file found"
exit 1
fi
runuser -u wpp -- python3 -m venv .venv
runuser -u wpp -- .venv/bin/pip install -U pip >/dev/null
runuser -u wpp -- .venv/bin/pip install -r requirements.txt >/dev/null
runuser -u wpp -- .venv/bin/python manage.py shell -c "from django.conf import settings; import sys; engine = settings.DATABASES['default']['ENGINE']; print(f'DB_ENGINE={engine}'); sys.exit(0 if engine != 'django.db.backends.sqlite3' else 1)"
runuser -u wpp -- .venv/bin/python manage.py migrate --noinput
# Promote candidate only after migrations succeed (issue #130): avoid code/schema drift after failed deploy.
# Use find instead of rm "${APP_DIR}"/* to reliably remove dotfiles between deploys.
find "${APP_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
cp -a "${CANDIDATE_DIR}"/. "${APP_DIR}/"
chown -R wpp:wpp "${APP_DIR}"
runuser -u wpp -- test -w "${APP_DIR}"
systemctl restart wpp-staging.service
HEALTH_URL="http://127.0.0.1:8000/healthz"
MAX_ATTEMPTS=7
SLEEP_SECONDS=1
ATTEMPT=1
until curl -fsS "${HEALTH_URL}" >/dev/null; do
if [ "${ATTEMPT}" -ge "${MAX_ATTEMPTS}" ]; then
echo "[deploy] ERROR: health check failed after ${MAX_ATTEMPTS} attempts: ${HEALTH_URL}" >&2
echo "[deploy] service status (wpp-staging.service):" >&2
systemctl --no-pager --full status wpp-staging.service >&2 || true
echo "[deploy] recent service logs (wpp-staging.service):" >&2
journalctl --no-pager -u wpp-staging.service -n 80 >&2 || true
exit 1
fi
echo "[deploy] health check not ready (attempt ${ATTEMPT}/${MAX_ATTEMPTS}); retrying in ${SLEEP_SECONDS}s"
sleep "${SLEEP_SECONDS}"
ATTEMPT=$((ATTEMPT + 1))
if [ "${SLEEP_SECONDS}" -lt 8 ]; then
SLEEP_SECONDS=$((SLEEP_SECONDS * 2))
fi
done
echo "[deploy] health check passed: ${HEALTH_URL}"
REMOTE
echo "[deploy] OK: staging deploy complete for CT ${CT_ID} (${REF_NAME})"
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
APP_DIR="${APP_DIR:-/opt/wpp-staging/app}"
ISSUE_ON_FAIL="${ISSUE_ON_FAIL:-1}"
fail() {
local message="$1"
echo "[smoke] FAIL: ${message}" >&2
if [[ "${ISSUE_ON_FAIL}" == "1" ]] && [[ -n "${GITEA_BASE:-}" ]] && [[ -n "${GITEA_REPO:-}" ]] && [[ -n "${GITEA_USER:-}" ]] && [[ -n "${GITEA_TOKEN:-}" ]]; then
python3 - <<PY || true
import json
import os
import urllib.request
import base64
from datetime import datetime, timezone
base = os.environ["GITEA_BASE"].rstrip("/")
repo = os.environ["GITEA_REPO"]
user = os.environ["GITEA_USER"]
token = os.environ["GITEA_TOKEN"]
message = os.environ.get("SMOKE_FAIL_MESSAGE", "unknown")
when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
payload = {
"title": f"[smoke-fail] staging smoke failed ({when})",
"body": (
"Automatisk oprettet af smoke-suite.\n\n"
f"Fejl: `{message}`\n"
"Kontekst: issue #22 (staging smoke-suite)"
),
"labels": ["smoke-fail", "need-to-have", "staging"],
}
url = f"{base}/api/v1/repos/{repo}/issues"
req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST")
auth = base64.b64encode(f"{user}:{token}".encode()).decode()
req.add_header("Authorization", f"Basic {auth}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
print(f"[smoke] Created fail issue: HTTP {r.status}")
PY
fi
exit 1
}
echo "[smoke] healthz check: ${BASE_URL}/healthz"
curl -fsS "${BASE_URL}/healthz" >/dev/null || { SMOKE_FAIL_MESSAGE="healthz check failed" fail "healthz check failed"; }
ENV_FILE="${ENV_FILE:-/etc/wpp/staging.env}"
run_manage() {
local cmd="$1"
(
cd "${APP_DIR}"
if [[ -f "${ENV_FILE}" ]]; then
set -a
# shellcheck disable=SC1090
source "${ENV_FILE}"
set +a
fi
.venv/bin/python manage.py ${cmd}
)
}
echo "[smoke] migration consistency check"
run_manage "migrate --check --noinput" || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; }
ARTIFACT_DIR="${ARTIFACT_DIR:-${APP_DIR}/artifacts/smoke}"
ARTIFACT_FILE="${ARTIFACT_FILE:-${ARTIFACT_DIR}/smoke-$(date -u +%Y%m%dT%H%M%SZ).json}"
echo "[smoke] gameplay flow via management command"
run_manage "smoke_staging --artifact ${ARTIFACT_FILE}" || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
echo "[smoke] artifact: ${ARTIFACT_FILE}"
echo "[smoke] OK"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -1,2 +1,3 @@
from django.contrib import admin
# Register your models here.
-6
View File
@@ -1,6 +0,0 @@
from django.conf import settings
def use_spa_ui() -> bool:
"""Central read-point for SPA cutover flag."""
return bool(getattr(settings, "USE_SPA_UI", False))
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -1 +0,0 @@
-164
View File
@@ -1,164 +0,0 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.test import Client
from fupogfakta.models import Category, GameSession, Player, Question, RoundQuestion
class Command(BaseCommand):
help = "Run minimal staging smoke flow for lobby gameplay"
def add_arguments(self, parser):
parser.add_argument(
"--artifact",
help="Optional path to write smoke result artifact as JSON",
)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
RoundQuestion.objects.all().delete()
category, _ = Category.objects.get_or_create(
slug="smoke",
defaults={"name": "Smoke", "is_active": True},
)
category.is_active = True
category.save(update_fields=["is_active"])
Question.objects.get_or_create(
category=category,
prompt="Smoke prompt?",
defaults={"correct_answer": "Correct", "is_active": True},
)
User = get_user_model()
host, _ = User.objects.get_or_create(username="smoke-host")
host.set_password("smoke-pass")
host.is_staff = True
host.save()
host_client = Client()
host_client.force_login(host)
create_res = host_client.post("/lobby/sessions/create", content_type="application/json")
if create_res.status_code != 201:
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}")
code = create_res.json()["session"]["code"]
players = []
for nickname in ["P1", "P2", "P3"]:
join_res = Client().post(
"/lobby/sessions/join",
data=json.dumps({"code": code, "nickname": nickname}),
content_type="application/json",
)
if join_res.status_code != 201:
raise CommandError(f"join_session failed for {nickname}: {join_res.status_code}")
players.append(join_res.json()["player"])
start_res = host_client.post(
f"/lobby/sessions/{code}/rounds/start",
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}")
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"]
for player in players:
nick = player["nickname"]
lie_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"text": f"Lie from {nick}",
}
),
content_type="application/json",
)
if lie_res.status_code != 201:
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
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")
for player in players:
nick = player["nickname"]
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
guess_res = 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": selected["text"],
}
),
content_type="application/json",
)
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}")
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
if finish_res.status_code != 200:
raise CommandError(f"finish_game failed: {finish_res.status_code}")
artifact_path = options.get("artifact")
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",
"show_question",
"submit_lies",
"mix_answers",
"submit_guesses",
"calculate_scores",
"reveal_scoreboard",
"finish_game",
],
}
output_path = Path(artifact_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
self.stdout.write(self.style.SUCCESS(f"Smoke flow OK for session {code}"))
+1
View File
@@ -1,2 +1,3 @@
from django.db import models
# Create your models here.
-96
View File
@@ -1,96 +0,0 @@
<!doctype html>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Host</title></head>
<body>
<h1>Host panel (MVP)</h1>
<p>Kræver login som host-bruger.</p>
<button id="createSessionBtn" onclick="createSession()">1) Opret session</button>
<p id="createSessionHint">Opret session er klar.</p>
<input id="code" placeholder="Sessionkode">
<select id="category">{% for c in categories %}<option value="{{ c.slug }}">{{ c.name }}</option>{% endfor %}</select>
<button id="startRoundBtn" onclick="startRound()" disabled>2) Start runde</button>
<p id="startRoundHint">Kræver 3-5 spillere i lobbyen.</p>
<p id="playerCountStatus">Spillere i session: ukendt</p>
<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="hostActionHint">Angiv sessionkode for at aktivere host-actions.</p>
<p id="hostErrorHint">Ingen fejl.</p>
<button id="sessionDetailBtn" onclick="sessionDetail()">Session-status</button>
<p id="sessionDetailHint">Session-opdatering klar.</p>
<button id="autoRefreshToggleBtn" onclick="toggleAutoRefresh()">Auto-refresh: OFF</button>
<p id="autoRefreshHint">Auto-refresh er slået fra.</p>
<p id="lastRefreshStatus">Session-data ikke opdateret endnu.</p>
<div id="hostShellErrorBoundary" style="display:none;margin:8px 0 10px;padding:8px 10px;border-radius:8px;border:1px solid #b91c1c;background:#fee2e2;color:#7f1d1d;">Der opstod en kritisk fejl i app-skallen.
<button id="hostRecoverRetryBtn" type="button" onclick="recoverHostShell('retry')">Prøv gendan</button>
<button id="hostRecoverReloadBtn" type="button" onclick="recoverHostShell('reload')">Genindlæs siden</button>
</div>
<pre id="out">Klar.</pre>
<script>
var currentSessionStatus="";
var autoRefreshEnabled=false;
var autoRefreshTimer=null;
var hostActionInFlight=false;
var lastRefreshAtLabel="";
var lastRefreshFailed=false;
var sessionDetailInFlight=false;
var hostShellRouteHint="";
var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",finished:"finished"};
var hostShellFatalError=false;
var hostShellRecoverInFlight=false;
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
function updateHostShellErrorBoundary(){var panel=document.getElementById("hostShellErrorBoundary");var retryBtn=document.getElementById("hostRecoverRetryBtn");var reloadBtn=document.getElementById("hostRecoverReloadBtn");if(!panel||!retryBtn||!reloadBtn){return;}panel.style.display=hostShellFatalError?"block":"none";retryBtn.disabled=hostShellRecoverInFlight||sessionDetailInFlight||!code();reloadBtn.disabled=hostShellRecoverInFlight;}
function setHostShellFatalError(detail){hostShellFatalError=true;var out=document.getElementById("out");if(out){out.textContent=JSON.stringify({status:0,data:{error:"host_shell_runtime_error",detail:detail||"Ukendt runtime-fejl"}},null,2);}var hint=document.getElementById("hostErrorHint");if(hint){hint.textContent="Fejl: Kritisk app-fejl. Brug recover-handlingerne for at fortsætte.";}updateHostShellErrorBoundary();}
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 phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
function hostShellRouteFromPath(){var marker="/lobby/ui/host";var path=(window.location.pathname||"").toLowerCase();var idx=path.indexOf(marker);if(idx===-1){return"";}var remainder=path.slice(idx+marker.length).replace(/^\/+|\/+$/g,"");if(!remainder){return"";}var route=remainder.split("/")[0];return HOST_SHELL_ROUTES[route]?route:"";}
function expectedHostShellRoute(){return HOST_SHELL_ROUTES[currentSessionStatus]||"";}
function syncHostShellRoute(){var currentRoute=hostShellRouteFromPath();var expectedRoute=expectedHostShellRoute();if(!currentRoute||!expectedRoute){hostShellRouteHint="";return;}if(currentRoute===expectedRoute){hostShellRouteHint="";return;}var nextPath="/lobby/ui/host/"+expectedRoute;window.history.replaceState(null,"",nextPath);hostShellRouteHint="Deep-link route guard: omdirigeret fra /"+currentRoute+" til /"+expectedRoute+" for fase "+phaseLabel(currentSessionStatus)+".";}
function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggleBtn");var hint=document.getElementById("autoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(autoRefreshEnabled?"ON":"OFF");btn.disabled=hostActionInFlight||sessionDetailInFlight||!code();}if(!hint){return;}if(hostActionInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv host-handling.";return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(!autoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(!code()){hint.textContent="Auto-refresh venter: angiv sessionkode.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for lobby-status.";}
function stopAutoRefresh(reason){autoRefreshEnabled=false;if(autoRefreshTimer){clearInterval(autoRefreshTimer);autoRefreshTimer=null;}if(reason){var hint=document.getElementById("autoRefreshHint");if(hint){hint.textContent=reason;}}updateAutoRefreshUi();saveHostContext();}
function startAutoRefresh(){if(!code()){updateAutoRefreshUi();return;}autoRefreshEnabled=true;if(autoRefreshTimer){clearInterval(autoRefreshTimer);}autoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updateAutoRefreshUi();saveHostContext();}
function toggleAutoRefresh(){if(hostActionInFlight||sessionDetailInFlight||!code()){updateAutoRefreshUi();return;}if(autoRefreshEnabled){stopAutoRefresh();return;}startAutoRefresh();}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
function markSessionRefresh(status){if(status>=200&&status<300){lastRefreshAtLabel=formatTimeLabel(new Date());lastRefreshFailed=false;}else{lastRefreshFailed=true;}updateLastRefreshStatus();}
function updateLastRefreshStatus(){var el=document.getElementById("lastRefreshStatus");if(!el){return;}if(!lastRefreshAtLabel){el.textContent=lastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(lastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+lastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+lastRefreshAtLabel+".";}
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
function updateErrorHint(status,data){var el=document.getElementById("hostErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updateHostShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updateHostShellErrorBoundary();}
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");syncHostShellRoute();if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";}
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
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;}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();});});
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);});
updatePhaseStatus();syncHostShellRoute();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();updateHostShellErrorBoundary();
if(restoreHostContext()){updatePhaseStatus();syncHostShellRoute();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
</script>
</body></html>
-138
View File
@@ -1,138 +0,0 @@
<!doctype html>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Player</title>
<style>
#answerOptions { margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; }
#answerOptions button { border: 1px solid #999; padding: 6px 10px; border-radius: 8px; background: #f4f4f4; cursor: pointer; }
#answerOptions button.active { border-color: #1652f0; background: #dfe9ff; }
#guessStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#lieStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#joinStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#contextLockHint { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#phaseStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#connectionBanner { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
#connectionBanner button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
#connectionBanner button[disabled] { opacity: 0.55; cursor: not-allowed; }
#playerShellErrorBoundary { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
#playerShellErrorBoundary button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
#playerShellErrorBoundary button[disabled] { opacity: 0.55; cursor: not-allowed; }
#lieStatus.locked { color: #0a5f2d; font-weight: 600; }
#guessStatus.locked { color: #0a5f2d; font-weight: 600; }
</style>
</head>
<body>
<h1>Player panel (MVP)</h1>
<input id="code" placeholder="Sessionkode">
<input id="nickname" placeholder="Nickname">
<button id="joinBtn" onclick="joinSession()">1) Join</button>
<p id="joinStatus">Klar til join.</p>
<p id="contextLockHint">Kontekst er ikke låst endnu.</p>
<input id="playerId" placeholder="Player id">
<input id="sessionToken" placeholder="Session token" type="password" readonly>
<input id="roundQuestionId" placeholder="Round question id" readonly>
<input id="lieText" placeholder="Din løgn">
<button id="lieSubmitBtn" onclick="submitLie()">2) Submit løgn</button>
<p id="lieStatus">Skriv din løgn.</p>
<input id="guessText" placeholder="Dit gæt" readonly>
<div id="answerOptions"></div>
<p id="guessStatus">Vælg et svar.</p>
<button id="guessSubmitBtn" onclick="submitGuess()" disabled>3) Submit gæt</button>
<button id="sessionDetailBtn" onclick="sessionDetail()">Opdater session-status</button>
<p id="sessionRefreshHint">Session-opdatering klar.</p>
<p id="roundContextHint">Runde-kontekst afventer session-opdatering.</p>
<button id="playerAutoRefreshToggleBtn" onclick="togglePlayerAutoRefresh()">Auto-refresh: OFF</button>
<p id="playerAutoRefreshHint">Auto-refresh er slået fra.</p>
<p id="playerLastRefreshStatus">Session-data ikke opdateret endnu.</p>
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
<p id="playerErrorHint">Ingen fejl.</p>
<div id="connectionBanner">Forbindelsen til serveren blev afbrudt.<button id="connectionRetryBtn" type="button" onclick="retryConnection()">Prøv igen</button></div>
<div id="playerShellErrorBoundary">Der opstod en kritisk fejl i app-skallen.
<button id="playerRecoverRetryBtn" type="button" onclick="recoverPlayerShell('retry')">Prøv gendan</button>
<button id="playerRecoverReloadBtn" type="button" onclick="recoverPlayerShell('reload')">Genindlæs siden</button>
</div>
<pre id="out">Klar.</pre>
<script>
var availableAnswers=[];
var guessSubmitted=false;
var lieSubmitted=false;
var lieSubmitInFlight=false;
var guessSubmitInFlight=false;
var PLAYER_CONTEXT_KEY="wppPlayerContext";
var joinInFlight=false;
var currentSessionStatus="";
var playerAutoRefreshEnabled=false;
var playerAutoRefreshTimer=null;
var sessionDetailInFlight=false;
var playerLastRefreshAtLabel="";
var playerLastRefreshFailed=false;
var connectionLost=false;
var connectionRetryInFlight=false;
var playerShellFatalError=false;
var playerShellRecoverInFlight=false;
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function updatePlayerShellErrorBoundary(){var panel=document.getElementById("playerShellErrorBoundary");var retryBtn=document.getElementById("playerRecoverRetryBtn");var reloadBtn=document.getElementById("playerRecoverReloadBtn");if(!panel||!retryBtn||!reloadBtn){return;}panel.style.display=playerShellFatalError?"block":"none";retryBtn.disabled=playerShellRecoverInFlight||sessionDetailInFlight||joinInFlight||!code();reloadBtn.disabled=playerShellRecoverInFlight;}
function setPlayerShellFatalError(detail){playerShellFatalError=true;var out=document.getElementById("out");if(out){out.textContent=JSON.stringify({status:0,data:{error:"player_shell_runtime_error",detail:detail||"Ukendt runtime-fejl"}},null,2);}var hint=document.getElementById("playerErrorHint");if(hint){hint.textContent="Fejl: Kritisk app-fejl. Brug recover-handlingerne for at fortsætte.";}updatePlayerShellErrorBoundary();}
function clearPlayerShellFatalError(){playerShellFatalError=false;playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();}
function recoverPlayerShell(mode){if(playerShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}playerShellRecoverInFlight=true;updatePlayerShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearPlayerShellFatalError();return result;}).catch(function(err){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();throw err;});}
function pid(){return document.getElementById("playerId").value.trim();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function savePlayerContext(){try{localStorage.setItem(PLAYER_CONTEXT_KEY,JSON.stringify({code:code(),nickname:document.getElementById("nickname").value.trim(),player_id:pid(),session_token:document.getElementById("sessionToken").value.trim(),round_question_id:rq(),auto_refresh:playerAutoRefreshEnabled}));}catch(_e){}}
function loadPlayerContext(){try{var raw=localStorage.getItem(PLAYER_CONTEXT_KEY);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
function restorePlayerContext(){var ctx=loadPlayerContext();if(!ctx){return false;}if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.nickname){document.getElementById("nickname").value=ctx.nickname;}if(ctx.player_id){document.getElementById("playerId").value=ctx.player_id;}if(ctx.session_token){document.getElementById("sessionToken").value=ctx.session_token;}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}playerAutoRefreshEnabled=!!ctx.auto_refresh;updatePlayerAutoRefreshUi();return !!(ctx.code&&ctx.player_id&&ctx.session_token);}
function hasSubmitContext(){return !!(code()&&pid()&&rq()&&document.getElementById("sessionToken").value.trim());}
function hasRoundQuestionContext(){return !!rq();}
function isPlayerContextLocked(){return !!(pid()&&document.getElementById("sessionToken").value.trim());}
function updateRoundContextHint(){var el=document.getElementById("roundContextHint");if(!el){return;}if(rq()){el.textContent="Runde-kontekst aktiv for spørgsmål #"+rq()+".";return;}el.textContent="Runde-kontekst afventer session-opdatering.";}
function resetRoundContextForManualChange(){document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);updateRoundContextHint();}
function updateContextLockState(){var locked=isPlayerContextLocked()||joinInFlight;var codeField=document.getElementById("code");var nicknameField=document.getElementById("nickname");var playerIdField=document.getElementById("playerId");var tokenField=document.getElementById("sessionToken");if(codeField){codeField.readOnly=locked;}if(nicknameField){nicknameField.readOnly=locked;}if(playerIdField){playerIdField.readOnly=locked;}if(tokenField){tokenField.readOnly=true;}var hint=document.getElementById("contextLockHint");if(!hint){return;}if(joinInFlight){hint.textContent="Låser kontekst…";return;}if(locked){hint.textContent="Spillerkontekst er låst efter join.";return;}hint.textContent="Kontekst er ikke låst endnu.";}
function canAttemptJoin(){return !!(code()&&document.getElementById("nickname").value.trim());}
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
function markPlayerSessionRefresh(status){if(status>=200&&status<300){playerLastRefreshAtLabel=formatTimeLabel(new Date());playerLastRefreshFailed=false;}else{playerLastRefreshFailed=true;}updatePlayerLastRefreshStatus();}
function updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";}
function stopPlayerAutoRefresh(reason){playerAutoRefreshEnabled=false;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);playerAutoRefreshTimer=null;}if(reason){var hint=document.getElementById("playerAutoRefreshHint");if(hint){hint.textContent=reason;}}updatePlayerAutoRefreshUi();savePlayerContext();}
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail().catch(function(){});},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
function togglePlayerAutoRefresh(){if(joinInFlight){updatePlayerAutoRefreshUi();return;}if(playerAutoRefreshEnabled){stopPlayerAutoRefresh();return;}startPlayerAutoRefresh();}
function updateConnectionBanner(){var banner=document.getElementById("connectionBanner");var retryBtn=document.getElementById("connectionRetryBtn");if(!banner||!retryBtn){return;}banner.style.display=connectionLost?"block":"none";retryBtn.disabled=connectionRetryInFlight||sessionDetailInFlight||joinInFlight||!code();}
function setConnectionLost(isLost){connectionLost=!!isLost;updateConnectionBanner();}
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";setConnectionLost(false);return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();updateConnectionBanner();updatePlayerShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();updateConnectionBanner();updatePlayerShellErrorBoundary();}
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();updateConnectionBanner();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();updateConnectionBanner();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();updateConnectionBanner();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();updateConnectionBanner();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();updateConnectionBanner();return;}status.textContent="Klar til join.";updateContextLockState();updateConnectionBanner();}
function guessStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppGuess",c,p,q].join(":");}
function lieStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppLie",c,p,q].join(":");}
function persistLieState(text,submitted){var key=lieStorageKey();if(!key){return;}try{localStorage.setItem(key,JSON.stringify({text:text||"",submitted:!!submitted}));}catch(_e){}}
function loadLieState(){var key=lieStorageKey();if(!key){return null;}try{var raw=localStorage.getItem(key);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
function updateLieSubmitState(){var text=(document.getElementById("lieText").value||"").trim();var btn=document.getElementById("lieSubmitBtn");var input=document.getElementById("lieText");var status=document.getElementById("lieStatus");var hasContext=hasSubmitContext();var inLiePhase=currentSessionStatus==="lie";if(input){input.readOnly=lieSubmitted||lieSubmitInFlight||sessionDetailInFlight||!inLiePhase;}if(btn){btn.disabled=lieSubmitted||lieSubmitInFlight||sessionDetailInFlight||!text||!hasContext||!inLiePhase;}if(status){if(lieSubmitted){status.textContent="Løgn sendt input er låst.";status.classList.add("locked");}else{status.classList.remove("locked");if(lieSubmitInFlight){status.textContent="Sender løgn…";}else if(sessionDetailInFlight){status.textContent="Afvent aktiv session-opdatering før løgn-submit.";}else if(!hasContext){status.textContent="Join først for at aktivere submit.";}else if(!currentSessionStatus){status.textContent="Opdatér session-status for at validere løgn-fase.";}else if(!inLiePhase){status.textContent="Løgn-input er låst i fase: "+phaseLabel(currentSessionStatus)+".";}else{status.textContent=text?"Løgn klar til afsendelse.":"Skriv din løgn.";}}}}
function setLieState(text,submitted){document.getElementById("lieText").value=text||"";if(typeof submitted==="boolean"){lieSubmitted=submitted;}updateLieSubmitState();}
function persistGuessState(text,submitted){var key=guessStorageKey();if(!key){return;}try{localStorage.setItem(key,JSON.stringify({selected_text:text||"",submitted:!!submitted}));}catch(_e){}}
function loadGuessState(){var key=guessStorageKey();if(!key){return null;}try{var raw=localStorage.getItem(key);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
function updateGuessStatus(){var el=document.getElementById("guessStatus");if(!el){return;}var selected=document.getElementById("guessText").value;var hasContext=hasSubmitContext();var hasRoundContext=hasRoundQuestionContext();var inGuessPhase=currentSessionStatus==="guess";if(guessSubmitted){el.textContent="Gæt sendt valg er låst.";el.classList.add("locked");return;}el.classList.remove("locked");if(guessSubmitInFlight){el.textContent="Sender gæt…";return;}if(sessionDetailInFlight){el.textContent="Afvent aktiv session-opdatering før gæt-submit.";return;}if(!code()||!pid()||!document.getElementById("sessionToken").value.trim()){el.textContent="Join først for at aktivere gæt.";return;}if(!hasRoundContext){el.textContent="Afvent aktivt spørgsmål fra host før du kan gætte.";return;}if(!hasContext){el.textContent="Join først for at aktivere gæt.";return;}if(!currentSessionStatus){el.textContent="Opdatér session-status for at validere gæt-fase.";return;}if(!inGuessPhase){el.textContent="Gæt er låst i fase: "+phaseLabel(currentSessionStatus)+".";return;}el.textContent=selected?"Valgt svar klar til afsendelse.":"Vælg et svar.";}
function updateGuessSubmitState(){var selected=document.getElementById("guessText").value;var hasValid=availableAnswers.indexOf(selected)!==-1;var hasContext=hasSubmitContext();var hasRoundContext=hasRoundQuestionContext();var inGuessPhase=currentSessionStatus==="guess";document.getElementById("guessSubmitBtn").disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase;var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight||!hasContext||!hasRoundContext||!inGuessPhase;});updateGuessStatus();}
function setGuess(text,submitted){document.getElementById("guessText").value=text||"";if(typeof submitted==="boolean"){guessSubmitted=submitted;}var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.classList.toggle("active",btn.dataset.answer===text);});updateGuessSubmitState();
updateJoinState();}
function renderAnswerOptions(roundQuestion){var wrap=document.getElementById("answerOptions");wrap.innerHTML="";availableAnswers=[];guessSubmitted=false;setGuess("",false);lieSubmitted=false;setLieState("",false);if(!roundQuestion||!Array.isArray(roundQuestion.answers)){updateGuessSubmitState();updateLieSubmitState();return;}roundQuestion.answers.forEach(function(item){if(!item||!item.text){return;}availableAnswers.push(item.text);var btn=document.createElement("button");btn.type="button";btn.dataset.answer=item.text;btn.textContent=item.text;btn.onclick=function(){if(guessSubmitted){return;}setGuess(item.text,false);persistGuessState(item.text,false);};wrap.appendChild(btn);});var saved=loadGuessState();if(saved&&availableAnswers.indexOf(saved.selected_text)!==-1){setGuess(saved.selected_text,!!saved.submitted);}updateGuessSubmitState();}
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.body=JSON.stringify(payload);}try{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){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}if(playerShellFatalError){clearPlayerShellFatalError();}savePlayerContext();return d;}catch(err){setConnectionLost(true);if((method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path)){markPlayerSessionRefresh(0);}document.getElementById("out").textContent=JSON.stringify({status:0,data:{error:"connection_lost",detail:"Kunne ikke kontakte serveren."}},null,2);document.getElementById("playerErrorHint").textContent="Fejl: Mistede forbindelsen til serveren. Prøv igen.";updateSessionDetailState();throw err;}}
function joinSession(){if(joinInFlight){return Promise.resolve({error:"join_in_flight"});}if(!canAttemptJoin()){updateJoinState();return Promise.resolve({error:"missing_join_input"});}if(pid()&&document.getElementById("sessionToken").value.trim()){updateJoinState();return Promise.resolve({error:"already_joined_client"});}joinInFlight=true;updateJoinState();updatePlayerAutoRefreshUi();return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()}).then(function(d){joinInFlight=false;if(d&&d.player&&d.player.id){updateJoinState();updatePlayerAutoRefreshUi();return d;}updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede prøv igen.";return d;}).catch(function(err){joinInFlight=false;updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede prøv igen.";throw err;});}
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 retryConnection(){if(connectionRetryInFlight||!code()){updateConnectionBanner();return Promise.resolve({error:"retry_unavailable"});}connectionRetryInFlight=true;updateConnectionBanner();return sessionDetail().then(function(result){setConnectionLost(false);return result;}).finally(function(){connectionRetryInFlight=false;updateConnectionBanner();});}
function submitLie(){if(lieSubmitted){return Promise.resolve({error:"lie_already_submitted_client"});}if(lieSubmitInFlight){return Promise.resolve({error:"lie_submit_in_flight"});}if(!hasSubmitContext()){updateLieSubmitState();return Promise.resolve({error:"missing_submit_context"});}var text=(document.getElementById("lieText").value||"").trim();if(!text){updateLieSubmitState();return Promise.resolve({error:"empty_lie_text"});}lieSubmitInFlight=true;updateLieSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/lies/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,text:text}).then(function(d){if(d&&d.lie&&d.lie.id){lieSubmitted=true;persistLieState(text,true);}return d;}).finally(function(){lieSubmitInFlight=false;updateLieSubmitState();});}
document.getElementById("lieText").addEventListener("input",function(){if(!lieSubmitted){updateLieSubmitState();persistLieState(document.getElementById("lieText").value,false);}});updateLieSubmitState();
function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_already_submitted_client"});}if(guessSubmitInFlight){return Promise.resolve({error:"guess_submit_in_flight"});}if(!hasSubmitContext()){updateGuessSubmitState();document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Join først for at aktivere gæt"}},null,2);return Promise.resolve({error:"missing_submit_context"});}var selected=document.getElementById("guessText").value;if(availableAnswers.indexOf(selected)===-1){document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Vælg et af de viste svarmuligheder"}},null,2);return Promise.resolve({error:"invalid_client_guess"});}guessSubmitInFlight=true;updateGuessSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,selected_text:selected}).finally(function(){guessSubmitInFlight=false;updateGuessSubmitState();});}
["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});});
window.addEventListener("error",function(event){setPlayerShellFatalError((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");setPlayerShellFatalError(detail);});
updatePhaseStatus();
updateGuessSubmitState();
updateJoinState();
updatePlayerAutoRefreshUi();
updatePlayerLastRefreshStatus();
updateRoundContextHint();
updateConnectionBanner();
updatePlayerShellErrorBoundary();
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail().catch(function(){});}else{savePlayerContext();}
</script>
</body></html>
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WPP SPA</title>
</head>
<body>
<app-root data-wpp-shell-route="{{ shell_route }}"></app-root>
<script type="module" src="{{ spa_asset_base }}/main.js"></script>
</body>
</html>
+2 -990
View File
File diff suppressed because it is too large Load Diff
-34
View File
@@ -1,34 +0,0 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from fupogfakta.models import Category
from .feature_flags import use_spa_ui
def _render_spa_shell(request, shell_route: str):
return render(
request,
"lobby/spa_shell.html",
{
"shell_route": shell_route,
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
},
)
@login_required
def host_screen(request, spa_path=None):
if use_spa_ui():
return _render_spa_shell(request, "/host")
categories = Category.objects.filter(is_active=True).order_by("name")
return render(request, "lobby/host_screen.html", {"categories": categories})
def player_screen(request):
if use_spa_ui():
return _render_spa_shell(request, "/player")
return render(request, "lobby/player_screen.html")
+1 -28
View File
@@ -1,39 +1,12 @@
from django.urls import path
from . import ui_views, views
from . import views
app_name = "lobby"
urlpatterns = [
path("ui/host", ui_views.host_screen, name="host_screen"),
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
path("ui/player", ui_views.player_screen, name="player_screen"),
path("sessions/create", views.create_session, name="create_session"),
path("sessions/join", views.join_session, name="join_session"),
path("sessions/<str:code>", views.session_detail, name="session_detail"),
path("sessions/<str:code>/rounds/start", views.start_round, name="start_round"),
path("sessions/<str:code>/questions/show", views.show_question, name="show_question"),
path(
"sessions/<str:code>/questions/<int:round_question_id>/lies/submit",
views.submit_lie,
name="submit_lie",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/answers/mix",
views.mix_answers,
name="mix_answers",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/guesses/submit",
views.submit_guess,
name="submit_guess",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/scores/calculate",
views.calculate_scores,
name="calculate_scores",
),
path("sessions/<str:code>/scoreboard", views.reveal_scoreboard, name="reveal_scoreboard"),
path("sessions/<str:code>/finish", views.finish_game, name="finish_game"),
path("sessions/<str:code>/rounds/next", views.start_next_round, name="start_next_round"),
]
+5 -613
View File
@@ -1,24 +1,12 @@
import json
import random
from datetime import timedelta
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction
from django.db import transaction
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import (
Category,
GameSession,
Guess,
LieAnswer,
Player,
Question,
RoundConfig,
RoundQuestion,
ScoreEvent,
)
from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6
@@ -45,10 +33,6 @@ def _generate_session_code() -> str:
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
def _normalize_session_code(code: str) -> str:
return code.strip().upper()
def _create_unique_session_code() -> str:
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
code = _generate_session_code()
@@ -58,45 +42,6 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code")
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_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"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_start_next_round": in_reveal,
"can_finish_game": in_reveal,
},
"player": {
"can_join": status in JOINABLE_STATUSES,
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
@require_POST
@login_required
def create_session(request: HttpRequest) -> JsonResponse:
@@ -120,7 +65,7 @@ def create_session(request: HttpRequest) -> JsonResponse:
def join_session(request: HttpRequest) -> JsonResponse:
payload = _json_body(request)
code = _normalize_session_code(str(payload.get("code", "")))
code = str(payload.get("code", "")).strip().upper()
nickname = str(payload.get("nickname", "")).strip()
if not code:
@@ -147,7 +92,6 @@ def join_session(request: HttpRequest) -> JsonResponse:
"player": {
"id": player.id,
"nickname": player.nickname,
"session_token": player.session_token,
"score": player.score,
},
"session": {
@@ -161,7 +105,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
@require_GET
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
session_code = code.strip().upper()
try:
session = GameSession.objects.get(code=session_code)
@@ -177,29 +121,6 @@ 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()
)
round_question_payload = None
if current_round_question:
round_question_payload = {
"id": current_round_question.id,
"round_number": current_round_question.round_number,
"prompt": current_round_question.question.prompt,
"shown_at": current_round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
}
phase_view_model = _build_phase_view_model(
session,
players_count=len(players),
has_round_question=bool(current_round_question),
)
return JsonResponse(
{
"session": {
@@ -210,8 +131,6 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"players_count": len(players),
},
"players": players,
"round_question": round_question_payload,
"phase_view_model": phase_view_model,
}
)
@@ -225,7 +144,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
if not category_slug:
return JsonResponse({"error": "category_slug is required"}, status=400)
session_code = _normalize_session_code(code)
session_code = code.strip().upper()
try:
session = GameSession.objects.get(code=session_code)
@@ -279,530 +198,3 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
},
status=201,
)
@require_POST
@login_required
def show_question(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can show question"}, status=403)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Question can only be shown in lie phase"}, status=400)
try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
return JsonResponse({"error": "Question already shown for this round"}, 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 JsonResponse({"error": "No available questions in category"}, 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,
)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return JsonResponse(
{
"round_question": {
"id": round_question.id,
"prompt": question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
},
status=201,
)
@require_POST
def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = _json_body(request)
session_code = _normalize_session_code(code)
player_id = payload.get("player_id")
session_token = str(payload.get("session_token", "")).strip()
lie_text = str(payload.get("text", "")).strip()
if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400)
if not session_token:
return JsonResponse({"error": "session_token is required"}, status=400)
if not lie_text or len(lie_text) > 255:
return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Lie submission is only allowed in lie phase"}, status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404)
if player.session_token != session_token:
return JsonResponse({"error": "Invalid player session token"}, status=403)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
if timezone.now() > lie_deadline_at:
return JsonResponse({"error": "Lie submission window has closed"}, status=400)
try:
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
except IntegrityError:
return JsonResponse({"error": "Lie already submitted for this player"}, status=409)
return JsonResponse(
{
"lie": {
"id": lie.id,
"player_id": player.id,
"round_question_id": round_question.id,
"text": lie.text,
"created_at": lie.created_at.isoformat(),
},
"window": {
"lie_deadline_at": lie_deadline_at.isoformat(),
},
},
status=201,
)
@require_POST
@login_required
def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can mix answers"}, status=403)
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
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 JsonResponse({"error": "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"])
if locked_session.status == GameSession.Status.LIE:
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=["status"])
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.GUESS,
"current_round": session.current_round,
},
"round_question": {
"id": round_question.id,
"round_number": round_question.round_number,
},
"answers": [{"text": text} for text in deduped_answers],
}
)
@require_POST
def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = _json_body(request)
session_code = _normalize_session_code(code)
player_id = payload.get("player_id")
session_token = str(payload.get("session_token", "")).strip()
selected_text = str(payload.get("selected_text", "")).strip()
if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400)
if not session_token:
return JsonResponse({"error": "session_token is required"}, status=400)
if not selected_text or len(selected_text) > 255:
return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404)
if player.session_token != session_token:
return JsonResponse({"error": "Invalid player session token"}, status=403)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
guess_deadline_at = round_question.shown_at + timedelta(
seconds=round_config.lie_seconds + round_config.guess_seconds
)
if timezone.now() > guess_deadline_at:
return JsonResponse({"error": "Guess submission window has closed"}, status=400)
allowed_answers = {
round_question.correct_answer.strip().casefold(),
*(
text.strip().casefold()
for text in round_question.lies.values_list("text", flat=True)
if text.strip()
),
}
selected_normalized = selected_text.casefold()
if selected_normalized not in allowed_answers:
return JsonResponse({"error": "Selected answer is not part of this round"}, status=400)
correct_normalized = round_question.correct_answer.strip().casefold()
fooled_player_id = None
if selected_normalized != correct_normalized:
fooled_player_id = (
round_question.lies.filter(text__iexact=selected_text).values_list("player_id", flat=True).first()
)
try:
guess = Guess.objects.create(
round_question=round_question,
player=player,
selected_text=selected_text,
is_correct=selected_normalized == correct_normalized,
fooled_player_id=fooled_player_id,
)
except IntegrityError:
return JsonResponse({"error": "Guess already submitted for this player"}, status=409)
return JsonResponse(
{
"guess": {
"id": guess.id,
"player_id": player.id,
"round_question_id": round_question.id,
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"fooled_player_id": guess.fooled_player_id,
"created_at": guess.created_at.isoformat(),
},
"window": {
"guess_deadline_at": guess_deadline_at.isoformat(),
},
},
status=201,
)
@require_GET
@login_required
def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can view scoreboard"}, status=403)
if session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400)
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
)
@require_POST
@login_required
def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can start next round"}, status=403)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Next round can only start from reveal phase"}, status=400)
locked_session.current_round += 1
locked_session.status = GameSession.Status.LOBBY
locked_session.save(update_fields=["current_round", "status"])
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.LOBBY,
"current_round": locked_session.current_round,
}
}
)
@require_POST
@login_required
def finish_game(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can finish game"}, status=403)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Game can only be finished from reveal phase"}, status=400)
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
winner = leaderboard[0] if leaderboard else None
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": winner,
"leaderboard": leaderboard,
}
)
@require_POST
@login_required
def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can calculate scores"}, status=403)
already_calculated = ScoreEvent.objects.filter(
session=session,
meta__round_question_id=round_question_id,
).exists()
if already_calculated:
return JsonResponse({"error": "Scores already calculated for this round question"}, status=409)
if session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
return JsonResponse({"error": "No guesses submitted for this round question"}, status=400)
bluff_counts = {}
for guess in guesses:
if guess.fooled_player_id:
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400)
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)
locked_session.status = GameSession.Status.REVEAL
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.REVEAL,
"current_round": session.current_round,
},
"round_question": {
"id": round_question.id,
"round_number": round_question.round_number,
},
"events_created": len(score_events),
"leaderboard": leaderboard,
}
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
-8
View File
@@ -99,14 +99,6 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
USE_SPA_UI_RAW = env('USE_SPA_UI')
if USE_SPA_UI_RAW is None:
# Backward-compatible fallback while cutover is rolling out.
USE_SPA_UI_RAW = env('WPP_SPA_ENABLED', 'false')
USE_SPA_UI = USE_SPA_UI_RAW.lower() == 'true'
WPP_SPA_ASSET_BASE = env('WPP_SPA_ASSET_BASE', '/static/frontend/angular/browser').rstrip('/')
CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1')
CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379'))
CHANNEL_LAYERS = {
+1
View File
@@ -11,4 +11,5 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("healthz", health, name="healthz"),
path("lobby/", include("lobby.urls")),
path("game/", include("fupogfakta.urls")),
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.