Compare commits
140 Commits
eed7bc73ee
...
fix/stagin
| Author | SHA1 | Date | |
|---|---|---|---|
| 173df0fd6f | |||
| 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 | |||
| 77dfefa9d4 | |||
| 03100c99cd | |||
| 9995203add | |||
| 2d9548b6de | |||
| 2f040c87fb | |||
| 811ef949eb | |||
| 534eb578a9 | |||
| 3bfa0f5b2e | |||
| 93d3e9eca2 | |||
| e921bd6b4b | |||
| eed43ac9ca | |||
| 7703cf7076 |
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
|
||||
34
TODO.md
34
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
|
||||
@@ -27,9 +27,9 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
## Faseplan
|
||||
|
||||
### Fase 0 — Scope + regler
|
||||
- [ ] Fastlæg MVP for Spil 1 (`Fup og Fakta`)
|
||||
- [x] Fastlæg MVP for Spil 1 (`Fup og Fakta`) — se `docs/F0_MVP_FUP_OG_FAKTA.md`
|
||||
- [x] Midlertidige defaults sat (X/Z, spillerantal)
|
||||
- [ ] Fastlæg anti-cheat regler (fx ingen identiske løgne)
|
||||
- [x] Fastlæg anti-cheat regler (fx ingen identiske løgne) — se docs/F0_ANTI_CHEAT_RULES.md
|
||||
|
||||
### Fase 1 — Monorepo + Django skelet
|
||||
- [x] Opret Django-projekt (`partyhub`)
|
||||
@@ -53,14 +53,14 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
- [x] `ScoreEvent` (auditérbar pointslog)
|
||||
|
||||
### Fase 3 — Spilflow `Fup og Fakta`
|
||||
- [ ] Lobby: host opretter session, spillere joiner via kode
|
||||
- [ ] 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] Lobby: host opretter session, spillere joiner via kode
|
||||
- [x] Runde starter med kategori
|
||||
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
|
||||
- [x] System blander korrekt svar + løgne
|
||||
- [x] Guessfase: alle gætter inden Z sek
|
||||
- [x] Pointudregning (konfigurerbar pr. runde)
|
||||
- [x] Scoreboard + næste spørgsmål/runde
|
||||
- [x] Slutresultat
|
||||
|
||||
### Fase 4 — Voice-acting (platformkrav)
|
||||
- [ ] Definér TTS provider-interface
|
||||
@@ -103,8 +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
|
||||
5
coordination/assignments.json
Normal file
5
coordination/assignments.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"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.
53
docs/F0_ANTI_CHEAT_RULES.md
Normal file
53
docs/F0_ANTI_CHEAT_RULES.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# F0 anti-cheat regler — Fup og Fakta
|
||||
|
||||
## Formål
|
||||
Fastlæg simple, håndhævelige anti-cheat regler for F0, så runderne opleves fair uden tung NLP/moderation.
|
||||
|
||||
## Scope (F0)
|
||||
Gælder for inputfasen hvor spillere indsender løgne til et spørgsmål.
|
||||
|
||||
## Regelsæt (F0)
|
||||
|
||||
### 1) Ingen identiske løgne i samme runde
|
||||
- To spillere må ikke have samme løgntekst efter normalisering.
|
||||
- Hvis en indsendt løgn matcher en eksisterende løgn i runden, afvises den med fejlbesked og spilleren skal indsende ny tekst.
|
||||
|
||||
### 2) Ingen løgn må være identisk med korrekt svar
|
||||
- En løgn, der matcher det korrekte svar efter normalisering, afvises.
|
||||
|
||||
### 3) Ingen tomme eller trivielt ugyldige svar
|
||||
- Tom tekst, kun whitespace eller kun tegnsætning afvises.
|
||||
- Minimumslængde: 2 tegn efter trim.
|
||||
|
||||
### 4) Én aktiv løgn pr. spiller pr. spørgsmål
|
||||
- Spilleren må gerne overskrive sin egen løgn inden deadline.
|
||||
- Ved deadline er kun seneste gyldige version gældende.
|
||||
|
||||
### 5) Ingen afslørende metatekst
|
||||
- Svar som eksplicit afslører bluff-mekanikken (fx “det rigtige svar er …”, “jeg lyver”) afvises i F0 via enkel nøgleordsblokliste.
|
||||
|
||||
## Normalisering (bruges i regel 1 + 2)
|
||||
Følgende normalisering anvendes før sammenligning:
|
||||
1. Unicode NFKC
|
||||
2. Trim + collapse af flere mellemrum til ét
|
||||
3. Lowercase
|
||||
4. Fjern afsluttende tegnsætning (`.`, `,`, `!`, `?`, `:` `;`)
|
||||
|
||||
Bemærk: F0 bruger **ikke** semantisk duplikatdetektion (synonymer/stavevarianter kan passere).
|
||||
|
||||
## Håndhævelse i UX/API
|
||||
- Validering sker server-side ved submit.
|
||||
- Klienten får konkret fejlårsag og kan indsende igen inden tidsfrist.
|
||||
- Samme valideringsregler gælder for alle klienter (web/mobil).
|
||||
|
||||
## Acceptance criteria (F0)
|
||||
- Identiske løgne (efter normalisering) kan ikke gemmes i samme runde.
|
||||
- Løgn == korrekt svar (efter normalisering) kan ikke gemmes.
|
||||
- Tom/ugyldig input afvises.
|
||||
- Overskrivning af egen løgn inden deadline virker.
|
||||
- Ved deadline bruges kun seneste gyldige løgn.
|
||||
|
||||
## Out-of-scope (bevidst udskudt)
|
||||
- Semantisk duplikatdetektion (embeddings/fuzzy matching)
|
||||
- Avanceret toxicitet/moderation
|
||||
- Sprogdetektion og translitterering
|
||||
51
docs/F0_MVP_FUP_OG_FAKTA.md
Normal file
51
docs/F0_MVP_FUP_OG_FAKTA.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# F0 MVP-definition — Spil 1: Fup og Fakta
|
||||
|
||||
## Formål
|
||||
Definere et klart, implementerbart MVP-scope for første spil, så teamet kan bygge og validere en første spilbar version uden scope creep.
|
||||
|
||||
## MVP-mål (Spilbar kerne)
|
||||
MVP er opfyldt når en host kan starte en session, 3-12 spillere kan deltage via kode, og mindst én fuld runde kan gennemføres med korrekt pointgivning og synlig scoreboard.
|
||||
|
||||
## In-scope (skal med i MVP)
|
||||
1. **Lobby + sessionstyring**
|
||||
- Host opretter session (kræver login).
|
||||
- Spillere joiner med nickname + session-kode.
|
||||
- Session håndterer 3-12 aktive spillere.
|
||||
|
||||
2. **Rundeflow (single game loop)**
|
||||
- System vælger kategori + spørgsmål.
|
||||
- Alle spillere indsender én løgn inden for X=45 sek.
|
||||
- System samler svarliste: korrekt svar + alle gyldige løgne (blandet rækkefølge).
|
||||
- Alle spillere gætter inden for Z=30 sek.
|
||||
|
||||
3. **Scoring (server-side)**
|
||||
- Point for korrekt gæt.
|
||||
- Point når andre vælger ens løgn (bluff-point).
|
||||
- Score opdateres pr. spørgsmål og logges i `ScoreEvent`.
|
||||
|
||||
4. **Resultatvisning**
|
||||
- Scoreboard efter hvert spørgsmål.
|
||||
- Slutresultat efter sidste spørgsmål i runden.
|
||||
|
||||
5. **Driftbar baseline**
|
||||
- Realtidsopdateringer via websocket (host + mobilklienter).
|
||||
- Grundlæggende fejlhåndtering for timeout/manglende svar (spillet går videre).
|
||||
|
||||
## Out-of-scope for F0 MVP
|
||||
- Voice-acting/TTS-afvikling (beholdes som platformkrav i senere fase).
|
||||
- Avancerede anti-cheat regler (fx semantisk duplikatdetektion).
|
||||
- Bulk-import/avanceret content moderation.
|
||||
- Reconnect-robusthed ud over basal håndtering.
|
||||
- Temaer, lydeffekter, onboarding-polish.
|
||||
|
||||
## Afgrænsning af “Done” for F0
|
||||
F0-MVP anses som leveret når følgende acceptance criteria er opfyldt:
|
||||
- Én host kan oprette og starte en `Fup og Fakta` session.
|
||||
- Mindst 3 spillere kan joine med kode og gennemføre en hel runde.
|
||||
- Tidsfaser (løgntid/gættetid) håndhæves server-side.
|
||||
- Pointtildeling er korrekt og auditerbar via `ScoreEvent`.
|
||||
- Scoreboard og slutresultat vises deterministisk for alle klienter.
|
||||
|
||||
## Åbne afhængigheder (ikke blokkerende for definitionen)
|
||||
- Endelig pointmatrix i `RoundConfig` (kan justeres uden at ændre MVP-scope).
|
||||
- Anti-cheat-regler specificeres separat i Fase 0-opgaven “Fastlæg anti-cheat regler”.
|
||||
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
|
||||
```
|
||||
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.
|
||||
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.
|
||||
|
||||
32
infra/staging/DB_SETUP.md
Normal file
32
infra/staging/DB_SETUP.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# DB setup runbook (Issue #21)
|
||||
|
||||
> Credentials ligger i Secrets-repo, ikke i applikationsrepo.
|
||||
|
||||
## Databaser
|
||||
- `wpp_test`
|
||||
- `wpp_prod`
|
||||
|
||||
## Brugere
|
||||
- `wpp_test_user` (least privilege på `wpp_test`)
|
||||
- `wpp_prod_user` (least privilege på `wpp_prod`)
|
||||
|
||||
## Secrets placering
|
||||
I Secrets-repo:
|
||||
- `wpp/wpp_test.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`.
|
||||
43
infra/staging/README.md
Normal file
43
infra/staging/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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
|
||||
|
||||
## 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
|
||||
|
||||
## 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).
|
||||
33
infra/staging/deploy_staging.sh
Executable file
33
infra/staging/deploy_staging.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/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 -lc \"set -euo pipefail
|
||||
mkdir -p /opt/wpp-staging/releases/src
|
||||
cd /opt/wpp-staging/releases
|
||||
curl -fsSL \\\"${ARCHIVE_URL}\\\" -o app.tar.gz
|
||||
rm -rf src && mkdir src
|
||||
tar -xzf app.tar.gz -C src --strip-components=1
|
||||
rm -rf /opt/wpp-staging/app/*
|
||||
cp -a src/. /opt/wpp-staging/app/
|
||||
# Ensure deploy artifact copied as root does not leave SQLite/app tree non-writable for wpp.
|
||||
chown -R wpp:wpp /opt/wpp-staging/app
|
||||
if [ -f /opt/wpp-staging/app/db.sqlite3 ]; then
|
||||
chmod 664 /opt/wpp-staging/app/db.sqlite3
|
||||
fi
|
||||
cd /opt/wpp-staging/app
|
||||
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 migrate --noinput
|
||||
systemctl restart wpp-staging.service
|
||||
curl -fsS http://127.0.0.1:8000/healthz\""
|
||||
|
||||
echo "[deploy] OK: staging deploy complete for CT ${CT_ID} (${REF_NAME})"
|
||||
59
infra/staging/smoke_suite.sh
Executable file
59
infra/staging/smoke_suite.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/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"; }
|
||||
|
||||
echo "[smoke] gameplay flow via management command"
|
||||
(
|
||||
cd "${APP_DIR}"
|
||||
.venv/bin/python manage.py smoke_staging
|
||||
) || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
|
||||
|
||||
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.
|
||||
|
||||
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 @@
|
||||
|
||||
118
lobby/management/commands/smoke_staging.py
Normal file
118
lobby/management/commands/smoke_staging.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
|
||||
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 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"], "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"], "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}")
|
||||
|
||||
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.
|
||||
|
||||
78
lobby/templates/lobby/host_screen.html
Normal file
78
lobby/templates/lobby/host_screen.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<!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>
|
||||
<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;
|
||||
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
|
||||
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 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.";return;}hint.textContent="Session-opdatering klar.";}
|
||||
|
||||
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 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;}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();}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();});});
|
||||
|
||||
updatePhaseStatus();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();
|
||||
if(restoreHostContext()){updatePhaseStatus();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
|
||||
</script>
|
||||
</body></html>
|
||||
112
lobby/templates/lobby/player_screen.html
Normal file
112
lobby/templates/lobby/player_screen.html
Normal file
@@ -0,0 +1,112 @@
|
||||
<!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; }
|
||||
#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>
|
||||
<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;
|
||||
function code(){return document.getElementById("code").value.trim().toUpperCase();}
|
||||
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();},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
|
||||
function togglePlayerAutoRefresh(){if(joinInFlight){updatePlayerAutoRefreshUi();return;}if(playerAutoRefreshEnabled){stopPlayerAutoRefresh();return;}startPlayerAutoRefresh();}
|
||||
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");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 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();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();}
|
||||
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();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();return;}status.textContent="Klar til join.";updateContextLockState();}
|
||||
|
||||
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);}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();}savePlayerContext();return d;}
|
||||
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 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();});});
|
||||
updatePhaseStatus();
|
||||
updateGuessSubmitState();
|
||||
updateJoinState();
|
||||
updatePlayerAutoRefreshUi();
|
||||
updatePlayerLastRefreshStatus();
|
||||
updateRoundContextHint();
|
||||
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail();}else{savePlayerContext();}
|
||||
</script>
|
||||
</body></html>
|
||||
928
lobby/tests.py
928
lobby/tests.py
@@ -1,3 +1,927 @@
|
||||
from django.test import TestCase
|
||||
from datetime import timedelta
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from fupogfakta.models import (
|
||||
Category,
|
||||
GameSession,
|
||||
Guess,
|
||||
LieAnswer,
|
||||
Player,
|
||||
Question,
|
||||
RoundConfig,
|
||||
RoundQuestion,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LobbyFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
|
||||
def test_create_session_requires_login(self):
|
||||
response = self.client.post(reverse("lobby:create_session"))
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(GameSession.objects.count(), 0)
|
||||
|
||||
def test_host_can_create_session(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(reverse("lobby:create_session"))
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
body = response.json()
|
||||
self.assertEqual(body["session"]["status"], GameSession.Status.LOBBY)
|
||||
self.assertEqual(len(body["session"]["code"]), 6)
|
||||
|
||||
session = GameSession.objects.get(code=body["session"]["code"])
|
||||
self.assertEqual(session.host, self.host)
|
||||
|
||||
def test_player_can_join_with_code(self):
|
||||
session = GameSession.objects.create(host=self.host, code="ABCD23")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": "abcd23", "nickname": "Luna"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
body = response.json()
|
||||
self.assertEqual(body["session"]["code"], "ABCD23")
|
||||
self.assertEqual(body["player"]["nickname"], "Luna")
|
||||
self.assertIn("session_token", body["player"])
|
||||
self.assertTrue(body["player"]["session_token"])
|
||||
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
||||
|
||||
def test_join_rejects_duplicate_nickname_case_insensitive(self):
|
||||
session = GameSession.objects.create(host=self.host, code="QWER12")
|
||||
Player.objects.create(session=session, nickname="Luna")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": "QWER12", "nickname": "lUna"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"], "Nickname already taken")
|
||||
|
||||
def test_join_rejects_non_joinable_session(self):
|
||||
GameSession.objects.create(host=self.host, code="ZXCV98", status=GameSession.Status.FINISHED)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": "ZXCV98", "nickname": "Kai"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Session is not joinable")
|
||||
|
||||
def test_session_detail_returns_players(self):
|
||||
session = GameSession.objects.create(host=self.host, code="LMNO45")
|
||||
Player.objects.create(session=session, nickname="Mia", score=7)
|
||||
Player.objects.create(session=session, nickname="Bo", score=2)
|
||||
|
||||
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": "lmno45"}))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["players_count"], 2)
|
||||
self.assertEqual([p["nickname"] for p in payload["players"]], ["Bo", "Mia"])
|
||||
|
||||
|
||||
class StartRoundTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCD23")
|
||||
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
|
||||
Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilket år faldt muren?",
|
||||
correct_answer="1989",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_host_can_start_round_with_selected_category(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
body = response.json()
|
||||
self.assertEqual(body["session"]["status"], GameSession.Status.LIE)
|
||||
self.assertEqual(body["round"]["number"], 1)
|
||||
self.assertEqual(body["round"]["category"]["slug"], self.category.slug)
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
||||
self.assertEqual(round_config.category, self.category)
|
||||
|
||||
def test_start_round_requires_host(self):
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can start round")
|
||||
|
||||
def test_start_round_requires_existing_active_category_with_questions(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": "ukendt"},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
empty_category = Category.objects.create(name="Sport", slug="sport", is_active=True)
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": empty_category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Category has no active questions")
|
||||
|
||||
def test_start_round_rejects_non_lobby_session(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Round can only be started from lobby")
|
||||
|
||||
|
||||
class LieSubmissionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
|
||||
self.category = Category.objects.create(name="Geografi", slug="geografi", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvad er hovedstaden i Australien?",
|
||||
correct_answer="Canberra",
|
||||
is_active=True,
|
||||
)
|
||||
RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=1,
|
||||
category=self.category,
|
||||
lie_seconds=45,
|
||||
)
|
||||
self.player = Player.objects.create(session=self.session, nickname="Luna")
|
||||
|
||||
def test_host_can_show_question_and_get_lie_deadline(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(reverse("lobby:show_question", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["config"]["lie_seconds"], 45)
|
||||
self.assertIn("lie_deadline_at", payload["round_question"])
|
||||
self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
|
||||
|
||||
def test_player_can_submit_lie_before_deadline(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(LieAnswer.objects.filter(round_question=round_question, player=self.player).exists())
|
||||
|
||||
def test_submit_lie_rejects_after_time_window(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
round_question.shown_at = timezone.now() - timedelta(seconds=46)
|
||||
round_question.save(update_fields=["shown_at"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Melbourne"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Lie submission window has closed")
|
||||
|
||||
def test_submit_lie_rejects_duplicate_submission(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.player, text="Perth")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Brisbane"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
|
||||
|
||||
def test_submit_lie_requires_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
|
||||
def test_submit_lie_rejects_invalid_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
|
||||
class MixAnswersTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
|
||||
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilken by er Danmarks hovedstad?",
|
||||
correct_answer="København",
|
||||
is_active=True,
|
||||
)
|
||||
RoundConfig.objects.create(session=self.session, number=1, category=self.category)
|
||||
self.round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer="København",
|
||||
)
|
||||
self.player_one = Player.objects.create(session=self.session, nickname="Luna")
|
||||
self.player_two = Player.objects.create(session=self.session, nickname="Mads")
|
||||
|
||||
def test_host_can_mix_answers_and_transition_to_guess(self):
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:mix_answers",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
answer_texts = [entry["text"] for entry in payload["answers"]]
|
||||
self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"})
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS)
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.round_question.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.GUESS)
|
||||
self.assertEqual(self.round_question.mixed_answers, answer_texts)
|
||||
|
||||
def test_mix_answers_requires_host(self):
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:mix_answers",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can mix answers")
|
||||
|
||||
def test_mix_answers_deduplicates_case_insensitive_lies(self):
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="københavn")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Aarhus")
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:mix_answers",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
answer_texts = [entry["text"] for entry in response.json()["answers"]]
|
||||
self.assertEqual(set(answer_texts), {"København", "Aarhus"})
|
||||
|
||||
def test_mix_answers_is_idempotent_after_transition_to_guess(self):
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
first = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
||||
second = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual([entry["text"] for entry in first.json()["answers"]], [entry["text"] for entry in second.json()["answers"]])
|
||||
|
||||
def test_session_detail_returns_persisted_mixed_answers_for_reconnect(self):
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
mix_response = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
||||
detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(mix_response.status_code, 200)
|
||||
self.assertEqual(detail_response.status_code, 200)
|
||||
self.assertEqual([entry["text"] for entry in mix_response.json()["answers"]], [entry["text"] for entry in detail_response.json()["round_question"]["answers"]])
|
||||
|
||||
|
||||
class GuessSubmissionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_guess", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="GU3551", status=GameSession.Status.GUESS)
|
||||
self.category = Category.objects.create(name="Videnskab", slug="videnskab", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilken planet kaldes den røde planet?",
|
||||
correct_answer="Mars",
|
||||
is_active=True,
|
||||
)
|
||||
self.round_config = RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=1,
|
||||
category=self.category,
|
||||
lie_seconds=45,
|
||||
guess_seconds=30,
|
||||
)
|
||||
self.round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer="Mars",
|
||||
)
|
||||
self.player = Player.objects.create(session=self.session, nickname="Luna")
|
||||
self.liar = Player.objects.create(session=self.session, nickname="Mads")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.liar, text="Jupiter")
|
||||
|
||||
def test_player_can_submit_guess_in_guess_phase(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
payload = response.json()
|
||||
self.assertTrue(payload["guess"]["is_correct"])
|
||||
self.assertIsNone(payload["guess"]["fooled_player_id"])
|
||||
self.assertIn("guess_deadline_at", payload["window"])
|
||||
|
||||
def test_submit_guess_rejects_when_not_in_guess_phase(self):
|
||||
self.session.status = GameSession.Status.LIE
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase")
|
||||
|
||||
def test_submit_guess_rejects_unknown_answer(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Venus"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Selected answer is not part of this round")
|
||||
|
||||
def test_submit_guess_rejects_duplicate_submission(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Jupiter"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"], "Guess already submitted for this player")
|
||||
|
||||
def test_submit_guess_rejects_after_deadline(self):
|
||||
self.round_question.shown_at = timezone.now() - timedelta(seconds=76)
|
||||
self.round_question.save(update_fields=["shown_at"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Guess submission window has closed")
|
||||
|
||||
|
||||
|
||||
def test_submit_guess_requires_session_token(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
|
||||
def test_submit_guess_rejects_invalid_session_token(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": "wrong-token", "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
|
||||
|
||||
class ScoreCalculationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_score", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other_score", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="SC0RE1", status=GameSession.Status.GUESS)
|
||||
self.category = Category.objects.create(name="Sport", slug="sport", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilken sport spiller man i Wimbledon?",
|
||||
correct_answer="Tennis",
|
||||
is_active=True,
|
||||
)
|
||||
RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=1,
|
||||
category=self.category,
|
||||
points_correct=5,
|
||||
points_bluff=2,
|
||||
)
|
||||
self.round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer="Tennis",
|
||||
)
|
||||
self.player_one = Player.objects.create(session=self.session, nickname="Luna")
|
||||
self.player_two = Player.objects.create(session=self.session, nickname="Mads")
|
||||
self.player_three = Player.objects.create(session=self.session, nickname="Nora")
|
||||
|
||||
def test_host_can_calculate_scores_and_transition_to_reveal(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
|
||||
Guess.objects.create(
|
||||
round_question=self.round_question,
|
||||
player=self.player_two,
|
||||
selected_text="Padel",
|
||||
is_correct=False,
|
||||
fooled_player=self.player_three,
|
||||
)
|
||||
Guess.objects.create(
|
||||
round_question=self.round_question,
|
||||
player=self.player_three,
|
||||
selected_text="Padel",
|
||||
is_correct=False,
|
||||
fooled_player=self.player_three,
|
||||
)
|
||||
|
||||
self.client.login(username="host_score", password="secret123")
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:calculate_scores",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL)
|
||||
self.assertEqual(payload["events_created"], 2)
|
||||
|
||||
self.player_one.refresh_from_db()
|
||||
self.player_three.refresh_from_db()
|
||||
self.session.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.player_one.score, 5)
|
||||
self.assertEqual(self.player_three.score, 4)
|
||||
self.assertEqual(self.session.status, GameSession.Status.REVEAL)
|
||||
|
||||
def test_calculate_scores_requires_host(self):
|
||||
self.client.login(username="other_score", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:calculate_scores",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can calculate scores")
|
||||
|
||||
def test_calculate_scores_rejects_duplicate_calculation(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
|
||||
|
||||
self.client.login(username="host_score", password="secret123")
|
||||
first = self.client.post(
|
||||
reverse(
|
||||
"lobby:calculate_scores",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
second = self.client.post(
|
||||
reverse(
|
||||
"lobby:calculate_scores",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 409)
|
||||
self.assertEqual(second.json()["error"], "Scores already calculated for this round question")
|
||||
|
||||
|
||||
class RevealRoundFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_reveal", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other_reveal", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL)
|
||||
self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9)
|
||||
self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3)
|
||||
|
||||
def test_host_can_get_reveal_scoreboard(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"lobby:reveal_scoreboard",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL)
|
||||
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
||||
|
||||
def test_reveal_scoreboard_requires_host(self):
|
||||
self.client.login(username="other_reveal", password="secret123")
|
||||
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"lobby:reveal_scoreboard",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
||||
|
||||
def test_host_can_finish_game_from_reveal(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:finish_game",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.FINISHED)
|
||||
self.assertEqual(payload["winner"]["nickname"], "Luna")
|
||||
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
||||
|
||||
def test_finish_game_requires_host(self):
|
||||
self.client.login(username="other_reveal", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:finish_game",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can finish game")
|
||||
|
||||
def test_finish_game_rejects_wrong_phase(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:finish_game",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Game can only be finished from reveal phase")
|
||||
|
||||
def test_host_can_start_next_round_from_reveal(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:start_next_round",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.LOBBY)
|
||||
self.assertEqual(payload["session"]["current_round"], 2)
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
|
||||
self.assertEqual(self.session.current_round, 2)
|
||||
|
||||
def test_start_next_round_rejects_wrong_phase(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:start_next_round",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Next round can only start from reveal phase")
|
||||
|
||||
class UiScreenTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_ui", password="secret123")
|
||||
|
||||
def test_host_screen_requires_login(self):
|
||||
response = self.client.get(reverse("lobby:host_screen"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_host_screen_renders_for_logged_in_user(self):
|
||||
self.client.login(username="host_ui", password="secret123")
|
||||
response = self.client.get(reverse("lobby:host_screen"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Host panel")
|
||||
self.assertContains(response, "id=\"createSessionBtn\"")
|
||||
self.assertContains(response, "id=\"createSessionHint\"")
|
||||
self.assertContains(response, "saveHostContext")
|
||||
self.assertContains(response, "restoreHostContext")
|
||||
self.assertContains(response, "id=\"showQuestionBtn\"")
|
||||
self.assertContains(response, "id=\"mixAnswersBtn\"")
|
||||
self.assertContains(response, "id=\"hostActionHint\"")
|
||||
self.assertContains(response, "id=\"categoryGuardHint\"")
|
||||
|
||||
self.assertContains(response, "id=\"phaseStatus\"")
|
||||
self.assertContains(response, "updateHostActionState")
|
||||
self.assertContains(response, "phaseLabel")
|
||||
self.assertContains(response, "Opdatér session-status for fasebaserede host-actions.")
|
||||
self.assertContains(response, "Angiv sessionkode for at aktivere host-actions.")
|
||||
self.assertContains(response, "Kategori er kun redigérbar i lobby-fasen.")
|
||||
self.assertContains(response, "Kræver 3-5 spillere i lobbyen.")
|
||||
self.assertContains(response, "For mange spillere: maks 5 i MVP før runde-start.")
|
||||
self.assertContains(response, "Round question-id kan kun redigeres i lie/guess/reveal-faser.")
|
||||
self.assertContains(response, "roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion")
|
||||
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==\"lobby\"")
|
||||
self.assertContains(response, "hostActionInFlight")
|
||||
self.assertContains(response, "withHostActionLock")
|
||||
self.assertContains(response, "updateCreateSessionState")
|
||||
self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight")
|
||||
self.assertContains(response, "Opret session er låst mens en host-handling kører.")
|
||||
self.assertContains(response, "Opret session er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "Handling kører… afvent svar før næste klik.")
|
||||
self.assertContains(response, "Session-data ikke opdateret endnu.")
|
||||
self.assertContains(response, "Sidst opdateret:")
|
||||
self.assertContains(response, "Session-data kan være forældet")
|
||||
self.assertContains(response, "id=\"sessionDetailBtn\"")
|
||||
self.assertContains(response, "id=\"sessionDetailHint\"")
|
||||
self.assertContains(response, "updateSessionDetailState")
|
||||
self.assertContains(response, "sessionDetailInFlight")
|
||||
self.assertContains(response, "session_detail_in_flight")
|
||||
self.assertContains(response, "codeInput.readOnly=sessionDetailInFlight||hostActionInFlight")
|
||||
self.assertContains(response, "id=\"autoRefreshToggleBtn\"")
|
||||
self.assertContains(response, "id=\"autoRefreshHint\"")
|
||||
self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight||!code()")
|
||||
self.assertContains(response, "Auto-refresh-lås: afvent aktiv host-handling.")
|
||||
self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.")
|
||||
self.assertContains(response, "Opdaterer session-status…")
|
||||
self.assertContains(response, "Session-opdatering er låst mens en host-handling kører.")
|
||||
self.assertContains(response, "Angiv sessionkode for at opdatere session-status.")
|
||||
self.assertContains(response, "markSessionRefresh")
|
||||
self.assertContains(response, "updateLastRefreshStatus")
|
||||
self.assertContains(response, "isSessionDetailRead")
|
||||
self.assertContains(response, "showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
|
||||
self.assertContains(response, "Host-actions er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "Round question-id er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "Kategori er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
|
||||
|
||||
def test_player_screen_is_public(self):
|
||||
response = self.client.get(reverse("lobby:player_screen"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Player panel")
|
||||
self.assertContains(response, "id=\"sessionToken\"")
|
||||
self.assertContains(response, "session_token")
|
||||
self.assertContains(response, "id=\"answerOptions\"")
|
||||
self.assertContains(response, "renderAnswerOptions")
|
||||
self.assertContains(response, "renderAnswerOptions(null)")
|
||||
self.assertContains(response, "availableAnswers")
|
||||
self.assertContains(response, "guessStorageKey")
|
||||
self.assertContains(response, "persistGuessState")
|
||||
self.assertContains(response, "savePlayerContext")
|
||||
self.assertContains(response, "restorePlayerContext")
|
||||
self.assertContains(response, "id=\"lieSubmitBtn\"")
|
||||
self.assertContains(response, "id=\"lieStatus\"")
|
||||
self.assertContains(response, "id=\"phaseStatus\"")
|
||||
self.assertContains(response, "currentSessionStatus")
|
||||
self.assertContains(response, "updatePhaseStatus")
|
||||
self.assertContains(response, "Løgn-input er låst i fase")
|
||||
self.assertContains(response, "Gæt er låst i fase")
|
||||
self.assertContains(response, "Afvent aktivt spørgsmål fra host før du kan gætte.")
|
||||
self.assertContains(response, "persistLieState")
|
||||
self.assertContains(response, "updateLieSubmitState")
|
||||
self.assertContains(response, "hasSubmitContext")
|
||||
self.assertContains(response, "hasRoundQuestionContext")
|
||||
self.assertContains(response, "canAttemptJoin")
|
||||
self.assertContains(response, "missing_join_input")
|
||||
self.assertContains(response, "Udfyld kode og nickname for at join.")
|
||||
self.assertContains(response, "Afvent aktiv session-opdatering før join.")
|
||||
self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin")
|
||||
self.assertContains(response, "id=\"contextLockHint\"")
|
||||
self.assertContains(response, "updateContextLockState")
|
||||
self.assertContains(response, "Spillerkontekst er låst efter join.")
|
||||
self.assertContains(response, "already_joined_client")
|
||||
self.assertContains(response, "missing_submit_context")
|
||||
self.assertContains(response, "invalid_client_guess")
|
||||
self.assertContains(response, "lieSubmitInFlight")
|
||||
self.assertContains(response, "guessSubmitInFlight")
|
||||
self.assertContains(response, "Sender løgn…")
|
||||
self.assertContains(response, "Sender gæt…")
|
||||
self.assertContains(response, "Afvent aktiv session-opdatering før løgn-submit.")
|
||||
self.assertContains(response, "Afvent aktiv session-opdatering før gæt-submit.")
|
||||
self.assertContains(response, "sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase")
|
||||
self.assertContains(response, "btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight")
|
||||
self.assertContains(response, "lie_submit_in_flight")
|
||||
self.assertContains(response, "guess_submit_in_flight")
|
||||
self.assertContains(response, "guess_already_submitted_client")
|
||||
self.assertContains(response, "id=\"playerAutoRefreshToggleBtn\"")
|
||||
self.assertContains(response, "id=\"playerAutoRefreshHint\"")
|
||||
self.assertContains(response, "id=\"playerLastRefreshStatus\"")
|
||||
self.assertContains(response, "id=\"sessionDetailBtn\"")
|
||||
self.assertContains(response, "id=\"sessionRefreshHint\"")
|
||||
self.assertContains(response, "id=\"roundContextHint\"")
|
||||
self.assertContains(response, "resetRoundContextForManualChange")
|
||||
self.assertContains(response, "Runde-kontekst afventer session-opdatering.")
|
||||
self.assertContains(response, "togglePlayerAutoRefresh")
|
||||
self.assertContains(response, "btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code()")
|
||||
self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.")
|
||||
self.assertContains(response, "Auto-refresh-lås: afvent aktiv join.")
|
||||
self.assertContains(response, "Auto-refresh kræver sessionkode.")
|
||||
self.assertContains(response, "markPlayerSessionRefresh")
|
||||
self.assertContains(response, "updatePlayerLastRefreshStatus")
|
||||
self.assertContains(response, "updateSessionDetailState")
|
||||
self.assertContains(response, "session_detail_in_flight")
|
||||
self.assertContains(response, "Opdaterer session-status…")
|
||||
self.assertContains(response, "Session-opdatering er låst mens join kører.")
|
||||
self.assertContains(response, "Session-opdatering er låst mens submit kører.")
|
||||
self.assertContains(response, "Session-data ikke opdateret endnu.")
|
||||
self.assertContains(response, "Sidst opdateret:")
|
||||
self.assertContains(response, "Session-data kan være forældet")
|
||||
|
||||
|
||||
class SessionDetailRoundQuestionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_detail", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCDE1", status=GameSession.Status.LIE)
|
||||
self.category = Category.objects.create(name="Historie", slug="historie-2", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvem opfandt pæren?",
|
||||
correct_answer="Edison",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_session_detail_includes_current_round_question_when_available(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["round_question"]["id"], round_question.id)
|
||||
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
|
||||
|
||||
14
lobby/ui_views.py
Normal file
14
lobby/ui_views.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
|
||||
from fupogfakta.models import Category
|
||||
|
||||
|
||||
@login_required
|
||||
def host_screen(request):
|
||||
categories = Category.objects.filter(is_active=True).order_by("name")
|
||||
return render(request, "lobby/host_screen.html", {"categories": categories})
|
||||
|
||||
|
||||
def player_screen(request):
|
||||
return render(request, "lobby/player_screen.html")
|
||||
38
lobby/urls.py
Normal file
38
lobby/urls.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import ui_views, views
|
||||
|
||||
app_name = "lobby"
|
||||
|
||||
urlpatterns = [
|
||||
path("ui/host", ui_views.host_screen, name="host_screen"),
|
||||
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"),
|
||||
]
|
||||
759
lobby/views.py
759
lobby/views.py
@@ -1,3 +1,758 @@
|
||||
from django.shortcuts import render
|
||||
import json
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
# Create your views here.
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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,
|
||||
Guess,
|
||||
LieAnswer,
|
||||
Player,
|
||||
Question,
|
||||
RoundConfig,
|
||||
RoundQuestion,
|
||||
ScoreEvent,
|
||||
)
|
||||
|
||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
SESSION_CODE_LENGTH = 6
|
||||
MAX_CODE_GENERATION_ATTEMPTS = 20
|
||||
JOINABLE_STATUSES = {
|
||||
GameSession.Status.LOBBY,
|
||||
GameSession.Status.LIE,
|
||||
GameSession.Status.GUESS,
|
||||
GameSession.Status.REVEAL,
|
||||
}
|
||||
|
||||
|
||||
def _json_body(request: HttpRequest) -> dict:
|
||||
if not request.body:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _generate_session_code() -> str:
|
||||
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
|
||||
|
||||
|
||||
def _create_unique_session_code() -> str:
|
||||
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
|
||||
code = _generate_session_code()
|
||||
if not GameSession.objects.filter(code=code).exists():
|
||||
return code
|
||||
|
||||
raise RuntimeError("Could not generate unique session code")
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def create_session(request: HttpRequest) -> JsonResponse:
|
||||
code = _create_unique_session_code()
|
||||
session = GameSession.objects.create(host=request.user, code=code)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"host_id": session.host_id,
|
||||
"current_round": session.current_round,
|
||||
}
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def join_session(request: HttpRequest) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
|
||||
code = str(payload.get("code", "")).strip().upper()
|
||||
nickname = str(payload.get("nickname", "")).strip()
|
||||
|
||||
if not code:
|
||||
return JsonResponse({"error": "Session code is required"}, status=400)
|
||||
|
||||
if len(nickname) < 2 or len(nickname) > 40:
|
||||
return JsonResponse({"error": "Nickname must be between 2 and 40 characters"}, status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.status not in JOINABLE_STATUSES:
|
||||
return JsonResponse({"error": "Session is not joinable"}, status=400)
|
||||
|
||||
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
||||
return JsonResponse({"error": "Nickname already taken"}, status=409)
|
||||
|
||||
player = Player.objects.create(session=session, nickname=nickname)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"player": {
|
||||
"id": player.id,
|
||||
"nickname": player.nickname,
|
||||
"session_token": player.session_token,
|
||||
"score": player.score,
|
||||
},
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@require_GET
|
||||
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
players = list(
|
||||
session.players.order_by("nickname").values(
|
||||
"id",
|
||||
"nickname",
|
||||
"score",
|
||||
"is_connected",
|
||||
)
|
||||
)
|
||||
|
||||
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 [])],
|
||||
}
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"host_id": session.host_id,
|
||||
"current_round": session.current_round,
|
||||
"players_count": len(players),
|
||||
},
|
||||
"players": players,
|
||||
"round_question": round_question_payload,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
category_slug = str(payload.get("category_slug", "")).strip()
|
||||
|
||||
if not category_slug:
|
||||
return JsonResponse({"error": "category_slug is required"}, status=400)
|
||||
|
||||
session_code = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return JsonResponse({"error": "Only host can start round"}, status=403)
|
||||
|
||||
if session.status != GameSession.Status.LOBBY:
|
||||
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
||||
|
||||
try:
|
||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
||||
except Category.DoesNotExist:
|
||||
return JsonResponse({"error": "Category not found"}, status=404)
|
||||
|
||||
if not Question.objects.filter(category=category, is_active=True).exists():
|
||||
return JsonResponse({"error": "Category has no active questions"}, status=400)
|
||||
|
||||
with transaction.atomic():
|
||||
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if session.status != GameSession.Status.LOBBY:
|
||||
return JsonResponse({"error": "Round can only be started from lobby"}, status=400)
|
||||
|
||||
round_config, created = RoundConfig.objects.get_or_create(
|
||||
session=session,
|
||||
number=session.current_round,
|
||||
defaults={"category": category},
|
||||
)
|
||||
if not created:
|
||||
return JsonResponse({"error": "Round already configured"}, status=409)
|
||||
|
||||
session.status = GameSession.Status.LIE
|
||||
session.save(update_fields=["status"])
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"round": {
|
||||
"number": round_config.number,
|
||||
"category": {
|
||||
"slug": round_config.category.slug,
|
||||
"name": round_config.category.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return JsonResponse({"error": "Only host can 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 = code.strip().upper()
|
||||
|
||||
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 = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return JsonResponse({"error": "Only host can 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 = code.strip().upper()
|
||||
|
||||
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 = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return JsonResponse({"error": "Only host can 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 = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return JsonResponse({"error": "Only host can start 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 = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return JsonResponse({"error": "Only host can 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 = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.host_id != request.user.id:
|
||||
return JsonResponse({"error": "Only host can 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.
@@ -1,13 +1,14 @@
|
||||
from django.contrib import admin
|
||||
from django.http import JsonResponse
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
|
||||
def health(_request):
|
||||
return JsonResponse({'ok': True, 'service': 'weirsoe-party-protocol'})
|
||||
return JsonResponse({"ok": True, "service": "weirsoe-party-protocol"})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('healthz', health, name='healthz'),
|
||||
path("admin/", admin.site.urls),
|
||||
path("healthz", health, name="healthz"),
|
||||
path("lobby/", include("lobby.urls")),
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user