Compare commits
168 Commits
1662d7d197
...
dev/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| b6617fc356 | |||
| fa6c5e30c9 | |||
| cd3c604ba6 | |||
| 1aa296c45c | |||
| ea8954e702 | |||
| 61eb08ad73 | |||
| 37b86d7065 | |||
| 2e25d32ba1 | |||
| 825f8c599b | |||
| 87ba42c68a | |||
| 2882a7737b | |||
| a9868ae450 | |||
| d6c7a36730 | |||
| de99e456c7 | |||
| 79c4734fe6 | |||
| c8c27346a8 | |||
| 994e2930d5 | |||
| 3e0cb9cee7 | |||
| 64bff4efb3 | |||
| c58b1e8102 | |||
| 7aae8f3798 | |||
| 0c0d27cc52 | |||
| 164416e4a9 | |||
| b782f73f49 | |||
| 9d1e41ef3b | |||
| 046212d29a | |||
| 6fd57d1714 | |||
| c4ea5ca208 | |||
| ab08303fc3 | |||
| 8b6f115759 | |||
| c75189deb9 | |||
| 30c22d2f0c | |||
| 30e3f1c77f | |||
| abb656d50b | |||
| b1e89b135a | |||
| e9104cdc44 | |||
| 65ee2fc0db | |||
| 12fc12f955 | |||
| e8f13646f9 | |||
| a36221ae4b | |||
| fce18c1ee3 | |||
| 850a364251 | |||
| 2adeb8536a | |||
| 726280e120 | |||
| 7ec9219487 | |||
| eabf95bc5c | |||
| ad67c63cca | |||
| 8dc1e709f8 | |||
| 0d65cfac82 | |||
| 68f934967a | |||
| 9e496763aa | |||
| ef80f85d67 | |||
| 348bebf358 | |||
| 3f9752aac4 | |||
| 48eae5d083 | |||
| 5edf1969ed | |||
| 95d3f1aa48 | |||
| 7b4110e896 | |||
| 1ff98f5e92 | |||
| 2892ecc555 | |||
| 0a07bfd7ad | |||
| 59e12d596d | |||
| 5b2e2132e7 | |||
| fc28f8499f | |||
| 204581aef5 | |||
| 73b63ed6a4 | |||
| 49286ca631 | |||
| 4613615e99 | |||
| 6732c75475 | |||
| 9600475e5e | |||
| 1319957e36 | |||
| 229e0f2c16 | |||
| 4a1ed80142 | |||
| b2211e2ac9 | |||
| 41a414bc97 | |||
| e0de58b4b3 | |||
| c0c303d45e | |||
| c568b34d51 | |||
| f5380f8a81 | |||
| 84c00da55f | |||
| 0e1a36b0b5 | |||
| 727e907650 | |||
| 8c655d10b6 | |||
| 03f6f35019 | |||
| b6110ec53e | |||
| bdc7e40677 | |||
| 2b574aa3b5 | |||
| 7e4cd0940a | |||
| c6f90c3564 | |||
| 042c8e70d0 | |||
| 2968c37e66 | |||
| 6b29b85792 | |||
| 15136537f4 | |||
| eb23d023a7 | |||
| b6e5b98837 | |||
| b8e0817d2d | |||
| b030ae6d4e | |||
| 5c8faf76a9 | |||
| 1709713bff | |||
| 07b5982ac4 | |||
| 4d22bb5d04 | |||
| a8fd012193 | |||
| 2a488c6530 | |||
| c9b4fe0077 | |||
| 7e7445cd07 | |||
| 9807ce8d2e | |||
| 032304f19b | |||
| 5170c779e4 | |||
| 82b90d3c5d | |||
| e0e1c6a7a0 | |||
| bd1b059c97 | |||
| 0858cbe892 | |||
| 0fe66f21c0 | |||
| 1a988469ec | |||
| d316b3bff6 | |||
| 5c1827c8b8 | |||
| 32a85c0790 | |||
| d8b44411a9 | |||
| 16b365c66d | |||
| 630af2333b | |||
| 7c526c0bdb | |||
| 867ea9602f | |||
| dce416f48a | |||
| 298381586f | |||
| fdaddc4f52 | |||
| a0562fa6a4 | |||
| a9bb2c9670 | |||
| 81a29a0e07 | |||
| 9d298e083e | |||
| 0a028bb499 | |||
| 59f2b0b29e | |||
| 5894987a1c | |||
| 177171706b | |||
| 37e1d32675 | |||
| 86dbd4fabc | |||
| 8e4ce8c4da | |||
| fa523d84f5 | |||
| 05b3d982b4 | |||
| 0d13ab9f80 | |||
| 7fbec0d604 | |||
| cfffc9934c | |||
| 12ecf32e50 | |||
| d6e7198fe8 | |||
| c0145f9e5f | |||
| 71a0f8e9d4 | |||
| 77e94c3125 | |||
| 08c22be3e9 | |||
| 92afeb9a75 | |||
| 6f8061644d | |||
| 3622b9f024 | |||
| 32ed75ae1e | |||
| 6f4a99637e | |||
| adce99b82b | |||
| e19535b24c | |||
| 102c8b91ec | |||
| ae25403e18 | |||
| 1017ed0c4c | |||
| 49e9d1be41 | |||
| 0cb936173f | |||
| d66c21ecb3 | |||
| 3ee478f094 | |||
| adbdf5c876 | |||
| 9ed5a909f1 | |||
| 648da2407b | |||
| 2db87561d8 | |||
| 2f400e2eff | |||
| f0026ba35d | |||
| ec04325e43 |
33
.gitea/workflows/ci.yml
Normal file
33
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
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
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ media/
|
||||
.env
|
||||
.env.*
|
||||
!.env.test.example
|
||||
!.env.staging.example
|
||||
!.env.prod.example
|
||||
|
||||
# Editors/OS
|
||||
|
||||
25
TODO.md
25
TODO.md
@@ -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, max 12
|
||||
- Spillere: min 3, default max 5 (MVP)
|
||||
- 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
|
||||
- [ ] 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
|
||||
- [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
|
||||
|
||||
### Fase 4 — Voice-acting (platformkrav)
|
||||
- [ ] Definér TTS provider-interface
|
||||
@@ -103,9 +103,20 @@ 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
coordination/PO_DECISIONS_2026-02-27.md
Normal file
41
coordination/PO_DECISIONS_2026-02-27.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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,5 +1,5 @@
|
||||
{
|
||||
"updatedAt": "2026-02-27T13:15:06Z",
|
||||
"updatedAt": "2026-02-27T13:38:35Z",
|
||||
"active": [],
|
||||
"queue": []
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
28
docs/RELEASE_POLICY.md
Normal file
28
docs/RELEASE_POLICY.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 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
docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md
Normal file
43
docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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
docs/UI_SMOKE.md
Normal file
18
docs/UI_SMOKE.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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
docs/spa-cutover-flag.md
Normal file
18
docs/spa-cutover-flag.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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
frontend/package-lock.json
generated
Normal file
1454
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/package.json
Normal file
15
frontend/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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
frontend/src/api/angular-client.ts
Normal file
62
frontend/src/api/angular-client.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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
frontend/src/api/client.ts
Normal file
81
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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
frontend/src/api/types.ts
Normal file
115
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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
frontend/src/spa/vertical-slice.ts
Normal file
87
frontend/src/spa/vertical-slice.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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
frontend/tests/angular-api-client.test.ts
Normal file
92
frontend/tests/angular-api-client.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
148
frontend/tests/api-client.integration.test.ts
Normal file
148
frontend/tests/api-client.integration.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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
frontend/tests/vertical-slice.test.ts
Normal file
115
frontend/tests/vertical-slice.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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
frontend/tsconfig.json
Normal file
12
frontend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
8
frontend/vitest.config.ts
Normal file
8
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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.
19
fupogfakta/migrations/0002_roundquestion_shown_at.py
Normal file
19
fupogfakta/migrations/0002_roundquestion_shown_at.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
18
fupogfakta/migrations/0003_roundquestion_mixed_answers.py
Normal file
18
fupogfakta/migrations/0003_roundquestion_mixed_answers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
19
fupogfakta/migrations/0004_player_session_token.py
Normal file
19
fupogfakta/migrations/0004_player_session_token.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,15 @@
|
||||
import secrets
|
||||
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
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)
|
||||
@@ -53,6 +60,7 @@ 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)
|
||||
@@ -85,6 +93,8 @@ 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)
|
||||
|
||||
|
||||
class LieAnswer(models.Model):
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
||||
12
infra/env/.env.staging.example
vendored
Normal file
12
infra/env/.env.staging.example
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
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
infra/staging/DB_SETUP.md
Normal file
35
infra/staging/DB_SETUP.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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
infra/staging/README.md
Normal file
50
infra/staging/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 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
infra/staging/deploy_staging.sh
Executable file
96
infra/staging/deploy_staging.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/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
infra/staging/smoke_suite.sh
Executable file
79
infra/staging/smoke_suite.sh
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/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,3 +1,2 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
|
||||
6
lobby/feature_flags.py
Normal file
6
lobby/feature_flags.py
Normal file
@@ -0,0 +1,6 @@
|
||||
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
lobby/management/__init__.py
Normal file
1
lobby/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
lobby/management/commands/__init__.py
Normal file
1
lobby/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
164
lobby/management/commands/smoke_staging.py
Normal file
164
lobby/management/commands/smoke_staging.py
Normal file
@@ -0,0 +1,164 @@
|
||||
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}"))
|
||||
Binary file not shown.
@@ -1,3 +1,2 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
96
lobby/templates/lobby/host_screen.html
Normal file
96
lobby/templates/lobby/host_screen.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!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
lobby/templates/lobby/player_screen.html
Normal file
138
lobby/templates/lobby/player_screen.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!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
lobby/templates/lobby/spa_shell.html
Normal file
12
lobby/templates/lobby/spa_shell.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
996
lobby/tests.py
996
lobby/tests.py
File diff suppressed because it is too large
Load Diff
34
lobby/ui_views.py
Normal file
34
lobby/ui_views.py
Normal file
@@ -0,0 +1,34 @@
|
||||
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,12 +1,39 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
from . import ui_views, 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"),
|
||||
]
|
||||
|
||||
618
lobby/views.py
618
lobby/views.py
@@ -1,12 +1,24 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.db import IntegrityError, 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, Player, Question, RoundConfig
|
||||
from fupogfakta.models import (
|
||||
Category,
|
||||
GameSession,
|
||||
Guess,
|
||||
LieAnswer,
|
||||
Player,
|
||||
Question,
|
||||
RoundConfig,
|
||||
RoundQuestion,
|
||||
ScoreEvent,
|
||||
)
|
||||
|
||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
SESSION_CODE_LENGTH = 6
|
||||
@@ -33,6 +45,10 @@ 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()
|
||||
@@ -42,6 +58,45 @@ 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:
|
||||
@@ -65,7 +120,7 @@ def create_session(request: HttpRequest) -> JsonResponse:
|
||||
def join_session(request: HttpRequest) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
|
||||
code = str(payload.get("code", "")).strip().upper()
|
||||
code = _normalize_session_code(str(payload.get("code", "")))
|
||||
nickname = str(payload.get("nickname", "")).strip()
|
||||
|
||||
if not code:
|
||||
@@ -92,6 +147,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
"player": {
|
||||
"id": player.id,
|
||||
"nickname": player.nickname,
|
||||
"session_token": player.session_token,
|
||||
"score": player.score,
|
||||
},
|
||||
"session": {
|
||||
@@ -105,7 +161,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
|
||||
@require_GET
|
||||
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -121,6 +177,29 @@ 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": {
|
||||
@@ -131,6 +210,8 @@ 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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -144,7 +225,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if not category_slug:
|
||||
return JsonResponse({"error": "category_slug is required"}, status=400)
|
||||
|
||||
session_code = code.strip().upper()
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -198,3 +279,530 @@ 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.
@@ -99,6 +99,14 @@ 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 = {
|
||||
|
||||
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.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user