140 Commits

Author SHA1 Message Date
173df0fd6f fix(staging): ensure wpp can write sqlite db during deploy
All checks were successful
CI / test-and-quality (push) Successful in 1m31s
CI / test-and-quality (pull_request) Successful in 1m34s
2026-02-28 11:20:54 +00:00
850a364251 Merge pull request 'UI: lås "Opret session" under aktiv handling/session-opdatering (#127)' (#128) from feature/ui-host-create-session-lock-127 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 07:58:04 +01:00
2adeb8536a UI: lås opret-session knap under aktiv handling/opdatering (#127)
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 07:56:05 +01:00
726280e120 Merge pull request 'UI: lås sessionkode-felt under aktiv host-handling (#125)' (#126) from feature/ui-host-code-lock-125 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m29s
2026-02-28 07:47:46 +01:00
7ec9219487 UI: lås sessionkode under aktiv host-opdatering (#125)
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 07:46:00 +01:00
eabf95bc5c Merge pull request 'UI: lås kategori-valg under session-opdatering (#123)' (#124) from feature/ui-host-category-session-lock-123 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 07:42:34 +01:00
ad67c63cca UI: lås kategori-valg under session-opdatering (#123)
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m39s
2026-02-28 07:39:03 +01:00
8dc1e709f8 Merge pull request 'UI: lås round question-id under aktiv session-opdatering (#121)' (#122) from feature/ui-host-round-question-session-lock-121 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m28s
2026-02-28 07:26:43 +01:00
0d65cfac82 ui: lås round question-id under session-opdatering (#121)
All checks were successful
CI / test-and-quality (push) Successful in 1m42s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 07:24:55 +01:00
68f934967a Merge pull request 'UI: lås host-actions under aktiv session-opdatering (#119)' (#120) from feature/ui-host-actions-lock-sessiondetail-119 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 07:16:44 +01:00
9e496763aa UI: lås host-actions under session-opdatering (#119)
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 07:14:56 +01:00
ef80f85d67 Merge pull request 'UI: lås host auto-refresh-toggle under in-flight handlinger (#117)' (#118) from feature/ui-host-autorefresh-lock-inflight-117 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 07:12:16 +01:00
348bebf358 UI: lock host auto-refresh toggle during in-flight actions (#117)
All checks were successful
CI / test-and-quality (push) Successful in 1m40s
CI / test-and-quality (pull_request) Successful in 1m40s
2026-02-28 07:03:32 +01:00
3f9752aac4 Merge pull request 'UI: lås session-opdatering mens lie/guess submit kører (#115)' (#116) from feature/ui-session-refresh-lock-during-submit-115 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m25s
2026-02-28 06:56:16 +01:00
48eae5d083 UI: lock session refresh while lie/guess submit is in-flight (#115)
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 06:53:52 +01:00
5edf1969ed Merge pull request 'UI: lås auto-refresh-toggle mens join kører i player-panelet (#113)' (#114) from feature/ui-player-autorefresh-join-lock-113 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 06:47:49 +01:00
95d3f1aa48 UI: lock player auto-refresh toggle while join is in-flight (#113)
All checks were successful
CI / test-and-quality (push) Successful in 1m38s
CI / test-and-quality (pull_request) Successful in 1m38s
2026-02-28 06:44:28 +01:00
7b4110e896 Merge pull request 'UI: lås session-opdatering mens join kører (#111)' (#112) from feature/ui-player-session-detail-join-lock-111 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 06:40:01 +01:00
1ff98f5e92 UI: lock session-detail while join is in-flight (#111)
All checks were successful
CI / test-and-quality (push) Successful in 1m38s
CI / test-and-quality (pull_request) Successful in 1m39s
2026-02-28 06:35:51 +01:00
2892ecc555 Merge pull request 'UI: lock Join while player session refresh is active (#109)' (#110) from feature/ui-player-join-lock-session-refresh-109 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m23s
2026-02-28 06:31:48 +01:00
0a07bfd7ad ui: lock join button during player session refresh (#109)
All checks were successful
CI / test-and-quality (push) Successful in 1m38s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 06:25:40 +01:00
59e12d596d Merge PR #108: UI: lås lie/guess submit under aktiv session-opdatering (#107)
All checks were successful
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 06:20:12 +01:00
5b2e2132e7 UI: lås lie/guess submit under session-opdatering (#107)
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 06:04:30 +01:00
fc28f8499f Merge pull request 'UI: lås auto-refresh-toggle under aktiv session-opdatering (#105)' (#106) from feature/ui-player-autorefresh-toggle-guard-105 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m25s
2026-02-28 05:55:49 +01:00
204581aef5 UI: lock player auto-refresh toggle during session refresh (#105)
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 05:53:51 +01:00
73b63ed6a4 Merge pull request 'UI: nulstil spiller-runde-kontekst ved kode-/spillerskift (#103)' (#104) from feature/ui-player-reset-round-context-103 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 05:43:46 +01:00
49286ca631 UI: nulstil spiller-runde-kontekst ved manuel kontekstændring (#103)
All checks were successful
CI / test-and-quality (push) Successful in 1m23s
CI / test-and-quality (pull_request) Successful in 1m24s
2026-02-28 05:36:53 +01:00
4613615e99 Merge pull request 'UI: lås host session-status under in-flight request (#101)' (#102) from feature/ui-host-session-detail-inflight-101 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m29s
2026-02-28 05:26:02 +01:00
6732c75475 UI: lock host session-status during in-flight request (#101)
All checks were successful
CI / test-and-quality (push) Successful in 1m40s
CI / test-and-quality (pull_request) Successful in 1m41s
2026-02-28 05:24:11 +01:00
9600475e5e Merge pull request 'UI: lås round_question_id felt i player-panelet (#99)' (#100) from feature/ui-player-round-question-readonly-99 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m39s
2026-02-28 05:18:10 +01:00
1319957e36 ui: lock round question id field in player panel
All checks were successful
CI / test-and-quality (push) Successful in 1m42s
CI / test-and-quality (pull_request) Successful in 1m43s
2026-02-28 05:16:10 +01:00
229e0f2c16 Merge pull request 'UI need-to-have: aktiv round-question guard i player-panelet (#97)' (#98) from feature/ui-player-active-round-guard-97 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 05:06:48 +01:00
4a1ed80142 UI: kræv aktiv round-question før guess-svarvalg (#97)
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m38s
2026-02-28 05:04:06 +01:00
b2211e2ac9 Merge pull request 'UI need-to-have: guard mod dobbelt guess-submit i player panel' (#96) from feature/ui-guess-submit-client-guard-95 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 05:00:19 +01:00
41a414bc97 ui: guard duplicate guess submit on player panel (closes #95)
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m35s
2026-02-28 04:51:31 +01:00
e0de58b4b3 Merge pull request 'UI: lås session-opdatering ved in-flight request (#93)' (#94) from feature/ui-player-session-refresh-lock-93 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 04:45:22 +01:00
c0c303d45e UI: lås session-opdatering ved in-flight request (#93)
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 04:43:37 +01:00
c568b34d51 Merge pull request 'MVP UI: lås spillerkontekstfelter efter join (#91)' (#92) from feature/ui-player-context-lock-91 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 04:34:29 +01:00
f5380f8a81 UI: lås spillerkontekstfelter efter join (#91)
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 04:32:33 +01:00
84c00da55f Merge pull request 'UI: player auto-refresh + sidst opdateret status (#88)' (#89) from feature/ui-player-auto-refresh-88 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 04:26:04 +01:00
0e1a36b0b5 ui(player): auto-refresh + last refresh status (#88)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m35s
2026-02-28 04:24:09 +01:00
727e907650 Merge pull request 'UI: vis sidst opdateret status i host auto-refresh (#86)' (#87) from feature/ui-host-last-refresh-status-86 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 04:14:51 +01:00
8c655d10b6 UI: vis sidst opdateret status i host auto-refresh (#86)
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m35s
2026-02-28 04:12:42 +01:00
03f6f35019 Merge pull request 'UI: lås host-actions under in-flight request (#84)' (#85) from feature/ui-host-inflight-guard-84 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 04:01:45 +01:00
b6110ec53e UI: lås host-actions under in-flight requests (#84)
All checks were successful
CI / test-and-quality (push) Successful in 1m44s
CI / test-and-quality (pull_request) Successful in 1m46s
2026-02-28 03:59:33 +01:00
bdc7e40677 Merge pull request 'UI: lås lie/guess submit under in-flight request (#82)' (#83) from feature/ui-inflight-submit-guard-82 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m25s
2026-02-28 03:45:24 +01:00
2b574aa3b5 UI: lås lie/guess submit under in-flight request (#82)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 03:43:23 +01:00
7e4cd0940a Merge pull request 'UI: ensartede fejl-hints for fase/token/round-state (#80)' (#81) from feature/ui-standardized-api-error-hints-80 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 03:34:35 +01:00
c6f90c3564 UI: ensartede fejl-hints for fase/token/round-state (#80)
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 03:32:12 +01:00
042c8e70d0 Merge pull request 'UI: host auto-refresh toggle for lobby-status (#78)' (#79) from feature/ui-host-auto-refresh-78 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 03:25:04 +01:00
2968c37e66 UI: host auto-refresh toggle for lobby status (#78)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 03:23:14 +01:00
6b29b85792 Merge pull request 'UI: håndhæv 3-5 spillere i host start-runde guard (#76)' (#77) from feature/ui-start-round-max-player-guard-76 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 03:18:44 +01:00
15136537f4 UI: håndhæv 3-5 spillere i host start-runde guard (#76)
All checks were successful
CI / test-and-quality (push) Successful in 1m40s
CI / test-and-quality (pull_request) Successful in 1m40s
2026-02-28 03:09:45 +01:00
eb23d023a7 Merge pull request 'UI: lås round question-id input efter fase (#74)' (#75) from feature/ui-round-question-id-guard-74 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m32s
2026-02-28 03:02:34 +01:00
b6e5b98837 UI: lås round question-id input efter fase (#74)
All checks were successful
CI / test-and-quality (push) Successful in 1m41s
CI / test-and-quality (pull_request) Successful in 1m41s
2026-02-28 03:00:31 +01:00
b8e0817d2d Merge pull request 'UI: fase-lock af lie/guess i spillerpanelet (#72)' (#73) from feature/ui-player-phase-lock-72 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
2026-02-28 02:52:56 +01:00
b030ae6d4e ui: phase-lock player lie/guess actions (#72)
All checks were successful
CI / test-and-quality (push) Successful in 1m45s
CI / test-and-quality (pull_request) Successful in 1m45s
2026-02-28 02:50:55 +01:00
5c8faf76a9 Merge pull request 'UI: lås kategori-valg udenfor lobby-fasen (#70)' (#71) from feature/ui-host-category-phase-guard-70 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m33s
2026-02-28 02:42:04 +01:00
1709713bff UI: lås kategori-valg udenfor lobby-fasen (#70)
All checks were successful
CI / test-and-quality (push) Successful in 1m42s
CI / test-and-quality (pull_request) Successful in 1m42s
2026-02-28 02:40:07 +01:00
07b5982ac4 Merge pull request 'UI: fasebaserede host-action guards i hostpanelet (#68)' (#69) from feature/ui-host-phase-action-guards-68 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m32s
2026-02-28 02:30:30 +01:00
4d22bb5d04 Merge main into feature/ui-host-phase-action-guards-68
All checks were successful
CI / test-and-quality (pull_request) Successful in 1m47s
CI / test-and-quality (push) Successful in 1m47s
2026-02-28 02:28:37 +01:00
a8fd012193 UI: fasebaserede host-action guards i hostpanelet (#68)
All checks were successful
CI / test-and-quality (push) Successful in 1m44s
CI / test-and-quality (pull_request) Successful in 1m43s
2026-02-28 02:21:14 +01:00
2a488c6530 Merge pull request 'UI: host action guards ved manglende kontekst (#66)' (#67) from feature/ui-host-action-guards into main
All checks were successful
CI / test-and-quality (push) Successful in 1m28s
2026-02-28 02:14:56 +01:00
c9b4fe0077 UI: guard host actions on missing context (#66)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 02:09:05 +01:00
7e7445cd07 Merge pull request 'UI: robust join-state guard i player panel (#64)' (#65) from feature/ui-robust-join-state-64 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m33s
2026-02-28 01:58:50 +01:00
9807ce8d2e UI: robust join-state guard i player panel (#64)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 01:57:03 +01:00
032304f19b Merge pull request 'UI: guard mod dobbelt-join i player panel (#62)' (#63) from feature/ui-join-request-guard-62 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 01:53:30 +01:00
5170c779e4 ui: guard join request against double-submit (#62)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m23s
2026-02-28 01:46:52 +01:00
82b90d3c5d Merge pull request 'MVP UI: robust reconnect-konsistens for host + spiller (#60)' (#61) from feature/ui-reconnect-consistency-60 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 01:39:52 +01:00
e0e1c6a7a0 ui: persist/reload host+player context after refresh (#60)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m35s
2026-02-28 01:38:03 +01:00
bd1b059c97 Merge pull request 'MVP UI: Host-screen viser spillerantal og blokerer start ved <3 spillere' (#58) from feature/ui-host-min-player-guard-57 into main
All checks were successful
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 01:24:06 +01:00
0858cbe892 ui(host): guard start round until 3 players in lobby
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m34s
2026-02-28 01:16:15 +01:00
0fe66f21c0 Merge pull request 'UI: guard submit-knapper indtil spillerkontekst er klar (#55)' (#56) from feature/ui-submit-context-guard into main
All checks were successful
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 01:08:16 +01:00
1a988469ec ui(player): guard lie/guess submit until context is ready
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 01:06:00 +01:00
d316b3bff6 Merge pull request 'UI: lås løgn-input efter submit + status (#53)' (#54) from feature/ui-lie-submit-locked-status into main
All checks were successful
CI / test-and-quality (push) Successful in 1m25s
2026-02-28 00:59:00 +01:00
5c1827c8b8 UI: lås løgn-input efter submit med tydelig status (#53)
All checks were successful
CI / test-and-quality (push) Successful in 1m40s
CI / test-and-quality (pull_request) Successful in 1m41s
2026-02-28 00:56:31 +01:00
32a85c0790 Merge pull request 'UI: vis låst status efter guess-submit (#51)' (#52) from feature/ui-guess-submit-locked-status into main
All checks were successful
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 00:51:08 +01:00
d8b44411a9 ui: vis låst status efter guess-submit
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m37s
2026-02-28 00:46:19 +01:00
16b365c66d Merge pull request 'UI: lås svarvalg efter guess-submit (#49)' (#50) from dev/issue-49-lock-answer-options-after-guess into main
All checks were successful
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 00:42:50 +01:00
630af2333b UI: lock answer options after submitted guess (refs #49)
All checks were successful
CI / test-and-quality (push) Successful in 1m45s
CI / test-and-quality (pull_request) Successful in 1m46s
2026-02-28 00:34:23 +01:00
7c526c0bdb Merge pull request 'UI: reconnect-konsistens for valgt svar i guess-fase' (#48) from feature/ui-reconnect-guess-state into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-02-28 00:29:00 +01:00
867ea9602f UI: bevar valgt guess ved refresh i guess-fase
All checks were successful
CI / test-and-quality (push) Successful in 1m39s
CI / test-and-quality (pull_request) Successful in 1m38s
2026-02-28 00:27:11 +01:00
dce416f48a Merge pull request 'MVP UI: disable guess-submit indtil klikket svarvalg' (#46) from feature/ui-guess-submit-disabled-until-choice into main
All checks were successful
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 00:19:01 +01:00
298381586f ui: disable guess submit until option selected (#45)
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m35s
2026-02-28 00:16:45 +01:00
fdaddc4f52 Merge pull request 'UI: lås guess-submit til viste svarmuligheder' (#44) from feature/ui-guess-click-only into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 00:07:16 +01:00
a0562fa6a4 ui: lock guess submit to rendered answer options
All checks were successful
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 00:05:31 +01:00
a9bb2c9670 Merge pull request 'UI: klikbare svarvalg i guess-fase (MVP)' (#42) from feature/ui-guess-answer-options into main
All checks were successful
CI / test-and-quality (push) Successful in 1m25s
2026-02-27 23:57:10 +01:00
81a29a0e07 feat(ui): klikbare guess-svar i player panel
All checks were successful
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m39s
2026-02-27 23:55:11 +01:00
9d298e083e Merge pull request 'F3 UI+sikkerhed: kræv session_token ved guess submit' (#40) from feature/ui-guess-session-token into main
All checks were successful
CI / test-and-quality (push) Successful in 1m27s
2026-02-27 23:34:34 +01:00
0a028bb499 feat(ui): require session_token for guess submit (#39)
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-27 23:32:47 +01:00
59f2b0b29e Merge pull request 'UI: player screen sender session_token til lie-submit' (#38) from feature/ui-player-session-token into main
All checks were successful
CI / test-and-quality (push) Successful in 1m21s
2026-02-27 23:23:17 +01:00
5894987a1c fix(ui): send session_token from player screen on lie submit
All checks were successful
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m34s
2026-02-27 23:20:43 +01:00
177171706b Merge pull request 'F3: beskyt lie-submit med player session token' (#36) from feature/f3-lie-submit-session-token into main
All checks were successful
CI / test-and-quality (push) Successful in 1m23s
2026-02-27 23:15:26 +01:00
37e1d32675 feat(f3): require player session token for lie submission
All checks were successful
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m34s
2026-02-27 23:11:59 +01:00
86dbd4fabc Merge pull request 'F3/UI: Persistér mixed svarrækkefølge for reconnect' (#34) from feature/f3-persist-mixed-answer-order into main
All checks were successful
CI / test-and-quality (push) Successful in 1m18s
2026-02-27 23:03:46 +01:00
8e4ce8c4da F3: persist mixed answer order for stable UI reconnect
All checks were successful
CI / test-and-quality (push) Successful in 1m33s
CI / test-and-quality (pull_request) Successful in 1m34s
2026-02-27 22:58:40 +01:00
fa523d84f5 Merge pull request 'test(staging): smoke-suite for gameplay flow' (#32) from feature/test-smoke-fail-label into main
All checks were successful
CI / test-and-quality (push) Successful in 1m16s
2026-02-27 22:47:14 +01:00
05b3d982b4 test(staging): add smoke suite script and gameplay smoke command (closes #22)
All checks were successful
CI / test-and-quality (push) Successful in 1m25s
CI / test-and-quality (pull_request) Successful in 1m26s
2026-02-27 22:45:12 +01:00
0d13ab9f80 Merge pull request 'F3 UI: MVP host + player templates' (#31) from feature/f3-mvp-ui-templates into main
All checks were successful
CI / test-and-quality (push) Successful in 1m16s
2026-02-27 22:32:26 +01:00
7fbec0d604 Merge pull request 'chore: remove tracked __pycache__ artifacts' (#29) from chore/remove-pycache-tracked into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-02-27 22:32:08 +01:00
cfffc9934c feat(ui): add MVP host/player web screens
All checks were successful
CI / test-and-quality (push) Successful in 1m25s
CI / test-and-quality (pull_request) Successful in 1m25s
2026-02-27 22:26:55 +01:00
12ecf32e50 chore: remove tracked pycache artifacts
All checks were successful
CI / test-and-quality (push) Successful in 1m17s
CI / test-and-quality (pull_request) Successful in 1m17s
2026-02-27 22:10:19 +01:00
d6e7198fe8 Merge pull request 'fix(staging): canonical deploy context via proxmox SSH wrapper' (#28) from fix/staging-deploy-canonical-context into main
All checks were successful
CI / test-and-quality (push) Successful in 1m8s
2026-02-27 21:59:52 +01:00
c0145f9e5f fix(staging): run deploy via canonical proxmox ssh context
All checks were successful
CI / test-and-quality (push) Successful in 1m20s
CI / test-and-quality (pull_request) Successful in 1m19s
2026-02-27 21:50:05 +01:00
71a0f8e9d4 Merge pull request 'devops: staging runbook, db setup runbook, and release policy' (#25) from devops/staging-db-release-policy into main
All checks were successful
CI / test-and-quality (push) Successful in 1m10s
Merge PR #25: staging deploy policy updates
2026-02-27 20:38:13 +01:00
77e94c3125 devops: fix staging deploy archive URL fetch (refs #20 #23)
All checks were successful
CI / test-and-quality (push) Successful in 1m18s
CI / test-and-quality (pull_request) Successful in 1m18s
2026-02-27 20:35:55 +01:00
08c22be3e9 devops: harden staging runbooks and db setup docs (refs #20 #21 #23)
All checks were successful
CI / test-and-quality (push) Successful in 1m19s
CI / test-and-quality (pull_request) Successful in 1m19s
2026-02-27 20:16:23 +01:00
92afeb9a75 Merge pull request 'DevOps: staging deploy runbook + release policy (#20 #23)' (#24) from devops/staging-db-release-policy into main
All checks were successful
CI / test-and-quality (push) Successful in 1m8s
2026-02-27 20:11:21 +01:00
6f8061644d devops: add staging deploy runbook and release policy (refs #20 #23)
All checks were successful
CI / test-and-quality (push) Successful in 1m19s
CI / test-and-quality (pull_request) Successful in 1m20s
2026-02-27 20:01:08 +01:00
3622b9f024 Merge pull request 'docs(f3): align MVP player default with scope guardrail' (#19) from feature/f3-start-round-player-guardrail into main
All checks were successful
CI / test-and-quality (push) Successful in 1m9s
2026-02-27 18:20:29 +01:00
32ed75ae1e feat(f3): enforce player guardrail when starting round
All checks were successful
CI / test-and-quality (push) Successful in 1m11s
CI / test-and-quality (pull_request) Successful in 1m11s
2026-02-27 18:12:39 +01:00
6f4a99637e Merge pull request 'F3: Slutresultat-endpoint (afslut spil + final leaderboard)' (#15) from feature/f3-final-result-endpoint into main
All checks were successful
CI / test-and-quality (push) Successful in 1m10s
2026-02-27 18:04:52 +01:00
adce99b82b feat(f3): add final result endpoint to finish game
All checks were successful
CI / test-and-quality (push) Successful in 1m21s
CI / test-and-quality (pull_request) Successful in 1m21s
2026-02-27 18:02:13 +01:00
e19535b24c Merge pull request 'F3: Reveal scoreboard + next-round transition' (#12) from feature/f3-scoreboard-next-round into main
All checks were successful
CI / test-and-quality (push) Successful in 1m1s
2026-02-27 17:49:56 +01:00
102c8b91ec feat(f3): add reveal scoreboard and next-round transition
All checks were successful
CI / test-and-quality (push) Successful in 1m15s
CI / test-and-quality (pull_request) Successful in 1m13s
2026-02-27 17:20:57 +01:00
ae25403e18 Merge pull request 'F3: Beregn point efter guessfase og skift til reveal' (#10) from feature/f3-score-calculate into main
All checks were successful
CI / test-and-quality (push) Successful in 51s
2026-02-27 17:07:48 +01:00
1017ed0c4c feat(f3): calculate round scores and move to reveal phase
All checks were successful
CI / test-and-quality (push) Successful in 57s
CI / test-and-quality (pull_request) Successful in 59s
2026-02-27 17:01:36 +01:00
49e9d1be41 Merge pull request 'F3: Guessfase submit-endpoint med deadline-validering' (#8) from feature/f3-guess-submit into main
All checks were successful
CI / test-and-quality (push) Successful in 44s
2026-02-27 16:41:02 +01:00
0cb936173f ci: add gitea workflow for required PR status checks
All checks were successful
CI / test-and-quality (push) Successful in 50s
CI / test-and-quality (pull_request) Successful in 50s
2026-02-27 16:37:06 +01:00
d66c21ecb3 feat(f3): add guess submission endpoint with deadline checks 2026-02-27 16:31:31 +01:00
3ee478f094 Merge pull request 'feat(f3): bland korrekt svar med løgne og skift til guessfase' (#6) from feature/f3-answer-mix-pr-flow into main 2026-02-27 16:22:26 +01:00
adbdf5c876 feat(f3): mix correct answer with lies and open guess phase 2026-02-27 16:18:30 +01:00
9ed5a909f1 Record PO decisions and lock MVP scope/release criteria 2026-02-27 14:53:52 +01:00
648da2407b chore(todo): classify lie-submit follow-ups as need/nice-to-have 2026-02-27 14:39:14 +01:00
2db87561d8 chore(coordination): clear F3-LIE-SUBMIT assignment and capture follow-ups 2026-02-27 14:38:35 +01:00
2f400e2eff Merge branch feature/f3-lie-submit-x-sek 2026-02-27 14:38:00 +01:00
f0026ba35d feat(f3): add lie submission window with 45s default 2026-02-27 14:37:55 +01:00
ec04325e43 chore(coordination): assign F3-LIE-SUBMIT to dev-runner 2026-02-27 14:29:34 +01:00
77dfefa9d4 chore(coordination): clear assignment for F3 round start after merge 2026-02-27 14:15:06 +01:00
03100c99cd feat(lobby): start round with selected category 2026-02-27 14:14:40 +01:00
9995203add chore(coordination): clear completed F0 anti-cheat assignment 2026-02-27 14:02:04 +01:00
2d9548b6de merge: F0 anti-cheat rules 2026-02-27 14:01:09 +01:00
2f040c87fb docs: define F0 anti-cheat rules for fup og fakta 2026-02-27 14:01:04 +01:00
811ef949eb merge: F3 lobby create/join 2026-02-27 13:51:27 +01:00
534eb578a9 docs(todo): mark F3 lobby create/join complete 2026-02-27 13:51:04 +01:00
3bfa0f5b2e test(lobby): cover host create and player join flow 2026-02-27 13:50:55 +01:00
93d3e9eca2 feat(lobby): add create/join/detail session endpoints 2026-02-27 13:50:55 +01:00
e921bd6b4b chore(todo): add high-priority lint blocker from integrator checks 2026-02-27 13:36:18 +01:00
eed43ac9ca chore: add coordination assignments tracker 2026-02-27 13:02:25 +01:00
7703cf7076 docs: define F0 MVP scope for Fup og Fakta 2026-02-27 13:00:31 +01:00
59 changed files with 2535 additions and 23 deletions

33
.gitea/workflows/ci.yml Normal file
View 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
View File

@@ -14,7 +14,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- Deployment: Proxmox LXC (ikke Docker)
## Midlertidige defaults (kan finjusteres senere)
- Spillere: min 3, 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.

View 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

View File

@@ -0,0 +1,5 @@
{
"updatedAt": "2026-02-27T13:38:35Z",
"active": [],
"queue": []
}

View 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

View 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
View 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
View 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.

View 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),
),
]

View 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),
),
]

View 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),
),
]

View File

@@ -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):

View File

@@ -1,3 +1,2 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -1,3 +1,2 @@
from django.shortcuts import render
# Create your views here.

32
infra/staging/DB_SETUP.md Normal file
View 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
View 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
View 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
View 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"

View File

@@ -1,3 +1,2 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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}"))

View File

@@ -1,3 +1,2 @@
from django.db import models
# Create your models here.

View 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>

View 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>

View File

@@ -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
View 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
View 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"),
]

View File

@@ -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,
}
)

View File

@@ -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")),
]