Compare commits

..

168 Commits

Author SHA1 Message Date
agw b6617fc356 feat(spa): add Angular API client for health and session read
CI / test-and-quality (pull_request) Successful in 2m9s
CI / test-and-quality (push) Successful in 1m47s
2026-03-01 12:21:18 +00:00
integrator-bot fa6c5e30c9 Merge pull request '[SPA] MVP vertical slice: Lobby -> Join -> Start round (#160)' (#165) from dev/issue-160-spa-vertical-slice-v2 into main
CI / test-and-quality (push) Successful in 1m48s
2026-03-01 12:22:34 +01:00
integrator-bot cd3c604ba6 Merge pull request '[SPA] Cutover feature-flag USE_SPA_UI med sikker fallback (#152)' (#166) from dev/issue-152-spa-cutover-flag into main
CI / test-and-quality (push) Has been cancelled
2026-03-01 12:22:14 +01:00
agw 1aa296c45c feat(spa): add USE_SPA_UI cutover flag with legacy fallback
CI / test-and-quality (push) Successful in 2m17s
CI / test-and-quality (pull_request) Successful in 2m5s
2026-03-01 11:14:38 +00:00
dev-bot ea8954e702 feat(spa): add lobby-join-start vertical slice controller
CI / test-and-quality (push) Successful in 2m6s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-01 11:13:48 +00:00
integrator-bot 61eb08ad73 Merge pull request '[SPA] API-client baseline for health + session read (#158)' (#163) from dev/issue-158-spa-api-client-baseline into main
CI / test-and-quality (push) Successful in 1m46s
Merged by integrator-runner
2026-03-01 12:05:30 +01:00
dev-bot 37b86d7065 test(spa): add integration coverage for API client error mapping
CI / test-and-quality (push) Successful in 1m54s
CI / test-and-quality (pull_request) Successful in 1m54s
2026-03-01 11:02:00 +00:00
dev-bot 2e25d32ba1 feat(spa): add baseline API client for health and session read 2026-03-01 11:01:58 +00:00
integrator-bot 825f8c599b Merge pull request '[SPA] Shared contract for lobby/game phase view-model' (#155) from dev/issue-149-phase-view-model into main
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:55:25 +01:00
integrator-bot 87ba42c68a Merge pull request '[SPA] Error boundary + recover actions on top-level app shell' (#156) from dev/issue-151-app-shell-error-boundary into main
CI / test-and-quality (push) Successful in 1m38s
2026-03-01 11:50:18 +01:00
dev-bot 2882a7737b feat(spa): add top-level app-shell error boundary recover actions (#151)
CI / test-and-quality (push) Successful in 1m56s
CI / test-and-quality (pull_request) Successful in 1m56s
2026-03-01 10:46:48 +00:00
dev-bot a9868ae450 feat(lobby): add shared phase view-model contract
CI / test-and-quality (push) Successful in 2m12s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-01 10:41:16 +00:00
integrator-bot d6c7a36730 Merge pull request '[SPA] Host shell route-guards + deep-link fallback (#147)' (#154) from dev/issue-147-host-shell-route-guards into main
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:34:46 +01:00
dev-bot de99e456c7 merge(main): resolve host_screen deep-link route guard conflict
CI / test-and-quality (pull_request) Successful in 1m58s
CI / test-and-quality (push) Successful in 1m58s
2026-03-01 10:29:07 +00:00
integrator-bot 79c4734fe6 Merge pull request '[SPA] Player reconnect UX-state (lost connection banner + retry)' (#153) from dev/issue-148-player-reconnect-ux into main
CI / test-and-quality (push) Successful in 1m41s
2026-03-01 11:24:15 +01:00
dev-bot c8c27346a8 fix(host-ui): accept deep-link routes and normalize shell path
CI / test-and-quality (push) Successful in 2m14s
CI / test-and-quality (pull_request) Successful in 1m55s
2026-03-01 10:20:32 +00:00
agw 994e2930d5 chore(ci): retrigger checks after stale review pending gate
CI / test-and-quality (push) Successful in 2m27s
CI / test-and-quality (pull_request) Successful in 2m31s
2026-03-01 10:17:31 +00:00
dev-bot 3e0cb9cee7 feat(lobby): add host SPA deep-link fallback and route guards
CI / test-and-quality (push) Successful in 2m12s
CI / test-and-quality (pull_request) Successful in 2m12s
2026-03-01 10:13:26 +00:00
dev-bot 64bff4efb3 feat(player): show reconnect banner with retry action
CI / test-and-quality (push) Failing after 2m3s
CI / test-and-quality (pull_request) Failing after 2m16s
2026-03-01 10:11:29 +00:00
integrator-bot c58b1e8102 Merge pull request '[Execution] Add staging gameplay smoke artifact template (#144)' (#146) from dev/staging-gameplay-smoke-artifact-144 into main
CI / test-and-quality (push) Successful in 1m38s
2026-03-01 10:33:48 +01:00
dev-bot 7aae8f3798 chore(ci): retrigger pipeline for PR #146
CI / test-and-quality (pull_request) Successful in 2m38s
CI / test-and-quality (push) Successful in 2m39s
2026-03-01 09:11:41 +00:00
dev-bot 0c0d27cc52 docs: add staging gameplay smoke evidence artifact template (#144)
CI / test-and-quality (push) Failing after 24s
CI / test-and-quality (pull_request) Successful in 1m59s
2026-03-01 07:08:44 +00:00
integrator-bot 164416e4a9 Merge pull request 'Issue #144: staging gameplay smoke artifact output' (#145) from dev/issue-144-smoke-artifact into main
CI / test-and-quality (push) Failing after 25s
2026-03-01 07:51:09 +01:00
agw b782f73f49 Add staging gameplay smoke artifact output
CI / test-and-quality (pull_request) Successful in 2m2s
CI / test-and-quality (push) Successful in 2m8s
2026-03-01 06:39:51 +00:00
integrator-bot 9d1e41ef3b Merge pull request 'Fix #129: normalize session code input across host/player flows' (#143) from feature/issue-129-normalize-session-code into main
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 21:37:49 +01:00
dev-bot 046212d29a Normalize session code input in join and lookup flows
CI / test-and-quality (push) Successful in 1m42s
CI / test-and-quality (pull_request) Successful in 1m42s
2026-02-28 20:22:58 +00:00
integrator-bot 6fd57d1714 Merge pull request 'fix(devops): harden staging deploy health check race' (#142) from fix/141-staging-healthcheck-retry into main
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 18:40:44 +01:00
dev-bot c4ea5ca208 fix(staging): retry health check after restart
CI / test-and-quality (push) Successful in 1m30s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 17:36:14 +00:00
integrator-bot ab08303fc3 Merge pull request 'fix(smoke): load staging env before migration/gameplay checks' (#140) from fix/smoke-env-load-130 into main
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 17:50:33 +01:00
agw 8b6f115759 fix(smoke): load staging env before migrate/smoke checks (refs #130 #90)
CI / test-and-quality (push) Successful in 1m35s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 16:35:25 +00:00
integrator-bot c75189deb9 Merge pull request 'fix(staging): keep /opt/wpp-staging/app writable for wpp runtime (fix #138)' (#139) from feature/fix-138-staging-app-ownership into main
CI / test-and-quality (push) Successful in 1m23s
2026-02-28 17:29:29 +01:00
dev-bot 30c22d2f0c fix(staging): enforce writable app ownership during deploy
CI / test-and-quality (push) Successful in 1m37s
CI / test-and-quality (pull_request) Successful in 1m38s
2026-02-28 16:06:58 +00:00
integrator-bot 30e3f1c77f Merge pull request 'fix(smoke): schema-drift guard + token-aware staging smoke flow (refs #130 #90)' (#137) from fix/staging-promote-after-migrate-130 into main
CI / test-and-quality (push) Successful in 1m23s
2026-02-28 16:48:31 +01:00
dev-bot abb656d50b fix(smoke): guard staging schema and include player session tokens (refs #130 #90)
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 15:36:29 +00:00
integrator-bot b1e89b135a Merge pull request 'fix(staging): avoid schema/code drift after failed deploy' (#136) from fix/staging-deploy-schema-drift-130 into main
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 16:25:47 +01:00
dev-bot e9104cdc44 fix(staging): prevent app/schema drift on failed deploy (refs #130 #90)
CI / test-and-quality (push) Successful in 1m30s
CI / test-and-quality (pull_request) Successful in 1m32s
2026-02-28 15:04:06 +00:00
integrator-bot 65ee2fc0db Merge pull request 'fix(staging): remove tracked sqlite artifact from deploy archives (fixes #131)' (#135) from fix/staging-sqlite-artifact-131 into main
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 15:55:12 +01:00
dev-bot 12fc12f955 fix(staging): remove tracked sqlite artifact from deploy archives (refs #131 #130)
CI / test-and-quality (push) Successful in 1m34s
CI / test-and-quality (pull_request) Successful in 1m34s
2026-02-28 14:33:31 +00:00
integrator-bot e8f13646f9 Merge pull request 'feat(staging): enforce MySQL-only staging deploy (fixes #133)' (#134) from feat/staging-mysql-133 into main
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 15:14:56 +01:00
agw a36221ae4b staging deploy: load env before manage.py checks
CI / test-and-quality (push) Successful in 1m36s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 14:02:39 +00:00
agw fce18c1ee3 staging: enforce MySQL and add staging env template (#133)
CI / test-and-quality (push) Successful in 1m33s
CI / test-and-quality (pull_request) Successful in 1m36s
2026-02-28 11:31:39 +00:00
integrator-bot 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
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 07:58:04 +01:00
agw 2adeb8536a UI: lås opret-session knap under aktiv handling/opdatering (#127)
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
integrator-bot 726280e120 Merge pull request 'UI: lås sessionkode-felt under aktiv host-handling (#125)' (#126) from feature/ui-host-code-lock-125 into main
CI / test-and-quality (push) Successful in 1m29s
2026-02-28 07:47:46 +01:00
agw 7ec9219487 UI: lås sessionkode under aktiv host-opdatering (#125)
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
integrator-bot eabf95bc5c Merge pull request 'UI: lås kategori-valg under session-opdatering (#123)' (#124) from feature/ui-host-category-session-lock-123 into main
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 07:42:34 +01:00
agw ad67c63cca UI: lås kategori-valg under session-opdatering (#123)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m28s
2026-02-28 07:26:43 +01:00
agw 0d65cfac82 ui: lås round question-id under session-opdatering (#121)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 07:16:44 +01:00
agw 9e496763aa UI: lås host-actions under session-opdatering (#119)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 07:12:16 +01:00
agw 348bebf358 UI: lock host auto-refresh toggle during in-flight actions (#117)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m25s
2026-02-28 06:56:16 +01:00
agw 48eae5d083 UI: lock session refresh while lie/guess submit is in-flight (#115)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 06:47:49 +01:00
agw 95d3f1aa48 UI: lock player auto-refresh toggle while join is in-flight (#113)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 06:40:01 +01:00
agw 1ff98f5e92 UI: lock session-detail while join is in-flight (#111)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m23s
2026-02-28 06:31:48 +01:00
agw 0a07bfd7ad ui: lock join button during player session refresh (#109)
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
integrator-bot 59e12d596d Merge PR #108: UI: lås lie/guess submit under aktiv session-opdatering (#107)
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 06:20:12 +01:00
agw 5b2e2132e7 UI: lås lie/guess submit under session-opdatering (#107)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m25s
2026-02-28 05:55:49 +01:00
agw 204581aef5 UI: lock player auto-refresh toggle during session refresh (#105)
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
integrator-bot 73b63ed6a4 Merge pull request 'UI: nulstil spiller-runde-kontekst ved kode-/spillerskift (#103)' (#104) from feature/ui-player-reset-round-context-103 into main
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 05:43:46 +01:00
agw 49286ca631 UI: nulstil spiller-runde-kontekst ved manuel kontekstændring (#103)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m29s
2026-02-28 05:26:02 +01:00
agw 6732c75475 UI: lock host session-status during in-flight request (#101)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m39s
2026-02-28 05:18:10 +01:00
agw 1319957e36 ui: lock round question id field in player panel
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 05:06:48 +01:00
agw 4a1ed80142 UI: kræv aktiv round-question før guess-svarvalg (#97)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 05:00:19 +01:00
agw 41a414bc97 ui: guard duplicate guess submit on player panel (closes #95)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 04:45:22 +01:00
agw c0c303d45e UI: lås session-opdatering ved in-flight request (#93)
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
integrator-bot c568b34d51 Merge pull request 'MVP UI: lås spillerkontekstfelter efter join (#91)' (#92) from feature/ui-player-context-lock-91 into main
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 04:34:29 +01:00
agw f5380f8a81 UI: lås spillerkontekstfelter efter join (#91)
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
integrator-bot 84c00da55f Merge pull request 'UI: player auto-refresh + sidst opdateret status (#88)' (#89) from feature/ui-player-auto-refresh-88 into main
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 04:26:04 +01:00
agw 0e1a36b0b5 ui(player): auto-refresh + last refresh status (#88)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 04:14:51 +01:00
agw 8c655d10b6 UI: vis sidst opdateret status i host auto-refresh (#86)
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
integrator-bot 03f6f35019 Merge pull request 'UI: lås host-actions under in-flight request (#84)' (#85) from feature/ui-host-inflight-guard-84 into main
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 04:01:45 +01:00
agw b6110ec53e UI: lås host-actions under in-flight requests (#84)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m25s
2026-02-28 03:45:24 +01:00
agw 2b574aa3b5 UI: lås lie/guess submit under in-flight request (#82)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 03:34:35 +01:00
agw c6f90c3564 UI: ensartede fejl-hints for fase/token/round-state (#80)
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
integrator-bot 042c8e70d0 Merge pull request 'UI: host auto-refresh toggle for lobby-status (#78)' (#79) from feature/ui-host-auto-refresh-78 into main
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 03:25:04 +01:00
agw 2968c37e66 UI: host auto-refresh toggle for lobby status (#78)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 03:18:44 +01:00
agw 15136537f4 UI: håndhæv 3-5 spillere i host start-runde guard (#76)
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
integrator-bot 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
CI / test-and-quality (push) Successful in 1m32s
2026-02-28 03:02:34 +01:00
agw b6e5b98837 UI: lås round question-id input efter fase (#74)
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
integrator-bot b8e0817d2d Merge pull request 'UI: fase-lock af lie/guess i spillerpanelet (#72)' (#73) from feature/ui-player-phase-lock-72 into main
CI / test-and-quality (push) Successful in 1m35s
2026-02-28 02:52:56 +01:00
agw b030ae6d4e ui: phase-lock player lie/guess actions (#72)
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
integrator-bot 5c8faf76a9 Merge pull request 'UI: lås kategori-valg udenfor lobby-fasen (#70)' (#71) from feature/ui-host-category-phase-guard-70 into main
CI / test-and-quality (push) Successful in 1m33s
2026-02-28 02:42:04 +01:00
agw 1709713bff UI: lås kategori-valg udenfor lobby-fasen (#70)
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
integrator-bot 07b5982ac4 Merge pull request 'UI: fasebaserede host-action guards i hostpanelet (#68)' (#69) from feature/ui-host-phase-action-guards-68 into main
CI / test-and-quality (push) Successful in 1m32s
2026-02-28 02:30:30 +01:00
agw 4d22bb5d04 Merge main into feature/ui-host-phase-action-guards-68
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
agw a8fd012193 UI: fasebaserede host-action guards i hostpanelet (#68)
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
integrator-bot 2a488c6530 Merge pull request 'UI: host action guards ved manglende kontekst (#66)' (#67) from feature/ui-host-action-guards into main
CI / test-and-quality (push) Successful in 1m28s
2026-02-28 02:14:56 +01:00
agw c9b4fe0077 UI: guard host actions on missing context (#66)
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
integrator-bot 7e7445cd07 Merge pull request 'UI: robust join-state guard i player panel (#64)' (#65) from feature/ui-robust-join-state-64 into main
CI / test-and-quality (push) Successful in 1m33s
2026-02-28 01:58:50 +01:00
agw 9807ce8d2e UI: robust join-state guard i player panel (#64)
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
integrator-bot 032304f19b Merge pull request 'UI: guard mod dobbelt-join i player panel (#62)' (#63) from feature/ui-join-request-guard-62 into main
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 01:53:30 +01:00
agw 5170c779e4 ui: guard join request against double-submit (#62)
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
integrator-bot 82b90d3c5d Merge pull request 'MVP UI: robust reconnect-konsistens for host + spiller (#60)' (#61) from feature/ui-reconnect-consistency-60 into main
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 01:39:52 +01:00
agw e0e1c6a7a0 ui: persist/reload host+player context after refresh (#60)
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
email-manager 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
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 01:24:06 +01:00
agw 0858cbe892 ui(host): guard start round until 3 players in lobby
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
integrator-bot 0fe66f21c0 Merge pull request 'UI: guard submit-knapper indtil spillerkontekst er klar (#55)' (#56) from feature/ui-submit-context-guard into main
CI / test-and-quality (push) Successful in 1m26s
2026-02-28 01:08:16 +01:00
agw 1a988469ec ui(player): guard lie/guess submit until context is ready
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
integrator-bot d316b3bff6 Merge pull request 'UI: lås løgn-input efter submit + status (#53)' (#54) from feature/ui-lie-submit-locked-status into main
CI / test-and-quality (push) Successful in 1m25s
2026-02-28 00:59:00 +01:00
agw 5c1827c8b8 UI: lås løgn-input efter submit med tydelig status (#53)
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
integrator-bot 32a85c0790 Merge pull request 'UI: vis låst status efter guess-submit (#51)' (#52) from feature/ui-guess-submit-locked-status into main
CI / test-and-quality (push) Successful in 1m21s
2026-02-28 00:51:08 +01:00
agw d8b44411a9 ui: vis låst status efter guess-submit
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
integrator-bot 16b365c66d Merge pull request 'UI: lås svarvalg efter guess-submit (#49)' (#50) from dev/issue-49-lock-answer-options-after-guess into main
CI / test-and-quality (push) Successful in 1m22s
2026-02-28 00:42:50 +01:00
dev-bot 630af2333b UI: lock answer options after submitted guess (refs #49)
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
integrator-bot 7c526c0bdb Merge pull request 'UI: reconnect-konsistens for valgt svar i guess-fase' (#48) from feature/ui-reconnect-guess-state into main
CI / test-and-quality (push) Has been cancelled
2026-02-28 00:29:00 +01:00
agw 867ea9602f UI: bevar valgt guess ved refresh i guess-fase
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
integrator-bot dce416f48a Merge pull request 'MVP UI: disable guess-submit indtil klikket svarvalg' (#46) from feature/ui-guess-submit-disabled-until-choice into main
CI / test-and-quality (push) Successful in 1m24s
2026-02-28 00:19:01 +01:00
agw 298381586f ui: disable guess submit until option selected (#45)
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
integrator-bot fdaddc4f52 Merge pull request 'UI: lås guess-submit til viste svarmuligheder' (#44) from feature/ui-guess-click-only into main
CI / test-and-quality (push) Successful in 1m27s
2026-02-28 00:07:16 +01:00
agw a0562fa6a4 ui: lock guess submit to rendered answer options
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
integrator-bot a9bb2c9670 Merge pull request 'UI: klikbare svarvalg i guess-fase (MVP)' (#42) from feature/ui-guess-answer-options into main
CI / test-and-quality (push) Successful in 1m25s
2026-02-27 23:57:10 +01:00
agw 81a29a0e07 feat(ui): klikbare guess-svar i player panel
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
integrator-bot 9d298e083e Merge pull request 'F3 UI+sikkerhed: kræv session_token ved guess submit' (#40) from feature/ui-guess-session-token into main
CI / test-and-quality (push) Successful in 1m27s
2026-02-27 23:34:34 +01:00
agw 0a028bb499 feat(ui): require session_token for guess submit (#39)
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
integrator-bot 59f2b0b29e Merge pull request 'UI: player screen sender session_token til lie-submit' (#38) from feature/ui-player-session-token into main
CI / test-and-quality (push) Successful in 1m21s
2026-02-27 23:23:17 +01:00
agw 5894987a1c fix(ui): send session_token from player screen on lie submit
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
integrator-bot 177171706b Merge pull request 'F3: beskyt lie-submit med player session token' (#36) from feature/f3-lie-submit-session-token into main
CI / test-and-quality (push) Successful in 1m23s
2026-02-27 23:15:26 +01:00
agw 37e1d32675 feat(f3): require player session token for lie submission
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
integrator-bot 86dbd4fabc Merge pull request 'F3/UI: Persistér mixed svarrækkefølge for reconnect' (#34) from feature/f3-persist-mixed-answer-order into main
CI / test-and-quality (push) Successful in 1m18s
2026-02-27 23:03:46 +01:00
agw 8e4ce8c4da F3: persist mixed answer order for stable UI reconnect
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
integrator-bot fa523d84f5 Merge pull request 'test(staging): smoke-suite for gameplay flow' (#32) from feature/test-smoke-fail-label into main
CI / test-and-quality (push) Successful in 1m16s
2026-02-27 22:47:14 +01:00
agw 05b3d982b4 test(staging): add smoke suite script and gameplay smoke command (closes #22)
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
email-manager 0d13ab9f80 Merge pull request 'F3 UI: MVP host + player templates' (#31) from feature/f3-mvp-ui-templates into main
CI / test-and-quality (push) Successful in 1m16s
2026-02-27 22:32:26 +01:00
email-manager 7fbec0d604 Merge pull request 'chore: remove tracked __pycache__ artifacts' (#29) from chore/remove-pycache-tracked into main
CI / test-and-quality (push) Has been cancelled
2026-02-27 22:32:08 +01:00
agw cfffc9934c feat(ui): add MVP host/player web screens
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
agw 12ecf32e50 chore: remove tracked pycache artifacts
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
integrator-bot d6e7198fe8 Merge pull request 'fix(staging): canonical deploy context via proxmox SSH wrapper' (#28) from fix/staging-deploy-canonical-context into main
CI / test-and-quality (push) Successful in 1m8s
2026-02-27 21:59:52 +01:00
agw c0145f9e5f fix(staging): run deploy via canonical proxmox ssh context
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
integrator-bot 71a0f8e9d4 Merge pull request 'devops: staging runbook, db setup runbook, and release policy' (#25) from devops/staging-db-release-policy into main
CI / test-and-quality (push) Successful in 1m10s
Merge PR #25: staging deploy policy updates
2026-02-27 20:38:13 +01:00
agw 77e94c3125 devops: fix staging deploy archive URL fetch (refs #20 #23)
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
agw 08c22be3e9 devops: harden staging runbooks and db setup docs (refs #20 #21 #23)
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
integrator-bot 92afeb9a75 Merge pull request 'DevOps: staging deploy runbook + release policy (#20 #23)' (#24) from devops/staging-db-release-policy into main
CI / test-and-quality (push) Successful in 1m8s
2026-02-27 20:11:21 +01:00
agw 6f8061644d devops: add staging deploy runbook and release policy (refs #20 #23)
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
integrator-bot 3622b9f024 Merge pull request 'docs(f3): align MVP player default with scope guardrail' (#19) from feature/f3-start-round-player-guardrail into main
CI / test-and-quality (push) Successful in 1m9s
2026-02-27 18:20:29 +01:00
agw 32ed75ae1e feat(f3): enforce player guardrail when starting round
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
integrator-bot 6f4a99637e Merge pull request 'F3: Slutresultat-endpoint (afslut spil + final leaderboard)' (#15) from feature/f3-final-result-endpoint into main
CI / test-and-quality (push) Successful in 1m10s
2026-02-27 18:04:52 +01:00
agw adce99b82b feat(f3): add final result endpoint to finish game
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
integrator-bot e19535b24c Merge pull request 'F3: Reveal scoreboard + next-round transition' (#12) from feature/f3-scoreboard-next-round into main
CI / test-and-quality (push) Successful in 1m1s
2026-02-27 17:49:56 +01:00
agw 102c8b91ec feat(f3): add reveal scoreboard and next-round transition
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
email-manager ae25403e18 Merge pull request 'F3: Beregn point efter guessfase og skift til reveal' (#10) from feature/f3-score-calculate into main
CI / test-and-quality (push) Successful in 51s
2026-02-27 17:07:48 +01:00
agw 1017ed0c4c feat(f3): calculate round scores and move to reveal phase
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
email-manager 49e9d1be41 Merge pull request 'F3: Guessfase submit-endpoint med deadline-validering' (#8) from feature/f3-guess-submit into main
CI / test-and-quality (push) Successful in 44s
2026-02-27 16:41:02 +01:00
agw 0cb936173f ci: add gitea workflow for required PR status checks
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
agw d66c21ecb3 feat(f3): add guess submission endpoint with deadline checks 2026-02-27 16:31:31 +01:00
email-manager 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
agw adbdf5c876 feat(f3): mix correct answer with lies and open guess phase 2026-02-27 16:18:30 +01:00
agw 9ed5a909f1 Record PO decisions and lock MVP scope/release criteria 2026-02-27 14:53:52 +01:00
agw 648da2407b chore(todo): classify lie-submit follow-ups as need/nice-to-have 2026-02-27 14:39:14 +01:00
agw 2db87561d8 chore(coordination): clear F3-LIE-SUBMIT assignment and capture follow-ups 2026-02-27 14:38:35 +01:00
agw 2f400e2eff Merge branch feature/f3-lie-submit-x-sek 2026-02-27 14:38:00 +01:00
agw f0026ba35d feat(f3): add lie submission window with 45s default 2026-02-27 14:37:55 +01:00
agw ec04325e43 chore(coordination): assign F3-LIE-SUBMIT to dev-runner 2026-02-27 14:29:34 +01:00
80 changed files with 4837 additions and 325 deletions
+33
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
+1
View File
@@ -22,6 +22,7 @@ media/
.env
.env.*
!.env.test.example
!.env.staging.example
!.env.prod.example
# Editors/OS
+18 -7
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
@@ -55,12 +55,12 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
### Fase 3 — Spilflow `Fup og Fakta`
- [x] Lobby: host opretter session, spillere joiner via kode
- [x] Runde starter med kategori
- [ ] Spørgsmål vises -> alle skriver løgn inden X sek
- [ ] System blander korrekt svar + løgne
- [ ] Guessfase: alle gætter inden Z sek
- [ ] Pointudregning (konfigurerbar pr. runde)
- [ ] Scoreboard + næste spørgsmål/runde
- [ ] Slutresultat
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
- [x] System blander korrekt svar + løgne
- [x] Guessfase: alle gætter inden Z sek
- [x] Pointudregning (konfigurerbar pr. runde)
- [x] Scoreboard + næste spørgsmål/runde
- [x] Slutresultat
### Fase 4 — Voice-acting (platformkrav)
- [ ] Definér TTS provider-interface
@@ -103,9 +103,20 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [ ] Migrations + static + health checks
### Backlog — Need-to-have / Nice-to-have
- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh
- [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total)
- [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge.
- [ ] (Need-to-have) Rate limiting på join/submit endpoints
- [ ] (Need-to-have) Session-kode brute-force beskyttelse
- [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip)
- [ ] (Nice-to-have) Runde-tema musik/lyd-cues
- [ ] (Nice-to-have) Hurtig onboarding-skærm for nye spillere
## PO-beslutninger (2026-02-27)
- MVP: Første spil (Fup og Fakta) skal være spilbart end-to-end og stabilt.
- Realtime: WebSocket er krav i MVP (ingen polling).
- Join: kun i LOBBY i MVP (viewers senere).
- Sikkerhed: sanitised inputs + server-side validering + fairness logging er hard requirement.
- Spillere: default max 5, minimum 3 for start (konfigurerbart).
- Se detaljer: coordination/PO_DECISIONS_2026-02-27.md.
+41
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
+1 -1
View File
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-02-27T13:15:06Z",
"updatedAt": "2026-02-27T13:38:35Z",
"active": [],
"queue": []
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+28
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
```
+43
View File
@@ -0,0 +1,43 @@
# Staging gameplay smoke artifact (Issue #144)
Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke i #16/#90-sporet uden scope-udvidelse.
## Guardrails (MVP)
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
- Kun verifikation af eksisterende flow; ingen nye features/polish.
## Hvornår bruges artifacten
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
- Resultatet logges i issue/PR-kommentar med denne skabelon.
## Evidence template (kopiér i PR/issue-kommentar)
```markdown
### Staging gameplay smoke evidence
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
- Environment: staging
- Commit/Head SHA: <sha>
- Linked scope: #16 #17 #90 #129 #144
#### Setup
- Host authenticated in Django admin: <yes/no>
- Active category/questions present: <yes/no>
- Participants: host + <N> players
#### Checks (PASS/FAIL)
1. Lobby -> join -> start
- Mixed-case + whitespace session code accepted: <pass/fail>
2. One full round to scoreboard
- submit lie -> mix -> submit guess -> calculate score -> show scoreboard: <pass/fail>
3. Next-round + game-end sanity
- next round transitions: <pass/fail>
- final leaderboard visible: <pass/fail>
#### Evidence pointers
- Command(s): `<exact command(s)>`
- UI notes/screenshots/log refs: <short refs>
- Result: <PASS/FAIL>
- If FAIL: blocker issue link + shortest repro
```
## Anti-stall minimum
Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging.
+18
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.
+18
View File
@@ -0,0 +1,18 @@
# SPA cutover feature flag (`USE_SPA_UI`)
## Formål
`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates.
## Miljø-toggle (uden kodeændring)
Sæt env var pr. miljø:
- `USE_SPA_UI=true` -> `/lobby/ui/host` og `/lobby/ui/player` returnerer SPA shell
- `USE_SPA_UI=false` (default) -> legacy template-flow bruges uændret
Backward compatibility under cutover:
- Hvis `USE_SPA_UI` ikke er sat, bruges `WPP_SPA_ENABLED` som fallback.
## Verifikation
- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off`
- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled`
- Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled`
+1
View File
@@ -0,0 +1 @@
node_modules/
+12
View File
@@ -0,0 +1,12 @@
# Frontend API client baseline
Dette er baseline-klientlaget for SPA-sporet.
## Kører checks lokalt
```bash
cd frontend
npm install
npm test
npm run build
```
+1454
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "wpp-frontend-api-client-baseline",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"test": "vitest run",
"build": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { ApiFailure, ApiResult, HealthResponse, SessionDetailResponse } from './types';
export interface AngularHttpError {
status?: number;
message?: string;
error?: unknown;
}
export interface AngularHttpClientLike {
get<T>(url: string, options?: { withCredentials?: boolean }): Promise<T>;
}
export interface AngularApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
}
function toFailure(error: unknown): ApiFailure {
const candidate = (error ?? {}) as AngularHttpError;
const status = typeof candidate.status === 'number' ? candidate.status : 0;
const payload = candidate.error;
if (status === 0) {
return {
kind: 'network',
status: 0,
message: candidate.message ?? 'Network error while contacting API'
};
}
return {
kind: 'http',
status,
message: candidate.message ?? `HTTP ${status}`,
...(payload === undefined ? {} : { payload })
};
}
function normalizeCode(code: string): string {
return code.trim().toUpperCase();
}
async function wrapGet<T>(call: () => Promise<T>): Promise<ApiResult<T>> {
try {
const data = await call();
return { ok: true, status: 200, data };
} catch (error: unknown) {
return { ok: false, status: typeof (error as AngularHttpError)?.status === 'number' ? (error as AngularHttpError).status! : 0, error: toFailure(error) };
}
}
export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''): AngularApiClient {
return {
health: () => wrapGet(() => http.get<HealthResponse>(`${baseUrl}/healthz`, { withCredentials: true })),
getSession: (code: string) =>
wrapGet(() =>
http.get<SessionDetailResponse>(`${baseUrl}/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`, {
withCredentials: true
})
)
};
}
+81
View File
@@ -0,0 +1,81 @@
import type {
ApiResult,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
SessionDetailResponse,
StartRoundRequest,
StartRoundResponse
} from './types';
export interface ApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
}
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
async function request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<ApiResult<T>> {
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
});
} catch {
return {
ok: false,
status: 0,
error: { kind: 'network', status: 0, message: 'Network error while contacting API' }
};
}
let responsePayload: unknown;
try {
responsePayload = await response.json();
} catch {
return {
ok: false,
status: response.status,
error: { kind: 'parse', status: response.status, message: 'Invalid JSON response from API' }
};
}
if (!response.ok) {
return {
ok: false,
status: response.status,
error: {
kind: 'http',
status: response.status,
message: `HTTP ${response.status}`,
payload: responsePayload
}
};
}
return { ok: true, status: response.status, data: responsePayload as T };
}
return {
health: () => request<HealthResponse>('/healthz', 'GET'),
getSession: (code: string) =>
request<SessionDetailResponse>(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`, 'GET'),
joinSession: (payload: JoinSessionRequest) =>
request<JoinSessionResponse>('/lobby/sessions/join', 'POST', {
code: payload.code.trim().toUpperCase(),
nickname: payload.nickname.trim()
}),
startRound: (code: string, payload: StartRoundRequest) =>
request<StartRoundResponse>(
`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}/rounds/start`,
'POST',
payload
)
};
}
+115
View File
@@ -0,0 +1,115 @@
export interface HealthResponse {
ok: boolean;
service: string;
}
export interface SessionSummary {
code: string;
status: string;
host_id: number | null;
current_round: number;
players_count: number;
}
export interface SessionPlayer {
id: number;
nickname: string;
score: number;
is_connected: boolean;
}
export interface SessionAnswer {
text: string;
}
export interface SessionRoundQuestion {
id: number;
round_number: number;
prompt: string;
shown_at: string;
answers: SessionAnswer[];
}
export interface PhaseViewModel {
status: string;
round_number: number;
players_count: number;
constraints: {
min_players_to_start: number;
max_players_mvp: number;
min_players_reached: boolean;
max_players_allowed: boolean;
};
host: {
can_start_round: boolean;
can_show_question: boolean;
can_mix_answers: boolean;
can_calculate_scores: boolean;
can_reveal_scoreboard: boolean;
can_start_next_round: boolean;
can_finish_game: boolean;
};
player: {
can_join: boolean;
can_submit_lie: boolean;
can_submit_guess: boolean;
can_view_final_result: boolean;
};
}
export interface SessionDetailResponse {
session: SessionSummary;
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
phase_view_model: PhaseViewModel;
}
export interface JoinSessionRequest {
code: string;
nickname: string;
}
export interface JoinSessionResponse {
player: {
id: number;
nickname: string;
session_token: string;
score: number;
};
session: {
code: string;
status: string;
};
}
export interface StartRoundRequest {
category_slug: string;
}
export interface StartRoundResponse {
session: {
code: string;
status: string;
current_round: number;
};
round: {
number: number;
category: {
slug: string;
name: string;
};
};
}
export type ApiErrorKind = 'network' | 'http' | 'parse';
export interface ApiFailure {
kind: ApiErrorKind;
message: string;
status: number;
payload?: unknown;
}
export type ApiResult<T> =
| { ok: true; status: number; data: T }
| { ok: false; status: number; error: ApiFailure };
+87
View File
@@ -0,0 +1,87 @@
import type { ApiClient } from '../api/client';
import type { SessionDetailResponse } from '../api/types';
export type AsyncState = 'idle' | 'loading' | 'success' | 'error';
export interface VerticalSliceState {
sessionCode: string;
session: SessionDetailResponse | null;
joinState: AsyncState;
startRoundState: AsyncState;
loadingSession: boolean;
errorMessage: string | null;
}
export interface VerticalSliceController {
getState(): VerticalSliceState;
hydrateLobby(sessionCode: string): Promise<VerticalSliceState>;
joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState>;
startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState>;
}
export function createVerticalSliceController(api: ApiClient): VerticalSliceController {
const state: VerticalSliceState = {
sessionCode: '',
session: null,
joinState: 'idle',
startRoundState: 'idle',
loadingSession: false,
errorMessage: null
};
const normalizeCode = (value: string): string => value.trim().toUpperCase();
async function hydrateLobby(sessionCode: string): Promise<VerticalSliceState> {
state.loadingSession = true;
state.errorMessage = null;
state.sessionCode = normalizeCode(sessionCode);
const result = await api.getSession(state.sessionCode);
state.loadingSession = false;
if (!result.ok) {
state.errorMessage = 'Kunne ikke hente lobby-status.';
return { ...state };
}
state.session = result.data;
return { ...state };
}
async function joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState> {
state.joinState = 'loading';
state.errorMessage = null;
const join = await api.joinSession({ code: sessionCode, nickname });
if (!join.ok) {
state.joinState = 'error';
state.errorMessage = 'Join fejlede. Tjek kode eller nickname og prøv igen.';
return { ...state };
}
state.joinState = 'success';
return hydrateLobby(sessionCode);
}
async function startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState> {
state.startRoundState = 'loading';
state.errorMessage = null;
const start = await api.startRound(sessionCode, { category_slug: categorySlug });
if (!start.ok) {
state.startRoundState = 'error';
state.errorMessage = 'Kunne ikke starte runden. Opdatér lobbyen og prøv igen.';
return { ...state };
}
state.startRoundState = 'success';
return hydrateLobby(sessionCode);
}
return {
getState: () => ({ ...state }),
hydrateLobby,
joinLobby,
startRound
};
}
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client';
describe('createAngularApiClient', () => {
it('reads health and session detail using Django-compatible endpoints', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/healthz') {
return { ok: true, service: 'partyhub' } as T;
}
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
{ id: 3, nickname: 'Bo', score: 0, is_connected: false }
],
round_question: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const http = { get };
const client = createAngularApiClient(http as AngularHttpClientLike);
const health = await client.health();
expect(health.ok).toBe(true);
if (health.ok) {
expect(health.data.ok).toBe(true);
expect(health.data.service).toBe('partyhub');
}
const session = await client.getSession(' abcd12 ');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.session.host_id).toBe(1);
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
}
expect(get).toHaveBeenNthCalledWith(1, '/healthz', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', { withCredentials: true });
});
it('maps HttpErrorResponse-style failures to ApiResult errors', async () => {
const http = {
get: vi.fn<AngularHttpClientLike['get']>(async () => {
throw { status: 503, message: 'Service unavailable', error: { error: 'maintenance' } };
})
};
const client = createAngularApiClient(http as AngularHttpClientLike);
const result = await client.health();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(503);
expect(result.error.kind).toBe('http');
expect(result.error.payload).toEqual({ error: 'maintenance' });
expect(result.error.message).toContain('Service unavailable');
}
});
});
@@ -0,0 +1,148 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AddressInfo } from 'node:net';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { createApiClient } from '../src/api/client';
let server: Server;
let baseUrl: string;
beforeAll(async () => {
server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' }));
return;
}
if (req.url === '/lobby/sessions/ABCD12' && req.method === 'GET') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
})
);
return;
}
if (req.url === '/lobby/sessions/join' && req.method === 'POST') {
res.writeHead(201, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 },
session: { code: 'ABCD12', status: 'lobby' }
})
);
return;
}
if (req.url === '/lobby/sessions/ABCD12/rounds/start' && req.method === 'POST') {
res.writeHead(201, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
})
);
return;
}
if (req.url?.startsWith('/lobby/sessions/')) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
return;
}
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unexpected route' }));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close((err?: Error) => (err ? reject(err) : resolve()))
);
});
describe('createApiClient', () => {
it('reads health + session detail through typed wrappers', async () => {
const client = createApiClient(baseUrl);
const health = await client.health();
expect(health.ok).toBe(true);
const session = await client.getSession('abcd12');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.phase_view_model.host.can_start_round).toBe(true);
}
});
it('supports join + start round writes for lobby vertical slice', async () => {
const client = createApiClient(baseUrl);
const join = await client.joinSession({ code: 'abcd12', nickname: 'Maja' });
expect(join.ok).toBe(true);
const start = await client.startRound('abcd12', { category_slug: 'history' });
expect(start.ok).toBe(true);
if (start.ok) {
expect(start.data.session.status).toBe('lie');
}
});
it('returns consistent HTTP error shape for 4xx/5xx', async () => {
const client = createApiClient(baseUrl);
const missing = await client.getSession('missing');
expect(missing.ok).toBe(false);
if (!missing.ok) {
expect(missing.status).toBe(404);
expect(missing.error.kind).toBe('http');
expect(missing.error.payload).toEqual({ error: 'Session not found' });
}
});
it('returns consistent network error shape', async () => {
const client = createApiClient('http://127.0.0.1:9');
const health = await client.health();
expect(health.ok).toBe(false);
if (!health.ok) {
expect(health.error.kind).toBe('network');
expect(health.status).toBe(0);
}
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from 'vitest';
import { createVerticalSliceController } from '../src/spa/vertical-slice';
import type { ApiClient } from '../src/api/client';
function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
const base: ApiClient = {
health: vi.fn(),
getSession: vi.fn().mockResolvedValue({
ok: true,
status: 200,
data: {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
}
}),
joinSession: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: { player: { id: 9, nickname: 'Maja', session_token: 'token-1', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }
}),
startRound: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
}
})
};
return { ...base, ...overrides };
}
describe('vertical slice controller: lobby -> join -> start round', () => {
it('tracks loading and success state for join + start flow', async () => {
const api = makeApiMock();
const controller = createVerticalSliceController(api);
const beforeJoinPromise = controller.joinLobby('abcd12', 'Maja');
expect(controller.getState().joinState).toBe('loading');
await beforeJoinPromise;
const postJoin = controller.getState();
expect(postJoin.joinState).toBe('success');
expect(postJoin.session?.session.code).toBe('ABCD12');
const beforeStartPromise = controller.startRound('abcd12', 'history');
expect(controller.getState().startRoundState).toBe('loading');
await beforeStartPromise;
const postStart = controller.getState();
expect(postStart.startRoundState).toBe('success');
});
it('surfaces a friendly error when join fails', async () => {
const api = makeApiMock({
joinSession: vi.fn().mockResolvedValue({
ok: false,
status: 404,
error: { kind: 'http', status: 404, message: 'HTTP 404', payload: { error: 'Session not found' } }
})
});
const controller = createVerticalSliceController(api);
await controller.joinLobby('missing', 'Maja');
const state = controller.getState();
expect(state.joinState).toBe('error');
expect(state.errorMessage).toContain('Join fejlede');
});
it('surfaces a friendly error when round start fails', async () => {
const api = makeApiMock({
startRound: vi.fn().mockResolvedValue({
ok: false,
status: 400,
error: { kind: 'http', status: 400, message: 'HTTP 400', payload: { error: 'Round can only be started from lobby' } }
})
});
const controller = createVerticalSliceController(api);
await controller.startRound('ABCD12', 'history');
const state = controller.getState();
expect(state.startRoundState).toBe('error');
expect(state.errorMessage).toContain('Kunne ikke starte runden');
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM"],
"types": ["vitest/globals", "node"]
},
"include": ["src", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
exclude: ['**/node_modules/**']
}
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,22 +0,0 @@
# Generated by Django 6.0.2 on 2026-02-27 13:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='roundquestion',
name='lie_deadline_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterUniqueTogether(
name='roundquestion',
unique_together={('session', 'round_number')},
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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),
),
]
+11 -5
View File
@@ -1,8 +1,15 @@
from django.contrib.auth import get_user_model
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,10 +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)
lie_deadline_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = (("session", "round_number"),)
shown_at = models.DateTimeField(default=timezone.now)
mixed_answers = models.JSONField(default=list, blank=True)
class LieAnswer(models.Model):
+1 -92
View File
@@ -1,93 +1,2 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from .models import Category, GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion
User = get_user_model()
class LiePhaseFlowTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvilket år faldt muren?",
correct_answer="1989",
is_active=True,
)
RoundConfig.objects.create(
session=self.session,
number=self.session.current_round,
category=self.category,
lie_seconds=20,
)
self.player = Player.objects.create(session=self.session, nickname="Luna")
def test_host_can_start_lie_question_with_deadline(self):
self.client.login(username="host", password="secret123")
response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 201)
payload = response.json()["question"]
self.assertEqual(payload["round"], 1)
self.assertEqual(payload["prompt"], self.question.prompt)
self.assertEqual(payload["lie_seconds"], 20)
round_question = RoundQuestion.objects.get(session=self.session, round_number=1)
self.assertIsNotNone(round_question.lie_deadline_at)
def test_submit_lie_before_deadline_is_accepted(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
lie_deadline_at=timezone.now() + timedelta(seconds=10),
)
response = self.client.post(
reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}),
data={"player_id": self.player.id, "text": "1991"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(
LieAnswer.objects.filter(round_question=round_question, player=self.player, text="1991").exists()
)
self.assertEqual(response.json()["progress"]["submitted_count"], 1)
def test_submit_lie_after_deadline_is_rejected(self):
RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
lie_deadline_at=timezone.now() - timedelta(seconds=1),
)
response = self.client.post(
reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}),
data={"player_id": self.player.id, "text": "1991"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Lie timer expired")
self.assertEqual(LieAnswer.objects.count(), 0)
def test_non_host_cannot_start_lie_question(self):
User.objects.create_user(username="other", password="secret123")
self.client.login(username="other", password="secret123")
response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error"], "Only host can start lie question")
# Create your tests here.
-10
View File
@@ -1,10 +0,0 @@
from django.urls import path
from . import views
app_name = "fupogfakta"
urlpatterns = [
path("sessions/<str:code>/lie-question/start", views.start_lie_question, name="start_lie_question"),
path("sessions/<str:code>/lies", views.submit_lie, name="submit_lie"),
]
+1 -175
View File
@@ -1,176 +1,2 @@
import json
import random
from datetime import timedelta
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_POST
from .models import GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion
MAX_LIE_LENGTH = 255
def _json_body(request: HttpRequest) -> dict:
if not request.body:
return {}
try:
return json.loads(request.body)
except json.JSONDecodeError:
return {}
def _emit_realtime_session_event(session_code: str, event: str, payload: dict) -> None:
channel_layer = get_channel_layer()
if not channel_layer:
return
try:
async_to_sync(channel_layer.group_send)(
f"session_{session_code}",
{
"type": "session.event",
"event": event,
"payload": payload,
},
)
except Exception:
# Realtime broadcasting must not fail game flow.
return
@require_POST
@login_required
def start_lie_question(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can start lie question"}, status=403)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
with transaction.atomic():
session = GameSession.objects.select_for_update().get(pk=session.pk)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
return JsonResponse({"error": "Lie question already started for round"}, status=409)
try:
round_config = RoundConfig.objects.select_related("category").get(
session=session,
number=session.current_round,
)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config not found"}, status=404)
question_qs = Question.objects.filter(category=round_config.category, is_active=True)
question_ids = list(question_qs.values_list("id", flat=True))
if not question_ids:
return JsonResponse({"error": "No active question for category"}, status=400)
question = question_qs.get(pk=random.choice(question_ids))
lie_deadline_at = timezone.now() + timedelta(seconds=round_config.lie_seconds)
round_question = RoundQuestion.objects.create(
session=session,
round_number=session.current_round,
question=question,
correct_answer=question.correct_answer,
lie_deadline_at=lie_deadline_at,
)
payload = {
"round": round_question.round_number,
"question_id": round_question.id,
"prompt": round_question.question.prompt,
"lie_deadline_at": round_question.lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
_emit_realtime_session_event(session.code, "lie_question_started", payload)
return JsonResponse({"question": payload}, status=201)
@require_POST
def submit_lie(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
payload = _json_body(request)
player_id = payload.get("player_id")
lie_text = str(payload.get("text", "")).strip()
if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400)
if len(lie_text) < 2 or len(lie_text) > MAX_LIE_LENGTH:
return JsonResponse({"error": "text must be between 2 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
try:
player = Player.objects.get(id=player_id, session=session)
except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404)
try:
round_question = RoundQuestion.objects.get(session=session, round_number=session.current_round)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "No active lie question"}, status=404)
now = timezone.now()
if round_question.lie_deadline_at and now > round_question.lie_deadline_at:
return JsonResponse({"error": "Lie timer expired"}, status=400)
lie_answer, _ = LieAnswer.objects.update_or_create(
round_question=round_question,
player=player,
defaults={"text": lie_text},
)
submitted_count = LieAnswer.objects.filter(round_question=round_question).count()
players_count = session.players.count()
event_payload = {
"round": session.current_round,
"question_id": round_question.id,
"player_id": player.id,
"submitted_count": submitted_count,
"players_count": players_count,
"lie_deadline_at": round_question.lie_deadline_at.isoformat() if round_question.lie_deadline_at else None,
}
_emit_realtime_session_event(session.code, "lie_submitted", event_payload)
return JsonResponse(
{
"lie": {
"id": lie_answer.id,
"player_id": player.id,
"question_id": round_question.id,
},
"progress": {
"submitted_count": submitted_count,
"players_count": players_count,
},
},
status=201,
)
# Create your views here.
+12
View File
@@ -0,0 +1,12 @@
DJANGO_SECRET_KEY=change-me-staging
DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=staging.party.weircon.dk
DB_ENGINE=django.db.backends.mysql
DB_NAME=wpp_staging
DB_USER=wpp_staging
DB_PASSWORD=change-me
DB_HOST=127.0.0.1
DB_PORT=3306
TEST_DB_NAME=
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6379
+35
View File
@@ -0,0 +1,35 @@
# DB setup runbook (Issue #21)
> Credentials ligger i Secrets-repo, ikke i applikationsrepo.
## Databaser
- `wpp_test`
- `wpp_staging`
- `wpp_prod`
## Brugere
- `wpp_test_user` (least privilege på `wpp_test`)
- `wpp_staging_user` (least privilege på `wpp_staging`)
- `wpp_prod_user` (least privilege på `wpp_prod`)
## Secrets placering
I Secrets-repo:
- `wpp/wpp_test.env`
- `wpp/wpp_staging.env`
- `wpp/wpp_prod.env`
Forventede felter:
- `DB_HOST`
- `DB_PORT`
- `DB_NAME`
- `DB_USER`
- `DB_PASSWORD`
## Verifikation (eksempel)
Kør fra staging-CT eller anden tilladt klient:
```bash
mysql -h <DB_HOST> -u <DB_USER> -p<DB_PASSWORD> -e "SELECT 1" <DB_NAME>
```
Alle forbindelser skal returnere `1`.
+50
View File
@@ -0,0 +1,50 @@
# Staging runbook (Issue #20)
## Mål
Staging-miljø for WPP i Proxmox LXC, så release-klar kode kan deployes og smoke-testes sikkert.
## Miljø
- LXC: CT 143 (wpp-staging)
- App path: /opt/wpp-staging/app
- Service: wpp-staging.service
- Health endpoint: GET /healthz
- Database: MySQL (staging må ikke bruge SQLite, issue #133)
## Verifikation
Kør fra devops-shell med Proxmox-adgang:
ssh proxmox-lan "sudo -n pct status 143"
ssh proxmox-lan "sudo -n pct exec 143 -- systemctl is-active wpp-staging.service"
ssh proxmox-lan "sudo -n pct exec 143 -- curl -fsS http://127.0.0.1:8000/healthz"
Forventet:
- CT er running
- service er active
- healthz returnerer JSON med ok=true
Smoke-suite skriver nu et gameplay-artifact som JSON under `/opt/wpp-staging/app/artifacts/smoke/` (kan overrides via `ARTIFACT_DIR`/`ARTIFACT_FILE`).
Efter deploy validerer scriptet, at `DB_ENGINE` ikke er `django.db.backends.sqlite3` før migrations køres.
Deploy-scriptet bruger en release-candidate mappe og promoverer først til `/opt/wpp-staging/app` efter succesfuld `migrate`. Det reducerer schema/code drift ved afbrudte deploys (issue #130) og understøtter release-readiness gate (issue #90).
## Deploy (canonical execution context)
Deploy skal altid køres via Proxmox host over SSH (ikke fra lokal coder-shell med direkte sudo pct).
Officiel kommando:
./infra/staging/deploy_staging.sh [ref]
Scriptet bruger default PROXMOX_HOST=proxmox-lan og kører sudo -n pct exec på hosten.
Eksempler:
./infra/staging/deploy_staging.sh
./infra/staging/deploy_staging.sh v0.3.0
PROXMOX_HOST=proxmox-prod ./infra/staging/deploy_staging.sh main
## Policy-kobling
Før deploy:
1. Bekræft at tester ikke er aktiv (ingen aktiv smoke-run).
2. Deploy til staging skal lykkes.
3. Først derefter må release-tag oprettes (se docs/RELEASE_POLICY.md).
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
CT_ID="${CT_ID:-143}"
REF_NAME="${1:-main}"
ARCHIVE_URL="https://gitea.weircon.dk/wpp/weirsoe-party-protocol/archive/${REF_NAME}.tar.gz"
PROXMOX_HOST="${PROXMOX_HOST:-proxmox-lan}"
echo "[deploy] host=${PROXMOX_HOST} CT_ID=${CT_ID} REF=${REF_NAME}"
echo "[deploy] extracting source + installing deps + migrate + restart"
ssh "${PROXMOX_HOST}" sudo -n /usr/sbin/pct exec "${CT_ID}" -- bash -s -- "${ARCHIVE_URL}" <<'REMOTE'
set -euo pipefail
ARCHIVE_URL="$1"
RELEASES_DIR=/opt/wpp-staging/releases
CANDIDATE_DIR="${RELEASES_DIR}/src"
APP_DIR=/opt/wpp-staging/app
install -d -m 0755 -o wpp -g wpp "${RELEASES_DIR}" "${APP_DIR}"
mkdir -p "${CANDIDATE_DIR}"
cd "${RELEASES_DIR}"
curl -fsSL "${ARCHIVE_URL}" -o app.tar.gz
rm -rf "${CANDIDATE_DIR}" && mkdir -p "${CANDIDATE_DIR}"
tar -xzf app.tar.gz -C "${CANDIDATE_DIR}" --strip-components=1
# Ensure deploy artifact copied as root does not leave candidate tree non-writable for wpp.
chown -R wpp:wpp "${CANDIDATE_DIR}"
# Staging must not run on SQLite (issue #133). Remove bundled sqlite artifact from candidate.
rm -f "${CANDIDATE_DIR}/db.sqlite3"
cd "${CANDIDATE_DIR}"
# Load staging env before any manage.py call (issue #133 follow-up).
ENV_LOADED=0
for ENV_FILE in \
/opt/wpp-staging/.env.staging \
/opt/wpp-staging/.env \
/opt/wpp-staging/env/wpp_staging.env \
/opt/wpp-staging/secrets/wpp_staging.env
do
if [ -f "${ENV_FILE}" ]; then
set -a
. "${ENV_FILE}"
set +a
echo "[deploy] loaded staging env: ${ENV_FILE}"
ENV_LOADED=1
break
fi
done
if [ "${ENV_LOADED}" -ne 1 ]; then
echo "[deploy] ERROR: no staging env file found"
exit 1
fi
runuser -u wpp -- python3 -m venv .venv
runuser -u wpp -- .venv/bin/pip install -U pip >/dev/null
runuser -u wpp -- .venv/bin/pip install -r requirements.txt >/dev/null
runuser -u wpp -- .venv/bin/python manage.py shell -c "from django.conf import settings; import sys; engine = settings.DATABASES['default']['ENGINE']; print(f'DB_ENGINE={engine}'); sys.exit(0 if engine != 'django.db.backends.sqlite3' else 1)"
runuser -u wpp -- .venv/bin/python manage.py migrate --noinput
# Promote candidate only after migrations succeed (issue #130): avoid code/schema drift after failed deploy.
# Use find instead of rm "${APP_DIR}"/* to reliably remove dotfiles between deploys.
find "${APP_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
cp -a "${CANDIDATE_DIR}"/. "${APP_DIR}/"
chown -R wpp:wpp "${APP_DIR}"
runuser -u wpp -- test -w "${APP_DIR}"
systemctl restart wpp-staging.service
HEALTH_URL="http://127.0.0.1:8000/healthz"
MAX_ATTEMPTS=7
SLEEP_SECONDS=1
ATTEMPT=1
until curl -fsS "${HEALTH_URL}" >/dev/null; do
if [ "${ATTEMPT}" -ge "${MAX_ATTEMPTS}" ]; then
echo "[deploy] ERROR: health check failed after ${MAX_ATTEMPTS} attempts: ${HEALTH_URL}" >&2
echo "[deploy] service status (wpp-staging.service):" >&2
systemctl --no-pager --full status wpp-staging.service >&2 || true
echo "[deploy] recent service logs (wpp-staging.service):" >&2
journalctl --no-pager -u wpp-staging.service -n 80 >&2 || true
exit 1
fi
echo "[deploy] health check not ready (attempt ${ATTEMPT}/${MAX_ATTEMPTS}); retrying in ${SLEEP_SECONDS}s"
sleep "${SLEEP_SECONDS}"
ATTEMPT=$((ATTEMPT + 1))
if [ "${SLEEP_SECONDS}" -lt 8 ]; then
SLEEP_SECONDS=$((SLEEP_SECONDS * 2))
fi
done
echo "[deploy] health check passed: ${HEALTH_URL}"
REMOTE
echo "[deploy] OK: staging deploy complete for CT ${CT_ID} (${REF_NAME})"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
APP_DIR="${APP_DIR:-/opt/wpp-staging/app}"
ISSUE_ON_FAIL="${ISSUE_ON_FAIL:-1}"
fail() {
local message="$1"
echo "[smoke] FAIL: ${message}" >&2
if [[ "${ISSUE_ON_FAIL}" == "1" ]] && [[ -n "${GITEA_BASE:-}" ]] && [[ -n "${GITEA_REPO:-}" ]] && [[ -n "${GITEA_USER:-}" ]] && [[ -n "${GITEA_TOKEN:-}" ]]; then
python3 - <<PY || true
import json
import os
import urllib.request
import base64
from datetime import datetime, timezone
base = os.environ["GITEA_BASE"].rstrip("/")
repo = os.environ["GITEA_REPO"]
user = os.environ["GITEA_USER"]
token = os.environ["GITEA_TOKEN"]
message = os.environ.get("SMOKE_FAIL_MESSAGE", "unknown")
when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
payload = {
"title": f"[smoke-fail] staging smoke failed ({when})",
"body": (
"Automatisk oprettet af smoke-suite.\n\n"
f"Fejl: `{message}`\n"
"Kontekst: issue #22 (staging smoke-suite)"
),
"labels": ["smoke-fail", "need-to-have", "staging"],
}
url = f"{base}/api/v1/repos/{repo}/issues"
req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST")
auth = base64.b64encode(f"{user}:{token}".encode()).decode()
req.add_header("Authorization", f"Basic {auth}")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
print(f"[smoke] Created fail issue: HTTP {r.status}")
PY
fi
exit 1
}
echo "[smoke] healthz check: ${BASE_URL}/healthz"
curl -fsS "${BASE_URL}/healthz" >/dev/null || { SMOKE_FAIL_MESSAGE="healthz check failed" fail "healthz check failed"; }
ENV_FILE="${ENV_FILE:-/etc/wpp/staging.env}"
run_manage() {
local cmd="$1"
(
cd "${APP_DIR}"
if [[ -f "${ENV_FILE}" ]]; then
set -a
# shellcheck disable=SC1090
source "${ENV_FILE}"
set +a
fi
.venv/bin/python manage.py ${cmd}
)
}
echo "[smoke] migration consistency check"
run_manage "migrate --check --noinput" || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; }
ARTIFACT_DIR="${ARTIFACT_DIR:-${APP_DIR}/artifacts/smoke}"
ARTIFACT_FILE="${ARTIFACT_FILE:-${ARTIFACT_DIR}/smoke-$(date -u +%Y%m%dT%H%M%SZ).json}"
echo "[smoke] gameplay flow via management command"
run_manage "smoke_staging --artifact ${ARTIFACT_FILE}" || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
echo "[smoke] artifact: ${ARTIFACT_FILE}"
echo "[smoke] OK"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1,3 +1,2 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.conf import settings
def use_spa_ui() -> bool:
"""Central read-point for SPA cutover flag."""
return bool(getattr(settings, "USE_SPA_UI", False))
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+164
View File
@@ -0,0 +1,164 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.test import Client
from fupogfakta.models import Category, GameSession, Player, Question, RoundQuestion
class Command(BaseCommand):
help = "Run minimal staging smoke flow for lobby gameplay"
def add_arguments(self, parser):
parser.add_argument(
"--artifact",
help="Optional path to write smoke result artifact as JSON",
)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
RoundQuestion.objects.all().delete()
category, _ = Category.objects.get_or_create(
slug="smoke",
defaults={"name": "Smoke", "is_active": True},
)
category.is_active = True
category.save(update_fields=["is_active"])
Question.objects.get_or_create(
category=category,
prompt="Smoke prompt?",
defaults={"correct_answer": "Correct", "is_active": True},
)
User = get_user_model()
host, _ = User.objects.get_or_create(username="smoke-host")
host.set_password("smoke-pass")
host.is_staff = True
host.save()
host_client = Client()
host_client.force_login(host)
create_res = host_client.post("/lobby/sessions/create", content_type="application/json")
if create_res.status_code != 201:
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}")
code = create_res.json()["session"]["code"]
players = []
for nickname in ["P1", "P2", "P3"]:
join_res = Client().post(
"/lobby/sessions/join",
data=json.dumps({"code": code, "nickname": nickname}),
content_type="application/json",
)
if join_res.status_code != 201:
raise CommandError(f"join_session failed for {nickname}: {join_res.status_code}")
players.append(join_res.json()["player"])
start_res = host_client.post(
f"/lobby/sessions/{code}/rounds/start",
data=json.dumps({"category_slug": category.slug}),
content_type="application/json",
)
if start_res.status_code != 201:
raise CommandError(f"start_round failed: {start_res.status_code}")
show_res = host_client.post(f"/lobby/sessions/{code}/questions/show", content_type="application/json")
if show_res.status_code != 201:
raise CommandError(f"show_question failed: {show_res.status_code}")
round_question_id = show_res.json()["round_question"]["id"]
for player in players:
nick = player["nickname"]
lie_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"text": f"Lie from {nick}",
}
),
content_type="application/json",
)
if lie_res.status_code != 201:
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
mix_res = host_client.post(
f"/lobby/sessions/{code}/questions/{round_question_id}/answers/mix",
content_type="application/json",
)
if mix_res.status_code != 200:
raise CommandError(f"mix_answers failed: {mix_res.status_code}")
answers = mix_res.json().get("answers", [])
if not answers:
raise CommandError("mix_answers returned empty answers")
for player in players:
nick = player["nickname"]
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
guess_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"selected_text": selected["text"],
}
),
content_type="application/json",
)
if guess_res.status_code != 201:
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
calc_res = host_client.post(
f"/lobby/sessions/{code}/questions/{round_question_id}/scores/calculate",
content_type="application/json",
)
if calc_res.status_code != 200:
raise CommandError(f"calculate_scores failed: {calc_res.status_code}")
board_res = host_client.get(f"/lobby/sessions/{code}/scoreboard")
if board_res.status_code != 200:
raise CommandError(f"reveal_scoreboard failed: {board_res.status_code}")
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
if finish_res.status_code != 200:
raise CommandError(f"finish_game failed: {finish_res.status_code}")
artifact_path = options.get("artifact")
if artifact_path:
artifact = {
"ok": True,
"command": "smoke_staging",
"generated_at": datetime.now(timezone.utc).isoformat(),
"session_code": code,
"players": [player["nickname"] for player in players],
"round_question_id": round_question_id,
"steps": [
"create_session",
"join_players",
"start_round",
"show_question",
"submit_lies",
"mix_answers",
"submit_guesses",
"calculate_scores",
"reveal_scoreboard",
"finish_game",
],
}
output_path = Path(artifact_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
self.stdout.write(self.style.SUCCESS(f"Smoke flow OK for session {code}"))
-1
View File
@@ -1,3 +1,2 @@
from django.db import models
# Create your models here.
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Host</title></head>
<body>
<h1>Host panel (MVP)</h1>
<p>Kræver login som host-bruger.</p>
<button id="createSessionBtn" onclick="createSession()">1) Opret session</button>
<p id="createSessionHint">Opret session er klar.</p>
<input id="code" placeholder="Sessionkode">
<select id="category">{% for c in categories %}<option value="{{ c.slug }}">{{ c.name }}</option>{% endfor %}</select>
<button id="startRoundBtn" onclick="startRound()" disabled>2) Start runde</button>
<p id="startRoundHint">Kræver 3-5 spillere i lobbyen.</p>
<p id="playerCountStatus">Spillere i session: ukendt</p>
<p id="categoryGuardHint">Kategori er kun redigérbar i lobby-fasen.</p>
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
<button id="showQuestionBtn" onclick="showQuestion()" disabled>3) Vis spørgsmål</button>
<input id="roundQuestionId" placeholder="Round question id">
<p id="roundQuestionGuardHint">Round question-id kan kun redigeres i lie/guess/reveal-faser.</p>
<button id="mixAnswersBtn" onclick="mixAnswers()" disabled>4) Mix svar</button>
<button id="calcScoresBtn" onclick="calcScores()" disabled>5) Beregn score</button>
<button id="showScoreboardBtn" onclick="showScoreboard()" disabled>6) Scoreboard</button>
<button id="nextRoundBtn" onclick="nextRound()" disabled>7) Næste runde</button>
<button id="finishGameBtn" onclick="finishGame()" disabled>8) Afslut spil</button>
<p id="hostActionHint">Angiv sessionkode for at aktivere host-actions.</p>
<p id="hostErrorHint">Ingen fejl.</p>
<button id="sessionDetailBtn" onclick="sessionDetail()">Session-status</button>
<p id="sessionDetailHint">Session-opdatering klar.</p>
<button id="autoRefreshToggleBtn" onclick="toggleAutoRefresh()">Auto-refresh: OFF</button>
<p id="autoRefreshHint">Auto-refresh er slået fra.</p>
<p id="lastRefreshStatus">Session-data ikke opdateret endnu.</p>
<div id="hostShellErrorBoundary" style="display:none;margin:8px 0 10px;padding:8px 10px;border-radius:8px;border:1px solid #b91c1c;background:#fee2e2;color:#7f1d1d;">Der opstod en kritisk fejl i app-skallen.
<button id="hostRecoverRetryBtn" type="button" onclick="recoverHostShell('retry')">Prøv gendan</button>
<button id="hostRecoverReloadBtn" type="button" onclick="recoverHostShell('reload')">Genindlæs siden</button>
</div>
<pre id="out">Klar.</pre>
<script>
var currentSessionStatus="";
var autoRefreshEnabled=false;
var autoRefreshTimer=null;
var hostActionInFlight=false;
var lastRefreshAtLabel="";
var lastRefreshFailed=false;
var sessionDetailInFlight=false;
var hostShellRouteHint="";
var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",finished:"finished"};
var hostShellFatalError=false;
var hostShellRecoverInFlight=false;
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
function updateHostShellErrorBoundary(){var panel=document.getElementById("hostShellErrorBoundary");var retryBtn=document.getElementById("hostRecoverRetryBtn");var reloadBtn=document.getElementById("hostRecoverReloadBtn");if(!panel||!retryBtn||!reloadBtn){return;}panel.style.display=hostShellFatalError?"block":"none";retryBtn.disabled=hostShellRecoverInFlight||sessionDetailInFlight||!code();reloadBtn.disabled=hostShellRecoverInFlight;}
function setHostShellFatalError(detail){hostShellFatalError=true;var out=document.getElementById("out");if(out){out.textContent=JSON.stringify({status:0,data:{error:"host_shell_runtime_error",detail:detail||"Ukendt runtime-fejl"}},null,2);}var hint=document.getElementById("hostErrorHint");if(hint){hint.textContent="Fejl: Kritisk app-fejl. Brug recover-handlingerne for at fortsætte.";}updateHostShellErrorBoundary();}
function clearHostShellFatalError(){hostShellFatalError=false;hostShellRecoverInFlight=false;updateHostShellErrorBoundary();}
function recoverHostShell(mode){if(hostShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}hostShellRecoverInFlight=true;updateHostShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearHostShellFatalError();return result;}).catch(function(err){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();throw err;});}
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
function hostShellRouteFromPath(){var marker="/lobby/ui/host";var path=(window.location.pathname||"").toLowerCase();var idx=path.indexOf(marker);if(idx===-1){return"";}var remainder=path.slice(idx+marker.length).replace(/^\/+|\/+$/g,"");if(!remainder){return"";}var route=remainder.split("/")[0];return HOST_SHELL_ROUTES[route]?route:"";}
function expectedHostShellRoute(){return HOST_SHELL_ROUTES[currentSessionStatus]||"";}
function syncHostShellRoute(){var currentRoute=hostShellRouteFromPath();var expectedRoute=expectedHostShellRoute();if(!currentRoute||!expectedRoute){hostShellRouteHint="";return;}if(currentRoute===expectedRoute){hostShellRouteHint="";return;}var nextPath="/lobby/ui/host/"+expectedRoute;window.history.replaceState(null,"",nextPath);hostShellRouteHint="Deep-link route guard: omdirigeret fra /"+currentRoute+" til /"+expectedRoute+" for fase "+phaseLabel(currentSessionStatus)+".";}
function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggleBtn");var hint=document.getElementById("autoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(autoRefreshEnabled?"ON":"OFF");btn.disabled=hostActionInFlight||sessionDetailInFlight||!code();}if(!hint){return;}if(hostActionInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv host-handling.";return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(!autoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(!code()){hint.textContent="Auto-refresh venter: angiv sessionkode.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for lobby-status.";}
function stopAutoRefresh(reason){autoRefreshEnabled=false;if(autoRefreshTimer){clearInterval(autoRefreshTimer);autoRefreshTimer=null;}if(reason){var hint=document.getElementById("autoRefreshHint");if(hint){hint.textContent=reason;}}updateAutoRefreshUi();saveHostContext();}
function startAutoRefresh(){if(!code()){updateAutoRefreshUi();return;}autoRefreshEnabled=true;if(autoRefreshTimer){clearInterval(autoRefreshTimer);}autoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updateAutoRefreshUi();saveHostContext();}
function toggleAutoRefresh(){if(hostActionInFlight||sessionDetailInFlight||!code()){updateAutoRefreshUi();return;}if(autoRefreshEnabled){stopAutoRefresh();return;}startAutoRefresh();}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
function markSessionRefresh(status){if(status>=200&&status<300){lastRefreshAtLabel=formatTimeLabel(new Date());lastRefreshFailed=false;}else{lastRefreshFailed=true;}updateLastRefreshStatus();}
function updateLastRefreshStatus(){var el=document.getElementById("lastRefreshStatus");if(!el){return;}if(!lastRefreshAtLabel){el.textContent=lastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(lastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+lastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+lastRefreshAtLabel+".";}
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
function updateErrorHint(status,data){var el=document.getElementById("hostErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updateHostShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updateHostShellErrorBoundary();}
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");syncHostShellRoute();if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";}
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}if(hostShellFatalError){clearHostShellFatalError();}saveHostContext();return d;}
function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});}
function createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});}
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
function startRound(){if(document.getElementById("startRoundBtn").disabled){return Promise.resolve({error:"not_enough_players_client_guard"});}return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/start","POST",{category_slug:document.getElementById("category").value});});}
function showQuestion(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/show","POST",{});});}
function mixAnswers(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/answers/mix","POST",{});});}
function calcScores(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/scores/calculate","POST",{});});}
function showScoreboard(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/scoreboard","GET",null);});}
function nextRound(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/next","POST",{});});}
function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});}
["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});});
window.addEventListener("error",function(event){setHostShellFatalError((event&&event.message)||"Ukendt runtime-fejl");});
window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setHostShellFatalError(detail);});
updatePhaseStatus();syncHostShellRoute();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();updateHostShellErrorBoundary();
if(restoreHostContext()){updatePhaseStatus();syncHostShellRoute();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
</script>
</body></html>
+138
View File
@@ -0,0 +1,138 @@
<!doctype html>
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Player</title>
<style>
#answerOptions { margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; }
#answerOptions button { border: 1px solid #999; padding: 6px 10px; border-radius: 8px; background: #f4f4f4; cursor: pointer; }
#answerOptions button.active { border-color: #1652f0; background: #dfe9ff; }
#guessStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#lieStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#joinStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#contextLockHint { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#phaseStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
#connectionBanner { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
#connectionBanner button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
#connectionBanner button[disabled] { opacity: 0.55; cursor: not-allowed; }
#playerShellErrorBoundary { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
#playerShellErrorBoundary button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
#playerShellErrorBoundary button[disabled] { opacity: 0.55; cursor: not-allowed; }
#lieStatus.locked { color: #0a5f2d; font-weight: 600; }
#guessStatus.locked { color: #0a5f2d; font-weight: 600; }
</style>
</head>
<body>
<h1>Player panel (MVP)</h1>
<input id="code" placeholder="Sessionkode">
<input id="nickname" placeholder="Nickname">
<button id="joinBtn" onclick="joinSession()">1) Join</button>
<p id="joinStatus">Klar til join.</p>
<p id="contextLockHint">Kontekst er ikke låst endnu.</p>
<input id="playerId" placeholder="Player id">
<input id="sessionToken" placeholder="Session token" type="password" readonly>
<input id="roundQuestionId" placeholder="Round question id" readonly>
<input id="lieText" placeholder="Din løgn">
<button id="lieSubmitBtn" onclick="submitLie()">2) Submit løgn</button>
<p id="lieStatus">Skriv din løgn.</p>
<input id="guessText" placeholder="Dit gæt" readonly>
<div id="answerOptions"></div>
<p id="guessStatus">Vælg et svar.</p>
<button id="guessSubmitBtn" onclick="submitGuess()" disabled>3) Submit gæt</button>
<button id="sessionDetailBtn" onclick="sessionDetail()">Opdater session-status</button>
<p id="sessionRefreshHint">Session-opdatering klar.</p>
<p id="roundContextHint">Runde-kontekst afventer session-opdatering.</p>
<button id="playerAutoRefreshToggleBtn" onclick="togglePlayerAutoRefresh()">Auto-refresh: OFF</button>
<p id="playerAutoRefreshHint">Auto-refresh er slået fra.</p>
<p id="playerLastRefreshStatus">Session-data ikke opdateret endnu.</p>
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
<p id="playerErrorHint">Ingen fejl.</p>
<div id="connectionBanner">Forbindelsen til serveren blev afbrudt.<button id="connectionRetryBtn" type="button" onclick="retryConnection()">Prøv igen</button></div>
<div id="playerShellErrorBoundary">Der opstod en kritisk fejl i app-skallen.
<button id="playerRecoverRetryBtn" type="button" onclick="recoverPlayerShell('retry')">Prøv gendan</button>
<button id="playerRecoverReloadBtn" type="button" onclick="recoverPlayerShell('reload')">Genindlæs siden</button>
</div>
<pre id="out">Klar.</pre>
<script>
var availableAnswers=[];
var guessSubmitted=false;
var lieSubmitted=false;
var lieSubmitInFlight=false;
var guessSubmitInFlight=false;
var PLAYER_CONTEXT_KEY="wppPlayerContext";
var joinInFlight=false;
var currentSessionStatus="";
var playerAutoRefreshEnabled=false;
var playerAutoRefreshTimer=null;
var sessionDetailInFlight=false;
var playerLastRefreshAtLabel="";
var playerLastRefreshFailed=false;
var connectionLost=false;
var connectionRetryInFlight=false;
var playerShellFatalError=false;
var playerShellRecoverInFlight=false;
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function updatePlayerShellErrorBoundary(){var panel=document.getElementById("playerShellErrorBoundary");var retryBtn=document.getElementById("playerRecoverRetryBtn");var reloadBtn=document.getElementById("playerRecoverReloadBtn");if(!panel||!retryBtn||!reloadBtn){return;}panel.style.display=playerShellFatalError?"block":"none";retryBtn.disabled=playerShellRecoverInFlight||sessionDetailInFlight||joinInFlight||!code();reloadBtn.disabled=playerShellRecoverInFlight;}
function setPlayerShellFatalError(detail){playerShellFatalError=true;var out=document.getElementById("out");if(out){out.textContent=JSON.stringify({status:0,data:{error:"player_shell_runtime_error",detail:detail||"Ukendt runtime-fejl"}},null,2);}var hint=document.getElementById("playerErrorHint");if(hint){hint.textContent="Fejl: Kritisk app-fejl. Brug recover-handlingerne for at fortsætte.";}updatePlayerShellErrorBoundary();}
function clearPlayerShellFatalError(){playerShellFatalError=false;playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();}
function recoverPlayerShell(mode){if(playerShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}playerShellRecoverInFlight=true;updatePlayerShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearPlayerShellFatalError();return result;}).catch(function(err){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();throw err;});}
function pid(){return document.getElementById("playerId").value.trim();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function savePlayerContext(){try{localStorage.setItem(PLAYER_CONTEXT_KEY,JSON.stringify({code:code(),nickname:document.getElementById("nickname").value.trim(),player_id:pid(),session_token:document.getElementById("sessionToken").value.trim(),round_question_id:rq(),auto_refresh:playerAutoRefreshEnabled}));}catch(_e){}}
function loadPlayerContext(){try{var raw=localStorage.getItem(PLAYER_CONTEXT_KEY);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
function restorePlayerContext(){var ctx=loadPlayerContext();if(!ctx){return false;}if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.nickname){document.getElementById("nickname").value=ctx.nickname;}if(ctx.player_id){document.getElementById("playerId").value=ctx.player_id;}if(ctx.session_token){document.getElementById("sessionToken").value=ctx.session_token;}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}playerAutoRefreshEnabled=!!ctx.auto_refresh;updatePlayerAutoRefreshUi();return !!(ctx.code&&ctx.player_id&&ctx.session_token);}
function hasSubmitContext(){return !!(code()&&pid()&&rq()&&document.getElementById("sessionToken").value.trim());}
function hasRoundQuestionContext(){return !!rq();}
function isPlayerContextLocked(){return !!(pid()&&document.getElementById("sessionToken").value.trim());}
function updateRoundContextHint(){var el=document.getElementById("roundContextHint");if(!el){return;}if(rq()){el.textContent="Runde-kontekst aktiv for spørgsmål #"+rq()+".";return;}el.textContent="Runde-kontekst afventer session-opdatering.";}
function resetRoundContextForManualChange(){document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);updateRoundContextHint();}
function updateContextLockState(){var locked=isPlayerContextLocked()||joinInFlight;var codeField=document.getElementById("code");var nicknameField=document.getElementById("nickname");var playerIdField=document.getElementById("playerId");var tokenField=document.getElementById("sessionToken");if(codeField){codeField.readOnly=locked;}if(nicknameField){nicknameField.readOnly=locked;}if(playerIdField){playerIdField.readOnly=locked;}if(tokenField){tokenField.readOnly=true;}var hint=document.getElementById("contextLockHint");if(!hint){return;}if(joinInFlight){hint.textContent="Låser kontekst…";return;}if(locked){hint.textContent="Spillerkontekst er låst efter join.";return;}hint.textContent="Kontekst er ikke låst endnu.";}
function canAttemptJoin(){return !!(code()&&document.getElementById("nickname").value.trim());}
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
function markPlayerSessionRefresh(status){if(status>=200&&status<300){playerLastRefreshAtLabel=formatTimeLabel(new Date());playerLastRefreshFailed=false;}else{playerLastRefreshFailed=true;}updatePlayerLastRefreshStatus();}
function updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";}
function stopPlayerAutoRefresh(reason){playerAutoRefreshEnabled=false;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);playerAutoRefreshTimer=null;}if(reason){var hint=document.getElementById("playerAutoRefreshHint");if(hint){hint.textContent=reason;}}updatePlayerAutoRefreshUi();savePlayerContext();}
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail().catch(function(){});},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
function togglePlayerAutoRefresh(){if(joinInFlight){updatePlayerAutoRefreshUi();return;}if(playerAutoRefreshEnabled){stopPlayerAutoRefresh();return;}startPlayerAutoRefresh();}
function updateConnectionBanner(){var banner=document.getElementById("connectionBanner");var retryBtn=document.getElementById("connectionRetryBtn");if(!banner||!retryBtn){return;}banner.style.display=connectionLost?"block":"none";retryBtn.disabled=connectionRetryInFlight||sessionDetailInFlight||joinInFlight||!code();}
function setConnectionLost(isLost){connectionLost=!!isLost;updateConnectionBanner();}
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";setConnectionLost(false);return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();updateConnectionBanner();updatePlayerShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();updateConnectionBanner();updatePlayerShellErrorBoundary();}
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();updateConnectionBanner();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();updateConnectionBanner();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();updateConnectionBanner();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();updateConnectionBanner();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();updateConnectionBanner();return;}status.textContent="Klar til join.";updateContextLockState();updateConnectionBanner();}
function guessStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppGuess",c,p,q].join(":");}
function lieStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppLie",c,p,q].join(":");}
function persistLieState(text,submitted){var key=lieStorageKey();if(!key){return;}try{localStorage.setItem(key,JSON.stringify({text:text||"",submitted:!!submitted}));}catch(_e){}}
function loadLieState(){var key=lieStorageKey();if(!key){return null;}try{var raw=localStorage.getItem(key);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
function updateLieSubmitState(){var text=(document.getElementById("lieText").value||"").trim();var btn=document.getElementById("lieSubmitBtn");var input=document.getElementById("lieText");var status=document.getElementById("lieStatus");var hasContext=hasSubmitContext();var inLiePhase=currentSessionStatus==="lie";if(input){input.readOnly=lieSubmitted||lieSubmitInFlight||sessionDetailInFlight||!inLiePhase;}if(btn){btn.disabled=lieSubmitted||lieSubmitInFlight||sessionDetailInFlight||!text||!hasContext||!inLiePhase;}if(status){if(lieSubmitted){status.textContent="Løgn sendt input er låst.";status.classList.add("locked");}else{status.classList.remove("locked");if(lieSubmitInFlight){status.textContent="Sender løgn…";}else if(sessionDetailInFlight){status.textContent="Afvent aktiv session-opdatering før løgn-submit.";}else if(!hasContext){status.textContent="Join først for at aktivere submit.";}else if(!currentSessionStatus){status.textContent="Opdatér session-status for at validere løgn-fase.";}else if(!inLiePhase){status.textContent="Løgn-input er låst i fase: "+phaseLabel(currentSessionStatus)+".";}else{status.textContent=text?"Løgn klar til afsendelse.":"Skriv din løgn.";}}}}
function setLieState(text,submitted){document.getElementById("lieText").value=text||"";if(typeof submitted==="boolean"){lieSubmitted=submitted;}updateLieSubmitState();}
function persistGuessState(text,submitted){var key=guessStorageKey();if(!key){return;}try{localStorage.setItem(key,JSON.stringify({selected_text:text||"",submitted:!!submitted}));}catch(_e){}}
function loadGuessState(){var key=guessStorageKey();if(!key){return null;}try{var raw=localStorage.getItem(key);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
function updateGuessStatus(){var el=document.getElementById("guessStatus");if(!el){return;}var selected=document.getElementById("guessText").value;var hasContext=hasSubmitContext();var hasRoundContext=hasRoundQuestionContext();var inGuessPhase=currentSessionStatus==="guess";if(guessSubmitted){el.textContent="Gæt sendt valg er låst.";el.classList.add("locked");return;}el.classList.remove("locked");if(guessSubmitInFlight){el.textContent="Sender gæt…";return;}if(sessionDetailInFlight){el.textContent="Afvent aktiv session-opdatering før gæt-submit.";return;}if(!code()||!pid()||!document.getElementById("sessionToken").value.trim()){el.textContent="Join først for at aktivere gæt.";return;}if(!hasRoundContext){el.textContent="Afvent aktivt spørgsmål fra host før du kan gætte.";return;}if(!hasContext){el.textContent="Join først for at aktivere gæt.";return;}if(!currentSessionStatus){el.textContent="Opdatér session-status for at validere gæt-fase.";return;}if(!inGuessPhase){el.textContent="Gæt er låst i fase: "+phaseLabel(currentSessionStatus)+".";return;}el.textContent=selected?"Valgt svar klar til afsendelse.":"Vælg et svar.";}
function updateGuessSubmitState(){var selected=document.getElementById("guessText").value;var hasValid=availableAnswers.indexOf(selected)!==-1;var hasContext=hasSubmitContext();var hasRoundContext=hasRoundQuestionContext();var inGuessPhase=currentSessionStatus==="guess";document.getElementById("guessSubmitBtn").disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase;var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight||!hasContext||!hasRoundContext||!inGuessPhase;});updateGuessStatus();}
function setGuess(text,submitted){document.getElementById("guessText").value=text||"";if(typeof submitted==="boolean"){guessSubmitted=submitted;}var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.classList.toggle("active",btn.dataset.answer===text);});updateGuessSubmitState();
updateJoinState();}
function renderAnswerOptions(roundQuestion){var wrap=document.getElementById("answerOptions");wrap.innerHTML="";availableAnswers=[];guessSubmitted=false;setGuess("",false);lieSubmitted=false;setLieState("",false);if(!roundQuestion||!Array.isArray(roundQuestion.answers)){updateGuessSubmitState();updateLieSubmitState();return;}roundQuestion.answers.forEach(function(item){if(!item||!item.text){return;}availableAnswers.push(item.text);var btn=document.createElement("button");btn.type="button";btn.dataset.answer=item.text;btn.textContent=item.text;btn.onclick=function(){if(guessSubmitted){return;}setGuess(item.text,false);persistGuessState(item.text,false);};wrap.appendChild(btn);});var saved=loadGuessState();if(saved&&availableAnswers.indexOf(saved.selected_text)!==-1){setGuess(saved.selected_text,!!saved.submitted);}updateGuessSubmitState();}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}try{var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}if(playerShellFatalError){clearPlayerShellFatalError();}savePlayerContext();return d;}catch(err){setConnectionLost(true);if((method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path)){markPlayerSessionRefresh(0);}document.getElementById("out").textContent=JSON.stringify({status:0,data:{error:"connection_lost",detail:"Kunne ikke kontakte serveren."}},null,2);document.getElementById("playerErrorHint").textContent="Fejl: Mistede forbindelsen til serveren. Prøv igen.";updateSessionDetailState();throw err;}}
function joinSession(){if(joinInFlight){return Promise.resolve({error:"join_in_flight"});}if(!canAttemptJoin()){updateJoinState();return Promise.resolve({error:"missing_join_input"});}if(pid()&&document.getElementById("sessionToken").value.trim()){updateJoinState();return Promise.resolve({error:"already_joined_client"});}joinInFlight=true;updateJoinState();updatePlayerAutoRefreshUi();return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()}).then(function(d){joinInFlight=false;if(d&&d.player&&d.player.id){updateJoinState();updatePlayerAutoRefreshUi();return d;}updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede prøv igen.";return d;}).catch(function(err){joinInFlight=false;updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede prøv igen.";throw err;});}
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
function retryConnection(){if(connectionRetryInFlight||!code()){updateConnectionBanner();return Promise.resolve({error:"retry_unavailable"});}connectionRetryInFlight=true;updateConnectionBanner();return sessionDetail().then(function(result){setConnectionLost(false);return result;}).finally(function(){connectionRetryInFlight=false;updateConnectionBanner();});}
function submitLie(){if(lieSubmitted){return Promise.resolve({error:"lie_already_submitted_client"});}if(lieSubmitInFlight){return Promise.resolve({error:"lie_submit_in_flight"});}if(!hasSubmitContext()){updateLieSubmitState();return Promise.resolve({error:"missing_submit_context"});}var text=(document.getElementById("lieText").value||"").trim();if(!text){updateLieSubmitState();return Promise.resolve({error:"empty_lie_text"});}lieSubmitInFlight=true;updateLieSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/lies/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,text:text}).then(function(d){if(d&&d.lie&&d.lie.id){lieSubmitted=true;persistLieState(text,true);}return d;}).finally(function(){lieSubmitInFlight=false;updateLieSubmitState();});}
document.getElementById("lieText").addEventListener("input",function(){if(!lieSubmitted){updateLieSubmitState();persistLieState(document.getElementById("lieText").value,false);}});updateLieSubmitState();
function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_already_submitted_client"});}if(guessSubmitInFlight){return Promise.resolve({error:"guess_submit_in_flight"});}if(!hasSubmitContext()){updateGuessSubmitState();document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Join først for at aktivere gæt"}},null,2);return Promise.resolve({error:"missing_submit_context"});}var selected=document.getElementById("guessText").value;if(availableAnswers.indexOf(selected)===-1){document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Vælg et af de viste svarmuligheder"}},null,2);return Promise.resolve({error:"invalid_client_guess"});}guessSubmitInFlight=true;updateGuessSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,selected_text:selected}).finally(function(){guessSubmitInFlight=false;updateGuessSubmitState();});}
["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});});
window.addEventListener("error",function(event){setPlayerShellFatalError((event&&event.message)||"Ukendt runtime-fejl");});
window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setPlayerShellFatalError(detail);});
updatePhaseStatus();
updateGuessSubmitState();
updateJoinState();
updatePlayerAutoRefreshUi();
updatePlayerLastRefreshStatus();
updateRoundContextHint();
updateConnectionBanner();
updatePlayerShellErrorBoundary();
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail().catch(function(){});}else{savePlayerContext();}
</script>
</body></html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WPP SPA</title>
</head>
<body>
<app-root data-wpp-shell-route="{{ shell_route }}"></app-root>
<script type="module" src="{{ spa_asset_base }}/main.js"></script>
</body>
</html>
+992 -4
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from fupogfakta.models import Category
from .feature_flags import use_spa_ui
def _render_spa_shell(request, shell_route: str):
return render(
request,
"lobby/spa_shell.html",
{
"shell_route": shell_route,
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
},
)
@login_required
def host_screen(request, spa_path=None):
if use_spa_ui():
return _render_spa_shell(request, "/host")
categories = Category.objects.filter(is_active=True).order_by("name")
return render(request, "lobby/host_screen.html", {"categories": categories})
def player_screen(request):
if use_spa_ui():
return _render_spa_shell(request, "/player")
return render(request, "lobby/player_screen.html")
+28 -1
View File
@@ -1,12 +1,39 @@
from django.urls import path
from . import views
from . import ui_views, views
app_name = "lobby"
urlpatterns = [
path("ui/host", ui_views.host_screen, name="host_screen"),
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
path("ui/player", ui_views.player_screen, name="player_screen"),
path("sessions/create", views.create_session, name="create_session"),
path("sessions/join", views.join_session, name="join_session"),
path("sessions/<str:code>", views.session_detail, name="session_detail"),
path("sessions/<str:code>/rounds/start", views.start_round, name="start_round"),
path("sessions/<str:code>/questions/show", views.show_question, name="show_question"),
path(
"sessions/<str:code>/questions/<int:round_question_id>/lies/submit",
views.submit_lie,
name="submit_lie",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/answers/mix",
views.mix_answers,
name="mix_answers",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/guesses/submit",
views.submit_guess,
name="submit_guess",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/scores/calculate",
views.calculate_scores,
name="calculate_scores",
),
path("sessions/<str:code>/scoreboard", views.reveal_scoreboard, name="reveal_scoreboard"),
path("sessions/<str:code>/finish", views.finish_game, name="finish_game"),
path("sessions/<str:code>/rounds/next", views.start_next_round, name="start_next_round"),
]
+613 -5
View File
@@ -1,12 +1,24 @@
import json
import random
from datetime import timedelta
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db import IntegrityError, transaction
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig
from fupogfakta.models import (
Category,
GameSession,
Guess,
LieAnswer,
Player,
Question,
RoundConfig,
RoundQuestion,
ScoreEvent,
)
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6
@@ -33,6 +45,10 @@ def _generate_session_code() -> str:
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
def _normalize_session_code(code: str) -> str:
return code.strip().upper()
def _create_unique_session_code() -> str:
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
code = _generate_session_code()
@@ -42,6 +58,45 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code")
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_reveal = status == GameSession.Status.REVEAL
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": in_lie and not has_round_question,
"can_mix_answers": in_lie or in_guess,
"can_calculate_scores": in_guess,
"can_reveal_scoreboard": in_reveal,
"can_start_next_round": in_reveal,
"can_finish_game": in_reveal,
},
"player": {
"can_join": status in JOINABLE_STATUSES,
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
@require_POST
@login_required
def create_session(request: HttpRequest) -> JsonResponse:
@@ -65,7 +120,7 @@ def create_session(request: HttpRequest) -> JsonResponse:
def join_session(request: HttpRequest) -> JsonResponse:
payload = _json_body(request)
code = str(payload.get("code", "")).strip().upper()
code = _normalize_session_code(str(payload.get("code", "")))
nickname = str(payload.get("nickname", "")).strip()
if not code:
@@ -92,6 +147,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
"player": {
"id": player.id,
"nickname": player.nickname,
"session_token": player.session_token,
"score": player.score,
},
"session": {
@@ -105,7 +161,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
@require_GET
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -121,6 +177,29 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
)
)
current_round_question = (
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
.select_related("question")
.order_by("-id")
.first()
)
round_question_payload = None
if current_round_question:
round_question_payload = {
"id": current_round_question.id,
"round_number": current_round_question.round_number,
"prompt": current_round_question.question.prompt,
"shown_at": current_round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
}
phase_view_model = _build_phase_view_model(
session,
players_count=len(players),
has_round_question=bool(current_round_question),
)
return JsonResponse(
{
"session": {
@@ -131,6 +210,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"players_count": len(players),
},
"players": players,
"round_question": round_question_payload,
"phase_view_model": phase_view_model,
}
)
@@ -144,7 +225,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
if not category_slug:
return JsonResponse({"error": "category_slug is required"}, status=400)
session_code = code.strip().upper()
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -198,3 +279,530 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
},
status=201,
)
@require_POST
@login_required
def show_question(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can show question"}, status=403)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Question can only be shown in lie phase"}, status=400)
try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
return JsonResponse({"error": "Question already shown for this round"}, status=409)
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter(
category=round_config.category,
is_active=True,
).exclude(pk__in=used_question_ids)
if not available_questions.exists():
return JsonResponse({"error": "No available questions in category"}, status=400)
question = random.choice(list(available_questions))
round_question = RoundQuestion.objects.create(
session=session,
round_number=session.current_round,
question=question,
correct_answer=question.correct_answer,
)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return JsonResponse(
{
"round_question": {
"id": round_question.id,
"prompt": question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
},
status=201,
)
@require_POST
def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = _json_body(request)
session_code = _normalize_session_code(code)
player_id = payload.get("player_id")
session_token = str(payload.get("session_token", "")).strip()
lie_text = str(payload.get("text", "")).strip()
if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400)
if not session_token:
return JsonResponse({"error": "session_token is required"}, status=400)
if not lie_text or len(lie_text) > 255:
return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Lie submission is only allowed in lie phase"}, status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404)
if player.session_token != session_token:
return JsonResponse({"error": "Invalid player session token"}, status=403)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
if timezone.now() > lie_deadline_at:
return JsonResponse({"error": "Lie submission window has closed"}, status=400)
try:
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
except IntegrityError:
return JsonResponse({"error": "Lie already submitted for this player"}, status=409)
return JsonResponse(
{
"lie": {
"id": lie.id,
"player_id": player.id,
"round_question_id": round_question.id,
"text": lie.text,
"created_at": lie.created_at.isoformat(),
},
"window": {
"lie_deadline_at": lie_deadline_at.isoformat(),
},
},
status=201,
)
@require_POST
@login_required
def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can mix answers"}, status=403)
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
deduped_answers = list(locked_round_question.mixed_answers or [])
if not deduped_answers:
lie_texts = list(locked_round_question.lies.values_list("text", flat=True))
seen = set()
for text in [locked_round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
return JsonResponse({"error": "Not enough answers to mix"}, status=400)
random.shuffle(deduped_answers)
locked_round_question.mixed_answers = deduped_answers
locked_round_question.save(update_fields=["mixed_answers"])
if locked_session.status == GameSession.Status.LIE:
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=["status"])
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.GUESS,
"current_round": session.current_round,
},
"round_question": {
"id": round_question.id,
"round_number": round_question.round_number,
},
"answers": [{"text": text} for text in deduped_answers],
}
)
@require_POST
def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = _json_body(request)
session_code = _normalize_session_code(code)
player_id = payload.get("player_id")
session_token = str(payload.get("session_token", "")).strip()
selected_text = str(payload.get("selected_text", "")).strip()
if not player_id:
return JsonResponse({"error": "player_id is required"}, status=400)
if not session_token:
return JsonResponse({"error": "session_token is required"}, status=400)
if not selected_text or len(selected_text) > 255:
return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return JsonResponse({"error": "Player not found in session"}, status=404)
if player.session_token != session_token:
return JsonResponse({"error": "Invalid player session token"}, status=403)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
guess_deadline_at = round_question.shown_at + timedelta(
seconds=round_config.lie_seconds + round_config.guess_seconds
)
if timezone.now() > guess_deadline_at:
return JsonResponse({"error": "Guess submission window has closed"}, status=400)
allowed_answers = {
round_question.correct_answer.strip().casefold(),
*(
text.strip().casefold()
for text in round_question.lies.values_list("text", flat=True)
if text.strip()
),
}
selected_normalized = selected_text.casefold()
if selected_normalized not in allowed_answers:
return JsonResponse({"error": "Selected answer is not part of this round"}, status=400)
correct_normalized = round_question.correct_answer.strip().casefold()
fooled_player_id = None
if selected_normalized != correct_normalized:
fooled_player_id = (
round_question.lies.filter(text__iexact=selected_text).values_list("player_id", flat=True).first()
)
try:
guess = Guess.objects.create(
round_question=round_question,
player=player,
selected_text=selected_text,
is_correct=selected_normalized == correct_normalized,
fooled_player_id=fooled_player_id,
)
except IntegrityError:
return JsonResponse({"error": "Guess already submitted for this player"}, status=409)
return JsonResponse(
{
"guess": {
"id": guess.id,
"player_id": player.id,
"round_question_id": round_question.id,
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"fooled_player_id": guess.fooled_player_id,
"created_at": guess.created_at.isoformat(),
},
"window": {
"guess_deadline_at": guess_deadline_at.isoformat(),
},
},
status=201,
)
@require_GET
@login_required
def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can view scoreboard"}, status=403)
if session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400)
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
)
@require_POST
@login_required
def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can start next round"}, status=403)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Next round can only start from reveal phase"}, status=400)
locked_session.current_round += 1
locked_session.status = GameSession.Status.LOBBY
locked_session.save(update_fields=["current_round", "status"])
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.LOBBY,
"current_round": locked_session.current_round,
}
}
)
@require_POST
@login_required
def finish_game(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can finish game"}, status=403)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Game can only be finished from reveal phase"}, status=400)
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
winner = leaderboard[0] if leaderboard else None
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": winner,
"leaderboard": leaderboard,
}
)
@require_POST
@login_required
def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can calculate scores"}, status=403)
already_calculated = ScoreEvent.objects.filter(
session=session,
meta__round_question_id=round_question_id,
).exists()
if already_calculated:
return JsonResponse({"error": "Scores already calculated for this round question"}, status=409)
if session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
return JsonResponse({"error": "No guesses submitted for this round question"}, status=400)
bluff_counts = {}
for guess in guesses:
if guess.fooled_player_id:
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.GUESS:
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400)
score_events = []
for guess in guesses:
if guess.is_correct:
guess.player.score += round_config.points_correct
guess.player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=guess.player,
delta=round_config.points_correct,
reason="guess_correct",
meta={"round_question_id": round_question.id, "guess_id": guess.id},
)
)
for player_id, fooled_count in bluff_counts.items():
delta = fooled_count * round_config.points_bluff
player = Player.objects.get(pk=player_id, session=session)
player.score += delta
player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=player,
delta=delta,
reason="bluff_success",
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
)
)
ScoreEvent.objects.bulk_create(score_events)
locked_session.status = GameSession.Status.REVEAL
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.REVEAL,
"current_round": session.current_round,
},
"round_question": {
"id": round_question.id,
"round_number": round_question.round_number,
},
"events_created": len(score_events),
"leaderboard": leaderboard,
}
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -99,6 +99,14 @@ STATIC_ROOT = BASE_DIR / 'staticfiles'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
USE_SPA_UI_RAW = env('USE_SPA_UI')
if USE_SPA_UI_RAW is None:
# Backward-compatible fallback while cutover is rolling out.
USE_SPA_UI_RAW = env('WPP_SPA_ENABLED', 'false')
USE_SPA_UI = USE_SPA_UI_RAW.lower() == 'true'
WPP_SPA_ASSET_BASE = env('WPP_SPA_ASSET_BASE', '/static/frontend/angular/browser').rstrip('/')
CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1')
CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379'))
CHANNEL_LAYERS = {
-1
View File
@@ -11,5 +11,4 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("healthz", health, name="healthz"),
path("lobby/", include("lobby.urls")),
path("game/", include("fupogfakta.urls")),
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.