Compare commits

...

151 Commits

Author SHA1 Message Date
Asger Geel Weirsøe a81bc1250c Big visual overhaul docker compsoe file etc
CI / test-and-quality (push) Failing after 4m4s
2026-03-23 14:11:30 +01:00
agw d86941fef8 Merge pull request '[READY][Gameplay] #310 Host transition idempotency and error catalog for scoreboard -> next round / finish' (#320) from dev/issue-310-host-transition-idempotency-v2 into main
CI / test-and-quality (push) Successful in 3m30s
Reviewed-on: #320
Reviewed-by: reviewer-bot <review-no-reply@weircon.dk>
2026-03-18 06:52:03 +01:00
dev-bot 21e390d200 test: tighten pr320 lobby ownership guard
CI / test-and-quality (push) Successful in 4m6s
CI / test-and-quality (pull_request) Successful in 4m8s
2026-03-18 06:44:54 +01:00
dev-bot df9b6d192c chore: refresh i18n parity artifact
CI / test-and-quality (push) Successful in 4m6s
CI / test-and-quality (pull_request) Successful in 4m6s
2026-03-18 05:19:53 +00:00
dev-bot 702f130de2 test(lobby): lock issue-310 transition ownership boundary
CI / test-and-quality (push) Successful in 4m14s
CI / test-and-quality (pull_request) Successful in 4m15s
2026-03-18 05:00:48 +00:00
dev-bot 92f2cda83a test(lobby): lock scoreboard next-round bootstrap target
CI / test-and-quality (push) Successful in 4m3s
CI / test-and-quality (pull_request) Successful in 4m5s
2026-03-18 04:36:20 +00:00
dev-bot d080f05661 fix(ci): retain lobby payload ownership export
CI / test-and-quality (push) Successful in 4m0s
CI / test-and-quality (pull_request) Successful in 4m2s
2026-03-18 04:17:17 +00:00
dev-bot e246bd648f fix(gameplay): scope next-round selection to target round
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-18 03:54:25 +00:00
dev-bot 06e4ccac61 fix(lobby): restore scoreboard payload import
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s
2026-03-18 02:55:30 +00:00
dev-bot 3c9214178e fix(ci): remove stale scoreboard payload import
CI / test-and-quality (pull_request) Failing after 3m34s
CI / test-and-quality (push) Failing after 3m35s
2026-03-18 02:33:36 +00:00
dev-bot feddd910eb fix: restore reveal payload import in submit_guess
CI / test-and-quality (push) Failing after 12s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-18 02:12:11 +00:00
dev-bot dd615796f4 refactor(payloads): delegate session detail gameplay payload
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-18 01:33:47 +00:00
dev-bot d2cdf16322 test(lobby): lock repaired stale next-round replay
CI / test-and-quality (push) Successful in 4m0s
CI / test-and-quality (pull_request) Successful in 4m1s
2026-03-18 00:46:51 +00:00
dev-bot 101c3f9c26 fix(gameplay): repair stale next-round question drift
CI / test-and-quality (pull_request) Successful in 3m55s
CI / test-and-quality (push) Successful in 3m56s
2026-03-17 23:25:02 +00:00
dev-bot 65eb5685f7 test(lobby): lock scoreboard ownership boundary
CI / test-and-quality (push) Successful in 3m57s
CI / test-and-quality (pull_request) Successful in 3m58s
2026-03-17 23:07:22 +00:00
dev-bot 8a70645fda test(lobby): lock session detail payload delegation
CI / test-and-quality (push) Successful in 3m53s
CI / test-and-quality (pull_request) Successful in 3m54s
2026-03-17 22:25:03 +00:00
dev-bot 2cd8d940f9 test(lobby): lock session detail ownership boundary
CI / test-and-quality (pull_request) Successful in 3m55s
CI / test-and-quality (push) Successful in 3m57s
2026-03-17 21:22:39 +00:00
dev-bot 72bc5997ff test(gameplay): keep lobby delegation checks in lobby suite
CI / test-and-quality (push) Successful in 4m3s
CI / test-and-quality (pull_request) Successful in 4m5s
2026-03-17 21:03:50 +00:00
dev-bot c9e64bc8a8 test(gameplay): lock lobby delegation for host transitions (#310)
CI / test-and-quality (push) Successful in 4m4s
CI / test-and-quality (pull_request) Successful in 4m4s
2026-03-17 20:48:39 +00:00
dev-bot 1c7f1e7c53 fix(ci): satisfy PR #320 lobby lint contract
CI / test-and-quality (pull_request) Successful in 3m56s
CI / test-and-quality (push) Successful in 3m56s
2026-03-17 20:06:12 +00:00
dev-bot 03850b5ed5 refactor(gameplay): extract start/show transitions from lobby views
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-17 19:44:13 +00:00
dev-bot 16c9cf6b57 refactor(gameplay): extract round start payload builders
CI / test-and-quality (push) Successful in 3m51s
CI / test-and-quality (pull_request) Successful in 3m53s
2026-03-17 19:15:44 +00:00
dev-bot c45f04f9f1 refactor(gameplay): extract round question payload builder
CI / test-and-quality (push) Successful in 3m41s
CI / test-and-quality (pull_request) Successful in 3m42s
2026-03-17 18:55:28 +00:00
dev-bot 319038555a refactor(gameplay): move phase view model into cartridge
CI / test-and-quality (pull_request) Successful in 3m42s
CI / test-and-quality (push) Successful in 3m44s
2026-03-17 18:29:11 +00:00
dev-bot e318711148 test(gameplay): lock refreshed next-round deadline contract
CI / test-and-quality (push) Successful in 3m47s
CI / test-and-quality (pull_request) Successful in 3m48s
2026-03-17 17:44:34 +00:00
dev-bot a9c6e4fd79 test(lobby): lock host transition ownership boundary
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m44s
2026-03-17 17:25:22 +00:00
dev-bot 7eb3507934 fix(gameplay): refresh stale next-round bootstrap config
CI / test-and-quality (push) Successful in 3m39s
CI / test-and-quality (pull_request) Successful in 3m40s
2026-03-17 17:06:59 +00:00
dev-bot dfa197b33b refactor(gameplay): keep host transition payloads in cartridge
CI / test-and-quality (pull_request) Successful in 3m37s
CI / test-and-quality (push) Successful in 3m38s
2026-03-17 16:06:46 +00:00
dev-bot fefc5ecd56 test(lobby): lock refreshed deadline for reused bootstrap round
CI / test-and-quality (push) Successful in 3m40s
CI / test-and-quality (pull_request) Successful in 3m40s
2026-03-17 15:42:00 +00:00
dev-bot 94f940e5d8 refactor(gameplay): delegate host transition events from service
CI / test-and-quality (push) Successful in 3m35s
CI / test-and-quality (pull_request) Successful in 3m35s
2026-03-17 13:43:44 +00:00
dev-bot a102a72a77 fix(gameplay): refresh reused bootstrap lie timer
CI / test-and-quality (pull_request) Successful in 3m27s
CI / test-and-quality (push) Successful in 3m27s
2026-03-17 13:21:52 +00:00
dev-bot d272e35a79 refactor(gameplay): keep host transition events in payload layer
CI / test-and-quality (push) Successful in 3m28s
CI / test-and-quality (pull_request) Successful in 3m28s
2026-03-17 13:04:58 +00:00
dev-bot 8a07433f11 refactor(gameplay): move transition event composition into service
CI / test-and-quality (pull_request) Successful in 3m36s
CI / test-and-quality (push) Successful in 3m37s
2026-03-17 11:58:39 +00:00
dev-bot 9baade0105 test(gameplay): lock lobby replay side-effect delegation
CI / test-and-quality (push) Successful in 3m43s
CI / test-and-quality (pull_request) Successful in 3m44s
2026-03-17 11:35:19 +00:00
dev-bot 35e2d09ee3 test(gameplay): lock lobby host-transition delegation
CI / test-and-quality (push) Successful in 3m34s
CI / test-and-quality (pull_request) Successful in 3m34s
2026-03-17 10:55:41 +00:00
dev-bot a916da12a7 refactor: move scoreboard promotion out of lobby view
CI / test-and-quality (pull_request) Successful in 3m26s
CI / test-and-quality (push) Successful in 3m28s
2026-03-17 10:41:09 +00:00
dev-bot 7f20cb3bf9 refactor(gameplay): move scoreboard phase events into cartridge payloads
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 3m27s
2026-03-17 10:13:41 +00:00
dev-bot f736f4f74e refactor(gameplay): move scoreboard transitions into cartridge service
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m28s
2026-03-17 09:29:02 +00:00
dev-bot 8247787404 refactor(gameplay): move transition payload builders to cartridge
CI / test-and-quality (pull_request) Successful in 3m30s
CI / test-and-quality (push) Successful in 3m31s
2026-03-17 09:08:14 +00:00
dev-bot 6722be43d4 merge(main): resolve PR #320 scoreboard transition conflict
CI / test-and-quality (pull_request) Successful in 3m24s
CI / test-and-quality (push) Successful in 3m26s
2026-03-17 08:45:55 +00:00
dev-bot 212549373b fix(gameplay): gate next-round replay on scoreboard exit marker
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 3m26s
2026-03-17 08:25:57 +00:00
dev-bot 47659ed673 test(gameplay): guard extracted lobby helper wiring
CI / test-and-quality (push) Successful in 3m26s
CI / test-and-quality (pull_request) Successful in 3m26s
2026-03-17 07:43:49 +00:00
root c8750af4d8 fix(gameplay): restore extracted helper imports
CI / test-and-quality (push) Successful in 3m29s
CI / test-and-quality (pull_request) Successful in 3m29s
2026-03-17 07:24:50 +00:00
dev-bot 44e480931b fix(gameplay): gate next-round replay on prior scoreboard exit
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-17 07:05:56 +00:00
integrator-bot 8c0a561a64 Merge pull request 'refactor(fupogfakta): extract first lobby gameplay slice (#312)' (#319) from dev/issue-312-extraction-map into main
CI / test-and-quality (push) Successful in 2m55s
2026-03-17 08:01:26 +01:00
dev-bot 1839b30e0a merge(main): resolve PR #320 gameplay conflicts
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s
2026-03-17 06:44:21 +00:00
dev-bot 7de843e44b fix(lobby): use extracted fupogfakta helpers
CI / test-and-quality (push) Successful in 3m32s
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-17 06:21:33 +00:00
dev-bot 542d326615 fix(gameplay): gate next-round replay on prior transition
CI / test-and-quality (push) Successful in 3m24s
CI / test-and-quality (pull_request) Successful in 3m27s
2026-03-17 06:21:00 +00:00
dev-bot e39605d782 merge(main): resolve PR #319 lobby extraction conflict
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-17 05:58:51 +00:00
dev-bot d36d256daf fix(gameplay): make scoreboard host exits idempotent
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m45s
2026-03-17 05:41:13 +00:00
dev-bot 2ee235c6c0 refactor(fupogfakta): extract first lobby gameplay slice (#312)
CI / test-and-quality (push) Successful in 3m8s
CI / test-and-quality (pull_request) Successful in 3m13s
2026-03-17 05:37:31 +00:00
dev-bot 592c265331 docs(architecture): map lobby vs fupogfakta extraction boundary refs #311 #312 2026-03-16 18:57:29 +00:00
integrator-bot 251ccfce19 Merge pull request 'fix(frontend): prefer canonical phase for client action gating (#301 follow-up)' (#306) from dev/issue-301-phase-action-gating-followup into main
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-16 18:09:04 +01:00
dev-bot d9c4cda966 fix(frontend): prefer canonical phase for client action gating
CI / test-and-quality (push) Successful in 2m56s
CI / test-and-quality (pull_request) Successful in 3m1s
2026-03-16 17:00:02 +00:00
integrator-bot 2437f0e8bd Merge pull request 'test(gameplay): add canonical loop smoke evidence (#302)' (#304) from dev/issue-302-canonical-loop-evidence into main
CI / test-and-quality (push) Successful in 2m43s
2026-03-16 17:31:23 +01:00
dev-bot 3b4b844126 chore(ci): retrigger canonical loop evidence checks
CI / test-and-quality (push) Successful in 3m9s
CI / test-and-quality (pull_request) Successful in 3m10s
2026-03-16 15:52:54 +00:00
integrator-bot c8c17654a4 Merge pull request 'fix(gameplay): harden scoreboard -> next round bootstrap invariants (#300)' (#305) from dev/issue-300-round-bootstrap-invariants-v2 into main
CI / test-and-quality (push) Successful in 2m39s
2026-03-16 16:44:22 +01:00
dev-bot fd6e3e86e8 ci: repair rollup optional dep on npm ci
CI / test-and-quality (pull_request) Successful in 3m35s
CI / test-and-quality (push) Failing after 4m8s
2026-03-16 15:35:49 +00:00
dev-bot 7c0332f95f fix(gameplay): harden scoreboard to round bootstrap invariants (#300)
CI / test-and-quality (push) Successful in 3m20s
CI / test-and-quality (pull_request) Successful in 2m52s
2026-03-16 15:22:03 +00:00
dev-bot 9970257f32 test(gameplay): add canonical loop smoke evidence (#302)
CI / test-and-quality (push) Failing after 3m42s
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-16 15:20:06 +00:00
integrator-bot 112a85a22d Merge pull request 'fix(gameplay): gate client actions from canonical phase state (#301)' (#303) from dev/issue-301-client-action-gating into main
CI / test-and-quality (push) Successful in 2m36s
2026-03-16 15:53:44 +01:00
dev-bot 33b428955b test(frontend): install angular spec runtime in root suite
CI / test-and-quality (push) Successful in 3m8s
CI / test-and-quality (pull_request) Successful in 3m9s
2026-03-16 13:53:00 +00:00
dev-bot 55fc758389 test(gameplay): stabilize canonical host gating specs
CI / test-and-quality (push) Successful in 3m9s
CI / test-and-quality (pull_request) Successful in 3m9s
2026-03-16 13:33:49 +00:00
dev-bot f0142f33b6 test(issue-301): align host gating specs with canonical phases
CI / test-and-quality (pull_request) Successful in 3m3s
CI / test-and-quality (push) Successful in 3m5s
2026-03-16 12:50:33 +00:00
dev-bot 3acaf3e370 test(frontend): include angular specs in vitest suite
CI / test-and-quality (push) Failing after 3m6s
CI / test-and-quality (pull_request) Failing after 3m6s
2026-03-16 12:06:57 +00:00
dev-bot 1cb36a5943 merge(main): resolve PR #303 conflicts
CI / test-and-quality (push) Failing after 3m6s
CI / test-and-quality (pull_request) Failing after 3m8s
2026-03-16 11:53:56 +00:00
dev-bot fc68e30cf4 fix(frontend): restore phase-gating build
CI / test-and-quality (push) Successful in 2m32s
CI / test-and-quality (pull_request) Successful in 2m32s
2026-03-16 11:29:45 +00:00
dev-bot 57ca237565 fix(issue-301): gate client actions from canonical phase flags
CI / test-and-quality (push) Successful in 2m20s
CI / test-and-quality (pull_request) Successful in 2m28s
2026-03-16 10:28:12 +00:00
dev-bot 076faf2ff1 feat: gate client actions by canonical phase state 2026-03-16 10:15:35 +00:00
integrator-bot f58e852246 Merge pull request 'feat(lobby): canonical backend round flow for issue #287' (#298) from issue-287-canonical-round-flow-backend into main
CI / test-and-quality (push) Successful in 2m36s
2026-03-16 07:25:52 +01:00
dev-bot 242aeaacd6 fix(lobby): avoid orphaned round configs on round start
CI / test-and-quality (pull_request) Successful in 3m6s
CI / test-and-quality (push) Successful in 3m8s
2026-03-16 04:22:45 +00:00
dev-bot 624bcd602b fix(lobby): gate reveal promotion on resolved rounds
CI / test-and-quality (pull_request) Successful in 2m58s
CI / test-and-quality (push) Successful in 2m59s
2026-03-16 03:45:10 +00:00
dev-bot bfa4ab859c fix(lobby): promote zero-score reveals to scoreboard
CI / test-and-quality (push) Failing after 2m35s
CI / test-and-quality (pull_request) Failing after 2m36s
2026-03-16 03:01:02 +00:00
dev-bot 3706bc3b1c fix(lobby): guard auto score calculation 2026-03-16 02:42:19 +00:00
dev-bot a6e09e2bea fix(lobby): remove dead reveal state flag
CI / test-and-quality (push) Successful in 2m57s
CI / test-and-quality (pull_request) Successful in 2m59s
2026-03-16 02:20:57 +00:00
dev-bot 5bb035deec fix(lobby): tighten canonical host round flow for issue 287
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 10s
2026-03-16 02:07:17 +00:00
dev-bot ab08dc2b6d feat(lobby): align canonical round flow for issue 287
CI / test-and-quality (push) Failing after 10s
CI / test-and-quality (pull_request) Failing after 10s
2026-03-16 01:00:07 +00:00
dev-bot a2c60749f8 feat(lobby): canonicalize round phase ownership 2026-03-16 00:44:11 +00:00
integrator-bot 89c7070e02 Merge pull request 'feat(gameplay): canonical reveal payload for round question refs #289 parent #287' (#297) from dev/issue-289-canonical-reveal-payload-devbot into main
CI / test-and-quality (push) Successful in 2m26s
2026-03-16 00:47:53 +01:00
dev-bot c43975a1c8 fix(frontend): enforce canonical reveal fooled-player refs
CI / test-and-quality (push) Successful in 2m58s
CI / test-and-quality (pull_request) Successful in 2m58s
2026-03-15 23:36:26 +00:00
dev-bot 2cc2a08ccb test(lobby): lock omitted reveal fooled-player nickname contract
CI / test-and-quality (pull_request) Successful in 2m53s
CI / test-and-quality (push) Successful in 2m54s
2026-03-15 23:16:17 +00:00
dev-bot 0d91531b90 test(frontend): lock omitted reveal nickname contract
CI / test-and-quality (push) Successful in 2m52s
CI / test-and-quality (pull_request) Successful in 2m52s
2026-03-15 22:56:34 +00:00
dev-bot e566e0967d test(frontend): harden reveal fooled-player normalization
CI / test-and-quality (push) Successful in 2m52s
CI / test-and-quality (pull_request) Successful in 2m53s
2026-03-15 22:14:54 +00:00
dev-bot 0b0e3c325c fix(frontend): normalize omitted reveal fooled-player ids
CI / test-and-quality (push) Successful in 2m51s
CI / test-and-quality (pull_request) Successful in 2m51s
2026-03-15 21:56:58 +00:00
dev-bot f44dd92543 test(frontend): normalize reveal guess fooled-player nullability
CI / test-and-quality (pull_request) Successful in 2m54s
CI / test-and-quality (push) Successful in 2m57s
2026-03-15 18:32:20 +00:00
dev-bot c363ec92da merge(main): resolve PR #297 conflicts
CI / test-and-quality (push) Successful in 2m52s
CI / test-and-quality (pull_request) Successful in 2m52s
2026-03-15 18:11:07 +00:00
dev-bot 2472b70d45 test(lobby): align lie submission assertions with i18n payload
CI / test-and-quality (push) Successful in 2m35s
CI / test-and-quality (pull_request) Successful in 2m34s
2026-03-15 17:54:00 +00:00
dev-bot 7a6eb0b88e fix(frontend): restore canonical reveal payload typecheck
CI / test-and-quality (push) Failing after 2m1s
CI / test-and-quality (pull_request) Failing after 2m7s
2026-03-15 16:51:21 +00:00
integrator-bot 1cbec3b70e Merge pull request '[Gameplay] Canonical reveal payload for round question incl. who-fooled-whom' (#295) from dev/issue-289-canonical-reveal into main
CI / test-and-quality (push) Successful in 2m25s
2026-03-15 16:46:24 +01:00
dev-bot 49257af0b0 fix(frontend): align session detail contract in tests
CI / test-and-quality (push) Successful in 2m58s
CI / test-and-quality (pull_request) Successful in 2m59s
2026-03-15 15:29:41 +00:00
dev-bot e8883e803b fix: preserve reveal before scoreboard
CI / test-and-quality (push) Successful in 2m52s
CI / test-and-quality (pull_request) Successful in 2m52s
2026-03-15 14:24:42 +00:00
dev-bot 076ca4ebbb test(gameplay): lock canonical reveal payload across scoreboard
CI / test-and-quality (push) Successful in 2m55s
CI / test-and-quality (pull_request) Successful in 2m56s
2026-03-15 13:27:25 +00:00
dev-bot 207c934b48 test(lobby): cover legacy scoreboard host gating
CI / test-and-quality (push) Successful in 2m54s
CI / test-and-quality (pull_request) Successful in 2m55s
2026-03-15 13:01:21 +00:00
root dffb3f49ff merge: rebase canonical reveal flow onto main
CI / test-and-quality (push) Successful in 2m55s
CI / test-and-quality (pull_request) Successful in 3m2s
2026-03-15 12:57:15 +00:00
root 6dcd5e5f03 test(lobby): align lie submission assertions with i18n errors
CI / test-and-quality (push) Failing after 3m1s
CI / test-and-quality (pull_request) Failing after 3m10s
2026-03-15 12:46:13 +00:00
dev-bot f0e87eb988 feat: expose canonical reveal payload in SPA refs #289 parent #287
CI / test-and-quality (push) Failing after 2m6s
CI / test-and-quality (pull_request) Failing after 2m11s
2026-03-15 12:29:14 +00:00
dev-bot a80b1ee354 test(gameplay): align guess error contract assertions 2026-03-15 11:54:39 +00:00
dev-bot 3f20f25902 fix: expose canonical reveal payload in scoreboard detail 2026-03-15 11:46:30 +00:00
integrator-bot 1a6869643f Merge pull request 'fix(gameplay): explicit scoreboard phase after reveal (#288)' (#291) from dev/issue-288-scoreboard-phase into main
CI / test-and-quality (push) Successful in 2m21s
2026-03-15 11:48:58 +01:00
agw 5c9d29a3a7 fix(realtime): restore websocket phase event type
CI / test-and-quality (push) Successful in 2m52s
CI / test-and-quality (pull_request) Successful in 2m53s
2026-03-15 10:32:10 +00:00
agw 62174135b8 fix(ci): remove duplicate realtime import
CI / test-and-quality (pull_request) Successful in 2m49s
CI / test-and-quality (push) Successful in 2m51s
2026-03-15 09:49:55 +00:00
agw 17234de5d1 Merge main into PR #291 and resolve scoreboard phase conflicts
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-15 09:34:14 +00:00
agw be38fe6ac2 fix(realtime): tolerate missing scoreboard channel layer
CI / test-and-quality (pull_request) Successful in 2m58s
CI / test-and-quality (push) Successful in 2m59s
2026-03-15 09:08:13 +00:00
agw 8fa39adc2b fix(gameplay): restore scoreboard phase error contract
CI / test-and-quality (push) Failing after 2m30s
CI / test-and-quality (pull_request) Failing after 2m32s
2026-03-15 08:52:35 +00:00
dev-bot 97b366d1e9 fix(gameplay): make scoreboard reads idempotent
CI / test-and-quality (push) Successful in 2m40s
CI / test-and-quality (pull_request) Successful in 2m42s
2026-03-15 08:05:21 +00:00
agw 558f8fe245 fix(gameplay): restore reveal before scoreboard
CI / test-and-quality (push) Successful in 2m43s
CI / test-and-quality (pull_request) Successful in 2m43s
2026-03-15 07:55:48 +00:00
dev-bot dc0c203f7f fix(gameplay): align scoreboard API contract
CI / test-and-quality (pull_request) Successful in 2m42s
CI / test-and-quality (push) Successful in 2m45s
2026-03-15 07:43:38 +00:00
agw 173cc8f2d9 fix(gameplay): align scoreboard phase contract 2026-03-13 19:34:05 +00:00
dev-bot 638c9452d8 fix(spa): register scoreboard host shell route
CI / test-and-quality (push) Successful in 2m34s
CI / test-and-quality (pull_request) Successful in 2m35s
2026-03-13 18:04:41 +00:00
dev-bot a0277fd8be fix(gameplay): add explicit scoreboard phase (#288)
CI / test-and-quality (push) Successful in 2m12s
CI / test-and-quality (pull_request) Successful in 2m11s
2026-03-13 16:11:06 +00:00
integrator-bot 8503e18e57 Merge pull request 'docs(#279): add i18n MVP close-out note' (#286) from dev/issue-279-i18n-mvp-closeout-note into main
CI / test-and-quality (push) Successful in 2m12s
2026-03-13 12:44:17 +01:00
dev-bot 3747081eb4 docs(#279): clarify merged snapshot in close-out note
CI / test-and-quality (push) Successful in 2m38s
CI / test-and-quality (pull_request) Successful in 2m39s
2026-03-13 11:27:14 +00:00
agw 4a12cee6ee docs(i18n): refresh issue 279 close-out status
CI / test-and-quality (push) Successful in 2m34s
CI / test-and-quality (pull_request) Successful in 2m35s
2026-03-13 11:11:12 +00:00
integrator-bot 1bc4c27273 Merge pull request 'feat(#275): harden django i18n locale negotiation and fallback' (#283) from feat/issue-275-django-i18n-hardening into main
CI / test-and-quality (push) Successful in 2m22s
2026-03-13 12:00:03 +01:00
integrator-bot 6ad5430302 Merge pull request 'docs(#277): add shared i18n parity artifact' (#282) from feat/issue-277-i18n-parity-report into main
CI / test-and-quality (push) Has been cancelled
2026-03-13 11:59:51 +01:00
root d6f4b5c0fb docs: align PR 283 close-out status wording
CI / test-and-quality (push) Successful in 2m40s
CI / test-and-quality (pull_request) Successful in 2m40s
2026-03-13 10:56:04 +00:00
agw ceb71aff6e docs(issue-279): restate close-out note as reviewed snapshot
CI / test-and-quality (push) Successful in 2m35s
CI / test-and-quality (pull_request) Successful in 2m35s
2026-03-13 10:38:26 +00:00
agw 864984273a fix(ci): drop unused lobby i18n import
CI / test-and-quality (pull_request) Successful in 2m39s
CI / test-and-quality (push) Successful in 2m40s
2026-03-13 10:38:07 +00:00
dev-bot b2e66389c3 docs(issue-279): refresh i18n close-out snapshot
CI / test-and-quality (push) Successful in 2m39s
CI / test-and-quality (pull_request) Successful in 2m40s
2026-03-13 10:17:19 +00:00
dev-bot 8ff552aeae merge(main): resolve PR #283 lobby/views.py conflict
CI / test-and-quality (push) Failing after 12s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-13 10:16:42 +00:00
dev-bot b968ea4430 test(i18n): guard issue-277 artifact determinism
CI / test-and-quality (push) Successful in 2m33s
CI / test-and-quality (pull_request) Successful in 2m32s
2026-03-13 10:08:32 +00:00
integrator-bot e6ca18ff30 Merge pull request 'test: issue #278 da+en smoke gate and primary-only audio verification' (#285) from feat/issue-278-smoke-locale-audio-primary into main
CI / test-and-quality (push) Successful in 2m12s
2026-03-13 10:57:14 +01:00
agw 575f4782b5 docs(issue-279): add i18n mvp close-out note
CI / test-and-quality (push) Successful in 2m52s
CI / test-and-quality (pull_request) Successful in 2m37s
2026-03-13 09:52:46 +00:00
agw e5b8081c10 test: add issue 278 locale and audio smoke gate
CI / test-and-quality (push) Successful in 2m35s
CI / test-and-quality (pull_request) Successful in 2m50s
2026-03-13 09:51:09 +00:00
dev-bot 5a580964c4 fix(i18n): make parity artifact reproducible
CI / test-and-quality (push) Successful in 2m29s
CI / test-and-quality (pull_request) Successful in 2m31s
2026-03-13 09:40:18 +00:00
agw db7be0dfc6 test(i18n): cover locale fallback and backend error payloads
CI / test-and-quality (push) Successful in 2m48s
CI / test-and-quality (pull_request) Successful in 2m40s
2026-03-13 09:16:23 +00:00
agw 80520bad51 feat(i18n): unify django api error resolution 2026-03-13 09:16:23 +00:00
dev-bot e0aba3fdf6 docs(i18n): add MVP keyspace parity artifact for issue 277
CI / test-and-quality (push) Successful in 3m13s
CI / test-and-quality (pull_request) Successful in 2m59s
2026-03-13 09:14:16 +00:00
agw c0c3ecd90c docs(issue-277): record PR delivery metadata
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 3m48s
2026-03-13 09:12:02 +00:00
agw b8a9fbf6d1 docs(issue-277): add shared i18n parity artifact
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 3m35s
2026-03-13 09:10:23 +00:00
integrator-bot 903c63ce17 Merge pull request 'feat: simplify Angular host/player MVP flow for issue #276' (#281) from feat/issue-276-angular-i18n-audio-guard into main
CI / test-and-quality (push) Successful in 2m7s
2026-03-13 09:39:39 +01:00
dev-bot 58874c0d78 feat: simplify angular host/player mvp controls
CI / test-and-quality (push) Successful in 2m27s
CI / test-and-quality (pull_request) Successful in 2m31s
2026-03-13 08:24:14 +00:00
integrator-bot fb657cb76c Merge pull request 'docs: design doc for fup og fakta game engine + platform architecture' (#280) from feature/planning-and-websocket into main
CI / test-and-quality (push) Successful in 2m12s
2026-03-13 09:14:28 +01:00
Asger Geel Weirsøe d15abf9d78 docs: add fupogfakta game engine implementation plan
CI / test-and-quality (pull_request) Successful in 2m46s
CI / test-and-quality (push) Successful in 2m50s
15 tasks across 8 batches covering:
- Celery infrastructure
- GameRun model + GameDriver interface
- FupOgFaktaConfig relational presets
- LieReaction model, reveal_order, ScoreEvent removal
- Full FupOgFaktaDriver with all phase transitions
- Platform play/pause/exit endpoints
- Fupogfakta lie/guess/react endpoints
- Angular frontend game screens rebuild
- Cleanup of obsolete manual-advance endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 07:38:04 +01:00
Asger Geel Weirsøe d2dbd8c802 docs: design doc for fup og fakta game engine + platform architecture
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 2m43s
Captures all brainstormed decisions:
- Pluggable game cartridge platform (GameDriver interface)
- Celery + Redis timer-driven phase transitions
- Session owner play/pause/exit controls (no skip)
- Escalating scoring per round, incremental reveal scoring
- Emoji reactions during guess phase → post-game awards
- Relational per-user config presets with game-specific models
- Ephemeral game state (no persistence after exit/finish)
- Full WebSocket event reference and data lifecycle

Also: updated TODO.md (WebSocket done, persisted answers done),
created CLAUDE.md, and PROMPT.md for ralph-loop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 07:35:55 +01:00
integrator-bot f1699841e6 Merge pull request 'docs(#252): document React fallback trigger criteria' (#274) from feat/issue-252-react-fallback-criteria into main
CI / test-and-quality (push) Successful in 2m39s
2026-03-02 06:14:59 +01:00
agw 7841fb7651 docs(issue-252): require issue/incident reference in fallback decision log
CI / test-and-quality (push) Successful in 2m53s
CI / test-and-quality (pull_request) Successful in 2m48s
2026-03-02 05:05:56 +00:00
integrator-bot a500056843 Merge pull request 'docs(issue-252): define React fallback triggers for delivery-blocking only' (#272) from feat/issue-252-react-fallback-criteria into main
CI / test-and-quality (push) Successful in 2m28s
2026-03-02 06:03:22 +01:00
dev-bot ad841dfe9f chore(ci): retrigger required push check for PR #272
CI / test-and-quality (push) Successful in 2m57s
CI / test-and-quality (pull_request) Successful in 2m57s
2026-03-02 04:50:59 +00:00
integrator-bot 6d6fd44662 Merge pull request 'feat(issue-175): share i18n locale/catalog in legacy lobby shells' (#273) from dev/issue-175-shared-i18n-fe-be-cleanup into main
CI / test-and-quality (push) Successful in 2m27s
2026-03-02 05:14:25 +01:00
agw 022ba24fd0 feat(i18n): wire legacy lobby shells to shared locale catalog
CI / test-and-quality (push) Successful in 2m58s
CI / test-and-quality (pull_request) Successful in 3m0s
2026-03-02 04:10:47 +00:00
dev-bot b63b0ccf7e docs: define React fallback triggers for delivery-blocking only (#252)
CI / test-and-quality (pull_request) Successful in 3m5s
CI / test-and-quality (push) Failing after 7m50s
2026-03-02 04:07:27 +00:00
integrator-bot 9594a8fcb0 Merge pull request 'test(#268): guard phone-client flow from triggering audio playback' (#271) from dev/issue-268-phone-ui-audio-guard into main
CI / test-and-quality (push) Successful in 2m32s
2026-03-02 04:58:11 +01:00
agw e4841afbaa test(issue-268): lock phone audio guard against playback regressions
CI / test-and-quality (push) Successful in 3m2s
CI / test-and-quality (pull_request) Successful in 3m5s
2026-03-02 03:49:29 +00:00
integrator-bot ee2a202f34 Merge pull request 'docs(#257): add acceptance artifact for shared i18n keyspace loader' (#269) from dev/issue-257-acceptance-artifact into main
CI / test-and-quality (push) Successful in 2m31s
2026-03-02 04:46:44 +01:00
integrator-bot f73b99b637 Merge pull request 'test(#225): lock backend i18n error payload contract keys' (#270) from dev/issue-225-backend-i18n-baseline-v2 into main
CI / test-and-quality (push) Has been cancelled
2026-03-02 04:46:38 +01:00
dev-bot 6d99741305 test(i18n): lock backend error payload contract keys for issue 225
CI / test-and-quality (push) Successful in 3m5s
CI / test-and-quality (pull_request) Successful in 2m40s
2026-03-02 03:37:48 +00:00
agw cf58ba8067 docs(issue-257): add shared i18n loader acceptance artifact
CI / test-and-quality (push) Successful in 3m6s
CI / test-and-quality (pull_request) Successful in 3m7s
2026-03-02 03:36:42 +00:00
integrator-bot f7ed3d9407 Merge pull request 'docs(#225): refresh backend i18n baseline acceptance artifact' (#267) from feature/issue-225-backend-i18n-baseline into main
CI / test-and-quality (push) Successful in 2m32s
2026-03-02 04:30:37 +01:00
integrator-bot 951e24b57d Merge pull request 'docs(#257): acceptance artifact for shared i18n keyspace + frontend loader' (#266) from feature/issue-257-shared-i18n-loader-doc into main
CI / test-and-quality (push) Successful in 2m58s
2026-03-02 04:25:24 +01:00
dev-bot 63fce7760a docs(issue-225): refresh backend i18n baseline verification evidence
CI / test-and-quality (push) Successful in 3m16s
CI / test-and-quality (pull_request) Successful in 3m14s
2026-03-02 03:20:36 +00:00
133 changed files with 20236 additions and 1562 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash"
]
}
}
+16
View File
@@ -0,0 +1,16 @@
.git
.venv
venv
__pycache__
*.py[cod]
*.egg-info
.pytest_cache
.mypy_cache
.ruff_cache
node_modules
frontend/node_modules
frontend/angular/node_modules
frontend/angular/dist
db.sqlite3
staticfiles
media
+23 -7
View File
@@ -27,18 +27,34 @@ jobs:
pip install ruff
- name: Lint
run: ruff check lobby
run: ruff check .
- name: Tests
run: python manage.py test lobby -v 1
- name: Django checks
run: |
python manage.py check
python scripts/check_i18n_drift.py
python manage.py test lobby fupogfakta -v 1
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install SPA dependencies
run: npm ci --prefix frontend/angular
- name: Install shared frontend dependencies
run: npm ci --prefix frontend
- name: SPA Angular smoke tests
run: npm --prefix frontend/angular test
- name: Shared frontend checks
run: |
npm --prefix frontend test
npm --prefix frontend run build
- name: Install SPA dependencies
run: |
npm ci --prefix frontend/angular
node -e "require('./frontend/angular/node_modules/rollup/dist/native.js')" \
|| npm install --prefix frontend/angular
- name: SPA Angular checks
run: |
npm --prefix frontend/angular test
npm --prefix frontend/angular run build
+2
View File
@@ -17,6 +17,7 @@ venv/
db.sqlite3
staticfiles/
media/
artifacts/
# Env/secrets
.env
@@ -24,6 +25,7 @@ media/
!.env.test.example
!.env.staging.example
!.env.prod.example
!.env.dev.example
# Editors/OS
.vscode/
+30
View File
@@ -0,0 +1,30 @@
# Repository Guidelines
## Project Structure & Module Organization
`partyhub/` is the Django project entrypoint (`settings.py`, `urls.py`, `asgi.py`). Backend apps live at the repo root: `lobby/` handles session and player flows, `fupogfakta/` owns game rules and scoring, `realtime/` holds websocket/broadcast code, and `core_admin/` plus `voice/` cover admin and future audio integration. Shared locale data lives in `shared/i18n/`, helper scripts in `scripts/`, deployment assets in `infra/`, and release or smoke evidence in `docs/`.
Frontend code is split in two layers: `frontend/src/` contains the framework-agnostic TypeScript API client and SPA state helpers, while `frontend/angular/src/` contains the Angular 19 shell for host and player screens.
## Build, Test, and Development Commands
Install backend dependencies with `.venv/bin/pip install -r requirements.txt`.
- `.venv/bin/python manage.py runserver` starts the Django dev server.
- `.venv/bin/python manage.py migrate` applies schema changes.
- `.venv/bin/python manage.py check` runs Django configuration checks.
- `.venv/bin/python manage.py test lobby` runs the backend suite currently enforced in CI.
- `npm --prefix frontend test` runs Vitest for the shared TypeScript client.
- `npm --prefix frontend run build` performs the TypeScript compile check.
- `npm --prefix frontend/angular start` serves the Angular shell locally.
- `npm --prefix frontend/angular test` runs Angular-side Vitest smoke tests.
- `.venv/bin/python scripts/check_i18n_drift.py` validates shared locale keys.
## Coding Style & Naming Conventions
Use 4-space indentation in Python and follow Django conventions: snake_case for functions, PascalCase for models, explicit `on_delete` on `ForeignKey`s, and committed migrations in each apps `migrations/` package. Keep business rules server-authoritative.
Use 2-space indentation in TypeScript and Angular. Match the existing style: single quotes, semicolons, PascalCase classes, camelCase functions, and kebab-case filenames such as `gameplay-phase-machine.ts`. Keep API types in `frontend/src/api/types.ts` aligned with backend JSON payloads.
## Testing Guidelines
Backend tests live in `<app>/tests.py`; frontend tests live in `frontend/tests/*.test.ts` and `frontend/angular/src/**/*.spec.ts`. No numeric coverage gate is committed, so add targeted tests for every gameplay, i18n, or payload-contract change. Before opening a PR, run `manage.py check`, `manage.py test lobby`, and `npm --prefix frontend/angular test` at minimum.
## Commit & Pull Request Guidelines
Recent history follows short, imperative subjects with optional scopes, for example `fix(gameplay): ...`, `test(lobby): ...`, and `chore: ...`. Keep commits small and reference issue numbers when relevant. Open PRs from `feature/<name>` branches with a clear problem statement, linked issue, test evidence, and screenshots for host/player UI changes. If you touch `USE_SPA_UI`, staging flow, or i18n artifacts, include the related smoke or parity document in `docs/`.
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## [Unreleased]
### Docs
- Added `docs/ISSUE-279-I18N-MVP-CLOSEOUT.md` with the issue #279 i18n MVP close-out note, including migration impact, reusable release-note text, and a release-readiness checklist refreshed against `main@1bc4c27` after PR #282/#283 landed on 2026-03-13 UTC.
- Clarified that the close-out note supersedes earlier PR snapshot assumptions and now treats PR #282 (`6ad5430`) and PR #283 (`1bc4c27`) as already merged on `main`.
## [0.1.0] - 2026-02-27
### Added
- Projekt scaffold for Weirsøe Party Protocol (Django 6.0.2)
+119
View File
@@ -0,0 +1,119 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Weirsøe Party Protocol** is a Danish party game web platform (Jackbox-style) where games display on a primary screen and players participate via mobile. The MVP game is "Fup og Fakta" (a Fibbage-style lie-and-guess game).
- Backend: Django 6.0.2 + Django Channels (WebSockets) + Redis
- Frontend: Angular 19 shell + shared TypeScript API client library
- Database: MySQL (SQLite fallback for dev)
- Deployment: Proxmox LXC containers (not Docker)
## Commands
### Backend (Django)
```bash
python manage.py runserver # Dev server
python manage.py migrate # Apply migrations
python manage.py test # Run all backend tests
python manage.py test lobby # Run tests for a single app
python manage.py shell # Django shell
```
### Frontend — API client (`/frontend`)
```bash
cd frontend
npm install
npm test # Vitest unit tests
npm run build # TypeScript compile check (--noEmit)
```
### Frontend — Angular shell (`/frontend/angular`)
```bash
cd frontend/angular
npm install
npm start # Dev server (ng serve)
npm run build # Production build
npm run test # Vitest unit tests
```
### i18n validation
```bash
python scripts/check_i18n_drift.py # Check for key drift between locales
```
## Architecture
### Backend apps
| App | Purpose |
|-----|---------|
| `partyhub/` | Main Django project — settings, root URLs, ASGI/WSGI, i18n bootstrap |
| `lobby/` | Session & player management — create/join session, locale-aware error responses |
| `fupogfakta/` | Game logic — all domain models, score calculation (server-authoritative) |
| `realtime/` | WebSocket event layer (stub) |
| `voice/` | Voice/TTS interface (stub, Phase 2) |
| `core_admin/` | Health endpoint (`/healthz`), global admin |
**Key domain models** (all in `fupogfakta/models.py`): `GameSession`, `Player`, `Category`, `Question`, `RoundConfig`, `RoundQuestion`, `LieAnswer`, `Guess`, `ScoreEvent`.
Score calculation is server-side only. `ScoreEvent` provides an auditable trail of all point changes.
### Frontend layers
1. **Shared API client** (`frontend/src/`) — pure TypeScript, framework-agnostic. Defines all API types (`api/types.ts`) and HTTP client abstraction (`api/client.ts`).
2. **Angular shell** (`frontend/angular/`) — Angular 19 standalone components (no NgModules), hash-based routing. `host-shell.component` for the presenter screen; `player-shell.component` for mobile players.
The Angular shell consumes the shared client via `frontend/src/api/angular-client.ts`.
### Real-time flow
`LOBBY → LIE → GUESS → REVEAL → FINISHED` — phase transitions broadcast a `PhaseViewModel` to all connected clients via WebSocket. Clients are read-only; only the server is authoritative for state.
### i18n
- **Single source of truth**: `shared/i18n/lobby.json` (keys in both `en` and `da`)
- Loaded once at startup with LRU cache (`partyhub/i18n_bootstrap.py`)
- Key naming: domain-first — `frontend.ui.host.*`, `frontend.ui.player.*`, `backend.errors.*`, `backend.error_codes.*`
- Locale resolved from `Accept-Language` header; missing key returns key + logs warning; missing translation falls back to `en`
## Key Conventions
### Errors
Backend error responses use stable machine-readable codes (`backend.error_codes.*`) with separately localized messages. Never couple error code strings to locale.
### Game constraints (MVP)
- 312 players per session
- Session codes: 6-char alphanumeric (no 0/O/1/I/L)
- Anti-cheat: no duplicate lies, lies cannot match the correct answer, answer order randomized
### Git workflow
- `main`: stable baseline
- `feature/<name>`: development branches
- `release/vX.Y.Z`: release preparation
- Release: merge → create release branch → update `VERSION` + `CHANGELOG.md` → tag → push
### TypeScript
Strict mode required. Target ES2022. API response interfaces in `frontend/src/api/types.ts` must match backend responses exactly.
### Database
Use `ForeignKey` with explicit `on_delete` (`PROTECT`/`CASCADE`/`SET_NULL`). Add `db_index=True` on frequently queried fields. Migrations are auto-generated by Django and versioned in `migrations/`.
## Environment Variables
```
DJANGO_SECRET_KEY, DJANGO_DEBUG, DJANGO_ALLOWED_HOSTS
DB_ENGINE, DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT
CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT
USE_SPA_UI (fallback: WPP_SPA_ENABLED)
WPP_SPA_ASSET_BASE, WPP_SPA_ASSET_VERSION
```
## Test Files of Note
- `lobby/tests.py` — comprehensive Django TestCase coverage for session/player/i18n/error flows
- `frontend/angular/src/app/api-contract-smoke.spec.ts` — API contract smoke tests
- `frontend/angular/src/app/lobby-i18n.spec.ts` — i18n parity checks
- `frontend/tests/lobby-loader.parity.test.ts` — shared i18n loader parity
+22
View File
@@ -0,0 +1,22 @@
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential default-libmysqlclient-dev pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --upgrade pip \
&& pip install -r /app/requirements.txt
COPY . /app
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+71
View File
@@ -0,0 +1,71 @@
# Ralph Loop: Implement WebSocket push for Weirsøe Party Protocol
## Context
- Project: /home/agw/projects/weirsoe-party-protocol
- Backend: Django 6.0.2 + Django Channels + Redis
- The full game REST flow is already implemented in lobby/views.py
(create_session, join_session, start_round, show_question, submit_lie,
mix_answers, submit_guess, calculate_scores, reveal_scoreboard, finish_game)
- realtime/ app exists but is a stub (no consumers.py, no routing)
- partyhub/settings.py has channels in INSTALLED_APPS but no CHANNEL_LAYERS or routing
- PO hard requirement: WebSocket push is mandatory in MVP (no polling)
## What to build
### 1. realtime/consumers.py — GameConsumer
- AsyncJsonWebsocketConsumer
- Connects to group game_{session_code} on connect (session_code from URL)
- Player auth: session_token query param validated against Player model
- Host auth: query param role=host, no token required for MVP
- On disconnect: clean leave from group
- Handles incoming message type "ping" -> replies with {"type": "pong"}
- Forwards broadcast group events to WebSocket client
### 2. partyhub/settings.py — CHANNEL_LAYERS
Add CHANNEL_LAYERS using channels_redis.core.RedisChannelLayer.
Read CHANNEL_REDIS_HOST (default 127.0.0.1) and CHANNEL_REDIS_PORT (default 6379) from env.
### 3. partyhub/asgi.py — ASGI routing
Wire URLRouter so ws/game/<session_code>/ routes to GameConsumer.
Keep existing HTTP routing intact.
### 4. realtime/routing.py
Define websocket_urlpatterns list.
### 5. realtime/broadcast.py — broadcast helper
- async def broadcast_phase_event(session_code, event_type, payload)
Sends to group game_{session_code} via channel layer.
- def sync_broadcast_phase_event(session_code, event_type, payload)
Sync wrapper using async_to_sync for calling from sync REST views.
### 6. lobby/views.py — hook broadcasts into phase transitions
After each phase transition, call sync_broadcast_phase_event:
- start_round -> phase.lie_started (question prompt + time limit)
- show_question -> phase.question_shown (question text)
- mix_answers -> phase.guess_started (shuffled answers + time limit)
- calculate_scores -> phase.scores_calculated (per-player score delta)
- reveal_scoreboard -> phase.scoreboard (ranked player list)
- finish_game -> phase.game_over (final rankings)
### 7. realtime/tests.py — basic tests
- Connect/disconnect test using channels.testing.WebsocketCommunicator
- Verify a broadcast reaches a connected client
## Constraints
- Keep auth simple: session_token query param for players, unauthenticated host in MVP
- Use async_to_sync wrapper for sync REST views calling async broadcast
- Do not break existing REST tests (python manage.py test lobby must still pass)
- After each file written, run: python manage.py check
- Follow existing code style in lobby/views.py
## Completion criteria
Output the exact text: WEBSOCKET COMPLETE
...when ALL of the following are true:
- realtime/consumers.py exists and handles connect/disconnect/ping
- realtime/broadcast.py exists with sync_broadcast_phase_event
- partyhub/settings.py has CHANNEL_LAYERS configured
- partyhub/asgi.py routes ws/game/<code>/ to GameConsumer
- All 6 phase transitions in lobby/views.py call sync_broadcast_phase_event
- python manage.py check passes with no errors
- python manage.py test lobby passes (existing tests not broken)
+13 -12
View File
@@ -37,8 +37,8 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [x] `core_admin` (global administration)
- [x] `fupogfakta` (Spil 1)
- [x] `lobby` (room/session/player join flow)
- [x] `realtime` (channels events, game state broadcast)
- [x] `voice` (fælles voice-acting interface)
- [x] `realtime` (app-skelet oprettet — consumers/routing IKKE implementeret endnu)
- [x] `voice` (fælles voice-acting interface — stub)
- [x] Miljøfiler (`.env.test`, `.env.prod` skabeloner)
- [x] Konfig for MySQL test/prod
@@ -53,14 +53,15 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [x] `ScoreEvent` (auditérbar pointslog)
### Fase 3 — Spilflow `Fup og Fakta`
- [x] Lobby: host opretter session, spillere joiner via kode
- [x] Runde starter med kategori
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
- [x] System blander korrekt svar + løgne
- [x] Guessfase: alle gætter inden Z sek
- [x] Pointudregning (konfigurerbar pr. runde)
- [x] Scoreboard + næste spørgsmål/runde
- [x] Slutresultat
- [x] Lobby: host opretter session, spillere joiner via kode (REST)
- [x] Runde starter med kategori (REST)
- [x] Spørgsmål vises -> alle skriver løgn inden X sek (REST)
- [x] System blander korrekt svar + løgne (persisted i JSONField, anti-cheat dedup)
- [x] Guessfase: alle gætter inden Z sek (REST)
- [x] Pointudregning (konfigurerbar pr. runde, ScoreEvent audit trail)
- [x] Scoreboard + næste spørgsmål/runde (REST)
- [x] Slutresultat (REST)
- [x] **WebSocket push af phase-events til host + spillere** (GameConsumer + broadcast.py, InMemoryChannelLayer i tests)
### Fase 4 — Voice-acting (platformkrav)
- [ ] Definér TTS provider-interface
@@ -103,10 +104,10 @@ 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) Persistér mixed svarrækkefølge pr. round question — DONE (JSONField + migration 0003 + test)
- [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) Fjern ubrugte scaffold-imports i core_admin/*, realtime/*, voice/*, fupogfakta/views.py (kør `ruff check --fix`) så CI quality gate er grøn
- [x] (Need-to-have) [Issue #251] Release-often lane: SPA MVP opdelt i 3 merge-klare micro-PR batches (plan + acceptance criteria dokumenteret i `docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md`).
- [ ] (Need-to-have) Rate limiting på join/submit endpoints
- [ ] (Need-to-have) Session-kode brute-force beskyttelse
+1 -3
View File
@@ -1,3 +1 @@
from django.contrib import admin
# Register your models here.
"""Admin registrations for the core_admin app."""
+1 -3
View File
@@ -1,3 +1 @@
from django.db import models
# Create your models here.
"""Database models for the core_admin app."""
+1 -3
View File
@@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here.
"""Test module placeholder for the core_admin app."""
+1 -3
View File
@@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here.
"""HTTP views for the core_admin app."""
+82
View File
@@ -0,0 +1,82 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
command: sh /app/scripts/docker_dev_entrypoint.sh
env_file:
- infra/env/.env.dev.example
environment:
DB_HOST: db
DB_PORT: "3306"
CHANNEL_REDIS_HOST: redis
CHANNEL_REDIS_PORT: "6379"
USE_SPA_UI: ${USE_SPA_UI:-false}
WPP_SPA_ASSET_BASE: ${WPP_SPA_ASSET_BASE:-http://localhost:4200/browser}
WPP_SPA_ASSET_VERSION: ${WPP_SPA_ASSET_VERSION:-dev}
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test:
- CMD
- python
- -c
- import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status == 200 else 1)
interval: 5s
timeout: 5s
retries: 20
start_period: 15s
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- .:/app
stdin_open: true
tty: true
db:
image: mysql:8.4
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_DATABASE: wpp_dev
MYSQL_USER: wpp_dev
MYSQL_PASSWORD: wpp_dev
MYSQL_ROOT_PASSWORD: wpp_root
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
ports:
- "${DB_FORWARD_PORT:-3307}:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
ports:
- "${REDIS_FORWARD_PORT:-6380}:6379"
volumes:
- redis_data:/data
spa-assets:
profiles: ["spa"]
image: node:22-alpine
working_dir: /workspace/frontend/angular
command: sh -c "npm ci && npm run build && node /workspace/scripts/serve_static_dir.mjs dist/wpp-angular-shell 4200 http://app:8000"
ports:
- "${SPA_PORT:-4200}:4200"
volumes:
- .:/workspace
- spa_node_modules:/workspace/frontend/angular/node_modules
volumes:
mysql_data:
redis_data:
spa_node_modules:
+89
View File
@@ -0,0 +1,89 @@
# Development Setup
## MVP Runtime Path
The current MVP runtime path is the legacy Django host/player UI with `USE_SPA_UI=false`.
## Docker Compose
The fastest MVP path is the legacy UI with MySQL and Redis behind Django:
```bash
docker compose up --build
```
App URLs:
- `http://localhost:8000/admin/login/`
- `http://localhost:8000/lobby/ui/host`
- `http://localhost:8000/lobby/ui/player`
Compose uses `infra/env/.env.dev.example` and overrides `DB_HOST`/`CHANNEL_REDIS_HOST` inside containers so the same file also works for host-side commands.
If port `8000` is already in use, run with `APP_PORT=18000 docker compose up --build` and use `http://localhost:18000/...` instead.
The app container now waits for the database and Redis endpoints before running migrations, so transient Docker DNS startup races do not kill the local stack.
## Bootstrap
Create deterministic demo credentials and sample questions with:
```bash
docker compose exec app python manage.py bootstrap_mvp
```
Default output:
- host username: `demo-host`
- host password: `demo-pass`
- category slug: `general`
- questions: `3`
You can override the host/category names with `--username`, `--password`, `--category-slug`, and `--category-name`.
For a quick seeded regression flow, run:
```bash
docker compose exec app python manage.py smoke_staging --artifact /tmp/wpp-smoke.json
```
That creates `smoke-host` / `smoke-pass`, ensures one active smoke question exists, and exercises one full round. Use `bootstrap_mvp` for the reusable local try-out account.
## Local MVP Smoke
For a one-command local MVP proof, run:
```bash
./scripts/run_local_mvp_smoke.sh
```
That starts the compose stack, waits for `/healthz`, runs `bootstrap_mvp`, executes `smoke_staging`, and writes a JSON artifact under `artifacts/local/`.
If port `8000` is busy on your machine, use `APP_PORT=18000 ./scripts/run_local_mvp_smoke.sh`.
By default the stack stays up after the smoke so you can continue in the browser. Use `KEEP_STACK_RUNNING=0` if you want the script to shut the stack down on exit.
## Release Gate
Run the full local MVP release gate with:
```bash
./scripts/verify_mvp_release.sh
```
That runs repo lint, shared i18n drift checks, Django checks/tests, both frontend test/build pipelines, and a `docker compose config` sanity pass.
## Optional SPA Shell
To serve the Angular shell as the UI path instead of the legacy templates:
```bash
USE_SPA_UI=true docker compose --profile spa up --build
```
Use these entry points:
- `http://localhost:4200/` for the SPA landing page with host login, host session creation, and player join
- `http://localhost:4200/host?session=ABC123` for the host shell after a session exists
- `http://localhost:4200/player?session=ABC123` for the player shell after join
- `http://localhost:8000/lobby/ui/host` and `http://localhost:8000/lobby/ui/player` if you want Django to render the SPA shell with `USE_SPA_UI=true`
The raw SPA container serves the compiled Angular app at `/` and also proxies `/accounts/*`, `/lobby/*`, and other Django endpoints back to `http://localhost:8000`.
`WPP_SPA_ASSET_BASE` still points at `http://localhost:4200/browser` because Django-rendered SPA pages load their static bundles from the compiled Angular `browser/` directory.
@@ -32,9 +32,10 @@ Command:
lobby.tests.StartRoundTests
```
Result:
Result (2026-03-02):
- `Ran 28 tests ... OK`
- `Ran 28 tests in 24.781s — OK`
- `System check identified no issues (0 silenced).`
- Confirms resolver behavior for locale normalization + fallback and stable error payload fields across flow endpoints.
## Notes
@@ -0,0 +1,27 @@
# ISSUE-252 Artifact — React fallback trigger criteria (delivery-blocking only)
Issue: **#252**
## Leveret ændring
Dokumentationen i `docs/spa-cutover-flag.md` er opdateret med en dedikeret sektion:
- **React fallback trigger-kriterier (kun delivery-blocking)**
- klare **tilladelses-kriterier** (alle skal være opfyldt)
- tydelige **scope-limits**
- eksplicitte **ikke-tilladte** anvendelser
## Acceptance mapping
1. **Clear trigger criteria**
- Definerer præcist hvornår fallback er tilladt:
- aktiv delivery-blocking fejl i Angular SPA
- ingen sikker Angular-fix inden release-vinduet
- rollback alene er utilstrækkelig for leveringsbehovet
- beslutning + evidens logges eksplicit (inkl. issue/incident-reference)
2. **Scope limits**
- Begrænset til delivery-blocking host/player-paths.
- Ingen feature-bundling eller ikke-kritiske ændringer.
- Midlertidig anvendelse kun i aktiv incident/release-vindue.
3. **When fallback is allowed**
- Kun når alle trigger-kriterier er opfyldt og dokumenteret.
## Resultat
Issue #252 er dokumenteret med operationelle guardrails, så React fallback kun bruges i kontrollerede, leveringsblokerende situationer.
@@ -0,0 +1,37 @@
# ISSUE-257 Artifact — shared i18n keyspace + frontend loader (Angular-first)
Issue: **#257** (`[MVP][READY] #175-B: Shared i18n keyspace + frontend loader (da/en, Angular-first)`)
## Acceptance mapping
### 1) Delt key-strategi dokumenteret (frontend/backend)
- Shared contract source: `shared/i18n/lobby.json`
- Architecture doc: `docs/I18N_ARCHITECTURE.md`
- Key-map/contract doc: `docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md`
### 2) Frontend loader kan indlæse da+en med samme keyspace
- Shared loader: `frontend/shared/i18n/lobby-loader.ts`
- Angular-first consumer path:
- `frontend/src/spa/lobby-i18n.ts`
- Angular shell/tests continue to consume same shared catalog through shared loader contract.
### 3) Minimal check for key-paritet da/en
- Guard test: `frontend/tests/lobby-loader.parity.test.ts`
- Contract test: `frontend/tests/lobby-i18n.contract.test.ts`
### 4) Ingen API-kontraktbrud
- Frontend API contract smoke:
- `frontend/angular/src/app/api-contract-smoke.spec.ts`
- `frontend/tests/angular-api-client.test.ts`
## Verification run (this lane)
```bash
cd frontend
npm test -- --run tests/lobby-loader.parity.test.ts tests/lobby-i18n.contract.test.ts tests/angular-api-client.test.ts
cd ../frontend/angular
npm test -- --run src/app/api-contract-smoke.spec.ts
```
Result: PASS (all selected suites green).
@@ -0,0 +1,80 @@
# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP)
Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`)
## Artifact metadata
- `artifact_id`: `issue-277-shared-i18n-parity-report`
- `artifact_version`: `1.0`
- `catalog_source`: `shared/i18n/lobby.json`
- `generator`: `scripts/report_i18n_parity.py`
## Naming/version rules (email-manager-inspired strategy)
- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE-<nr>-<slug>-ARTIFACT.md`.
- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops.
- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer.
- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter.
- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter.
## MVP-critical parity summary
- Frontend UI gameplay keys checked: **16**`OK`
- Frontend error keys checked: **7**`OK`
- Backend gameplay/error codes checked: **9**`OK`
- Distinct frontend error keys reached from backend MVP flow: **6** (`join_failed, nickname_invalid, nickname_taken, session_code_required, session_not_found, start_round_failed`)
Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.**
## Django ↔ Angular parity matrix (MVP-critical error contract)
| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note |
|---|---|---|---|---|
| `session_code_required` | `session_code_required` | `session_code_required` | `OK` | 1:1 |
| `nickname_invalid` | `nickname_invalid` | `nickname_invalid` | `OK` | 1:1 |
| `session_not_found` | `session_not_found` | `session_not_found` | `OK` | 1:1 |
| `session_not_joinable` | `session_not_joinable` | `join_failed` | `OK` | mapped alias |
| `nickname_taken` | `nickname_taken` | `nickname_taken` | `OK` | 1:1 |
| `category_slug_required` | `category_slug_required` | `start_round_failed` | `OK` | many:1 collapse |
| `category_not_found` | `category_not_found` | `start_round_failed` | `OK` | many:1 collapse |
| `round_start_invalid_phase` | `round_start_invalid_phase` | `start_round_failed` | `OK` | many:1 collapse |
| `round_already_configured` | `round_already_configured` | `start_round_failed` | `OK` | many:1 collapse |
## Scope notes
- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`.
- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`.
- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry.
## Verified MVP gameplay UI keyspace present in the shared registry
- `frontend.ui.host.title`
- `frontend.ui.player.title`
- `frontend.ui.common.session_code`
- `frontend.ui.player.nickname`
- `frontend.ui.player.join`
- `frontend.ui.host.start_round`
- `frontend.ui.host.show_question`
- `frontend.ui.player.lie_label`
- `frontend.ui.player.submit_lie`
- `frontend.ui.player.submit_guess`
- `frontend.ui.host.mix_answers`
- `frontend.ui.host.calculate_scores`
- `frontend.ui.host.load_scoreboard`
- `frontend.ui.host.final_leaderboard`
- `frontend.ui.player.final_leaderboard`
- `frontend.ui.common.points_short`
## Concrete deviations / follow-up items
1. **Error granularity collapse remains intentional**: backend codes `category_slug_required, category_not_found, round_start_invalid_phase, round_already_configured` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message.
2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states.
3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular.
## Re-run
```bash
python3 scripts/check_i18n_drift.py
python3 scripts/report_i18n_parity.py
python3 scripts/check_i18n_parity_artifact.py
```
+36
View File
@@ -0,0 +1,36 @@
# Issue #278 Artifact — smoke/e2e gate for da+en locale flow and primary-only audio
## Scope
Acceptance for `[READY][#175][P4]`:
1. Verify one MVP host+player smoke run in `en`.
2. Verify one MVP host+player smoke run in `da`.
3. Verify audio routing remains `primary-device only` so phone/player clients never take playback ownership.
Dette er en gate-/evidensleverance. Ingen ny produktfunktion ud over test/verifikation.
## Implemented smoke gate
Angular smoke spec: `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
The gate now runs two explicit locale scenarios:
- `en`: host refresh/start-round copy + player submit-guess copy
- `da`: samme flow med dansk copy
Audio-policy delen af samme smoke-spec verificerer:
- host/primary playback path er uændret før player mount
- player mount installerer no-audio guard på secondary device
- guard fjernes igen ved unmount, så primary path fortsat er eneste aktive output
## Recommended verification command
Køres fra `frontend/angular`:
```bash
npm test -- --run src/app/i18n-mvp-flow-smoke.spec.ts src/app/lobby-i18n.spec.ts src/app/features/player/player-shell.component.spec.ts
```
## Why this is the gate
- `i18n-mvp-flow-smoke.spec.ts` giver en lille, samlet smoke/e2e-lignende verifikation af host+player i begge locale-kontekster.
- `lobby-i18n.spec.ts` holder shared locale propagation + contract fallback grøn.
- `player-shell.component.spec.ts` dækker den dybere regressionflade for audio-guard på secondary device.
## Conclusion
Gateen verificerer nu eksplicit begge locale-runs (`da` + `en`) og bekræfter primary-only audio routing i MVP-flowet.
+168
View File
@@ -0,0 +1,168 @@
# ISSUE-279 — i18n MVP close-out note
Issue: **#279** (`[READY][#175][P5] MVP close-out note: migration/changelog + release-readiness checklist for i18n`)
## Scope
Dette dokument lukker MVP-sporet for issue #175 med tre konkrete ting:
1. en migrationsnote for release/deploy,
2. changelog-indhold der kan genbruges i næste release-note,
3. en release-readiness checkliste for i18n, forankret i et verificeret snapshot af `main` ved reviewtidspunktet.
Repo-state ved review-opdatering:
- `main` peger nu på merge commit `1bc4c27` (PR #283), og inkluderer også PR #282 via merge commit `6ad5430`.
- Denne note er opdateret mod repo-tilstanden verificeret 2026-03-13 UTC, ikke en løbende garanti for senere `main`-ændringer.
- Denne revision afløser de tidligere snapshot-antagelser fra PR-historikken, hvor #282/#283 endnu ikke var landet.
- Der er ingen åbne release-afklaringer tilbage for PR #282/#283; begge er allerede landet på `main`.
## Current i18n MVP state on `main`
Følgende er allerede til stede på `main`:
- **Shared contract** i `shared/i18n/lobby.json`
- default locale: `en`
- supported locales: `en`, `da`
- fælles frontend/backend keyspace + fallback-regler
- **Django bootstrap** via `partyhub/i18n_bootstrap.py` og `partyhub/settings.py`
- `LocaleMiddleware` aktiv
- `LANGUAGE_CODE` + `LANGUAGES` bootstrappes fra shared catalog
- **Backend locale/error flow** via `lobby/i18n.py`
- normalisering af locale-tags
- locale-aware fejlpayload med `error_code`, `error`, `locale`
- fallback til `en` når locale eller oversættelse mangler
- **Angular MVP wiring** via
- `frontend/shared/i18n/lobby-loader.ts`
- `frontend/angular/src/app/lobby-i18n.ts`
- host/player shells med locale switch og shared copy-opslag
- **Drift/parity guardrails**
- `shared/i18n/key-manifest.json`
- `scripts/check_i18n_drift.py`
- frontend parity/contract tests
- **Existing documentation/artifacts**
- `docs/I18N_ARCHITECTURE.md`
- `docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md`
- `docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md`
- `docs/ISSUE-257-SHARED-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md`
- `docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md`
- `docs/i18n-drift-check.md`
## Migration note for release
### Schema impact
**Der er ingen nye Django-migrations i selve i18n-MVP-sporet på `main`.**
Den i18n-relaterede leverance ligger i shared catalog, locale-bootstrap, error-payload-kontrakt, Angular wiring og test/documentation. Den kræver derfor ikke en særskilt i18n-database-migration for at gå i release.
### Release/deploy expectation
Selv om issue #279 ikke introducerer schemaændringer, skal release-flow stadig følge repoets generelle migreringsgate:
```bash
python manage.py makemigrations --check --dry-run
python manage.py migrate --check --noinput
```
Hvorfor: release-policyen kræver, at vi undgår code/schema drift, og staging-smoke-suiten forventer eksplicit migration consistency check.
### Praktisk migrationskonklusion
Til release-notes/deploy-runbook kan i18n-sporet beskrives sådan her:
- **Migration impact:** none for i18n MVP itself
- **Deploy requirement:** run standard Django migration consistency checks anyway
- **Rollback note:** rollback er primært kode-/asset-baseret (shared catalog, frontend bundles, backend locale resolver), ikke schema-baseret
## Suggested changelog content
Følgende tekst kan bruges direkte i næste unreleased/release-sektion:
```markdown
### i18n
- Shared da/en lobby i18n contract is now wired across Django and Angular MVP flows via `shared/i18n/lobby.json`.
- Backend error payloads expose stable locale-aware fields (`error_code`, `error`, `locale`) with fallback to English for unsupported locales.
- Angular host/player shells now consume shared i18n copy, persist preferred locale, and keep audio-policy messaging aligned with the shared catalog.
- Added repo guardrails for i18n drift/parity through the shared key manifest, drift checker, and focused frontend/backend contract tests.
- Release migration impact for the i18n MVP is **none** beyond the standard Django migration consistency checks.
```
Kort version til annoterede release-notes:
```markdown
## i18n MVP close-out
- Shared da/en contract is active across backend + Angular MVP shell.
- Locale fallback remains `en` for unsupported requests and missing translations.
- No i18n-specific schema migration is required; keep standard `migrate --check --noinput` in release verification.
```
## Release-readiness checklist for i18n
Status er vurderet mod verificeret snapshot `main@1bc4c27` (reviewet 2026-03-13 UTC, inkl. PR #282/#283).
### 1) Shared contract and locale behavior
- [x] Shared catalog findes i `shared/i18n/lobby.json`.
- [x] Default/supported locales er dokumenteret og implementeret som `en` + `da`.
- [x] Backend bruger shared contract til locale-aware fejlbeskeder.
- [x] Frontend/Angular bruger shared loader + shared keyspace.
- [x] Fallback-regel til `en` er dokumenteret og testet.
### 2) Verification artifacts and local checks
- [x] Arkitektur-note findes: `docs/I18N_ARCHITECTURE.md`.
- [x] Baseline artifact for issue #175 findes.
- [x] Backend artifact for issue #225 findes.
- [x] Frontend/shared loader artifact for issue #257 findes.
- [x] Drift-check dokumentation findes i `docs/i18n-drift-check.md`.
- [x] Parity artifact fra issue #277 er på `main` via PR #282 (merge commit `6ad5430`).
### 3) Code readiness on current branch topology
- [x] Angular MVP host/player i18n flow er på `main` (PR #281).
- [x] Shared locale/bootstrap wiring er på `main`.
- [x] Django i18n hardening fra issue #275 er på `main` via PR #283 (merge commit `1bc4c27`).
- [x] PR #283 er ikke længere en separat release-afklaring; hardeningen er allerede indarbejdet på `main`.
### 4) Release gate before shipping i18n as “done”
- [x] PR #282 er allerede merged, så parity-artifact-status er afklaret på `main`.
- [x] PR #283 er allerede merged, så backend hardening-status er afklaret på `main`.
- [ ] Kør drift-check fra repo root:
```bash
python3 scripts/check_i18n_drift.py
```
- [ ] Kør backend i18n regressions:
```bash
. .venv/bin/activate && python manage.py test \
partyhub.tests_i18n_bootstrap \
lobby.tests.I18nResolverTests
```
- [ ] Kør frontend shared-contract/parity checks:
```bash
cd frontend && npm test -- --run \
tests/lobby-loader.parity.test.ts \
tests/lobby-i18n.contract.test.ts
```
- [ ] Kør Angular MVP locale smoke:
```bash
cd frontend/angular && npm test -- --run \
src/app/lobby-i18n.spec.ts \
src/app/i18n-mvp-flow-smoke.spec.ts \
src/app/features/host/host-shell.component.spec.ts \
src/app/features/player/player-shell.component.spec.ts
```
- [ ] Bekræft standard migration consistency gate:
```bash
. .venv/bin/activate && python manage.py makemigrations --check --dry-run
. .venv/bin/activate && python manage.py migrate --check --noinput
```
- [ ] Følg `docs/RELEASE_POLICY.md`: staging deploy, `/healthz`, smoke-resultat og changelog-reference før tag.
## Close-out conclusion
**Konklusion:** i18n-MVP'en er implementeret på `main`, og issue #279 leverer den manglende release-/migration-closeout dokumentation uden nye kodeændringer i app-logikken.
PR #282 (parity artifact) og PR #283 (Django i18n hardening) er nu begge merged på `main`, så close-out-noten, changelog-teksten og release-readiness-checklisten kan behandles som indbyrdes konsistente for det verificerede snapshot.
Det betyder, at de resterende release-gates for i18n nu er de almindelige verificeringstrin (drift-check, backend/frontend-smoke, migrations-konsistens, staging deploy og changelog-reference) — ikke længere afklaring af om #282/#283 skal lande.
@@ -0,0 +1,22 @@
# Issue #287 — Canonical round-flow backend artifact
## State-transition matrix
| Trigger | From | To | Server-owned effect |
|---|---|---|---|
| `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar |
| Sidste gyldige `submit_lie` for aktivt spørgsmål | `lie` | `guess` | Dedupe/shuffle `correct_answer + lies`, persisterer `mixed_answers`, broadcaster `phase.guess_started` |
| Sidste gyldige `submit_guess` for aktivt spørgsmål | `guess` | `reveal` | Beregner score deterministisk, persisterer `ScoreEvent` + opdaterede `Player.score`, returnerer canonical reveal payload |
| Første canonical state-read efter resolved reveal (`session_detail`, og idempotent `GET /scoreboard` hvis state allerede er resolved) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness |
| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard` | `lie` | Increment round counter, kopierer seneste `RoundConfig`, vælger/låser næste spørgsmål i samme kategori og broadcaster `phase.lie_started` |
| `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard |
## Flow-log (happy path)
1. Host starter runde med kategori.
2. Server vælger straks spørgsmål og går i `lie`.
3. Spillere sender løgne; sidste submission auto-advancer til `guess`.
4. Spillere sender gæt; sidste submission auto-advancer til `reveal` og scorer runden.
5. Næste canonical state-read promoverer resolved reveal til `scoreboard`; state findes uden separat debug-knap.
6. Host kan nu kun vælge `next round` eller `finish game`.
7. `next round` starter næste runde direkte i `lie` med nyt konkret spørgsmål; ingen mellem-hop tilbage til `lobby`.
@@ -0,0 +1,52 @@
# Issue #301 Artifact — Client action gating from canonical phase state
Refs: #287, #301
## What changed
Frontend host/player shells now prefer the canonical phase exposed by `phase_view_model.current_phase` when deciding:
- which gameplay actions are enabled
- whether reveal data should still be shown
- which SPA hash-route should represent the active game state
This tightens the #301 slice so the client stays aligned with backend canonicalisation even when `session.status` lags during reveal/scoreboard promotion.
## Gated UI actions by phase
### Lobby
- **Host:** `startRound`
- **Player:** `join`
### Bluff / lie
- **Host:** `showQuestion`
- **Player:** `submitLie`
- **Blocked:** guess submission, scoreboard load, next round, finish game
### Guess
- **Host:** `mixAnswers`, `calculateScores`
- **Player:** `submitGuess`
- **Blocked:** lie submission, scoreboard load, next round, finish game
### Reveal
- **Host:** `loadScoreboard`
- **Player:** display-only reveal state
- **Blocked:** start next round, finish game, guess/lie submission
### Scoreboard
- **Host:** `startNextRound`, `finishGame`
- **Player:** display-only reveal/scoreboard state
- **Blocked:** scoreboard reload, guess/lie submission
## Test evidence
Targeted tests added/updated for:
- host shell canonical gating and route sync when `current_phase` differs from `session.status`
- player shell canonical gating and route sync when `current_phase` differs from `session.status`
- shared gameplay phase machine gating from canonical permissions
- shared API mapper contract coverage, including reveal/scoreboard payload stability
## Contract note
No backend protocol redesign was introduced. This follow-up only preserves and consumes the existing canonical phase/action contract more strictly on the client side.
+55
View File
@@ -0,0 +1,55 @@
# Issue #302 Evidence — canonical bluff → guess → reveal → scoreboard regression
## Runnable command
```bash
python manage.py migrate --noinput
python manage.py smoke_staging --artifact docs/artifacts/issue-302-canonical-loop-smoke.json
```
`migrate` is the normal local bootstrap precondition when the database has not been initialized yet; the regression evidence itself is produced by `smoke_staging`.
## What the regression proves
`smoke_staging` now exercises one full canonical round and fails fast with step-specific diagnostics if any of these break:
1. `start_round` lands the session in `lie` and returns a concrete `round_question_id`.
2. Final `submit_lie` auto-advances the session to `guess` and exposes mixed answers containing both the correct answer and player bluffs.
3. Final `submit_guess` auto-advances the session to `reveal` and returns the canonical reveal payload.
4. The reveal payload includes:
- correct answer
- all lies
- all guesses
- fooled-player references for bluff hits
5. The first canonical state read after reveal promotes the session to `scoreboard`.
6. Scoreboard promotion preserves the same reveal payload and exposes a leaderboard with `scoreboard_ready=true`.
## Artifact shape
When `--artifact` is provided, the JSON file records:
- the exact smoke command
- session code and round question id
- deterministic guess plan used to produce both bluff hits and one correct guess
- per-step evidence for:
- `create_session`
- `join_players`
- `start_round`
- `auto_guess_transition`
- `submit_guesses`
- `auto_reveal_transition`
- `auto_scoreboard_transition`
- `finish_game`
- reveal summary (`correct_answer`, lie/guess counts, fooled-player ids, correct guess player ids)
- promoted scoreboard leaderboard payload
## Targeted test coverage
Backend regression coverage lives in `lobby/tests.py`:
- `test_smoke_staging_command_runs_full_flow`
- `test_smoke_staging_writes_phase_evidence_artifact_when_requested`
Together they ensure the command stays runnable in normal workflow and that the evidence artifact contains phase-by-phase proof instead of only a generic pass/fail.
Refs #287 #302
@@ -0,0 +1,33 @@
# Issue #310 — Host transition idempotency and error catalog
## Scope
This artifact hardens the two host-owned scoreboard exits in the canonical gameplay flow:
- `POST /lobby/sessions/{code}/rounds/next`
- `POST /lobby/sessions/{code}/finish`
The goal is retry-safe host behavior when the scoreboard transition already succeeded server-side but the client retries because of a duplicate click, timeout, or lost response.
## Transition contract
| Endpoint | First valid transition | Idempotent replay state | Replay result | Broadcast behavior | Still-invalid states |
|---|---|---|---|---|---|
| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard -> lie` | `lie` with persisted current-round bootstrap (`RoundConfig` + `RoundQuestion`) | `200 OK` with the same canonical next-round payload shape | `phase.lie_started` fires only on the first transition | `lobby`, `guess`, `reveal`, `finished``next_round_invalid_phase` |
| `POST /lobby/sessions/{code}/finish` | `scoreboard -> finished` | `finished` | `200 OK` with the same final leaderboard payload shape | `phase.game_over` fires only on the first transition | `lobby`, `lie`, `guess`, `reveal``finish_game_invalid_phase` |
## Error catalog notes
No new backend error codes were introduced for this slice.
The contract change is behavioral:
- `next_round_invalid_phase` now means the session is in a phase where the scoreboard → next-round transition has **not** already been completed, or the expected bootstrap artifact for the already-started round is missing.
- `finish_game_invalid_phase` now means the session is in a phase where the scoreboard → finish transition has **not** already been completed.
- Successful replays are returned as normal `200 OK` canonical responses instead of phase errors.
## Acceptance evidence
- Repeated `rounds/next` calls after a successful scoreboard exit return the same canonical lie/bootstrap payload without incrementing the round twice.
- Repeated `finish` calls after a successful scoreboard exit return the same finished leaderboard payload without rebroadcasting game-over.
- Wrong-phase calls outside those replay states still return the existing shared error codes.
@@ -0,0 +1,202 @@
# Issue #312 — FupOgFakta extraction map for logic currently living in `lobby/`
Parent: #311
Issue: #312
## Purpose
This artifact documents the concrete FupOgFakta-specific logic that still lives in `lobby/`, separates it from true platform/session concerns, and names the intended destination ownership before any larger code move happens.
It is intentionally an inventory + extraction plan only. It does **not** perform the full move.
## Architectural boundary this map is enforcing
The target boundary is already described in:
- `docs/plans/2026-03-09-fupogfakta-game-engine-design.md`
- `docs/plans/2026-03-09-fupogfakta-implementation-plan.md`
- `docs/ARCHITECTURE.md`
Those docs consistently describe:
- `lobby/` as the **platform layer** for session lifecycle, player presence, host ownership, generic game-run orchestration, and transport-facing platform concerns.
- `fupogfakta/` as the **game cartridge** that owns question selection rules, round config semantics, lie/guess/reveal/scoreboard flow, answer mixing, scoring, and game-specific response/event payloads.
In other words:
- **Platform (`lobby/`)** should know that a session exists and that a game can be started/observed.
- **Cartridge (`fupogfakta/`)** should know what a lie is, what a guess is, how answers are mixed, when phases advance, and what payload shape those game phases expose.
## Summary split
### Generic platform/session concerns that belong in `lobby/`
These are not FupOgFakta-specific and should remain platform-owned:
- Session code parsing/generation:
- `lobby/views.py::_generate_session_code`
- `lobby/views.py::_normalize_session_code`
- `lobby/views.py::_create_unique_session_code`
- Generic request parsing:
- `lobby/views.py::_json_body`
- Session lifecycle and player presence endpoints:
- `lobby/views.py::create_session`
- `lobby/views.py::join_session`
- `lobby/views.py::session_detail` **only for the generic session/player shell part**
- Generic ownership / host authorization checks
- Generic session detail payload fields:
- `session.code`
- `session.status`
- `session.host_id`
- `session.current_round`
- `session.players_count`
- `players[].id|nickname|score|is_connected`
- Generic i18n/error transport helper usage:
- `lobby/i18n.py`
- `api_error(...)`
- Route mounting / namespace ownership in `lobby/urls.py` for platform routes only
### FupOgFakta-specific logic currently misplaced in `lobby/`
These items are game-cartridge logic and should move behind `fupogfakta/` ownership:
- Round question selection by category and previously-used questions
- Lie-phase payload construction and lie timer semantics
- Mixed-answer preparation for bluff gameplay
- Guess correctness / fooled-player detection
- Bluff/correct-answer score resolution
- Reveal payload construction
- Reveal → scoreboard promotion rules
- Start round / mix answers / submit lie / submit guess / calculate scores / reveal scoreboard / next round / finish game gameplay endpoints
- Phase view-model booleans that encode FupOgFakta rules rather than generic platform readiness
## Extraction map
| Source file | Current function / concern | Why it is FupOgFakta-specific | Intended destination / owner |
| --- | --- | --- | --- |
| `lobby/views.py` | `_build_player_ref(player)` | Helper is only used to shape FupOgFakta reveal payloads; not a generic platform concern today. | `fupogfakta/serializers.py` or `fupogfakta/payloads.py` owned by cartridge. |
| `lobby/views.py` | `_build_reveal_payload(round_question)` | Encodes FupOgFakta reveal contract: lies, guesses, fooled-player refs, correct answer, prompt. | `fupogfakta/payloads.py::build_reveal_payload` or equivalent cartridge response builder. |
| `lobby/views.py` | `_build_leaderboard(session)` | Current implementation is generic-ish, but used exclusively by FupOgFakta scoreboard/finish flow and coupled to that response shape. | Short term: keep shared helper if multiple games will consume same contract; otherwise move to `fupogfakta/payloads.py` until a true shared scoreboard contract exists. |
| `lobby/views.py` | `_get_current_round_question(session)` | Depends on FupOgFakta `RoundQuestion` model and current-round semantics. | `fupogfakta/services/rounds.py` or `fupogfakta/queries.py`. |
| `lobby/views.py` | `_select_round_question(session, round_config)` | Implements FupOgFakta question selection rules by category, active questions, and not-yet-used question set. | `fupogfakta/services/rounds.py::select_round_question`. |
| `lobby/views.py` | `_build_lie_started_payload(session, round_config, round_question)` | Builds a FupOgFakta event/response contract for lie phase, including category, prompt, lie deadline, round question id. | `fupogfakta/payloads.py::build_lie_started_payload`. |
| `lobby/views.py` | `_prepare_mixed_answers(round_question)` | Bluff-answer dedupe and shuffle is core FupOgFakta gameplay logic. | `fupogfakta/services/answers.py::prepare_mixed_answers`. |
| `lobby/views.py` | `_resolve_scores(session, round_question, round_config)` | Applies FupOgFakta scoring rules for correct guesses and successful bluffs; depends on `Guess`, `LieAnswer`, `ScoreEvent`, `points_correct`, `points_bluff`. | `fupogfakta/services/scoring.py::resolve_scores`. |
| `lobby/views.py` | `_maybe_promote_reveal_to_scoreboard(session)` | Encodes FupOgFakta reveal completion semantics and scoreboard transition trigger. | `fupogfakta/services/phases.py::maybe_promote_reveal_to_scoreboard`. |
| `lobby/views.py` | `_build_phase_view_model(session, players_count, has_round_question)` | Most booleans are not platform-generic; they encode FupOgFakta phase names (`lie`, `guess`, `scoreboard`) and MVP constraints (`3-5 players`, round-question readiness, next-round/finish gating). | Split: keep platform-shell fields in `lobby/`; move game-specific readiness/action flags to `fupogfakta/payloads.py::build_phase_view_model` or cartridge driver payload builder. |
| `lobby/views.py` | `start_round(request, code)` | Starts FupOgFakta round, binds category, creates `RoundConfig`, selects `RoundQuestion`, transitions to `LIE`, broadcasts `phase.lie_started`. | `fupogfakta/views.py` or cartridge command handler behind a future `GameDriver.on_game_start` / round bootstrap service. |
| `lobby/views.py` | `show_question(request, code)` | Emits lie-phase question payload using FupOgFakta `RoundQuestion` and `RoundConfig`. | `fupogfakta/views.py` or remove entirely once canonical driver flow owns the transition. |
| `lobby/views.py` | `submit_lie(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: lie validation, deadline semantics, auto-advance to guess phase, `phase.guess_started` payload. | `fupogfakta/views.py::submit_lie` (or cartridge intent handler). |
| `lobby/views.py` | `mix_answers(request, code, round_question_id)` | Manual FupOgFakta host action for lie→guess transition and answer mixing. | `fupogfakta/views.py` short term; long term likely deleted in favor of cartridge-driven automatic transition. |
| `lobby/views.py` | `submit_guess(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: validates answer choice, resolves correctness/bluff source, auto-calculates scores, transitions to reveal. | `fupogfakta/views.py::submit_guess` plus `fupogfakta/services/scoring.py` and `fupogfakta/services/phases.py`. |
| `lobby/views.py` | `reveal_scoreboard(request, code)` | FupOgFakta reveal/scoreboard progression, not a generic platform capability. | `fupogfakta/views.py::reveal_scoreboard` or cartridge phase service. |
| `lobby/views.py` | `start_next_round(request, code)` | FupOgFakta next-round bootstrap: copies prior `RoundConfig`, increments round, picks next question, re-enters lie phase. | `fupogfakta/services/rounds.py::start_next_round` plus cartridge-owned endpoint/driver integration. |
| `lobby/views.py` | `finish_game(request, code)` | Current finish path is tied to FupOgFakta scoreboard semantics and winner payload. | `fupogfakta/views.py::finish_game` until a truly generic platform finish contract exists. |
| `lobby/views.py` | `calculate_scores(request, code, round_question_id)` | Explicit FupOgFakta score resolution endpoint. | `fupogfakta/services/scoring.py` and/or remove when fully absorbed by cartridge phase driver. |
| `lobby/urls.py` | Gameplay routes for rounds, lies, guesses, scoreboard, finish | These route names expose FupOgFakta-specific phase/actions from the platform namespace. | Re-home under `fupogfakta/urls.py` or leave mounted under `/lobby/sessions/...` only as a temporary façade delegating to cartridge-owned code. |
| `lobby/tests.py` | `StartRoundTests`, `LieSubmissionTests`, `MixAnswersTests`, `GuessSubmissionTests`, `CanonicalRoundFlowTests`, `ScoreCalculationTests`, `RevealRoundFlowTests`, `SessionDetailRoundQuestionTests`, `SessionDetailPhaseViewModelTests`, `SmokeStagingCommandTests` | These test classes verify FupOgFakta game flow rather than platform mechanics. | Move/split into `fupogfakta/tests/` with only session creation/join/platform transport tests left in `lobby/tests.py`. |
| `lobby/management/commands/smoke_staging.py` | End-to-end gameplay smoke through lies/guesses/finish | Script executes one concrete game flow and should be cartridge-aware, not platform-owned. | `fupogfakta/management/commands/` or a shared smoke harness that delegates into cartridge-specific scenario runners. |
## Recommended ownership split by module
### Keep in `lobby/`
- Session creation/join and session-code lifecycle
- Generic player membership/presence reads
- Generic auth/host checks helpers (if extracted from views)
- Generic API error/i18n plumbing
- Future `GameRun` / driver orchestration, timers, and cartridge dispatch
- A slim generic `session_detail` envelope that can embed cartridge payloads under a dedicated game key
### Move to `fupogfakta/`
- Round state queries
- Question selection
- Lie/guess/reveal/scoreboard/finish transition rules
- Score calculation
- Answer mixing
- Gameplay payload/response builders
- Gameplay endpoints and tests
- Gameplay smoke command
## Explicit boundary for `session_detail`
`session_detail` is currently mixed.
### Generic part that should remain platform-owned
- Session identity/status metadata
- Player list / presence list
- Generic host/player capability envelope if it is game-agnostic
### FupOgFakta part that should move or be delegated
- `round_question` payload
- `reveal` payload
- `scoreboard` payload
- `phase_view_model` fields keyed to `lie`, `guess`, `scoreboard`, `finished`, `question_ready`, and 35-player MVP rules
A clean future shape would be:
```json
{
"session": {"code": "ABC123", "status": "active", "game_type": "fupogfakta"},
"players": [...],
"game": {
"phase": "lie",
"payload": {"round_question": {...}, "reveal": null, "scoreboard": null}
}
}
```
That makes `lobby/` the shell and `fupogfakta/` the authority for game-state payloads.
## Concrete extraction sequence
1. **Move pure helpers first**
- `_get_current_round_question`
- `_select_round_question`
- `_prepare_mixed_answers`
- `_resolve_scores`
- `_build_lie_started_payload`
- `_build_reveal_payload`
2. **Move gameplay endpoints behind cartridge-owned service functions**
- `submit_lie`
- `submit_guess`
- `start_round`
- `start_next_round`
- `finish_game`
- `reveal_scoreboard`
- `calculate_scores`
3. **Slim `session_detail` into platform envelope + delegated cartridge payload**
4. **Move gameplay tests out of `lobby/tests.py`**
5. **Optionally leave compatibility routes in `lobby/urls.py` as a façade** until clients are rewired
## Risks this map is explicitly preventing
- Moving only models but leaving hidden phase-transition rules in `lobby/views.py`
- Treating `session_detail` as platform-generic while it still leaks cartridge payload semantics
- Leaving scoreboard/reveal transition logic behind as an undocumented coupling
- Splitting tests incorrectly so regressions stay "green" in `lobby/` while FupOgFakta behavior silently drifts
## Decision
For #311 / #312, the repository should treat the following as **game-specific and extraction candidates**:
- round-question selection
- lie/guess/reveal/scoreboard/finish transitions
- answer mixing
- score resolution
- reveal/scoreboard payload builders
- FupOgFakta-specific session-detail subpayloads
- gameplay flow tests and smoke command
And it should treat the following as **platform-generic**:
- session identity/lifecycle
- player presence/membership
- host authorization shell
- generic error transport
- future game-driver dispatch/orchestration
That is the explicit `lobby` vs `fupogfakta` boundary this issue needs before code extraction proceeds.
+7 -5
View File
@@ -10,11 +10,13 @@ Sikre at release-tags altid repræsenterer faktisk deployet software.
## 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.
2. Kør den lokale MVP gate: `./scripts/verify_mvp_release.sh`.
3. Bekræft tester ikke er aktiv.
4. Kør helst `infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]`.
5. Hvis wrapper ikke bruges: deploy med `infra/staging/deploy_staging.sh` og kør derefter `infra/staging/run_mvp_smoke.sh`.
6. Verificér `/healthz` + smoke-resultat.
7. Tilføj changelog-entry i `CHANGELOG.md`.
8. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
## Minimum release-notes template
```markdown
@@ -0,0 +1,69 @@
# SPA visual + realtime smoke artifact
## Purpose
This is the Batch 6 manual evidence lane for the presenter-host and player-phone overhaul. Use it when `USE_SPA_UI=true` and you need reviewable proof that the Angular host/player shells behave correctly across realtime reconnects, role-based visibility, and multi-device presentation.
The automated companion lane for this checklist is:
```bash
npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts
```
## When to capture it
- Staging or local smoke after a host/player visual or realtime change.
- Before asking for SPA cutover confidence beyond unit-level component coverage.
- When reconnect recovery or developer-state safety changed and reviewers need concrete device evidence.
## Evidence template
```markdown
### SPA visual + realtime smoke evidence
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
- Environment: <local/staging>
- Commit/Head SHA: <sha>
- `USE_SPA_UI`: `true`
- Locale: <en/da>
- Devices: projected host + <N> player phones/tabs
#### Setup
- Host route: `/lobby/ui/host`
- Player route: `/lobby/ui/player`
- Session code: <code>
- Participants joined: <list or count>
- Developer-state left OFF by default before evidence capture: <yes/no>
#### Checks (PASS/FAIL)
1. Presenter-only question visibility
- Host lie/presenter scene shows the active prompt: <pass/fail>
- Player phones stay prompt-hidden until the allowed phase payload reveals it: <pass/fail>
2. Reconnect recovery
- Disconnect one player device or throttle network during an active lie/guess input: <pass/fail>
- Reconnect badge/card appears without clearing the local draft/selection: <pass/fail>
- Recovered websocket push resumes before the 3s polling fallback becomes the steady-state transport: <pass/fail>
3. Multi-device reveal + scoreboard
- At least 3 player devices reach reveal and scoreboard together: <pass/fail>
- Host projected scene remains presenter-grade through reveal and final standings: <pass/fail>
- Shared player identity tokens/colors/icons stay consistent between the projected host roster and player-phone developer-state snapshots: <pass/fail>
4. Developer-state safety
- Host developer-state screenshot or recording captured separately from the default presenter screen: <pass/fail>
- Player developer-state screenshot or recording captured separately from the default phone UI: <pass/fail>
- Host/player developer-state captures show the current `phase_display.theme` and `phase_display.ornament` tokens plus each player `identity.token` and `identity.icon` so contract-driven scene art/copy and roster styling can be traced back to the payload: <pass/fail>
- At least one lie/guess/reveal capture shows an authored question ornament slug from admin/bootstrap content instead of only the deterministic fallback set: <pass/fail>
5. Optional host voice cue check
- Host-only voice playback still routes on the primary device when enabled: <pass/fail/not-run>
#### Artifact pointers
- Automated smoke command: `npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts`
- Screenshot/video refs:
- host projected scene: <ref>
- reconnect recovery: <ref>
- reveal/scoreboard multi-device: <ref>
- host/player developer-state: <ref>
- Result: <PASS/FAIL>
- If FAIL: blocker link + shortest repro
```
## Minimum acceptable artifact
- One projected host screenshot during lie, reveal, or scoreboard.
- One player-device capture showing reconnect recovery or input preservation.
- One reveal or scoreboard capture with 3+ player devices.
- Separate host and player developer-state captures so diagnostics stay out of the default presentation.
+12 -13
View File
@@ -5,6 +5,7 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
## Guardrails (MVP)
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
- Kun verifikation af eksisterende flow; ingen nye features/polish.
- Primær MVP release-gate bruger legacy UI med `USE_SPA_UI=false`.
## Hvornår bruges artifacten
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
@@ -22,18 +23,13 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
- Host authenticated in Django admin: <yes/no>
- Active category/questions present: <yes/no>
- Participants: host + <N> players
- `USE_SPA_UI`: <on/off>
- `USE_SPA_UI`: `false`
- `WPP_SPA_ASSET_VERSION`: <release-token/sha>
- UI route used:
- OFF (legacy): `/lobby/ui/host` + `/lobby/ui/player`
- ON (SPA shell): `/lobby/ui/host/<spa-path>` + `/lobby/ui/player`
- UI routes used: `/lobby/ui/host` + `/lobby/ui/player`
#### Checks (PASS/FAIL)
0. Same release-window verification
- OFF + ON smoke kørt i samme release-vindue: <pass/fail>
1. Cutover route sanity
- Flag OFF serves legacy UI templates: <pass/fail>
- Flag ON serves SPA shell on expected path(s): <pass/fail>
1. Legacy route sanity
- Host/player legacy templates svarer korrekt: <pass/fail>
2. Lobby -> join -> start
- Mixed-case + whitespace session code accepted: <pass/fail>
3. One full round to scoreboard
@@ -42,10 +38,10 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
- next round transitions: <pass/fail>
- final leaderboard visible: <pass/fail>
#### Smoke-gate decision (før `USE_SPA_UI=true`)
#### MVP smoke-gate decision
- Gate status: <GREEN/RED>
- Gate criteria met:
- [ ] Cutover route sanity = PASS (OFF + ON)
- [ ] Legacy route sanity = PASS
- [ ] Full gameplay round = PASS
- [ ] Next-round/final leaderboard sanity = PASS
- [ ] Ingen nye blocker-regressioner i host/player flow
@@ -53,10 +49,10 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
#### Rollback checkpoint
- Rollback required: <yes/no>
- Trigger reason (if yes): <kort trigger>
- Rollback done (`USE_SPA_UI=false`) verified: <yes/no>
- Rollback done (`USE_SPA_UI=false`) verified: <yes/no/not-needed>
#### Evidence pointers
- Command(s): `<exact command(s)>`
- Command(s): `./infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]` or `./infra/staging/run_mvp_smoke.sh [artifact-path]`
- UI notes/screenshots/log refs: <short refs>
- Result: <PASS/FAIL>
- If FAIL: blocker issue link + shortest repro
@@ -64,3 +60,6 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
## 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.
## SPA cutover note
Hvis der køres separat SPA-cutover, dokumenteres det i et særskilt artifact med henvisning til `docs/spa-cutover-flag.md`. Brug i så fald `ALLOW_SPA_CUTOVER=1` eksplicit ved staging smoke.
+28 -41
View File
@@ -1,48 +1,35 @@
# UI smoke (MVP)
## Forudsætning
- Host er logget ind i Django.
- Mindst én aktiv kategori med spørgsmål findes.
## MVP path
- Current MVP path: `USE_SPA_UI=false`
- Canonical routes: `/lobby/ui/host` + `/lobby/ui/player`
- SPA shell verification is follow-up cutover work; keep it out of the primary MVP smoke.
## Cutover-forudsætning (`USE_SPA_UI`)
- `USE_SPA_UI=false` (default): brug legacy routes `/lobby/ui/host` + `/lobby/ui/player`.
- `USE_SPA_UI=true`: host må gerne testes på SPA deep-link route `/lobby/ui/host/<spa-path>` (fx `/lobby/ui/host/guess`), player på `/lobby/ui/player`.
## Preconditions
- Host can log in through Django.
- At least one active category with questions exists.
- Recommended local bootstrap: `python manage.py bootstrap_mvp`
- Fastest local setup: `./scripts/run_local_mvp_smoke.sh` and then keep the stack running for browser follow-up.
## Flow
1. Verificér cutover-route matcher valgt flag (legacy vs SPA shell).
2. Åbn host-siden og tryk Opret session.
3. Åbn player-siden i 3 faner/enheder.
4. Join alle spillere med sessionkode og nickname.
5. Host: vælg kategori, Start runde, Vis spørgsmål.
6. Spillere: brug round_question_id og submit løgn.
7. Host: Mix svar.
8. Spillere: submit gæt.
9. Host: Beregn score og Vis scoreboard.
10. Host: Næste runde eller Afslut spil.
1. Confirm `USE_SPA_UI=false`.
2. Open `/lobby/ui/host` and create a session.
3. Open `/lobby/ui/player` in 3 tabs or devices.
4. Join all players with the session code and nicknames.
5. Host selects a category, starts the round, and shows the question.
6. Players submit lies.
7. Host mixes answers.
8. Players submit guesses.
9. Host calculates scores and opens the scoreboard.
10. Host starts the next round or finishes the game.
## Smoke-gate (staging cutover)
`USE_SPA_UI` må kun aktiveres i staging når følgende er opfyldt:
- Cutover route sanity er PASS for både OFF (legacy) og ON (SPA shell).
- Én fuld gameplay-runde til scoreboard er PASS.
- Next-round/final leaderboard sanity er PASS.
- Ingen nye blocker-regressioner i host/player kerneflow.
## Pass criteria
- One full round reaches scoreboard without raw API calls.
- Error banners are absent in the host/player core flow.
- Session detail reflects the same phase on both screens.
- Finish-game path shows the final leaderboard.
## Samme release-vindue: SPA OFF + ON verifikation
Kør begge checks i samme release-vindue (samme deploy/artifact version):
1. **OFF-pass (legacy)**
- `USE_SPA_UI=false`
- Verificér legacy routes + fuld runde.
2. **ON-pass (SPA)**
- `USE_SPA_UI=true`
- Behold samme release artifact og kun toggl flag/version-token ved behov.
- Verificér SPA shell routes + fuld runde.
3. Dokumentér begge pass i samme smoke-artifact med UTC timestamps og `WPP_SPA_ASSET_VERSION`.
## Rollback check points
Skift straks tilbage til `USE_SPA_UI=false` hvis en gate fejler:
1. Verificér legacy routes (`/lobby/ui/host` + `/lobby/ui/player`) fungerer igen.
2. Log rollback trigger + kort repro i smoke artifact.
3. Opret/link blocker issue før nyt cutover-forsøg.
Resultat: En fuld runde kan køres uden rå API-kald fra terminal.
## Cutover note
If SPA shell validation is needed, use `docs/spa-cutover-flag.md` and `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md`. Those checks are not the primary MVP smoke gate.
For the presenter/player visual lane specifically, capture the manual evidence in `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md`.
For local SPA-only checks with the compose `spa` profile, start at `http://localhost:4200/`.
@@ -0,0 +1,110 @@
{
"ok": true,
"command": "python manage.py smoke_staging --artifact <path>",
"generated_at": "2026-03-16T15:19:30.105231+00:00",
"question": {
"prompt": "Smoke prompt?",
"correct_answer": "Correct"
},
"steps": [
{
"step": "create_session",
"session_status": "lobby"
},
{
"step": "join_players",
"players_count": 3
},
{
"step": "start_round",
"session_status": "lie",
"round_question_id": 1
},
{
"step": "auto_guess_transition",
"session_status": "guess",
"answers": [
"Lie from P3",
"Lie from P1",
"Lie from P2",
"Correct"
]
},
{
"step": "submit_guesses",
"guess_results": [
{
"player_id": 1,
"selected_text": "Lie from P2",
"is_correct": false,
"fooled_player_id": 2
},
{
"player_id": 2,
"selected_text": "Correct",
"is_correct": true,
"fooled_player_id": null
},
{
"player_id": 3,
"selected_text": "Lie from P1",
"is_correct": false,
"fooled_player_id": 1
}
]
},
{
"step": "auto_reveal_transition",
"session_status": "reveal",
"reveal": {
"correct_answer": "Correct",
"lies_count": 3,
"guesses_count": 3,
"fooled_player_ids": [
1,
2
],
"correct_guess_player_ids": [
2
]
}
},
{
"step": "auto_scoreboard_transition",
"session_status": "scoreboard",
"leaderboard": [
{
"id": 2,
"nickname": "P2",
"score": 7
},
{
"id": 1,
"nickname": "P1",
"score": 2
},
{
"id": 3,
"nickname": "P3",
"score": 0
}
]
},
{
"step": "finish_game",
"session_status": "finished"
}
],
"session_code": "7YV59E",
"players": [
"P1",
"P2",
"P3"
],
"round_question_id": 1,
"guess_plan": {
"P1": "Lie from P2",
"P2": "Correct",
"P3": "Lie from P1"
}
}
@@ -0,0 +1,272 @@
# Design: Fup og Fakta — Game Engine & Platform Architecture
**Date:** 2026-03-09
**Status:** Approved
---
## Overview
Build a working Fup og Fakta game (Fibbage-style) on top of a **pluggable game platform**. The platform handles sessions, players, WebSocket push, and Celery-driven timers. Each game is a self-contained **cartridge** that implements a shared driver interface and owns its own models, config, and phase logic.
---
## Platform Architecture
```
partyhub/ Django project — settings, Celery app, ASGI
lobby/ Platform layer — sessions, players, GameRun, timer dispatch
realtime/ WebSocket consumers (already built)
fupogfakta/ Game cartridge #1
future_game/ Game cartridge #N (same interface)
```
### Platform provides (`lobby/`)
#### Models
**`GameSession`** (exists, minor additions)
- `game_type` (CharField) — e.g. `"fupogfakta"`
- `host` (FK → User)
- `code` (6-char session code)
- `status` (LOBBY / ACTIVE / FINISHED)
- `config_id` / `config_snapshot` — see Config section
**`GameRun`** (new — ephemeral, deleted on game exit)
- `session` (OneToOne → GameSession)
- `current_state` (CharField — game-defined state string)
- `phase_deadline` (DateTimeField, nullable)
- `is_paused` (BooleanField, default False)
- `paused_remaining_seconds` (FloatField, nullable)
- `celery_task_id` (CharField, nullable)
- `state_data` (JSONField) — game-specific snapshot for current phase
**`Player`** (exists)
- `session`, `nickname`, `score`, `session_token`, `is_connected`
#### GameDriver interface
Each cartridge implements:
```python
class GameDriver:
game_type: str # e.g. "fupogfakta"
def on_game_start(session, run, config) -> PhaseResult
def on_timer_expired(session, run, config) -> PhaseResult
def on_pause(session, run) -> None
def on_resume(session, run) -> None
def on_exit(session, run) -> None # must clean up all game data
def get_ws_payload(state, state_data) -> dict
```
`PhaseResult` = `(next_state: str, duration_seconds: int | None, broadcast_payload: dict)`
#### Celery task
```python
@app.task
def handle_timer_expired(run_id: int, expected_state: str):
# If run no longer exists or state has changed → stale task, ignore
# Call driver.on_timer_expired(session, run, config)
# Apply PhaseResult: update run, broadcast via channel layer, schedule next task
```
`expected_state` prevents stale tasks from firing after pause/resume or manual state changes.
#### REST endpoints (platform-level)
- `POST /sessions/{code}/play` — start or resume
- `POST /sessions/{code}/pause` — pause current phase timer
- `POST /sessions/{code}/exit` — end game, delete GameRun + all game data
---
## Configuration System
### Base config model (`partyhub/`)
```python
class BaseGameConfig(models.Model):
class Meta:
abstract = True
name = models.CharField(max_length=100) # "Quick game", "Full evening"
user = models.ForeignKey(User, null=True, ...) # null = system default
is_default = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
```
### Game-specific config (`fupogfakta/`)
```python
class FupOgFaktaConfig(BaseGameConfig):
num_rounds = PositiveIntegerField(default=3)
questions_per_round = PositiveIntegerField(default=3)
min_players = PositiveIntegerField(default=2)
max_players = PositiveIntegerField(default=8)
lie_seconds = PositiveIntegerField(default=45)
guess_seconds = PositiveIntegerField(default=30)
reveal_seconds_per_lie = PositiveIntegerField(default=8)
scoreboard_recap_seconds = PositiveIntegerField(default=10)
# Escalating scoring per round (stored as arrays or separate fields)
points_correct = JSONField(default=[1500, 3000, 4500])
points_bluff = JSONField(default=[500, 1000, 1500])
# Reaction bonus (static, feeds post-game awards only)
reaction_bonus = IntegerField(default=5)
```
### Default resolution at session start
1. User has `is_default=True` row for this game type → use that
2. System default (`user=null, is_default=True`) — set in Django admin
3. Model field `default=` values (hardcoded)
User can have **multiple named presets** (one-to-many). When starting a session they choose which to use (or it auto-selects their default). The chosen config's values are **snapshotted into `GameRun.state_data`** at game start — immutable for the life of the session.
---
## Fup og Fakta — Game States
```
LOBBY
│ (host presses Play)
LIE_PHASE timer: lie_seconds
│ (all submitted OR timer expires)
GUESS_PHASE timer: guess_seconds
│ (timer expires — no mercy)
REVEAL_LIE_{n} timer: reveal_seconds_per_lie (one per lie with ≥1 guess)
│ → score liar incrementally as each is shown
REVEAL_TRUTH timer: reveal_seconds_per_lie
│ → score correct guessers
SCOREBOARD_RECAP timer: scoreboard_recap_seconds
├─ more questions in round → back to LIE_PHASE (next question)
├─ round done, more rounds → back to LIE_PHASE (next round, next category)
└─ all rounds done → POST_GAME_AWARDS
timer: configurable
→ FINISHED (GameRun deleted, GameSession status = FINISHED)
```
---
## Fup og Fakta — Phase Details
### LIE_PHASE
- Question shown to all clients via WebSocket (`phase.lie_started` event)
- Players submit lie via `POST /fupogfakta/{code}/lie`
- **If lie matches correct answer (case-insensitive):** return `error_code: lie_matches_correct_answer` — player prompted again, does not consume their submission
- Anonymous to other players during this phase
- `state_data` tracks: question id, round number, how many have submitted (for progress display on host screen)
- Timer expires → transition to GUESS_PHASE regardless of how many submitted
### GUESS_PHASE
- Answers mixed (lies + truth, deduped) broadcast to all clients (`phase.guess_started`)
- Players guess via `POST /fupogfakta/{code}/guess`
- **After selecting:** player can react to other lies with 👍 😂 ❤️ etc. until timer expires. Cannot change guess.
- Reactions stored in `LieReaction` model (player, lie, reaction_type)
- Timer expires → transition to first REVEAL_LIE (or REVEAL_TRUTH if no lies had guesses)
### REVEAL_LIE_{n}
- One Celery task per lie to reveal (only lies with ≥1 guesser)
- Broadcast: which lie, who wrote it, who guessed it (`phase.reveal_lie`)
- Score awarded to liar: `points_bluff[round_index] × guesser_count`
- Score broadcast immediately (`phase.score_delta`)
- Skipped lies (0 guesses): not shown at all
### REVEAL_TRUTH
- Broadcast: correct answer, who guessed correctly (`phase.reveal_truth`)
- Score awarded: `points_correct[round_index]` per correct guesser
- Also show reaction totals on each lie during this phase
### SCOREBOARD_RECAP
- Full leaderboard broadcast (`phase.scoreboard`)
- Auto-advances to next question, next round, or post-game
### POST_GAME_AWARDS
- Computed from `LieReaction` aggregate:
- "Most Hilarious Liar" — most 😂 reactions total
- "Most Beloved Lie" — most ❤️ reactions on a single lie
- etc. (extensible)
- Broadcast as `phase.awards`
- Then FINISHED → GameRun deleted, all session game data wiped
---
## Fup og Fakta — Models
**Existing (keep):** `Category`, `Question`, `RoundQuestion`, `LieAnswer`, `Guess`
**Remove:** `ScoreEvent` (no audit trail needed — game state is ephemeral)
**New:**
```python
class LieReaction(models.Model):
lie = ForeignKey(LieAnswer, on_delete=CASCADE)
player = ForeignKey(Player, on_delete=CASCADE)
reaction = CharField(max_length=20) # "laugh", "heart", "fire", etc.
created_at = auto_now_add
class Meta:
unique_together = [("lie", "player", "reaction")]
```
**Modify `RoundQuestion`:**
- Add `reveal_order` (PositiveIntegerField, nullable) — set when GUESS_PHASE ends, determines reveal sequence
---
## Pause / Resume
- **Pause:** compute `remaining = phase_deadline - now`, store in `paused_remaining_seconds`, set `is_paused=True`, revoke Celery task by `celery_task_id`
- **Resume:** set `phase_deadline = now + paused_remaining_seconds`, schedule new Celery task, clear pause fields
- Stale task guard: every Celery task checks `expected_state == run.current_state` before firing
---
## Host Controls (Session Owner Only)
| Action | Effect |
|--------|--------|
| Play | Starts game from LOBBY, or resumes from paused |
| Pause | Freezes current phase timer, broadcasts `phase.paused` |
| Exit | Ends game immediately, deletes GameRun + all game data |
Cannot skip. Cannot manually advance phases.
---
## WebSocket Event Reference
| Event | Triggered by | Payload |
|-------|-------------|---------|
| `phase.lie_started` | LIE_PHASE start | question prompt, deadline, round info |
| `phase.lie_progress` | Each lie submitted | n_submitted / n_players (no names) |
| `phase.guess_started` | GUESS_PHASE start | mixed answers, deadline |
| `phase.reveal_lie` | REVEAL_LIE_{n} | lie text, author, guessers, score delta |
| `phase.reveal_truth` | REVEAL_TRUTH | correct answer, correct guessers, score delta |
| `phase.scoreboard` | SCOREBOARD_RECAP | full leaderboard |
| `phase.awards` | POST_GAME_AWARDS | award winners |
| `phase.paused` | Pause | remaining_seconds |
| `phase.resumed` | Resume | new deadline |
| `phase.game_over` | FINISHED | final leaderboard |
---
## Data Lifecycle
All game session data (`GameRun`, `RoundQuestion`, `LieAnswer`, `Guess`, `LieReaction`, `Player`) is **deleted when host exits or game reaches FINISHED**. `GameSession` row is kept (with status=FINISHED) for the session code uniqueness constraint. `Category` and `Question` content is permanent.
---
## Not In Scope (This Implementation)
- TTS / read-aloud (Fase 4, deferred)
- Reconnect recovery after server restart (game is gone if server dies)
- Spectator/viewer mode (post-MVP)
- Rate limiting on endpoints (backlog)
- Bulk question import (Fase 5)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
# Host + Player Visual Overhaul Plan
**Date:** 2026-03-18
**Updated:** 2026-03-23
**Status:** Active
## Goal
- make the SPA host shell usable as the projected primary game screen
- make the SPA player shell a minimal mobile action surface instead of a secondary dashboard
- make phase changes feel shared across devices, without manual refresh in the normal path
- keep explicit developer-state available for host and player without contaminating the default presentation
## Product Decisions From Playtesting
- the authenticated host may still create the session and choose the game, but the first player to join becomes the lobby captain on a phone
- the lobby captain is the default pre-game operator on the player side:
- can start the game
- can confirm readiness
- can choose or rotate a player icon from a curated set when that is supported by the cartridge
- non-captain player devices should stay intentionally simple before game start:
- icon selection if the game supports it
- otherwise a passive "wait for the game to start" state
- once the game has started, player devices should only show the current player action:
- text input
- buttons
- draw area
- hidden private information when a cartridge needs it
- player devices should not show roster, session metadata, or broad game-state detail in the default UX
- pushed updates need to land across clients in the same phase-change window; perfect instant sync is not required, but manual update should not be part of the happy path
## Current State
- role-aware session-detail payloads already exist for host/player/public viewers
- websocket transport and reconnect fallback already exist
- host/player developer-state toggles already exist
- presenter/player visual styling, question ornaments, and host voice playback already exist
- the remaining gaps are mostly product-shape gaps:
- lobby captain flow is not yet the default
- the default player shell still exposes too much context
- synchronized multi-device updates are not yet strong enough for confidence
- authored player identity assets and presenter-copy content are still incomplete
## Non-Goals
- solving every future cartridge in this lane
- custom user-uploaded avatars
- full spectator mode
- native mobile apps
- replacing the existing REST contract wholesale
## Known Issues To Solve
- a player phone cannot yet reliably act as the pre-game "start game" controller
- some clients still feel stale until a manual update path is used
- the default player UI still behaves too much like a diagnostic shell
- the plan file itself previously drifted into websocket-only scope; this document is now the authoritative visual-overhaul plan again
## Verification
- `.venv/bin/python manage.py check`
- `.venv/bin/python manage.py test realtime.tests lobby.tests.SessionDetailPhaseViewModelTests fupogfakta.tests.FupOgFaktaExtractionSliceTests`
- `npm --prefix frontend test -- angular-api-client.test.ts`
- `npm --prefix frontend/angular test -- src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts src/app/realtime-visual-smoke.spec.ts`
- `npm --prefix frontend/angular run build`
- local or staging multi-device smoke captured with `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md`
## Batch 1 — Lobby Captain Flow
- add an explicit lobby-captain concept to the session contract
- the first player to join becomes the default captain
- expose captain capability to the player shell:
- start game
- ready/continue controls where appropriate
- pre-game icon selection when supported
- keep a clear fallback/override story for dev and staging:
- authenticated host can still inspect or recover the flow
- developer-state shows who the current captain is and why
- update lobby and gameplay rules so "who can start" is deterministic and testable
**Acceptance:** in the normal couch + TV flow, the first joined player can start the game from a phone without needing the projected host screen as an operator console.
## Batch 2 — Realtime Coherence
- treat websocket push as the primary phase-change transport
- make every significant phase transition emit enough information for all connected clients to converge without user action
- add or tighten revision/phase markers so stale refreshes do not leave some devices behind
- keep polling only as fallback and recovery
- if realtime recovery takes longer than a short threshold, show a clear reconnect notice instead of silently leaving stale UI on screen
- extend tests to cover one host plus three player clients moving through the same phase transition window
**Acceptance:** phase changes propagate across host and player clients together closely enough that manual update is not required in the happy path.
## Batch 3 — Simplified Player Phone UX
- reduce the default player shell to one main action surface per phase
- pre-game states:
- captain device: start game + identity/icon choice if supported
- non-captain devices: icon choice or passive wait state
- in-game states:
- input-only or choice-only when action is required
- simple waiting state when no action is required
- private hidden information panel when a cartridge needs it
- remove roster/session/debug context from the default player presentation
- keep developer-state behind the existing explicit toggle/query override
- preserve draft text, selections, and focus during background sync and reconnect
**Acceptance:** a player can glance at the phone and immediately know what to do, without seeing unnecessary room/session detail.
## Batch 4 — Presenter Host Experience
- keep the host screen presenter-first:
- lobby scene
- question/lie scene
- guess scene
- reveal scene
- scoreboard scene
- final result scene
- show the question prominently on the projected host even when players only get input controls
- display player icons/colors consistently across all presenter beats
- keep operational controls in a secondary backstage layer instead of the default projected surface
- integrate voice cues and uploaded audio into the presenter rhythm
- make the default lobby scene support the new player-captain model:
- projected host shows readiness and roster
- captain phone owns the start action
**Acceptance:** the host screen can stay on the TV as the main visual surface without asking the projected operator to click through the game.
## Batch 5 — Content, Assets, and Cartridge Hooks
- add content/admin support for curated player icon sets or avatar-like identity options
- keep authored question ornaments and extend them where useful
- add optional phase-specific presenter copy that can be authored instead of only inferred from shared i18n keys
- define one generic private-info contract for cartridges that need hidden player instructions or roles
- keep the fallback story explicit:
- deterministic icons and copy still work when authored assets are absent
**Acceptance:** the visual lane is not blocked on hardcoded placeholder assets, and future cartridges have a place to attach hidden player info without bloating the default phone UX.
## Batch 6 — Smoke Coverage and Sign-Off
- extend automated smoke for:
- first-player captain start
- websocket phase propagation across host + 3 player clients
- no-input-loss during reconnect
- presenter-only question visibility
- hidden player info routing where applicable
- developer-state visibility and safety gates
- extend the manual artifact checklist so it proves:
- projected host during lobby, reveal, and scoreboard
- captain phone start flow
- at least 3 player devices
- reconnect recovery without manual refresh
- separate host/player developer-state captures
**Acceptance:** the overhaul is demonstrable in a realistic living-room flow, not just in isolated component tests.
## Definition of Done
This lane is complete when:
- the first joined player can start the game from a phone in the normal flow
- non-captain phones stay intentionally minimal before game start
- player phones show only the current action or private hidden info by default
- websocket state sync is the primary update path and manual update is not required in the happy path
- the projected host screen is usable as a real presenter surface
- backend contracts remain role-correct
- host/player visuals share one coherent visual system
- regression tests cover realtime, visibility, and input preservation
- host and player both provide an explicit developer-state that is useful in dev/staging and hidden by default
- the multi-device artifact in `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md` has been captured for the current flow
## Recommended Order
1. Batch 1: lobby captain flow.
2. Batch 2: realtime coherence.
3. Batch 3: simplified player phone UX.
4. Batch 4: presenter host flow adjustments for the captain model.
5. Batch 5: authored assets and cartridge hooks.
6. Batch 6: smoke coverage and sign-off.
## Ralph Loop Exit Rule
- this plan should only be marked `Completed` when the Definition of Done is satisfied
- narrower websocket or sub-feature slices should be tracked as check-ins under this plan or in separate plan files, not by rewriting this file into a different plan
## Explicitly Deferred
- custom uploaded user avatars
- advanced moderation/admin tooling
- spectator mode
- native mobile clients
- multi-cartridge theming beyond the shared shell and contract hooks above
@@ -0,0 +1,137 @@
# MVP Plan — Deployable and Testable Repository
**Date:** 2026-03-18
**Status:** In progress
## Goal
Get the repository into a state where one person can deploy it to staging or a local VM, run one documented setup flow, and verify one full playable round through the intended UI without ad hoc fixes.
For this plan, **deployable and testable** means:
- backend boots with documented env vars
- frontend assets build successfully
- one UI path is designated as the MVP path
- a development `docker compose` setup exists for the app and its required services
- CI covers the checks needed to trust a release candidate
- a smoke flow proves host + 3 players can complete one full round
## Current Baseline
Verified on 2026-03-18:
- `manage.py check` passes
- `manage.py test lobby fupogfakta` passes
- `npm --prefix frontend test` passes
- `npm --prefix frontend run build` passes
- `npm --prefix frontend/angular test` passes
- `npm --prefix frontend/angular run build` **fails**
Main blockers observed:
1. Angular production build fails on strict template nullability in host/player reveal panels.
2. `shared/i18n/lobby.json` contains duplicate keys and produces build warnings.
3. The repo still has two UI modes (`USE_SPA_UI` ON/OFF), so the MVP “real path” is ambiguous.
4. CI does not yet enforce the full MVP gate.
## Progress Snapshot
Implemented on 2026-03-18:
- legacy UI (`USE_SPA_UI=false`) is the explicit MVP path
- Angular build and test pipeline passes
- duplicate shared i18n keys were removed and drift is checked
- development `docker compose` exists for Django + MySQL + Redis
- local deterministic bootstrap is available via `python manage.py bootstrap_mvp`
- local release verification is available via `./scripts/verify_mvp_release.sh`
- staging deploy + smoke wrappers exist via `infra/staging/deploy_and_smoke_staging.sh`
Remaining MVP sign-off item:
- run the real staging deploy + smoke flow and record the resulting artifact
## Plan
### Batch 1 — Make the chosen UI path buildable
- Decide the MVP runtime path:
- Current choice: legacy UI (`USE_SPA_UI=false`)
- SPA remains a follow-up cutover path after the MVP release gate is stable
- Fix Angular template errors in `frontend/angular/src/app/features/host/host-shell.component.ts` and `frontend/angular/src/app/features/player/player-shell.component.ts`.
- Remove duplicate i18n keys in `shared/i18n/lobby.json`.
- Require these checks to pass locally:
- `npm --prefix frontend/angular test`
- `npm --prefix frontend/angular run build`
### Batch 2 — Make local/staging setup deterministic
- Add one documented bootstrap path for:
- backend venv install
- database migrate
- host user creation
- sample category/question seed
- Create a development `docker compose` file that can start the services needed for local work:
- Django app
- MySQL or the chosen dev database
- Redis for Channels
- optional frontend dev server if the team wants one-command startup
- Ensure the compose setup matches the documented env var contract and supports the chosen MVP UI path.
- Keep SQLite acceptable for local try-out; keep MySQL/Redis for staging.
- Document the exact env vars and `USE_SPA_UI` setting required for the MVP path.
### Batch 3 — Make smoke verification release-grade
- Treat one canonical smoke as required:
- create session
- 3 players join
- start round
- submit lies
- submit guesses
- reveal
- scoreboard
- next round or finish
- Keep `python manage.py smoke_staging --artifact <path>` as the canonical backend smoke entrypoint.
- Provide one staging wrapper command that chains deploy + MVP smoke for release candidates.
- Add one short manual UI smoke checklist for the chosen MVP path only.
### Batch 4 — Align CI with MVP readiness
- Expand CI to run:
- `python manage.py check`
- `python manage.py test lobby fupogfakta`
- `npm --prefix frontend test`
- `npm --prefix frontend run build`
- `npm --prefix frontend/angular test`
- `npm --prefix frontend/angular run build`
- Update lint scope so it no longer only checks `lobby/`.
- Provide one local wrapper command for the same MVP gate before staging deploy.
### Batch 5 — Freeze the MVP boundary
- Declare which items are required for MVP and which are explicitly deferred.
- Defer non-blockers:
- broader game-driver redesign
- extra cartridges
- polished host presentation UX
- voice integration
- post-game awards or richer reactions
## Definition of Done
The repo is MVP-deployable and testable when all of the following are true:
- one UI path is explicitly marked as the MVP path
- Angular production build passes for that path
- a development `docker compose` setup can bring up the required local dependencies
- staging deploy docs match the code and env flags
- CI enforces the same checks used for release sign-off
- one full smoke round passes through host/player UI and is captured as evidence
## Recommended Order
1. Fix Angular build blockers and i18n duplication.
2. Choose SPA ON or legacy as the single MVP runtime path.
3. Add the development `docker compose` setup.
4. Write the bootstrap/setup doc and seed flow.
5. Expand CI to the full MVP gate.
6. Re-run staging smoke and record the artifact.
+27
View File
@@ -3,6 +3,8 @@
## Formål
`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates.
Aktuel MVP release-gate bruger `USE_SPA_UI=false`. Denne note beskriver separat cutover-arbejde, ikke den primære MVP deploy-path.
## Miljø-toggle (uden kodeændring)
Sæt env var pr. miljø:
@@ -59,6 +61,31 @@ Trin-for-trin:
Target: rollback + sanity-verifikation inden for 10 minutter.
## React fallback trigger-kriterier (kun delivery-blocking)
Formål: React fallback må kun bruges som kortvarig leverings-sikring, når release ellers er blokeret.
### Hvornår fallback er tilladt
Alle punkter skal være opfyldt:
1. **Delivery-blocking fejl i Angular SPA**
- Host/player kerneflow kan ikke leveres i release-vinduet (fx login/join/start/round/scoreboard stopper).
2. **Ingen hurtig Angular-fix inden for release-vinduet**
- Teamet har vurderet at patch + verificering ikke kan nås sikkert i tide.
3. **Rollback alene løser ikke leveringsbehovet**
- `USE_SPA_UI=false` (legacy) er enten utilstrækkelig for den konkrete leverance eller allerede verificeret som ikke tilstrækkelig.
4. **Beslutning er eksplicit logget**
- Trigger, impact, UTC-tid, ansvarlig, issue/incident-reference og plan for tilbagevenden til Angular er dokumenteret i release/smoke artifact.
### Scope-limits for fallback
- Fallback omfatter kun **delivery-blocking host/player-paths**.
- Ingen nye features, UX-forbedringer eller ikke-kritiske ændringer må bundtes ind i fallback.
- Fallback er **midlertidig** og gælder kun for aktiv incident/release-vindue.
- Når blocker er fjernet, skal miljøet tilbage på standard cutover-spor (Angular + `USE_SPA_UI` styring).
### Ikke tilladt
- Proaktiv fallback "for en sikkerheds skyld" uden aktiv blocker.
- Brug af fallback til at omgå normale kvalitetsgates eller testkrav.
- Langvarig drift i fallback-mode uden dokumenteret blocker og opfølgningsplan.
## 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`
@@ -19,6 +19,24 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'B',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Maja',
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'lobby',
round_number: 1,
@@ -31,17 +49,69 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
},
host: {
can_start_round: true,
can_show_question: 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;
}
if (url === '/lobby/sessions/ABCD12?session_token=session-token-1') {
return {
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 2 },
viewer_role: 'player',
players: [
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
{ id: 3, nickname: 'Bo', score: 0, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: null,
shown_at: '2026-03-01T18:00:00Z',
answers: []
},
reveal: null,
voice_cues: {
default_locale: 'en',
intro: null,
phase: null,
question_prompt: null,
question_reveal: null
},
phase_view_model: {
status: 'lie',
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: false,
can_show_question: false,
can_mix_answers: true,
can_calculate_scores: true,
can_reveal_scoreboard: true,
can_start_next_round: true,
can_finish_game: true
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: true,
can_submit_guess: true,
can_submit_guess: false,
can_view_final_result: false
}
}
@@ -50,7 +120,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
if (url === '/lobby/sessions/ABCD12/scoreboard') {
return {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 }
@@ -70,6 +140,13 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
} as T;
}
if (url === '/lobby/sessions/create') {
expect(body).toEqual({});
return {
session: { code: 'H0ST42', status: 'lobby', host_id: 1, current_round: 1 }
} as T;
}
if (url === '/lobby/sessions/ABCD12/rounds/start') {
expect(body).toEqual({ category_slug: 'history' });
return {
@@ -107,6 +184,12 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 2,
reveal: {
round_question_id: 77,
correct_answer: 'A',
lies: [],
guesses: []
},
leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 }
@@ -167,13 +250,23 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
const client = createAngularApiClient({ get, post } as AngularHttpClientLike);
const session = await client.getSession(' abcd12 ');
const playerSession = await client.getSession(' abcd12 ', { session_token: 'session-token-1' });
expect(session.ok).toBe(true);
expect(playerSession.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(true);
expect(session.data.phase_view_model.host.can_start_next_round).toBe(false);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(false);
expect(session.data.reveal?.correct_answer).toBe('A');
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
}
if (playerSession.ok) {
expect(playerSession.data.viewer_role).toBe('player');
expect(playerSession.data.round_question?.prompt).toBeNull();
expect(playerSession.data.voice_cues?.question_prompt).toBeNull();
}
expect((await client.createSession()).ok).toBe(true);
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);
expect((await client.startRound(' abcd12 ', { category_slug: 'history' })).ok).toBe(true);
expect((await client.showQuestion(' abcd12 ')).ok).toBe(true);
@@ -202,43 +295,50 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
).toBe(true);
expect(get).toHaveBeenNthCalledWith(1, '/lobby/sessions/ABCD12', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12?session_token=session-token-1', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/create',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/join',
{ code: 'ABCD12', nickname: 'Maja' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
2,
3,
'/lobby/sessions/ABCD12/rounds/start',
{ category_slug: 'history' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(4, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
4,
5,
'/lobby/sessions/ABCD12/questions/77/answers/mix',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
5,
6,
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(6, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(8, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
8,
9,
'/lobby/sessions/ABCD12/questions/77/lies/submit',
{ player_id: 9, session_token: 'session-token-1', text: 'my lie' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
9,
10,
'/lobby/sessions/ABCD12/questions/77/guesses/submit',
{ player_id: 9, session_token: 'session-token-1', selected_text: 'B' },
{ withCredentials: true }
+86 -5
View File
@@ -1,5 +1,86 @@
.shell { font-family: Arial, sans-serif; margin: 1rem; }
.shell__header { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; }
.shell__header nav { display: flex; gap: 0.75rem; }
.shell__content { margin-top: 1rem; }
.locale-picker { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.95rem; }
.shell {
margin: 0 auto;
max-width: 84rem;
min-height: 100vh;
padding: 1.25rem;
}
.shell__header {
display: grid;
gap: 1rem;
padding: 1rem 1.1rem;
border: 1px solid var(--wpp-border);
border-radius: var(--wpp-radius-xl);
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.5), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(245, 249, 250, 0.94));
box-shadow: var(--wpp-shadow-soft);
backdrop-filter: blur(10px);
}
.shell__brand {
display: grid;
gap: 0.3rem;
}
.shell__brand h1 {
margin: 0;
font-family: var(--wpp-font-display);
font-size: clamp(1.8rem, 3vw, 2.6rem);
line-height: 0.95;
}
.shell__brand p {
margin: 0;
color: var(--wpp-ink-muted);
}
.shell__nav-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.9rem;
align-items: center;
}
.shell__header nav {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.shell__header nav a {
display: inline-flex;
align-items: center;
padding: 0.58rem 0.86rem;
border-radius: var(--wpp-radius-pill);
color: var(--wpp-accent);
text-decoration: none;
font-weight: 800;
background: var(--wpp-accent-soft);
}
.shell__content {
margin-top: 1.1rem;
}
.locale-picker {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-size: 0.95rem;
color: var(--wpp-ink);
}
.locale-picker select {
border: 1px solid var(--wpp-border);
border-radius: var(--wpp-radius-pill);
padding: 0.46rem 0.8rem;
background: rgba(255, 255, 255, 0.78);
}
@media (max-width: 720px) {
.shell {
padding: 0.85rem;
}
}
+19 -12
View File
@@ -1,17 +1,24 @@
<main class="shell">
<header class="shell__header">
<h1>{{ copy('app.title') }}</h1>
<nav>
<a routerLink="/host">{{ copy('app.host_nav') }}</a>
<a routerLink="/player">{{ copy('app.player_nav') }}</a>
</nav>
<label class="locale-picker">
{{ copy('app.language_label') }}
<select [ngModel]="locale" (ngModelChange)="setLocale($event)">
<option value="en">English</option>
<option value="da">Dansk</option>
</select>
</label>
<div class="shell__brand">
<p class="wpp-eyebrow">{{ copy('app.home_badge') }}</p>
<h1>{{ copy('app.title') }}</h1>
<p>{{ copy('app.home_intro') }}</p>
</div>
<div class="shell__nav-row">
<nav>
<a routerLink="/">{{ copy('app.home_nav') }}</a>
<a routerLink="/host">{{ copy('app.host_nav') }}</a>
<a routerLink="/player">{{ copy('app.player_nav') }}</a>
</nav>
<label class="locale-picker">
{{ copy('app.language_label') }}
<select [ngModel]="locale" (ngModelChange)="setLocale($event)">
<option value="en">English</option>
<option value="da">Dansk</option>
</select>
</label>
</div>
</header>
<section class="shell__content" [attr.data-wpp-locale]="locale">
+6 -2
View File
@@ -8,6 +8,11 @@ import {
} from './session-route-context';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./features/home/home-shell.component').then((m) => m.HomeShellComponent),
},
{
path: 'host',
resolve: { routeContext: hostRouteContextResolver },
@@ -44,6 +49,5 @@ export const routes: Routes = [
canActivate: [playerRouteGuard],
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{ path: '', pathMatch: 'full', redirectTo: 'player' },
{ path: '**', redirectTo: 'player' },
{ path: '**', redirectTo: '' },
];
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveDeveloperState, toggleDeveloperState } from './developer-state';
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
};
}
describe('developer-state helpers', () => {
it('reads persisted developer state when no query override is present', () => {
const storage = storageMock({ 'wpp.host.developer-mode': 'true' });
expect(
resolveDeveloperState('wpp.host.developer-mode', {
storage,
location: { search: '', hash: '#/host/lobby/ABCD12' },
}),
).toBe(true);
});
it('lets query params override persisted state and persists the override', () => {
const storage = storageMock({ 'wpp.host.developer-mode': 'false' });
expect(
resolveDeveloperState('wpp.host.developer-mode', {
storage,
location: { search: '?dev=1', hash: '#/host/lobby/ABCD12' },
}),
).toBe(true);
expect(storage.setItem).toHaveBeenCalledWith('wpp.host.developer-mode', 'true');
});
it('reads hash query overrides for hash-routed SPA paths', () => {
const storage = storageMock();
expect(
resolveDeveloperState('wpp.player.developer-mode', {
storage,
location: { search: '', hash: '#/player/guess/ABCD12?session=ABCD12&dev=1' },
}),
).toBe(true);
});
it('toggles and persists the next developer state value', () => {
const storage = storageMock();
expect(toggleDeveloperState('wpp.player.developer-mode', false, storage)).toBe(true);
expect(storage.setItem).toHaveBeenCalledWith('wpp.player.developer-mode', 'true');
});
});
@@ -0,0 +1,68 @@
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
type LocationLike = Pick<Location, 'search' | 'hash'>;
function readFlag(value: string | null): boolean | null {
if (value === null) {
return null;
}
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false;
}
return null;
}
function readHashQuery(hash: string): URLSearchParams {
const queryIndex = hash.indexOf('?');
if (queryIndex === -1) {
return new URLSearchParams();
}
return new URLSearchParams(hash.slice(queryIndex + 1));
}
function resolveFlagFromLocation(locationLike: LocationLike | null | undefined): boolean | null {
if (!locationLike) {
return null;
}
const searchValue = readFlag(new URLSearchParams(locationLike.search || '').get('dev'));
if (searchValue !== null) {
return searchValue;
}
return readFlag(readHashQuery(locationLike.hash || '').get('dev'));
}
export function resolveDeveloperState(
storageKey: string,
options: {
storage?: StorageLike | null;
location?: LocationLike | null;
} = {},
): boolean {
const storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
const locationLike = options.location ?? (typeof window !== 'undefined' ? window.location : null);
const locationValue = resolveFlagFromLocation(locationLike);
if (locationValue !== null) {
storage?.setItem(storageKey, String(locationValue));
return locationValue;
}
return storage?.getItem(storageKey) === 'true';
}
export function toggleDeveloperState(
storageKey: string,
currentValue: boolean,
storage?: StorageLike | null,
): boolean {
const nextValue = !currentValue;
const targetStorage = storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
targetStorage?.setItem(storageKey, String(nextValue));
return nextValue;
}
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AngularApiClient } from '../../../../../src/api/angular-client';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { HomeShellComponent } from './home-shell.component';
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
removeItem: vi.fn((key: string) => {
data.delete(key);
}),
};
}
function apiMock(overrides: Partial<AngularApiClient> = {}): AngularApiClient {
return {
health: vi.fn(),
createSession: vi.fn(),
getSession: vi.fn(),
joinSession: vi.fn(),
startRound: vi.fn(),
showQuestion: vi.fn(),
mixAnswers: vi.fn(),
calculateScores: vi.fn(),
getScoreboard: vi.fn(),
startNextRound: vi.fn(),
finishGame: vi.fn(),
submitLie: vi.fn(),
submitGuess: vi.fn(),
...overrides,
} as unknown as AngularApiClient;
}
describe('HomeShellComponent', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('creates a host session and routes to the host shell', async () => {
const sessionStorage = storageMock();
vi.stubGlobal('window', {
sessionStorage,
localStorage: storageMock(),
location: { assign: vi.fn() },
});
const router = { navigate: vi.fn().mockResolvedValue(true) };
const api = apiMock({
createSession: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: { session: { code: 'ABCD12', status: 'lobby', host_id: 4, current_round: 1 } },
}),
});
const component = new HomeShellComponent().withTestingDependencies({
router,
api,
sessionContextStore: createSessionContextStore(storageMock() as Storage),
location: { assign: vi.fn() },
});
await component.createSession();
expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'ABCD12');
expect(router.navigate).toHaveBeenCalledWith(['/host'], { queryParams: { session: 'ABCD12' } });
expect(component.hostError).toBe('');
});
it('joins a player session and persists player context before routing', async () => {
const localStorage = storageMock();
vi.stubGlobal('window', {
sessionStorage: storageMock(),
localStorage,
location: { assign: vi.fn() },
});
const router = { navigate: vi.fn().mockResolvedValue(true) };
const api = apiMock({
joinSession: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: {
player: { id: 9, nickname: 'Luna', session_token: 'tok-9', score: 0 },
session: { code: 'ABCD12', status: 'lobby' },
},
}),
});
const store = createSessionContextStore(localStorage as Storage);
const component = new HomeShellComponent().withTestingDependencies({
router,
api,
sessionContextStore: store,
location: { assign: vi.fn() },
});
component.sessionCode = ' abcd12 ';
component.nickname = ' Luna ';
await component.joinSession();
expect(store.get()).toEqual({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-9' });
expect(router.navigate).toHaveBeenCalledWith(['/player'], { queryParams: { session: 'ABCD12' } });
expect(component.playerError).toBe('');
});
});
@@ -0,0 +1,219 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import type { AngularApiClient } from '../../../../../src/api/angular-client';
import { createSessionContextStore, type SessionContextStore } from '../../../../../src/spa/session-context-store';
import { subscribeToLocaleChanges, resolvePreferredLocale, t } from '../../lobby-i18n';
import { WPP_API_CLIENT } from '../../wpp-api-client';
type RouterLike = Pick<Router, 'navigate'>;
type LocationLike = Pick<Location, 'assign'>;
type HomeShellDependencies = {
router: RouterLike;
api: AngularApiClient;
sessionContextStore: SessionContextStore;
location: LocationLike | null;
};
function resolveLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return window.localStorage;
}
function fallbackRouter(): RouterLike {
return { navigate: async () => false };
}
function fallbackApi(): AngularApiClient {
return {
health: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
createSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
getSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
joinSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
startRound: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
showQuestion: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
mixAnswers: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
calculateScores: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
getScoreboard: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
startNextRound: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
finishGame: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
submitLie: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
submitGuess: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
};
}
function tryInject<T>(factory: () => T, fallback: T): T {
try {
return factory();
} catch {
return fallback;
}
}
@Component({
selector: 'app-home-shell',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<section class="wpp-page landing">
<div class="wpp-hero-card hero">
<div class="wpp-stack">
<p class="wpp-eyebrow">{{ copy('app.home_badge') }}</p>
<h2 class="wpp-title">{{ copy('app.home_title') }}</h2>
<p class="wpp-subtitle hero__intro">{{ copy('app.home_intro') }}</p>
</div>
</div>
<div class="wpp-grid wpp-grid--two">
<article class="wpp-card wpp-stack">
<h3 class="wpp-section-title">{{ copy('app.host_card_title') }}</h3>
<p class="wpp-section-copy">{{ copy('app.host_card_body') }}</p>
<p class="wpp-inline-note">{{ copy('app.host_login_hint') }}</p>
<div class="wpp-action-row">
<button type="button" class="wpp-button wpp-button--secondary" (click)="loginAsHost()">
{{ copy('app.host_login') }}
</button>
<button type="button" class="wpp-button" (click)="createSession()" [disabled]="hostBusy">
{{ hostBusy ? copy('app.creating_session') : copy('app.create_session') }}
</button>
</div>
<p *ngIf="hostError" class="wpp-error">{{ hostError }}</p>
</article>
<article class="wpp-card wpp-stack">
<h3 class="wpp-section-title">{{ copy('app.player_card_title') }}</h3>
<p class="wpp-section-copy">{{ copy('app.player_card_body') }}</p>
<label class="wpp-field">
<span class="wpp-field-label">{{ copy('common.session_code') }}</span>
<input [(ngModel)]="sessionCode" autocomplete="one-time-code" />
</label>
<label class="wpp-field">
<span class="wpp-field-label">{{ copy('player.nickname') }}</span>
<input [(ngModel)]="nickname" autocomplete="nickname" />
</label>
<div class="wpp-action-row">
<button type="button" class="wpp-button" (click)="joinSession()" [disabled]="playerBusy">
{{ playerBusy ? copy('app.joining_session') : copy('app.join_session') }}
</button>
</div>
<p *ngIf="playerError" class="wpp-error">{{ playerError }}</p>
</article>
</div>
</section>
`,
styles: [`
.landing { gap: 1.4rem; }
.hero { max-width: 54rem; }
.hero__intro { max-width: 42rem; }
`],
})
export class HomeShellComponent implements OnInit, OnDestroy {
locale = resolvePreferredLocale();
sessionCode = '';
nickname = '';
hostBusy = false;
playerBusy = false;
hostError = '';
playerError = '';
private router: RouterLike;
private api: AngularApiClient;
private sessionContextStore: SessionContextStore;
private location: LocationLike | null;
private unsubscribeLocale: (() => void) | null = null;
constructor() {
this.router = tryInject(() => inject(Router), fallbackRouter());
this.api = tryInject(() => inject(WPP_API_CLIENT), fallbackApi());
this.sessionContextStore = createSessionContextStore(resolveLocalStorage());
this.location = typeof window !== 'undefined' ? window.location : null;
}
withTestingDependencies(deps: Partial<HomeShellDependencies>): this {
if (deps.router) {
this.router = deps.router;
}
if (deps.api) {
this.api = deps.api;
}
if (deps.sessionContextStore) {
this.sessionContextStore = deps.sessionContextStore;
}
if ('location' in deps) {
this.location = deps.location ?? null;
}
return this;
}
ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
}
ngOnDestroy(): void {
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
}
copy(key: string): string {
return t(key, this.locale);
}
loginAsHost(): void {
this.location?.assign(`/accounts/login/?next=${encodeURIComponent('/')}`);
}
async createSession(): Promise<void> {
this.hostBusy = true;
this.hostError = '';
const result = await this.api.createSession();
if (!result.ok) {
this.hostBusy = false;
this.hostError = `${this.copy('app.create_session_failed')}: ${result.error.message}`;
return;
}
const code = result.data.session.code;
if (typeof window !== 'undefined') {
window.sessionStorage.setItem('wpp.host-session-code', code);
}
this.hostBusy = false;
await this.router.navigate(['/host'], { queryParams: { session: code } });
}
async joinSession(): Promise<void> {
this.playerBusy = true;
this.playerError = '';
const normalizedCode = this.sessionCode.trim().toUpperCase();
this.sessionCode = normalizedCode;
const result = await this.api.joinSession({
code: normalizedCode,
nickname: this.nickname,
});
if (!result.ok) {
this.playerBusy = false;
this.playerError = `${this.copy('app.join_session_failed')}: ${result.error.message}`;
return;
}
this.sessionContextStore.set({
sessionCode: result.data.session.code,
playerId: result.data.player.id,
token: result.data.player.session_token,
});
this.playerBusy = false;
await this.router.navigate(['/player'], { queryParams: { session: result.data.session.code } });
}
}
@@ -4,6 +4,8 @@ import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
@@ -12,8 +14,78 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response;
}
function sessionDetailPayload(status: string, options?: { roundQuestionId?: number | null }) {
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
}
class HostRealtimeSocketMock {
static instances: HostRealtimeSocketMock[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
HostRealtimeSocketMock.instances.push(this);
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitOpen(): void {
this.onopen?.();
}
}
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
roundQuestionId?: number | null;
roundQuestionPrompt?: string | null;
answers?: string[];
players?: Array<{
id: number;
nickname: string;
score: number;
is_connected?: boolean;
identity?: { token: string; tone: string; icon?: string };
}>;
phaseDisplay?: Record<string, string> | null;
voiceCues?: Record<string, unknown> | null;
reveal?: {
correct_answer: string;
prompt?: string;
lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>;
guesses?: Array<{
player_id: number;
nickname: string;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
fooled_player_nickname?: string;
created_at?: string;
}>;
} | null;
}
) {
const roundQuestionId = options?.roundQuestionId ?? 41;
const roundQuestionPrompt = options?.roundQuestionPrompt === undefined ? 'Q?' : options.roundQuestionPrompt;
const players = (options?.players ?? [
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
]).map((player) => ({
...player,
is_connected: player.is_connected ?? true,
}));
return {
session: {
@@ -21,7 +93,7 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
status,
host_id: 1,
current_round: status === 'lobby' ? 2 : 1,
players_count: 2,
players_count: players.length,
},
round_question:
roundQuestionId === null
@@ -29,16 +101,68 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
: {
id: roundQuestionId,
round_number: 1,
prompt: 'Q?',
prompt: roundQuestionPrompt,
shown_at: '2026-01-01T00:00:00Z',
answers: [],
answers: (options?.answers ?? []).map((text) => ({ text })),
},
players: [
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
],
players,
reveal:
options?.reveal === undefined || options?.reveal === null
? null
: {
round_question_id: roundQuestionId,
round_number: 1,
prompt: options.reveal.prompt ?? 'Q?',
correct_answer: options.reveal.correct_answer,
lies: (options.reveal.lies ?? []).map((lie) => ({
...lie,
created_at: lie.created_at ?? '2026-01-01T00:00:05Z',
})),
guesses: (options.reveal.guesses ?? []).map((guess) => ({
...guess,
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
})),
},
voice_cues:
options?.voiceCues === undefined
? {
default_locale: 'en',
intro: {
cue: 'intro',
translations: { en: 'Welcome to the round.', da: 'Velkommen til runden.' },
audio_urls: {},
source: 'default',
},
phase: {
cue: options?.currentPhase ?? status,
translations: { en: 'Phase cue.', da: 'Fase-tekst.' },
audio_urls: {},
source: 'default',
},
question_prompt:
roundQuestionId === null
? null
: {
cue: 'question_prompt',
translations: { en: 'The question is: Q?', da: 'Sporgsmalet er: Q?' },
audio_urls: {},
source: 'default',
},
question_reveal:
options?.reveal === undefined || options?.reveal === null
? null
: {
cue: 'question_reveal',
translations: { en: 'The correct answer is Mercury.', da: 'Det rigtige svar er Mercury.' },
audio_urls: {},
source: 'default',
},
}
: options.voiceCues,
phase_display: options?.phaseDisplay ?? null,
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: 2,
constraints: {
@@ -47,14 +171,18 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: (options?.currentPhase ?? status) !== 'lobby',
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
},
host: {
can_start_round: status === 'lobby',
can_show_question: status === 'lie',
can_mix_answers: status === 'lie',
can_calculate_scores: status === 'guess',
can_reveal_scoreboard: status === 'reveal',
can_start_next_round: status === 'scoreboard',
can_finish_game: status === 'scoreboard',
can_start_round: (options?.currentPhase ?? status) === 'lobby',
can_show_question: (options?.currentPhase ?? status) === 'lie',
can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess',
can_calculate_scores: (options?.currentPhase ?? status) === 'guess',
can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal',
can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard',
can_finish_game: (options?.currentPhase ?? status) === 'scoreboard',
},
player: {
can_join: status === 'lobby',
@@ -68,6 +196,8 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
describe('HostShellComponent gameplay wiring', () => {
afterEach(() => {
HostRealtimeSocketMock.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -101,54 +231,471 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.loading).toBe(false);
});
it('captures scoreboard error for retry path', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' }));
it('hydrates canonical reveal payload in reveal phase', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('reveal', {
roundQuestionId: 77,
reveal: {
correct_answer: 'Mercury',
lies: [{ player_id: 2, nickname: 'Mads', text: 'Venus' }],
guesses: [
{
player_id: 3,
nickname: 'Luna',
selected_text: 'Venus',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Mads',
},
],
},
})
)
);
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.loadScoreboard();
await component.refreshSession();
expect(fetchMock).toHaveBeenCalledWith('/lobby/sessions/ABCD12/scoreboard', expect.objectContaining({ method: 'GET' }));
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
expect(component.loading).toBe(false);
expect(component.session?.reveal?.correct_answer).toBe('Mercury');
expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 2, nickname: 'Mads', text: 'Venus' });
expect(component.session?.reveal?.guesses[0]).toMatchObject({
player_id: 3,
nickname: 'Luna',
selected_text: 'Venus',
fooled_player_id: 2,
fooled_player_nickname: 'Mads',
});
});
it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse(200, {
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-01-01T00:00:00Z',
lie_deadline_at: '2026-01-01T00:00:45Z',
it('builds a presenter-focused lie scene with deterministic player tones', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('lie', { roundQuestionId: 77, roundQuestionPrompt: 'Who built the first telescope?' }) as any;
expect(component.showLiePresenterScene).toBe(true);
expect(component.showPresenterScene).toBe(true);
expect(component.heroTitle).toBe('Backstage control');
expect(component.presenterCueLabel).toBe('Move into the answer mix');
expect(component.presenterPlayers.map((player) => player.tone)).toEqual(['ember', 'lagoon']);
expect(component.presenterPlayers[0]).toMatchObject({
nickname: 'Host',
badge: 'H',
scoreLabel: '0 pts',
tone: 'ember',
});
expect(component.livePlayersCount).toBe(2);
});
it('builds a presenter-focused lobby scene and keeps controls in backstage mode', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('lobby', { roundQuestionId: null }) as any;
expect(component.showLobbyPresenterScene).toBe(true);
expect(component.showPresenterScene).toBe(true);
expect(component.showPresenterRoster).toBe(true);
expect(component.heroTitle).toBe('Backstage control');
expect(component.presenterSceneTitle).toBe('Room open');
expect(component.presenterSceneHeadline).toBe('ABCD12');
expect(component.presenterCueLabel).toBe('Open the next round');
expect(component.presenterLobbyStats).toEqual([
{ label: 'Players', value: '2' },
{ label: 'Live', value: '2' },
{ label: 'Start ready', value: '2/2' },
]);
});
it('prefers contract-driven presenter copy and theme when phase_display is present', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('guess', {
roundQuestionId: 77,
phaseDisplay: {
theme: 'host-verdict',
ornament: 'verdict-wave',
title_key: 'host.presenter_scene_title_lobby',
body_key: 'host.presenter_scene_body_reveal',
cue_label_key: 'host.presenter_scene_cue_scoreboard_label',
cue_body_key: 'host.presenter_scene_cue_scoreboard_body',
},
}) as any;
expect(component.presenterSceneTheme).toBe('host-verdict');
expect(component.presenterSceneOrnament).toBe('verdict-wave');
expect(component.presenterSceneTitle).toBe(component.copy('host.presenter_scene_title_lobby'));
expect(component.presenterSceneBody).toBe(component.copy('host.presenter_scene_body_reveal'));
expect(component.presenterCueLabel).toBe(component.copy('host.presenter_scene_cue_scoreboard_label'));
expect(component.presenterCueBody).toBe(component.copy('host.presenter_scene_cue_scoreboard_body'));
});
it('prefers contract-driven player identity tokens when the session payload includes them', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('scoreboard', {
players: [
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
{ id: 2, nickname: 'Mads', score: 120, identity: { token: 'M2', tone: 'lagoon', icon: 'wave' } },
],
}) as any;
component.finalLeaderboard = [
{ id: 2, nickname: 'Mads', score: 120 },
{ id: 1, nickname: 'Host', score: 0 },
];
expect(component.presenterPlayers[0]).toMatchObject({
badge: 'H1',
tone: 'ember',
icon: 'spark',
});
expect(component.presenterPlayers[1]).toMatchObject({
badge: 'M2',
tone: 'lagoon',
icon: 'wave',
});
expect(component.playerIcon(2, 'Mads', 1)).toBe('wave');
expect(component.presenterLeaderboard[0]).toMatchObject({
id: 2,
tone: 'lagoon',
icon: 'wave',
});
});
it('builds a presenter-focused guess scene with projected answer cards', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('guess', {
roundQuestionId: 77,
roundQuestionPrompt: 'Who built the first telescope?',
answers: ['Galileo Galilei', 'Isaac Newton', 'Christiaan Huygens'],
}) as any;
expect(component.showGuessPresenterScene).toBe(true);
expect(component.showPresenterRoster).toBe(true);
expect(component.heroTitle).toBe('Backstage control');
expect(component.presenterSceneTitle).toBe('Answer mix in play');
expect(component.presenterCueLabel).toBe('Trigger the reveal');
expect(component.presenterAnswerCards).toEqual([
{ badge: 'A', text: 'Galileo Galilei' },
{ badge: 'B', text: 'Isaac Newton' },
{ badge: 'C', text: 'Christiaan Huygens' },
]);
});
it('summarizes the reveal phase for the presenter screen', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('reveal', {
roundQuestionId: 77,
reveal: {
correct_answer: 'Mercury',
prompt: 'Which planet is closest to the sun?',
lies: [{ player_id: 2, nickname: 'Mads', text: 'Venus' }],
guesses: [
{
player_id: 3,
nickname: 'Luna',
selected_text: 'Venus',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Mads',
},
{
player_id: 1,
nickname: 'Host',
selected_text: 'Mercury',
is_correct: true,
fooled_player_id: null,
},
],
},
}) as any;
expect(component.showRevealPresenterScene).toBe(true);
expect(component.presenterSceneHeadline).toBe('Mercury');
expect(component.presenterCueLabel).toBe('Land the scoreboard');
expect(component.presenterRevealStats).toEqual([
{ label: 'Lies in play', value: '1' },
{ label: 'Correct guesses', value: '1' },
{ label: 'Players fooled', value: '1' },
]);
});
it('sorts the presenter leaderboard for scoreboard and finished scenes', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
component.session.players = [
{ id: 1, nickname: 'Host', score: 40, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
{ id: 3, nickname: 'Luna', score: 120, is_connected: false },
];
expect(component.showScoreboardPresenterScene).toBe(true);
expect(component.presenterCueLabel).toBe('Choose the next beat');
expect(component.presenterLeaderboard.map((entry) => [entry.rank, entry.nickname, entry.scoreLabel])).toEqual([
[1, 'Luna', '120 pts'],
[2, 'Mads', '120 pts'],
[3, 'Host', '40 pts'],
]);
component.finalLeaderboard = [
{ id: 2, nickname: 'Mads', score: 180 },
{ id: 3, nickname: 'Luna', score: 240 },
];
component.session = sessionDetailPayload('finished', { roundQuestionId: null }) as any;
expect(component.showFinishedPresenterScene).toBe(true);
expect(component.presenterLeader?.nickname).toBe('Luna');
expect(component.presenterCueLabel).toBe('Hold the closing frame');
});
it('suppresses the lie presenter scene when the prompt is unavailable', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('lie', { roundQuestionId: 77, roundQuestionPrompt: null }) as any;
expect(component.showLiePresenterScene).toBe(false);
expect(component.heroTitle).toBe('Waiting for the next round to begin.');
});
it('speaks resolved voice cues on host refresh and can replay them', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('lie', {
roundQuestionId: 77,
voiceCues: {
default_locale: 'en',
intro: {
cue: 'intro',
translations: { en: 'Welcome intro.', da: 'Velkomst intro.' },
audio_urls: {},
source: 'custom',
},
phase: {
cue: 'lie',
translations: { en: 'Write a believable lie.', da: 'Skriv en trovardig logn.' },
audio_urls: {},
source: 'custom',
},
question_prompt: {
cue: 'question_prompt',
translations: { en: 'The question is: Q?', da: 'Sporgsmalet er: Q?' },
audio_urls: {},
source: 'custom',
},
question_reveal: null,
},
config: { lie_seconds: 45 },
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 77 })))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 77, round_number: 1 },
answers: [{ text: 'A' }],
);
const speak = vi.fn();
const cancel = vi.fn();
class FakeSpeechSynthesisUtterance {
text: string;
lang = '';
constructor(text: string) {
this.text = text;
}
}
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
location: { hash: '' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
speechSynthesis: { speak, cancel },
});
vi.stubGlobal('SpeechSynthesisUtterance', FakeSpeechSynthesisUtterance as unknown as typeof SpeechSynthesisUtterance);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(speak).toHaveBeenCalledTimes(1);
const spoken = speak.mock.calls[0][0] as FakeSpeechSynthesisUtterance;
expect(spoken.text).toContain('Welcome intro.');
expect(spoken.text).toContain('Write a believable lie.');
expect(spoken.lang).toBe('en-US');
component.replayVoiceCue();
expect(cancel).toHaveBeenCalledTimes(2);
expect(speak).toHaveBeenCalledTimes(2);
});
it('prefers uploaded audio assets over speech synthesis when available', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('lobby', {
roundQuestionId: null,
voiceCues: {
default_locale: 'en',
intro: {
cue: 'intro',
translations: { en: 'Welcome intro.', da: 'Velkomst intro.' },
audio_urls: { en: '/media/voice/phase/intro-en.mp3' },
source: 'custom',
},
phase: null,
question_prompt: null,
question_reveal: null,
},
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 2,
leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }],
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 })));
);
const speak = vi.fn();
const cancel = vi.fn();
const audioPlay = vi.fn(function (this: { onended?: (() => void) | null }) {
this.onended?.();
return Promise.resolve();
});
class FakeAudio {
src?: string;
currentTime = 0;
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(src?: string) {
this.src = src;
}
addEventListener(type: 'ended' | 'error', listener: () => void): void {
if (type === 'ended') {
this.onended = listener;
return;
}
this.onerror = listener;
}
removeEventListener(type: 'ended' | 'error', listener: () => void): void {
if (type === 'ended' && this.onended === listener) {
this.onended = null;
return;
}
if (type === 'error' && this.onerror === listener) {
this.onerror = null;
}
}
pause = vi.fn();
play = audioPlay;
}
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('Audio', FakeAudio as unknown as typeof Audio);
vi.stubGlobal('window', {
location: { hash: '' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
speechSynthesis: { speak, cancel },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(audioPlay).toHaveBeenCalledTimes(1);
expect((audioPlay.mock.instances[0] as FakeAudio).src).toBe('/media/voice/phase/intro-en.mp3');
expect(speak).not.toHaveBeenCalled();
});
it('bootstraps csrf before host transition posts when shell uses direct fetch', async () => {
let cookieValue = '';
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'GET' && url === '/lobby/csrf') {
cookieValue = 'csrftoken=csrf-token-1';
return jsonResponse(200, { csrf_token: 'csrf-token-1' });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 1 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 41 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
location: { hash: '' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
});
vi.stubGlobal('document', {});
Object.defineProperty(document, 'cookie', {
configurable: true,
get: () => cookieValue,
set: (value: string) => {
cookieValue = value;
},
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('lie', { roundQuestionId: 41 }) as any;
await component.showQuestion();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/csrf',
expect.objectContaining({
method: 'GET',
credentials: 'same-origin',
})
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12/questions/show',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
headers: expect.objectContaining({
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': 'csrf-token-1',
}),
})
);
});
it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
let refreshCount = 0;
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
refreshCount += 1;
if (refreshCount === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
if (refreshCount === 2) {
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }));
}
return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
@@ -156,19 +703,131 @@ describe('HostShellComponent gameplay wiring', () => {
component.sessionCode = ' abcd12 ';
component.roundQuestionId = ' 77 ';
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
await component.showQuestion();
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('99');
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
await component.mixAnswers();
expect(component.session?.session.status).toBe('guess');
await component.calculateScores();
expect(component.session?.session.status).toBe('reveal');
expect(component.error).toBe('');
expect(component.loading).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it('switches host sync to websocket when realtime is available and refreshes on pushed phase events', async () => {
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
if (fetchMock.mock.calls.length === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 41 }));
}
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 41 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', HostRealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
speechSynthesis: { speak: vi.fn(), cancel: vi.fn() },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
HostRealtimeSocketMock.instances[0]?.emitOpen();
HostRealtimeSocketMock.instances[0]?.emitMessage({ type: 'phase.guess_started' });
await vi.waitFor(() => {
expect(component.session?.session.status).toBe('guess');
});
expect(HostRealtimeSocketMock.instances[0]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
expect(component.syncTransport).toBe('websocket');
expect(component.lastRealtimeEventType).toBe('phase.guess_started');
expect(component.loading).toBe(false);
});
it('recovers host realtime subscriptions after disconnects before polling fallback fires', async () => {
vi.useFakeTimers();
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
if (fetchMock.mock.calls.length === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 41 }));
}
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 41 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', HostRealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
speechSynthesis: { speak: vi.fn(), cancel: vi.fn() },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
HostRealtimeSocketMock.instances[0]?.emitOpen();
HostRealtimeSocketMock.instances[0]?.emitClose({ code: 1006, wasClean: false });
expect(component.syncTransport).toBe('polling');
await vi.advanceTimersByTimeAsync(1500);
expect(HostRealtimeSocketMock.instances).toHaveLength(2);
HostRealtimeSocketMock.instances[1]?.emitOpen();
expect(component.syncTransport).toBe('websocket');
await vi.advanceTimersByTimeAsync(3000);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(component.loading).toBe(false);
HostRealtimeSocketMock.instances[1]?.emitMessage({ type: 'phase.guess_started' });
await vi.waitFor(() => {
expect(component.session?.session.status).toBe('guess');
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(component.lastRealtimeEventType).toBe('phase.guess_started');
});
it('runs next-round transition without reload and clears scoreboard payload', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } }))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
@@ -177,6 +836,7 @@ describe('HostShellComponent gameplay wiring', () => {
component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound();
@@ -186,8 +846,8 @@ describe('HostShellComponent gameplay wiring', () => {
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }));
expect(component.session?.session.status).toBe('lobby');
expect(component.scoreboardPayload).toBe('');
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('99');
expect(component.finalLeaderboardPayload).toBe('');
expect(component.finalLeaderboard).toEqual([]);
expect(component.nextRoundError).toBe('');
@@ -213,6 +873,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.finishGame();
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
@@ -236,6 +897,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = ' ';
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound();
await component.finishGame();
@@ -245,6 +907,77 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.finishError).toContain('Session code is required');
});
it('blocks illegal host actions outside canonical phase permissions', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.roundQuestionId = '77';
for (const status of ['guess', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.showQuestion();
}
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.calculateScores();
}
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.loadScoreboard();
}
for (const status of ['lie', 'guess', 'reveal'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.startNextRound();
await component.finishGame();
}
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
expect(component.canShowQuestion).toBe(false);
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
expect(component.canCalculateScores).toBe(false);
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
});
it('prefers canonical current_phase for reveal panel and host routing when status lags behind', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 77, reveal: { correct_answer: 'Mercury' } }))
);
vi.stubGlobal('fetch', fetchMock);
const replaceState = vi.fn();
vi.stubGlobal('window', {
location: { hash: '#/host/reveal/ABCD12' },
history: { state: null, replaceState },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.gameplayPhase).toBe('scoreboard');
expect(component.showRevealPanel).toBe(true);
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/scoreboard/ABCD12');
});
it('syncs host hash-route with latest phase after refresh without page reload', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock);
@@ -262,5 +995,32 @@ describe('HostShellComponent gameplay wiring', () => {
await component.refreshSession();
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12');
expect(component.canStartRound).toBe(false);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
});
it('uses phase_view_model to keep host action surface bound to round boundaries only', async () => {
const component = new HostShellComponent();
expect(component.canStartRound).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('lie') as any;
expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('reveal') as any;
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('scoreboard') as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,33 @@ import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
class RealtimeSocketMock {
static instances: RealtimeSocketMock[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
RealtimeSocketMock.instances.push(this);
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitOpen(): void {
this.onopen?.();
}
}
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
@@ -13,7 +40,41 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response;
}
function sessionDetailPayload(status: string, options?: { answers?: string[]; players?: Array<{ id: number; nickname: string; score: number }>; roundQuestionId?: number | null }) {
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
answers?: string[];
phaseDisplay?: Record<string, string> | null;
players?: Array<{
id: number;
nickname: string;
score: number;
identity?: { token: string; tone: string; icon?: string };
}>;
playerPermissions?: Partial<{
can_join: boolean;
can_submit_lie: boolean;
can_submit_guess: boolean;
can_view_final_result: boolean;
}>;
roundQuestionId?: number | null;
reveal?: {
correct_answer: string;
prompt?: string;
lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>;
guesses?: Array<{
player_id: number;
nickname: string;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
fooled_player_nickname?: string;
created_at?: string;
}>;
} | null;
}
) {
const answers = options?.answers ?? [];
const roundQuestionId = options?.roundQuestionId ?? 11;
@@ -39,8 +100,27 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
...player,
is_connected: true,
})),
reveal:
options?.reveal === undefined || options?.reveal === null
? null
: {
round_question_id: roundQuestionId,
round_number: 1,
prompt: options.reveal.prompt ?? 'Q?',
correct_answer: options.reveal.correct_answer,
lies: (options.reveal.lies ?? []).map((lie) => ({
...lie,
created_at: lie.created_at ?? '2026-01-01T00:00:05Z',
})),
guesses: (options.reveal.guesses ?? []).map((guess) => ({
...guess,
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
})),
},
phase_display: options?.phaseDisplay ?? null,
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: (options?.players ?? []).length,
constraints: {
@@ -49,6 +129,10 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: (options?.currentPhase ?? status) !== 'lobby',
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
},
host: {
can_start_round: false,
can_show_question: false,
@@ -59,10 +143,10 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
can_finish_game: false,
},
player: {
can_join: status === 'lobby',
can_submit_lie: status === 'lie',
can_submit_guess: status === 'guess',
can_view_final_result: status === 'finished',
can_join: options?.playerPermissions?.can_join ?? (options?.currentPhase ?? status) === 'lobby',
can_submit_lie: options?.playerPermissions?.can_submit_lie ?? (options?.currentPhase ?? status) === 'lie',
can_submit_guess: options?.playerPermissions?.can_submit_guess ?? (options?.currentPhase ?? status) === 'guess',
can_view_final_result: options?.playerPermissions?.can_view_final_result ?? (options?.currentPhase ?? status) === 'finished',
},
},
};
@@ -70,6 +154,7 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
describe('PlayerShellComponent gameplay wiring', () => {
afterEach(() => {
RealtimeSocketMock.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -94,6 +179,176 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.selectedGuess).toBe('');
});
it('builds a player join scene with room stats before the device is claimed', () => {
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('lobby', {
roundQuestionId: null,
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showPlayerScene).toBe(true);
expect(component.showJoinControls).toBe(true);
expect(component.showRoomRoster).toBe(true);
expect(component.playerSceneTitle).toBe('Player station');
expect(component.playerSceneHeadline).toBe('ABCD12');
expect(component.playerSceneCueLabel).toBe('Claim this screen');
expect(component.playerSceneStats).toEqual([
{ label: 'Players', value: '2' },
{ label: 'Live', value: '2' },
]);
});
it('builds a joined lobby waiting scene with score-aware room stats', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('lobby', {
roundQuestionId: null,
playerPermissions: { can_join: false },
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showPlayerLobbyScene).toBe(true);
expect(component.showPlayerScene).toBe(true);
expect(component.playerSceneTitle).toBe('Seat reserved');
expect(component.playerSceneHeadline).toBe('Luna');
expect(component.playerSceneCueLabel).toBe('Wait for the host');
expect(component.playerSceneStats).toEqual([
{ label: 'Players', value: '2' },
{ label: 'Live', value: '2' },
{ label: 'Your score', value: '35 pts' },
]);
});
it('prefers contract-driven player copy and theme when phase_display is present', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('guess', {
answers: ['A', 'B'],
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
phaseDisplay: {
theme: 'player-ripple',
ornament: 'ripple-flare',
title_key: 'player.reveal_title',
body_key: 'player.phase_summary_reveal',
cue_label_key: 'player.active_scene_cue_reveal_label',
cue_body_key: 'player.active_scene_cue_reveal_body',
},
}) as any;
expect(component.activeSceneTheme).toBe('player-ripple');
expect(component.activeSceneOrnament).toBe('ripple-flare');
expect(component.activeSceneTitle).toBe(component.copy('player.reveal_title'));
expect(component.activeSceneBody).toBe(component.copy('player.phase_summary_reveal'));
expect(component.activeSceneCueLabel).toBe(component.copy('player.active_scene_cue_reveal_label'));
expect(component.activeSceneCueBody).toBe(component.copy('player.active_scene_cue_reveal_body'));
});
it('prefers contract-driven player identity tokens when the session payload includes them', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('scoreboard', {
roundQuestionId: null,
playerPermissions: { can_join: false, can_view_final_result: false },
players: [
{ id: 2, nickname: 'Mads', score: 20, identity: { token: 'M1', tone: 'ember', icon: 'spark' } },
{ id: 3, nickname: 'Luna', score: 35, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
],
}) as any;
expect(component.playerIdentityToken(3, 'Luna', 1)).toBe('L2');
expect(component.playerTone(3, 'Luna', 1)).toBe('lagoon');
expect(component.playerIcon(3, 'Luna', 1)).toBe('wave');
expect(component.resultLeaderboard[0].identityToken).toBe('L2');
expect(component.resultLeaderboard[0].identityTone).toBe('lagoon');
expect(component.resultLeaderboard[0].identityIcon).toBe('wave');
});
it('treats post-submit lie state as a waiting-room scene instead of a blank task area', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('lie', {
roundQuestionId: 11,
playerPermissions: { can_submit_lie: false },
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showLieControls).toBe(false);
expect(component.showWaitingState).toBe(true);
expect(component.showPlayerWaitingScene).toBe(true);
expect(component.playerSceneTitle).toBe('Lie locked in');
expect(component.playerSceneHeadline).toBe('Luna');
expect(component.playerSceneCueLabel).toBe('Hold your place');
expect(component.showRoomRoster).toBe(true);
});
it('builds a lie-phase active scene with a prompt-first composer and room stats', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('lie', {
roundQuestionId: 11,
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showActivePlayerScene).toBe(true);
expect(component.showLieControls).toBe(true);
expect(component.showRoomRoster).toBe(true);
expect(component.activeSceneTitle).toBe('Submit lie');
expect(component.activeSceneHeadline).toBe('Q?');
expect(component.activeSceneCueLabel).toBe('Sell the bluff');
expect(component.activeSceneStats).toEqual([
{ label: 'Players', value: '2' },
{ label: 'Live', value: '2' },
{ label: 'Your score', value: '35 pts' },
]);
expect(component.activeSupportBody).toBe('Type a believable lie and send it when you are ready.');
});
it('builds a guess-phase active scene with answer counts and local selection state', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('guess', {
answers: ['A', 'B', 'C'],
roundQuestionId: 11,
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showActivePlayerScene).toBe(true);
expect(component.showGuessControls).toBe(true);
expect(component.showRoomRoster).toBe(true);
expect(component.activeSceneTitle).toBe('Submit guess');
expect(component.activeSceneCueLabel).toBe('Pick the truth');
expect(component.activeSceneStats).toEqual([
{ label: 'Answers', value: '3' },
{ label: 'Selected answer', value: 'Not picked yet' },
{ label: 'Your score', value: '35 pts' },
]);
});
it('surfaces lie submit error and allows retry success flow', async () => {
const fetchMock: FetchMock = vi
.fn()
@@ -109,9 +364,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [] },
players: [],
};
await component.submitLie();
@@ -134,6 +388,88 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('bootstraps csrf before lie submit when player shell posts directly', async () => {
let cookieValue = '';
const fetchMock: FetchMock = vi.fn().mockImplementation(async (input: string, init?: RequestInit) => {
if (input === '/lobby/csrf') {
cookieValue = 'csrftoken=csrf-token-1';
return jsonResponse(200, { csrf_token: 'csrf-token-1' });
}
if (input === '/lobby/sessions/ABCD12/questions/11/lies/submit') {
return jsonResponse(201, {
lie: {
id: 1,
player_id: 9,
round_question_id: 11,
text: 'my lie',
created_at: '2026-01-01T00:00:01Z',
},
window: { lie_deadline_at: '2026-01-01T00:00:45Z' },
});
}
if (input === '/lobby/sessions/ABCD12?session_token=token-1') {
return jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] }));
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
location: { hash: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {});
Object.defineProperty(document, 'cookie', {
configurable: true,
get: () => cookieValue,
set: (value: string) => {
cookieValue = value;
},
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [] },
};
await component.submitLie();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/csrf',
expect.objectContaining({
method: 'GET',
credentials: 'same-origin',
})
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12/questions/11/lies/submit',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
headers: expect.objectContaining({
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': 'csrf-token-1',
}),
})
);
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('guess');
});
it('builds final leaderboard in finished status without legacy page hop', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
@@ -156,6 +492,142 @@ describe('PlayerShellComponent gameplay wiring', () => {
await component.refreshSession();
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
expect(component.showResultScene).toBe(true);
expect(component.activeSceneTitle).toBe('Final leaderboard');
expect(component.phaseSummary).toBe('The game is over. The final standings are ready below.');
});
it('hydrates canonical reveal payload after guess -> reveal', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('reveal', {
answers: ['A', 'B'],
reveal: {
correct_answer: 'A',
lies: [{ player_id: 3, nickname: 'Løgnhals', text: 'B' }],
guesses: [
{
player_id: 9,
nickname: 'Detektiv',
selected_text: 'B',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Løgnhals',
},
{
player_id: 10,
nickname: 'Sandhed',
selected_text: 'A',
is_correct: true,
fooled_player_id: null,
},
],
},
})
)
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.session?.reveal?.correct_answer).toBe('A');
expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 3, nickname: 'Løgnhals', text: 'B' });
expect(component.session?.reveal?.guesses[0]).toMatchObject({
player_id: 9,
nickname: 'Detektiv',
selected_text: 'B',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Løgnhals',
});
expect(component.session?.reveal?.guesses[1]).toMatchObject({
player_id: 10,
nickname: 'Sandhed',
selected_text: 'A',
is_correct: true,
fooled_player_id: null,
});
});
it('builds a reveal-phase active scene with personal outcome stats and recap copy', () => {
const component = new PlayerShellComponent();
component.playerId = 9;
component.sessionToken = 'tok-9';
component.session = sessionDetailPayload('reveal', {
answers: ['A', 'B'],
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 9, nickname: 'Luna', score: 35 },
{ id: 10, nickname: 'Omar', score: 28 },
],
reveal: {
correct_answer: 'A',
lies: [{ player_id: 9, nickname: 'Luna', text: 'B' }],
guesses: [
{
player_id: 9,
nickname: 'Luna',
selected_text: 'B',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Mads',
},
{
player_id: 10,
nickname: 'Omar',
selected_text: 'B',
is_correct: false,
fooled_player_id: 9,
fooled_player_nickname: 'Luna',
},
],
},
}) as any;
expect(component.showActivePlayerScene).toBe(true);
expect(component.showRevealScene).toBe(true);
expect(component.showRoomRoster).toBe(false);
expect(component.activeSceneTitle).toBe('Reveal');
expect(component.activeSceneHeadline).toBe('A');
expect(component.revealGuessResultText).toBe('fooled by Mads');
expect(component.playersFooledCount).toBe(1);
expect(component.activeSceneStats).toEqual([
{ label: 'Your guess', value: 'fooled by Mads' },
{ label: 'Players fooled', value: '1' },
{ label: 'Your score', value: '35 pts' },
]);
});
it('builds a scoreboard result scene with current placement and lead score', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('scoreboard', {
roundQuestionId: null,
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
{ id: 5, nickname: 'Omar', score: 32 },
],
reveal: {
correct_answer: 'A',
},
}) as any;
expect(component.showResultScene).toBe(true);
expect(component.playerHeadline).toBe('Scoreboard');
expect(component.activeSceneHeadline).toBe('#1');
expect(component.activeSceneStats).toEqual([
{ label: 'Your place', value: '#1' },
{ label: 'Lead score', value: '35 pts' },
{ label: 'Your score', value: '35 pts' },
]);
expect(component.playerActionSummary).toBe('Check the current standings and stay ready for the next transition.');
});
it('surfaces guess submit error and retries with selected answer payload', async () => {
@@ -173,9 +645,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
component.session = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
};
await component.submitGuess();
@@ -199,6 +670,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('blocks illegal player guess submission outside canonical guess phase', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = {
...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
};
await component.submitGuess();
}
expect(component.canSubmitGuess).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
vi.useFakeTimers();
@@ -223,6 +717,218 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.ngOnDestroy();
});
it('prefers websocket sync for connected players and refreshes on pushed phase events', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] })))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'], reveal: { correct_answer: 'A' } })));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', RealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
await component.refreshSession();
RealtimeSocketMock.instances[0]?.emitOpen();
RealtimeSocketMock.instances[0]?.emitMessage({ type: 'phase.reveal_started' });
await vi.waitFor(() => {
expect(component.session?.session.status).toBe('reveal');
});
expect(RealtimeSocketMock.instances[0]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?session_token=tok-1');
expect(component.syncTransport).toBe('websocket');
expect(component.lastRealtimeEventType).toBe('phase.reveal_started');
});
it('keeps the selected guess through websocket refresh when the canonical phase is still guess', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] })))
.mockResolvedValueOnce(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'guess', answers: ['A', 'B', 'C'] }))
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', RealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
await component.refreshSession();
component.selectedGuess = 'B';
RealtimeSocketMock.instances[0]?.emitOpen();
RealtimeSocketMock.instances[0]?.emitMessage({ type: 'phase.guess_snapshot' });
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
expect(component.gameplayPhase).toBe('guess');
expect(component.selectedGuess).toBe('B');
expect(component.lastRealtimeEventType).toBe('phase.guess_snapshot');
});
it('recovers player websocket sync before polling fallback and keeps the selected guess intact', async () => {
vi.useFakeTimers();
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] })))
.mockResolvedValueOnce(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'guess', answers: ['A', 'B', 'C'] }))
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', RealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
await component.refreshSession();
component.selectedGuess = 'B';
RealtimeSocketMock.instances[0]?.emitOpen();
RealtimeSocketMock.instances[0]?.emitClose({ code: 1006, wasClean: false });
expect(component.syncTransport).toBe('polling');
expect(component.showSyncStatusCard).toBe(true);
await vi.advanceTimersByTimeAsync(1500);
expect(RealtimeSocketMock.instances).toHaveLength(2);
RealtimeSocketMock.instances[1]?.emitOpen();
expect(component.syncTransport).toBe('websocket');
expect(component.showSyncStatusCard).toBe(false);
await vi.advanceTimersByTimeAsync(3000);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(component.selectedGuess).toBe('B');
RealtimeSocketMock.instances[1]?.emitMessage({ type: 'phase.guess_snapshot' });
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
expect(component.gameplayPhase).toBe('guess');
expect(component.selectedGuess).toBe('B');
expect(component.lastRealtimeEventType).toBe('phase.guess_snapshot');
});
it('keeps polling in the background without toggling loading or clearing active input focus state', async () => {
vi.useFakeTimers();
let resolveBackgroundRefresh: ((value: Response) => void) | null = null;
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: 11 })))
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveBackgroundRefresh = resolve;
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.lieText = 'typed lie';
await component.refreshSession();
await vi.advanceTimersByTimeAsync(3100);
expect(component.loading).toBe(false);
expect(component.loadingTransition).toBeNull();
expect(component.lieText).toBe('typed lie');
expect(component.backgroundRefreshNotice).toBe('');
expect(component.showSyncStatusCard).toBe(false);
await vi.advanceTimersByTimeAsync(5000);
expect(component.backgroundRefreshNotice).toBe('Refreshing in the background is taking longer than expected…');
expect(component.showSyncStatusCard).toBe(true);
expect(component.syncStatusTitle).toBe('Background refresh is slow');
expect(component.syncStatusBody).toContain('Keep your input here');
resolveBackgroundRefresh?.(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
await vi.advanceTimersByTimeAsync(0);
expect(component.backgroundRefreshNotice).toBe('');
expect(component.session?.session.status).toBe('lobby');
component.ngOnDestroy();
});
it('shows reconnect recovery state while fallback polling keeps a lie draft intact', async () => {
vi.useFakeTimers();
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 11 })))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 11 })));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', RealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
await component.refreshSession();
RealtimeSocketMock.instances[0]?.emitOpen();
component.lieText = 'typed lie';
RealtimeSocketMock.instances[0]?.emitClose({ code: 1006, wasClean: false });
expect(component.syncTransport).toBe('polling');
expect(component.showSyncStatusCard).toBe(true);
expect(component.syncStatusTitle).toBe('Live sync is reconnecting');
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(component.lieText).toBe('typed lie');
expect(component.showSyncStatusCard).toBe(true);
expect(component.syncStatusChips).toContainEqual({ label: 'Draft length', value: '9' });
component.ngOnDestroy();
});
it('enters reconnecting state when network request fails while online', async () => {
vi.stubGlobal('navigator', { onLine: true });
@@ -255,26 +961,46 @@ describe('PlayerShellComponent gameplay wiring', () => {
it('tracks loading transition message for join action', async () => {
let resolveJoin: ((value: Response) => void) | null = null;
const fetchMock: FetchMock = vi.fn().mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveJoin = resolve;
})
);
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { csrf_token: 'csrf-token-1' }))
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveJoin = resolve;
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
vi.spyOn(component as never, 'scheduleStateSync').mockImplementation(() => {});
component.sessionCode = 'ABCD12';
component.nickname = 'Luna';
const joinPromise = component.joinSession();
await Promise.resolve();
expect(component.loading).toBe(true);
expect(component.loadingMessage).toBe('Joining session… restoring your player state.');
resolveJoin?.(jsonResponse(201, sessionDetailPayload('lobby', { roundQuestionId: null })));
resolveJoin?.(
jsonResponse(201, {
player: { id: 9, nickname: 'Luna', session_token: 'tok-9', score: 0 },
session: { code: 'ABCD12', status: 'lobby' },
})
);
await joinPromise;
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/join',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }),
})
);
expect(component.loading).toBe(false);
expect(component.loadingTransition).toBeNull();
});
@@ -321,6 +1047,34 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(values.get('wpp.session-context')).toBeUndefined();
});
it('prefers canonical current_phase for player reveal panel and routing when status lags behind', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 11, reveal: { correct_answer: 'A' } }))
);
vi.stubGlobal('fetch', fetchMock);
const replaceState = vi.fn();
const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() };
vi.stubGlobal('window', {
location: { hash: '#/player/reveal/ABCD12' },
history: { state: null, replaceState },
localStorage,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.gameplayPhase).toBe('scoreboard');
expect(component.showRevealPanel).toBe(true);
expect(component.showGuessControls).toBe(false);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/scoreboard/ABCD12');
});
it('syncs player hash-route with latest phase during periodic state sync', async () => {
vi.useFakeTimers();
@@ -518,4 +1272,28 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.clientHasNoAudioOutput).toBe(false);
});
it('keeps phone client controls phase-specific and low-complexity', () => {
const component = new PlayerShellComponent();
expect(component.showJoinControls).toBe(true);
expect(component.showLieControls).toBe(false);
expect(component.showGuessControls).toBe(false);
expect(component.showFinalLeaderboard).toBe(false);
component.session = sessionDetailPayload('lie') as any;
component.playerId = 9;
component.sessionToken = 'tok';
expect(component.showJoinControls).toBe(false);
expect(component.showLieControls).toBe(true);
expect(component.showGuessControls).toBe(false);
component.session = sessionDetailPayload('guess', { answers: ['A', 'B'] }) as any;
expect(component.showLieControls).toBe(false);
expect(component.showGuessControls).toBe(true);
component.session = sessionDetailPayload('finished', { players: [{ id: 1, nickname: 'Luna', score: 8 }] }) as any;
expect(component.showGuessControls).toBe(false);
expect(component.showFinalLeaderboard).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
@@ -4,41 +4,58 @@ import { HostShellComponent } from './features/host/host-shell.component';
import { PlayerShellComponent } from './features/player/player-shell.component';
import { setPreferredLocale } from './lobby-i18n';
function stubShellGlobals(initialLocale: string) {
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(initialLocale), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: `${initialLocale}-US`, onLine: true });
}
describe('i18n MVP flow smoke (host/player + audio policy)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('resolves host/player copy in en and da from shared catalog', () => {
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
it.each([
{
locale: 'en',
hostRefresh: 'Refresh',
hostStartRound: 'Start round',
playerSubmitGuess: 'Submit guess',
},
{
locale: 'da',
hostRefresh: 'Opdatér',
hostStartRound: 'Start runde',
playerSubmitGuess: 'Send gæt',
},
])('resolves one host/player locale run for $locale', ({ locale, hostRefresh, hostStartRound, playerSubmitGuess }) => {
stubShellGlobals(locale);
const host = new HostShellComponent();
const player = new PlayerShellComponent();
host.ngOnInit();
player.ngOnInit();
setPreferredLocale(locale);
expect(host.copy('game.host.start_round')).toBe('Start round');
expect(player.copy('game.player.submit_guess')).toBe('Submit guess');
setPreferredLocale('da');
expect(host.copy('game.host.start_round')).toBe('Start runde');
expect(player.copy('game.player.submit_guess')).toBe('Send gæt');
expect(host.copy('common.refresh')).toBe(hostRefresh);
expect(host.copy('game.host.start_round')).toBe(hostStartRound);
expect(player.copy('game.player.submit_guess')).toBe(playerSubmitGuess);
player.ngOnDestroy();
host.ngOnDestroy();
});
it('keeps audio routing policy primary-only (client has no audio output)', () => {
it('keeps audio routing primary-only by guarding player playback without muting the host path', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('primary host playback'));
const mediaPrototype = { play: originalPlay };
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
@@ -46,16 +63,25 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => {
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLMediaElement: { prototype: mediaPrototype },
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
vi.stubGlobal('document', { querySelectorAll: vi.fn().mockReturnValue([]) });
const host = new HostShellComponent();
const player = new PlayerShellComponent();
host.ngOnInit();
expect(host.clientHasNoAudioOutput).toBe(true);
expect(player.clientHasNoAudioOutput).toBe(true);
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
const player = new PlayerShellComponent();
player.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
player.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
host.ngOnDestroy();
});
});
@@ -104,9 +104,11 @@ describe('lobby i18n locale propagation', () => {
const baselineKeys = [
'lobby.shell.title',
'lobby.shell.home_nav',
'lobby.shell.host_nav',
'lobby.shell.player_nav',
'lobby.shell.language_label',
'lobby.shell.home_title',
'common.refresh',
'common.session_code',
'game.host.title',
@@ -0,0 +1,363 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SessionDetailResponse } from '../../../src/api/types';
import { HostShellComponent } from './features/host/host-shell.component';
import { PlayerShellComponent } from './features/player/player-shell.component';
import { setPreferredLocale } from './lobby-i18n';
type ViewerRole = 'host' | 'player';
type SessionSeedPlayer = Pick<SessionDetailResponse['players'][number], 'id' | 'nickname' | 'score'> & {
is_connected?: boolean;
identity?: SessionDetailResponse['players'][number]['identity'];
};
type SessionSeed = {
viewerRole: ViewerRole;
status: string;
currentPhase?: string;
prompt?: string | null;
answers?: string[];
phaseDisplay?: SessionDetailResponse['phase_display'];
players?: SessionSeedPlayer[];
playerPermissions?: Partial<SessionDetailResponse['phase_view_model']['player']>;
roundQuestionId?: number | null;
};
class SharedRealtimeSocketMock {
static instances: SharedRealtimeSocketMock[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
SharedRealtimeSocketMock.instances.push(this);
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitOpen(): void {
this.onopen?.();
}
}
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
function buildSessionDetail(seed: SessionSeed): SessionDetailResponse {
const phase = seed.currentPhase ?? seed.status;
const players = (seed.players ?? [
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 9, nickname: 'Luna', score: 120, is_connected: true },
{ id: 10, nickname: 'Mads', score: 80, is_connected: true },
]).map((player) => ({
...player,
is_connected: player.is_connected ?? true,
}));
const roundQuestionId = seed.roundQuestionId ?? 41;
return {
session: {
code: 'ABCD12',
status: seed.status,
host_id: seed.viewerRole === 'host' ? 1 : null,
current_round: 1,
players_count: players.length,
},
viewer_role: seed.viewerRole,
players,
round_question:
roundQuestionId === null
? null
: {
id: roundQuestionId,
round_number: 1,
prompt: seed.prompt === undefined ? 'Which planet is closest to the sun?' : seed.prompt,
shown_at: '2026-03-23T11:24:02Z',
answers: (seed.answers ?? []).map((text) => ({ text })),
},
reveal: null,
voice_cues: null,
phase_display: seed.phaseDisplay ?? null,
phase_view_model: {
status: seed.status,
current_phase: phase,
round_number: 1,
players_count: players.length,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: phase !== 'lobby',
scoreboard_ready: phase === 'reveal' || phase === 'scoreboard',
},
host: {
can_start_round: phase === 'lobby',
can_show_question: phase === 'lie',
can_mix_answers: phase === 'lie' || phase === 'guess',
can_calculate_scores: phase === 'guess',
can_reveal_scoreboard: phase === 'reveal',
can_start_next_round: phase === 'scoreboard',
can_finish_game: phase === 'scoreboard',
},
player: {
can_join: seed.playerPermissions?.can_join ?? phase === 'lobby',
can_submit_lie: seed.playerPermissions?.can_submit_lie ?? phase === 'lie',
can_submit_guess: seed.playerPermissions?.can_submit_guess ?? phase === 'guess',
can_view_final_result: seed.playerPermissions?.can_view_final_result ?? phase === 'finished',
},
},
};
}
function stubShellGlobals(): void {
vi.stubGlobal('window', {
location: { hash: '', search: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
speechSynthesis: { speak: vi.fn(), cancel: vi.fn() },
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
}
function latestSocket(search: string): SharedRealtimeSocketMock {
const socket = [...SharedRealtimeSocketMock.instances].reverse().find((candidate) => candidate.url.includes(search));
expect(socket).toBeDefined();
return socket as SharedRealtimeSocketMock;
}
describe('realtime visual smoke (host/player resilience + visibility)', () => {
afterEach(() => {
SharedRealtimeSocketMock.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('keeps lie prompts presenter-only while host and player hydrate the same session', async () => {
stubShellGlobals();
setPreferredLocale('en');
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === '/lobby/sessions/ABCD12') {
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'host',
status: 'lie',
prompt: 'Which planet is closest to the sun?',
players: [
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
{ id: 9, nickname: 'Luna', score: 120, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
{ id: 10, nickname: 'Mads', score: 80, identity: { token: 'M3', tone: 'gold', icon: 'comet' } },
],
phaseDisplay: {
theme: 'host-spotlight',
ornament: 'harbor-flare',
title_key: 'host.presenter_scene_title',
body_key: 'host.presenter_scene_body_lie',
cue_label_key: 'host.presenter_scene_cue_mix_label',
cue_body_key: 'host.presenter_scene_cue_mix_body',
},
playerPermissions: { can_join: false, can_submit_lie: true },
}),
),
);
}
if (url === '/lobby/sessions/ABCD12?session_token=tok-9') {
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'player',
status: 'lie',
prompt: null,
players: [
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
{ id: 9, nickname: 'Luna', score: 120, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
{ id: 10, nickname: 'Mads', score: 80, identity: { token: 'M3', tone: 'gold', icon: 'comet' } },
],
phaseDisplay: {
theme: 'player-ink',
ornament: 'harbor-flare',
title_key: 'player.submit_lie',
body_key: 'player.phase_summary_lie',
cue_label_key: 'player.active_scene_cue_lie_label',
cue_body_key: 'player.active_scene_cue_lie_body',
},
playerPermissions: { can_join: false, can_submit_lie: true },
}),
),
);
}
throw new Error(`Unhandled fetch in realtime visual smoke: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', SharedRealtimeSocketMock as unknown as typeof WebSocket);
const host = new HostShellComponent();
host.sessionCode = 'ABCD12';
await host.refreshSession();
latestSocket('?role=host').emitOpen();
const player = new PlayerShellComponent();
player.sessionCode = 'ABCD12';
player.playerId = 9;
player.sessionToken = 'tok-9';
await player.refreshSession();
latestSocket('session_token=tok-9').emitOpen();
expect(host.showLiePresenterScene).toBe(true);
expect(host.presenterSceneHeadline).toBe('Which planet is closest to the sun?');
expect(host.presenterSceneTheme).toBe('host-spotlight');
expect(host.presenterSceneOrnament).toBe('harbor-flare');
expect(host.syncTransport).toBe('websocket');
expect(host.presenterPlayers[1].badge).toBe('L2');
expect(host.presenterPlayers[1].tone).toBe('lagoon');
expect(host.presenterPlayers[1].icon).toBe('wave');
expect(player.showLieControls).toBe(true);
expect(player.currentPrompt).toBe('');
expect(player.activeSceneHeadline).toBe(player.copy('player.round_prompt_waiting'));
expect(player.activeSceneTheme).toBe('player-ink');
expect(player.activeSceneOrnament).toBe('harbor-flare');
expect(player.syncTransport).toBe('websocket');
expect(player.playerIdentityToken(9, 'Luna', 1)).toBe('L2');
expect(player.playerTone(9, 'Luna', 1)).toBe('lagoon');
expect(player.playerIcon(9, 'Luna', 1)).toBe('wave');
expect(player.activeSceneOrnament).toBe(host.presenterSceneOrnament);
player.ngOnDestroy();
host.ngOnDestroy();
});
it('recovers shared host/player realtime sync before polling fallback fires', async () => {
vi.useFakeTimers();
stubShellGlobals();
setPreferredLocale('en');
let hostFetchCount = 0;
let playerFetchCount = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === '/lobby/sessions/ABCD12') {
hostFetchCount += 1;
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'host',
status: 'guess',
prompt: 'Which planet is closest to the sun?',
answers: ['Mercury', 'Venus', 'Mars'],
playerPermissions: { can_join: false, can_submit_guess: true },
}),
),
);
}
if (url === '/lobby/sessions/ABCD12?session_token=tok-9') {
playerFetchCount += 1;
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'player',
status: playerFetchCount === 1 ? 'guess' : 'reveal',
currentPhase: 'guess',
prompt: 'Which planet is closest to the sun?',
answers: ['Mercury', 'Venus', 'Mars', 'Earth'],
playerPermissions: { can_join: false, can_submit_guess: true },
}),
),
);
}
throw new Error(`Unhandled fetch in realtime visual smoke: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', SharedRealtimeSocketMock as unknown as typeof WebSocket);
const host = new HostShellComponent();
host.sessionCode = 'ABCD12';
await host.refreshSession();
const firstHostSocket = latestSocket('?role=host');
firstHostSocket.emitOpen();
const player = new PlayerShellComponent();
player.sessionCode = 'ABCD12';
player.playerId = 9;
player.sessionToken = 'tok-9';
await player.refreshSession();
player.selectedGuess = 'Venus';
const firstPlayerSocket = latestSocket('session_token=tok-9');
firstPlayerSocket.emitOpen();
firstHostSocket.emitClose({ code: 1006, wasClean: false });
firstPlayerSocket.emitClose({ code: 1006, wasClean: false });
expect(host.syncTransport).toBe('polling');
expect(player.syncTransport).toBe('polling');
expect(player.showSyncStatusCard).toBe(true);
await vi.advanceTimersByTimeAsync(1500);
const recoveredHostSocket = latestSocket('?role=host');
const recoveredPlayerSocket = latestSocket('session_token=tok-9');
expect(recoveredHostSocket).not.toBe(firstHostSocket);
expect(recoveredPlayerSocket).not.toBe(firstPlayerSocket);
recoveredHostSocket.emitOpen();
recoveredPlayerSocket.emitOpen();
expect(host.syncTransport).toBe('websocket');
expect(player.syncTransport).toBe('websocket');
expect(player.showSyncStatusCard).toBe(false);
await vi.advanceTimersByTimeAsync(3000);
expect(hostFetchCount).toBe(1);
expect(playerFetchCount).toBe(1);
expect(player.selectedGuess).toBe('Venus');
recoveredHostSocket.emitMessage({ type: 'phase.guess_snapshot' });
recoveredPlayerSocket.emitMessage({ type: 'phase.guess_snapshot' });
await vi.waitFor(() => {
expect(hostFetchCount).toBe(2);
expect(playerFetchCount).toBe(2);
});
expect(host.lastRealtimeEventType).toBe('phase.guess_snapshot');
expect(player.lastRealtimeEventType).toBe('phase.guess_snapshot');
expect(player.gameplayPhase).toBe('guess');
expect(player.selectedGuess).toBe('Venus');
player.ngOnDestroy();
host.ngOnDestroy();
});
});
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSessionRealtimeClient, resolveSessionRealtimeUrl } from './session-realtime';
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
FakeWebSocket.instances.push(this);
}
emitOpen(): void {
this.onopen?.();
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitError(event: unknown = new Event('error')): void {
this.onerror?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
}
describe('session realtime client', () => {
afterEach(() => {
FakeWebSocket.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
});
it('builds host and player websocket URLs from the current location', () => {
expect(
resolveSessionRealtimeUrl(
{ protocol: 'http:', host: 'localhost:4200' },
{ sessionCode: 'abcd12', role: { mode: 'host' } },
),
).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
expect(
resolveSessionRealtimeUrl(
{ protocol: 'https:', host: 'party.example' },
{ sessionCode: 'abcd12', role: { mode: 'player', sessionToken: 'tok-1' } },
),
).toBe('wss://party.example/ws/game/ABCD12/?session_token=tok-1');
});
it('publishes websocket events and reconnects after unexpected disconnects', async () => {
vi.useFakeTimers();
const events: string[] = [];
const states: string[] = [];
const client = createSessionRealtimeClient({
onEvent: (event) => {
events.push(String(event.type));
},
onStatusChange: (status) => {
states.push(status.connectionState);
},
webSocketFactory: (url) => new FakeWebSocket(url),
windowLike: { location: { protocol: 'http:', host: 'localhost:4200' } as Location },
});
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'host' } });
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
FakeWebSocket.instances[0]?.emitOpen();
FakeWebSocket.instances[0]?.emitMessage({ type: 'phase.guess_started' });
FakeWebSocket.instances[0]?.emitClose();
expect(events).toEqual(['phase.guess_started']);
expect(states).toEqual(['connecting', 'connected', 'connected', 'reconnecting']);
await vi.advanceTimersByTimeAsync(1500);
expect(FakeWebSocket.instances).toHaveLength(2);
FakeWebSocket.instances[1]?.emitOpen();
expect(client.getStatus().connectionState).toBe('connected');
});
it('reconfigures the socket when the player session token changes', () => {
const client = createSessionRealtimeClient({
onEvent: vi.fn(),
webSocketFactory: (url) => new FakeWebSocket(url),
windowLike: { location: { protocol: 'http:', host: 'localhost:4200' } as Location },
});
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'player', sessionToken: 'tok-1' } });
const firstSocket = FakeWebSocket.instances[0];
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'player', sessionToken: 'tok-2' } });
expect(firstSocket?.close).toHaveBeenCalledWith(1000, 'reconfigure');
expect(FakeWebSocket.instances[1]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?session_token=tok-2');
});
});
@@ -0,0 +1,264 @@
export type SessionRealtimeRole =
| { mode: 'host' }
| { mode: 'player'; sessionToken: string };
export type SessionRealtimeEvent = {
type?: string;
[key: string]: unknown;
};
export type SessionRealtimeConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting';
export type SessionRealtimeStatus = {
connectionState: SessionRealtimeConnectionState;
lastEventAt: number | null;
lastEventType: string | null;
reconnectAttempt: number;
};
type TimeoutHandle = ReturnType<typeof setTimeout>;
type WebSocketLike = {
close: (code?: number, reason?: string) => void;
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null;
onerror: ((event: unknown) => void) | null;
onmessage: ((event: { data: string }) => void) | null;
onopen: (() => void) | null;
};
type SessionRealtimeTarget = {
role: SessionRealtimeRole;
sessionCode: string;
};
type SessionRealtimeOptions = {
clearTimeoutImpl?: (handle: TimeoutHandle) => void;
onEvent: (event: SessionRealtimeEvent) => void;
onStatusChange?: (status: SessionRealtimeStatus) => void;
setTimeoutImpl?: (callback: () => void, delayMs: number) => TimeoutHandle;
webSocketFactory?: ((url: string) => WebSocketLike) | null;
windowLike?: Pick<Window, 'location'>;
};
const DEFAULT_RECONNECT_DELAY_MS = 1500;
const MAX_RECONNECT_DELAY_MS = 5000;
function resolveWindowLike(windowLike?: Pick<Window, 'location'>): Pick<Window, 'location'> | null {
if (windowLike) {
return windowLike;
}
if (typeof window === 'undefined') {
return null;
}
return window;
}
function normalizeSessionCode(value: string): string {
return value.trim().toUpperCase();
}
export function resolveSessionRealtimeUrl(
location: Pick<Location, 'host' | 'protocol'>,
target: SessionRealtimeTarget,
): string {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = new URL(`${protocol}//${location.host}/ws/game/${encodeURIComponent(normalizeSessionCode(target.sessionCode))}/`);
if (target.role.mode === 'host') {
url.searchParams.set('role', 'host');
} else {
url.searchParams.set('session_token', target.role.sessionToken);
}
return url.toString();
}
function sameTarget(left: SessionRealtimeTarget | null, right: SessionRealtimeTarget | null): boolean {
if (!left || !right) {
return left === right;
}
if (normalizeSessionCode(left.sessionCode) !== normalizeSessionCode(right.sessionCode)) {
return false;
}
if (left.role.mode !== right.role.mode) {
return false;
}
if (left.role.mode === 'host' && right.role.mode === 'host') {
return true;
}
if (left.role.mode === 'player' && right.role.mode === 'player') {
return left.role.sessionToken === right.role.sessionToken;
}
return false;
}
export function createSessionRealtimeClient(options: SessionRealtimeOptions) {
const setTimeoutImpl = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle));
const webSocketFactory =
options.webSocketFactory ??
(typeof WebSocket === 'function' ? ((url: string) => new WebSocket(url) as unknown as WebSocketLike) : null);
let reconnectTimer: TimeoutHandle | null = null;
let reconnectAttempt = 0;
let socket: WebSocketLike | null = null;
let target: SessionRealtimeTarget | null = null;
let status: SessionRealtimeStatus = {
connectionState: 'idle',
lastEventAt: null,
lastEventType: null,
reconnectAttempt: 0,
};
function publishStatus(next: Partial<SessionRealtimeStatus>): void {
status = {
...status,
...next,
reconnectAttempt,
};
options.onStatusChange?.(status);
}
function clearReconnectTimer(): void {
if (!reconnectTimer) {
return;
}
clearTimeoutImpl(reconnectTimer);
reconnectTimer = null;
}
function clearSocket(closeCode?: number, reason?: string): void {
if (!socket) {
return;
}
const currentSocket = socket;
socket = null;
currentSocket.onopen = null;
currentSocket.onmessage = null;
currentSocket.onerror = null;
currentSocket.onclose = null;
currentSocket.close(closeCode, reason);
}
function scheduleReconnect(): void {
if (!target || reconnectTimer) {
return;
}
publishStatus({ connectionState: 'reconnecting' });
const delayMs = Math.min(DEFAULT_RECONNECT_DELAY_MS * Math.max(1, reconnectAttempt + 1), MAX_RECONNECT_DELAY_MS);
reconnectTimer = setTimeoutImpl(() => {
reconnectTimer = null;
reconnectAttempt += 1;
connect();
}, delayMs);
}
function connect(): void {
if (!target) {
publishStatus({ connectionState: 'idle' });
return;
}
const windowLike = resolveWindowLike(options.windowLike);
if (
!windowLike ||
typeof windowLike.location?.protocol !== 'string' ||
typeof windowLike.location?.host !== 'string' ||
!windowLike.location.host ||
!webSocketFactory
) {
publishStatus({ connectionState: 'idle' });
return;
}
clearReconnectTimer();
clearSocket(1000, 'reconnect');
publishStatus({ connectionState: reconnectAttempt > 0 ? 'reconnecting' : 'connecting' });
const nextSocket = webSocketFactory(resolveSessionRealtimeUrl(windowLike.location, target));
socket = nextSocket;
nextSocket.onopen = () => {
if (socket !== nextSocket) {
return;
}
reconnectAttempt = 0;
publishStatus({ connectionState: 'connected' });
};
nextSocket.onmessage = (event) => {
if (socket !== nextSocket) {
return;
}
try {
const payload = JSON.parse(event.data) as SessionRealtimeEvent;
publishStatus({
lastEventAt: Date.now(),
lastEventType: typeof payload.type === 'string' ? payload.type : null,
});
options.onEvent(payload);
} catch {
// Ignore malformed websocket frames; the HTTP refresh path remains authoritative.
}
};
nextSocket.onerror = () => {
if (socket !== nextSocket) {
return;
}
publishStatus({ connectionState: 'reconnecting' });
};
nextSocket.onclose = () => {
if (socket !== nextSocket) {
return;
}
socket = null;
scheduleReconnect();
};
}
return {
disconnect(): void {
target = null;
reconnectAttempt = 0;
clearReconnectTimer();
clearSocket(1000, 'disconnect');
publishStatus({ connectionState: 'idle' });
},
getStatus(): SessionRealtimeStatus {
return status;
},
updateTarget(nextTarget: SessionRealtimeTarget | null): void {
if (sameTarget(target, nextTarget)) {
if (!nextTarget) {
this.disconnect();
}
return;
}
target = nextTarget;
reconnectAttempt = 0;
clearReconnectTimer();
clearSocket(1000, 'reconfigure');
if (!target) {
publishStatus({ connectionState: 'idle' });
return;
}
connect();
},
};
}
@@ -15,15 +15,25 @@ describe('WPP Angular API client skeleton', () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 1 }, players: [], round_question: null, phase_view_model: { status: 'lobby', round_number: 1, players_count: 1, constraints: { min_players_to_start: 2, max_players_mvp: 8, min_players_reached: false, max_players_allowed: true }, host: { can_start_round: false, 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 } } }))
.mockResolvedValueOnce(jsonResponse(201, { player: { id: 1, nickname: 'Luna', session_token: 'tok', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }));
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 1 }, viewer_role: 'player', players: [], round_question: { id: 77, round_number: 1, prompt: null, shown_at: '2026-03-18T12:00:00Z', answers: [] }, phase_view_model: { status: 'lie', round_number: 1, players_count: 1, constraints: { min_players_to_start: 2, max_players_mvp: 8, min_players_reached: false, max_players_allowed: true }, host: { can_start_round: false, 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: true, can_submit_guess: false, can_view_final_result: false } } }))
.mockResolvedValueOnce(jsonResponse(201, { player: { id: 1, nickname: 'Luna', session_token: 'tok', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }))
.mockResolvedValueOnce(jsonResponse(201, { session: { code: 'ZXCV12', status: 'lobby', host_id: 1, current_round: 1 } }));
const client = createWppApiClient(fetchMock);
const session = await client.getSession(' abcd12 ');
const playerSession = await client.getSession(' abcd12 ', { session_token: 'tok-1' });
const joined = await client.joinSession({ code: ' abcd12 ', nickname: ' Luna ' });
const created = await client.createSession();
expect(session.ok).toBe(true);
expect(playerSession.ok).toBe(true);
expect(joined.ok).toBe(true);
expect(created.ok).toBe(true);
if (playerSession.ok) {
expect(playerSession.data.viewer_role).toBe('player');
expect(playerSession.data.round_question?.prompt).toBeNull();
}
expect(fetchMock).toHaveBeenNthCalledWith(
1,
@@ -32,6 +42,11 @@ describe('WPP Angular API client skeleton', () => {
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12?session_token=tok-1',
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/lobby/sessions/join',
expect.objectContaining({
method: 'POST',
@@ -39,5 +54,14 @@ describe('WPP Angular API client skeleton', () => {
body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }),
})
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
'/lobby/sessions/create',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({}),
})
);
});
});
+51 -2
View File
@@ -13,6 +13,53 @@ export interface FetchLike {
}
export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLike {
function readCookie(name: string): string {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
return '';
}
const prefix = `${name}=`;
for (const part of document.cookie.split(';')) {
const trimmed = part.trim();
if (trimmed.startsWith(prefix)) {
return decodeURIComponent(trimmed.slice(prefix.length));
}
}
return '';
}
async function ensureCsrfToken(): Promise<string> {
const existing = readCookie('csrftoken');
if (existing || typeof document === 'undefined' || typeof window === 'undefined') {
return existing;
}
try {
await fetchImpl('/lobby/csrf', {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
} catch {
return '';
}
return readCookie('csrftoken');
}
async function parsePayload(response: Response): Promise<unknown> {
if (response.redirected && response.url.includes('/accounts/login')) {
throw {
status: 401,
message: 'Login required',
error: { redirect: response.url },
};
}
return response.json().catch(() => ({}));
}
return {
async get<T>(url: string): Promise<T> {
const response = await fetchImpl(url, {
@@ -20,7 +67,7 @@ export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLi
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
const payload = await response.json().catch(() => ({}));
const payload = await parsePayload(response);
if (!response.ok) {
throw {
status: response.status,
@@ -31,16 +78,18 @@ export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLi
return payload as T;
},
async post<T>(url: string, body: unknown): Promise<T> {
const csrfToken = await ensureCsrfToken();
const response = await fetchImpl(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
},
body: JSON.stringify(body),
credentials: 'same-origin',
});
const payload = await response.json().catch(() => ({}));
const payload = await parsePayload(response);
if (!response.ok) {
throw {
status: response.status,
File diff suppressed because it is too large Load Diff
+134
View File
@@ -7,12 +7,125 @@
"": {
"name": "wpp-frontend-api-client-baseline",
"version": "0.1.0",
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
}
},
"node_modules/@angular/common": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.20.tgz",
"integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/core": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.20.tgz",
"integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
}
},
"node_modules/@angular/core": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.20.tgz",
"integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0"
}
},
"node_modules/@angular/forms": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.20.tgz",
"integrity": "sha512-agi7InbMzop1jrud6L7SlNwnZk3iNolORcFIwBQMvKxLkcJ+ttbSYuM0KAw56IundWHf4dL9GP4cSygm4kUeFA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.2.20",
"@angular/core": "19.2.20",
"@angular/platform-browser": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.20.tgz",
"integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/animations": "19.2.20",
"@angular/common": "19.2.20",
"@angular/core": "19.2.20"
},
"peerDependenciesMeta": {
"@angular/animations": {
"optional": true
}
}
},
"node_modules/@angular/router": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.20.tgz",
"integrity": "sha512-y0fyKycxJHr82kxXKE50Vac5hPn5Kx3gw9CfqyEuwJ9VQzEixDljU+chrQK4Wods14jJn9Tt2ncNPGH1rLya3Q==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.2.20",
"@angular/core": "19.2.20",
"@angular/platform-browser": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1188,6 +1301,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1263,6 +1385,12 @@
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1449,6 +1577,12 @@
"engines": {
"node": ">=8"
}
},
"node_modules/zone.js": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
"license": "MIT"
}
}
}
+11
View File
@@ -7,6 +7,17 @@
"test": "vitest run",
"build": "tsc --noEmit"
},
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
+23 -6
View File
@@ -1,5 +1,6 @@
import {
mapCalculateScoresResponse,
mapCreateSessionResponse,
mapFinishGameResponse,
mapHealthResponse,
mapJoinSessionResponse,
@@ -16,12 +17,14 @@ import type {
ApiFailure,
ApiResult,
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
ScoreboardResponse,
SessionDetailRequestOptions,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
@@ -46,7 +49,8 @@ export interface AngularHttpClientLike {
export interface AngularApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
createSession(): Promise<ApiResult<CreateSessionResponse>>;
getSession(code: string, options?: SessionDetailRequestOptions): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
@@ -96,6 +100,17 @@ function buildUrl(baseUrl: string, path: string): string {
return `${normalizeBaseUrl(baseUrl)}${path}`;
}
function buildSessionDetailPath(code: string, options?: SessionDetailRequestOptions): string {
const path = `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`;
const sessionToken = options?.session_token?.trim();
if (!sessionToken) {
return path;
}
const params = new URLSearchParams({ session_token: sessionToken });
return `${path}?${params.toString()}`;
}
async function wrap<T>(call: () => Promise<unknown>, mapper: (payload: unknown) => T): Promise<ApiResult<T>> {
let payload: unknown;
try {
@@ -128,12 +143,14 @@ export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''
return {
health: () =>
wrap(() => http.get<HealthResponse>(buildUrl(baseUrl, '/healthz'), { withCredentials: true }), mapHealthResponse),
getSession: (code: string) =>
createSession: () =>
wrap(
() =>
http.get<SessionDetailResponse>(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), {
withCredentials: true
}),
() => http.post<CreateSessionResponse>(buildUrl(baseUrl, '/lobby/sessions/create'), {}, { withCredentials: true }),
mapCreateSessionResponse
),
getSession: (code: string, options?: SessionDetailRequestOptions) =>
wrap(
() => http.get<SessionDetailResponse>(buildUrl(baseUrl, buildSessionDetailPath(code, options)), { withCredentials: true }),
mapSessionDetailResponse
),
joinSession: (payload: JoinSessionRequest) =>
+83 -10
View File
@@ -1,13 +1,14 @@
import {
mapCalculateScoresResponse,
mapCreateSessionResponse,
mapFinishGameResponse,
mapHealthResponse,
mapJoinSessionResponse,
mapMixAnswersResponse,
mapNextRoundResponse,
mapScoreboardResponse,
mapSessionDetailResponse,
mapShowQuestionResponse,
mapStartNextRoundResponse,
mapStartRoundResponse,
mapSubmitGuessResponse,
mapSubmitLieResponse
@@ -15,15 +16,17 @@ import {
import type {
ApiResult,
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
NextRoundResponse,
ScoreboardResponse,
SessionDetailRequestOptions,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
StartRoundRequest,
StartRoundResponse,
SubmitGuessRequest,
@@ -34,35 +37,74 @@ import type {
export interface ApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
createSession(): Promise<ApiResult<CreateSessionResponse>>;
getSession(code: string, options?: SessionDetailRequestOptions): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
mixAnswers(code: string, roundQuestionId: number): Promise<ApiResult<MixAnswersResponse>>;
calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>;
getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>;
startNextRound(code: string): Promise<ApiResult<NextRoundResponse>>;
startNextRound(code: string): Promise<ApiResult<StartNextRoundResponse>>;
finishGame(code: string): Promise<ApiResult<FinishGameResponse>>;
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>;
submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise<ApiResult<SubmitGuessResponse>>;
}
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
function readCookie(name: string): string {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
return '';
}
const prefix = `${name}=`;
for (const part of document.cookie.split(';')) {
const trimmed = part.trim();
if (trimmed.startsWith(prefix)) {
return decodeURIComponent(trimmed.slice(prefix.length));
}
}
return '';
}
async function ensureCsrfToken(): Promise<string> {
const existing = readCookie('csrftoken');
if (existing || typeof document === 'undefined' || typeof window === 'undefined') {
return existing;
}
try {
await fetchImpl(`${baseUrl}/lobby/csrf`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin'
});
} catch {
return '';
}
return readCookie('csrftoken');
}
async function request<T>(
path: string,
method: 'GET' | 'POST',
mapper: (payload: unknown) => T,
payload?: unknown
): Promise<ApiResult<T>> {
const csrfToken = method === 'POST' ? await ensureCsrfToken() : '';
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {})
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
credentials: 'same-origin'
});
} catch {
return {
@@ -72,6 +114,19 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
};
}
if (response.redirected && response.url.includes('/accounts/login')) {
return {
ok: false,
status: 401,
error: {
kind: 'http',
status: 401,
message: 'Login required',
payload: { redirect: response.url }
}
};
}
let responsePayload: unknown;
try {
responsePayload = await response.json();
@@ -114,11 +169,29 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
const normalizeCode = (value: string): string => value.trim().toUpperCase();
function buildSessionDetailPath(code: string, options?: SessionDetailRequestOptions): string {
const path = `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`;
const sessionToken = options?.session_token?.trim();
if (!sessionToken) {
return path;
}
const params = new URLSearchParams({ session_token: sessionToken });
return `${path}?${params.toString()}`;
}
return {
health: () => request<HealthResponse>('/healthz', 'GET', mapHealthResponse),
getSession: (code: string) =>
createSession: () =>
request<CreateSessionResponse>(
'/lobby/sessions/create',
'POST',
mapCreateSessionResponse,
{}
),
getSession: (code: string, options?: SessionDetailRequestOptions) =>
request<SessionDetailResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`,
buildSessionDetailPath(code, options),
'GET',
mapSessionDetailResponse
),
@@ -167,10 +240,10 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
mapScoreboardResponse
),
startNextRound: (code: string) =>
request<NextRoundResponse>(
request<StartNextRoundResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`,
'POST',
mapNextRoundResponse,
mapStartNextRoundResponse,
{}
),
finishGame: (code: string) =>
+201 -7
View File
@@ -1,5 +1,6 @@
import type {
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionResponse,
@@ -10,7 +11,8 @@ import type {
StartNextRoundResponse,
StartRoundResponse,
SubmitGuessResponse,
SubmitLieResponse
SubmitLieResponse,
VoiceCue,
} from './types';
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -52,6 +54,17 @@ function readNumber(record: Record<string, unknown>, key: string, path: string):
return value;
}
function readNullableString(record: Record<string, unknown>, key: string, path: string): string | null {
const value = record[key];
if (value === undefined || value === null) {
return null;
}
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string|null at ${path}.${key}`);
}
return value;
}
function readBoolean(record: Record<string, unknown>, key: string, path: string): boolean {
const value = record[key];
if (!isBoolean(value)) {
@@ -60,6 +73,74 @@ function readBoolean(record: Record<string, unknown>, key: string, path: string)
return value;
}
function readNullableNumber(record: Record<string, unknown>, key: string, path: string): number | null {
const value = record[key];
if (value === undefined || value === null) {
return null;
}
if (!isNumber(value)) {
throw new Error(`Invalid API contract: expected number|null at ${path}.${key}`);
}
return value;
}
function mapVoiceCue(payload: unknown, path: string): VoiceCue | null {
if (payload === null || payload === undefined) {
return null;
}
const record = asRecord(payload, path);
const translations = record.translations;
if (!isRecord(translations)) {
throw new Error(`Invalid API contract: expected object at ${path}.translations`);
}
const audioUrls = record.audio_urls;
if (audioUrls !== undefined && audioUrls !== null && !isRecord(audioUrls)) {
throw new Error(`Invalid API contract: expected object at ${path}.audio_urls`);
}
const textByLocale: Record<string, string> = {};
for (const [locale, value] of Object.entries(translations)) {
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string at ${path}.translations.${locale}`);
}
textByLocale[locale] = value;
}
const audioByLocale: Record<string, string> = {};
for (const [locale, value] of Object.entries(audioUrls ?? {})) {
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string at ${path}.audio_urls.${locale}`);
}
audioByLocale[locale] = value;
}
return {
cue: readString(record, 'cue', path),
translations: textByLocale,
audio_urls: audioByLocale,
source: readString(record, 'source', path),
};
}
function mapSessionPlayerIdentity(payload: unknown, path: string): { token: string; tone: string; icon?: string } | undefined {
if (payload === undefined || payload === null) {
return undefined;
}
const record = asRecord(payload, path);
const icon = record.icon;
if (icon !== undefined && icon !== null && !isString(icon)) {
throw new Error(`Invalid API contract: expected string at ${path}.icon`);
}
return {
token: readString(record, 'token', path),
tone: readString(record, 'tone', path),
...(icon === undefined || icon === null ? {} : { icon }),
};
}
export function mapHealthResponse(payload: unknown): HealthResponse {
const root = asRecord(payload, 'health');
return {
@@ -68,6 +149,20 @@ export function mapHealthResponse(payload: unknown): HealthResponse {
};
}
export function mapCreateSessionResponse(payload: unknown): CreateSessionResponse {
const root = asRecord(payload, 'create_session');
const session = asRecord(root.session, 'create_session.session');
return {
session: {
code: readString(session, 'code', 'create_session.session'),
status: readString(session, 'status', 'create_session.session'),
host_id: readNullableNumber(session, 'host_id', 'create_session.session'),
current_round: readNumber(session, 'current_round', 'create_session.session')
}
};
}
function mapSessionDetail(payload: unknown): SessionDetailResponse {
const root = asRecord(payload, 'session_detail');
const session = asRecord(root.session, 'session_detail.session');
@@ -88,7 +183,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
roundQuestion = {
id: readNumber(roundQuestionRecord, 'id', 'session_detail.round_question'),
round_number: readNumber(roundQuestionRecord, 'round_number', 'session_detail.round_question'),
prompt: readString(roundQuestionRecord, 'prompt', 'session_detail.round_question'),
prompt: readNullableString(roundQuestionRecord, 'prompt', 'session_detail.round_question'),
shown_at: readString(roundQuestionRecord, 'shown_at', 'session_detail.round_question'),
answers: answersRaw.map((answer, index) => {
const answerRecord = asRecord(answer, `session_detail.round_question.answers[${index}]`);
@@ -102,6 +197,86 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
const player = asRecord(phase.player, 'session_detail.phase_view_model.player');
const revealRaw = root.reveal;
let reveal: SessionDetailResponse['reveal'] = null;
if (revealRaw !== null && revealRaw !== undefined) {
const revealRecord = asRecord(revealRaw, 'session_detail.reveal');
const liesRaw = revealRecord.lies;
const guessesRaw = revealRecord.guesses;
if (!Array.isArray(liesRaw)) {
throw new Error('Invalid API contract: expected array at session_detail.reveal.lies');
}
if (!Array.isArray(guessesRaw)) {
throw new Error('Invalid API contract: expected array at session_detail.reveal.guesses');
}
reveal = {
round_question_id: readNumber(revealRecord, 'round_question_id', 'session_detail.reveal'),
round_number: readNumber(revealRecord, 'round_number', 'session_detail.reveal'),
prompt: readString(revealRecord, 'prompt', 'session_detail.reveal'),
correct_answer: readString(revealRecord, 'correct_answer', 'session_detail.reveal'),
lies: liesRaw.map((lie, index) => {
const record = asRecord(lie, `session_detail.reveal.lies[${index}]`);
return {
player_id: readNumber(record, 'player_id', `session_detail.reveal.lies[${index}]`),
nickname: readString(record, 'nickname', `session_detail.reveal.lies[${index}]`),
text: readString(record, 'text', `session_detail.reveal.lies[${index}]`),
created_at: readString(record, 'created_at', `session_detail.reveal.lies[${index}]`)
};
}),
guesses: guessesRaw.map((guess, index) => {
const path = `session_detail.reveal.guesses[${index}]`;
const record = asRecord(guess, path);
const fooledPlayerId = readNullableNumber(record, 'fooled_player_id', path);
const fooledPlayerNickname = record.fooled_player_nickname;
if (fooledPlayerId === null) {
if (fooledPlayerNickname !== undefined) {
throw new Error(`Invalid API contract: expected ${path}.fooled_player_nickname to be omitted when fooled_player_id is null`);
}
} else if (!isString(fooledPlayerNickname)) {
throw new Error(`Invalid API contract: expected string at ${path}.fooled_player_nickname when fooled_player_id is set`);
}
return {
player_id: readNumber(record, 'player_id', path),
nickname: readString(record, 'nickname', path),
selected_text: readString(record, 'selected_text', path),
is_correct: readBoolean(record, 'is_correct', path),
fooled_player_id: fooledPlayerId,
...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }),
created_at: readString(record, 'created_at', path)
};
})
};
}
const voiceCuesRaw = root.voice_cues;
let voiceCues: SessionDetailResponse['voice_cues'] = null;
if (voiceCuesRaw !== null && voiceCuesRaw !== undefined) {
const record = asRecord(voiceCuesRaw, 'session_detail.voice_cues');
voiceCues = {
default_locale: readString(record, 'default_locale', 'session_detail.voice_cues'),
intro: mapVoiceCue(record.intro, 'session_detail.voice_cues.intro'),
phase: mapVoiceCue(record.phase, 'session_detail.voice_cues.phase'),
question_prompt: mapVoiceCue(record.question_prompt, 'session_detail.voice_cues.question_prompt'),
question_reveal: mapVoiceCue(record.question_reveal, 'session_detail.voice_cues.question_reveal'),
};
}
const phaseDisplayRaw = root.phase_display;
let phaseDisplay: SessionDetailResponse['phase_display'] = null;
if (phaseDisplayRaw !== null && phaseDisplayRaw !== undefined) {
const record = asRecord(phaseDisplayRaw, 'session_detail.phase_display');
const ornament = readNullableString(record, 'ornament', 'session_detail.phase_display');
phaseDisplay = {
theme: readString(record, 'theme', 'session_detail.phase_display'),
...(ornament === null ? {} : { ornament }),
title_key: readString(record, 'title_key', 'session_detail.phase_display'),
body_key: readString(record, 'body_key', 'session_detail.phase_display'),
cue_label_key: readString(record, 'cue_label_key', 'session_detail.phase_display'),
cue_body_key: readString(record, 'cue_body_key', 'session_detail.phase_display'),
};
}
return {
session: {
code: readString(session, 'code', 'session_detail.session'),
@@ -119,18 +294,27 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
current_round: readNumber(session, 'current_round', 'session_detail.session'),
players_count: readNumber(session, 'players_count', 'session_detail.session')
},
viewer_role:
root.viewer_role === 'host' || root.viewer_role === 'player' || root.viewer_role === 'public'
? root.viewer_role
: undefined,
players: players.map((item, index) => {
const record = asRecord(item, `session_detail.players[${index}]`);
return {
id: readNumber(record, 'id', `session_detail.players[${index}]`),
nickname: readString(record, 'nickname', `session_detail.players[${index}]`),
score: readNumber(record, 'score', `session_detail.players[${index}]`),
is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`)
is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`),
identity: mapSessionPlayerIdentity(record.identity, `session_detail.players[${index}].identity`),
};
}),
round_question: roundQuestion,
reveal,
voice_cues: voiceCues,
phase_display: phaseDisplay,
phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'),
current_phase: typeof phase.current_phase === 'string' ? phase.current_phase : undefined,
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
constraints: {
@@ -139,6 +323,19 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints')
},
readiness:
phase.readiness && typeof phase.readiness === 'object'
? {
question_ready:
typeof (phase.readiness as Record<string, unknown>).question_ready === 'boolean'
? ((phase.readiness as Record<string, unknown>).question_ready as boolean)
: undefined,
scoreboard_ready:
typeof (phase.readiness as Record<string, unknown>).scoreboard_ready === 'boolean'
? ((phase.readiness as Record<string, unknown>).scoreboard_ready as boolean)
: undefined,
}
: undefined,
host: {
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
@@ -337,10 +534,7 @@ export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse {
const root = asRecord(payload, 'submit_guess');
const guess = asRecord(root.guess, 'submit_guess.guess');
const window = asRecord(root.window, 'submit_guess.window');
const fooledPlayerId = guess.fooled_player_id;
if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) {
throw new Error('Invalid API contract: expected number|null at submit_guess.guess.fooled_player_id');
}
const fooledPlayerId = readNullableNumber(guess, 'fooled_player_id', 'submit_guess.guess');
return {
guess: {
+78 -1
View File
@@ -3,6 +3,15 @@ export interface HealthResponse {
service: string;
}
export interface CreateSessionResponse {
session: {
code: string;
status: string;
host_id: number | null;
current_round: number;
};
}
export interface SessionSummary {
code: string;
status: string;
@@ -16,6 +25,11 @@ export interface SessionPlayer {
nickname: string;
score: number;
is_connected: boolean;
identity?: {
token: string;
tone: string;
icon?: string;
};
}
export interface SessionAnswer {
@@ -25,13 +39,14 @@ export interface SessionAnswer {
export interface SessionRoundQuestion {
id: number;
round_number: number;
prompt: string;
prompt: string | null;
shown_at: string;
answers: SessionAnswer[];
}
export interface PhaseViewModel {
status: string;
current_phase?: string;
round_number: number;
players_count: number;
constraints: {
@@ -40,6 +55,10 @@ export interface PhaseViewModel {
min_players_reached: boolean;
max_players_allowed: boolean;
};
readiness?: {
question_ready?: boolean;
scoreboard_ready?: boolean;
};
host: {
can_start_round: boolean;
can_show_question: boolean;
@@ -57,13 +76,71 @@ export interface PhaseViewModel {
};
}
export interface RevealLie {
player_id: number;
nickname: string;
text: string;
created_at: string;
}
export interface RevealGuess {
player_id: number;
nickname: string;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
fooled_player_nickname?: string;
created_at: string;
}
export interface RevealPayload {
round_question_id: number;
round_number: number;
prompt: string;
correct_answer: string;
lies: RevealLie[];
guesses: RevealGuess[];
}
export interface VoiceCue {
cue: string;
translations: Record<string, string>;
audio_urls: Record<string, string>;
source: string;
}
export interface SessionVoiceCues {
default_locale: string;
intro: VoiceCue | null;
phase: VoiceCue | null;
question_prompt: VoiceCue | null;
question_reveal: VoiceCue | null;
}
export interface SessionPhaseDisplay {
theme: string;
ornament?: string;
title_key: string;
body_key: string;
cue_label_key: string;
cue_body_key: string;
}
export interface SessionDetailResponse {
session: SessionSummary;
viewer_role?: 'host' | 'player' | 'public';
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
reveal: RevealPayload | null;
voice_cues?: SessionVoiceCues | null;
phase_display?: SessionPhaseDisplay | null;
phase_view_model: PhaseViewModel;
}
export interface SessionDetailRequestOptions {
session_token?: string;
}
export interface JoinSessionRequest {
code: string;
nickname: string;
+67 -3
View File
@@ -1,6 +1,15 @@
import type { SessionDetailResponse } from '../api/types';
import type { PhaseViewModel, SessionDetailResponse } from '../api/types';
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
export type HostGameplayAction =
| 'startRound'
| 'showQuestion'
| 'mixAnswers'
| 'calculateScores'
| 'loadScoreboard'
| 'startNextRound'
| 'finishGame';
export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult';
export type GameplayPhaseEvent =
| 'LIES_LOCKED'
@@ -40,8 +49,7 @@ export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
}
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
const status = session?.session.status;
function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null {
if (!status) {
return null;
}
@@ -56,3 +64,59 @@ export function deriveGameplayPhase(session: SessionDetailResponse | null): Game
return null;
}
function deriveCanonicalPhaseStatus(phaseViewModel: PhaseViewModel | null | undefined): string | null {
if (!phaseViewModel) {
return null;
}
const currentPhase = (phaseViewModel as PhaseViewModel & { current_phase?: string }).current_phase;
return currentPhase ?? phaseViewModel.status ?? null;
}
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
const canonicalStatus = deriveCanonicalPhaseStatus(session?.phase_view_model);
return derivePhaseFromStatus(canonicalStatus ?? session?.session.status);
}
export function isHostGameplayActionAllowed(session: SessionDetailResponse | null, action: HostGameplayAction): boolean {
if (!session) {
return action === 'startRound';
}
const host = session.phase_view_model?.host;
switch (action) {
case 'startRound':
return Boolean(host?.can_start_round ?? false);
case 'showQuestion':
return Boolean(host?.can_show_question ?? false);
case 'mixAnswers':
return Boolean(host?.can_mix_answers ?? false);
case 'calculateScores':
return Boolean(host?.can_calculate_scores ?? false);
case 'loadScoreboard':
return Boolean(host?.can_reveal_scoreboard ?? false);
case 'startNextRound':
return Boolean(host?.can_start_next_round ?? false);
case 'finishGame':
return Boolean(host?.can_finish_game ?? false);
}
}
export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | null, action: PlayerGameplayAction): boolean {
if (!session) {
return action === 'join';
}
const player = session.phase_view_model?.player;
switch (action) {
case 'join':
return Boolean(player?.can_join ?? false);
case 'submitLie':
return Boolean(player?.can_submit_lie ?? false);
case 'submitGuess':
return Boolean(player?.can_submit_guess ?? false);
case 'viewFinalResult':
return Boolean(player?.can_view_final_result ?? false);
}
}
+5 -5
View File
@@ -1,5 +1,5 @@
import type { ApiClient } from '../api/client';
import type { SessionDetailResponse } from '../api/types';
import type { SessionDetailRequestOptions, SessionDetailResponse } from '../api/types';
import {
createSessionContextStore,
type SessionContext,
@@ -25,7 +25,7 @@ export interface VerticalSliceState {
export interface VerticalSliceController {
getState(): VerticalSliceState;
hydrateLobby(sessionCode: string): Promise<VerticalSliceState>;
hydrateLobby(sessionCode: string, options?: SessionDetailRequestOptions): Promise<VerticalSliceState>;
joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState>;
startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState>;
}
@@ -48,7 +48,7 @@ export function createVerticalSliceController(
const normalizeCode = (value: string): string => value.trim().toUpperCase();
async function hydrateLobby(sessionCode: string): Promise<VerticalSliceState> {
async function hydrateLobby(sessionCode: string, options?: SessionDetailRequestOptions): Promise<VerticalSliceState> {
state.loadingSession = true;
state.errorMessage = null;
@@ -62,7 +62,7 @@ export function createVerticalSliceController(
return { ...state };
}
const result = await api.getSession(state.sessionCode);
const result = await api.getSession(state.sessionCode, options);
state.loadingSession = false;
if (!result.ok) {
@@ -107,7 +107,7 @@ export function createVerticalSliceController(
};
sessionContextStore.set(nextContext);
return hydrateLobby(state.sessionCode);
return hydrateLobby(state.sessionCode, { session_token: nextContext.token });
}
async function startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState> {
+480 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client';
import { mapSessionDetailResponse, mapSubmitGuessResponse } from '../src/api/mappers';
describe('createAngularApiClient', () => {
it('reads health and session detail using Django-compatible endpoints', async () => {
@@ -206,11 +207,487 @@ describe('createAngularApiClient', () => {
}
});
it('keeps canonical reveal payload stable when session detail is already in scoreboard phase', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'B',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Maja',
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'scoreboard',
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: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: true,
can_finish_game: true
},
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 client = createAngularApiClient({ get, post: vi.fn() } as unknown as AngularHttpClientLike);
const session = await client.getSession('abcd12');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.status).toBe('scoreboard');
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
expect(session.data.phase_view_model.host.can_finish_game).toBe(true);
}
});
it('normalizes omitted fooled_player_id to null in canonical reveal payloads', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'A',
is_correct: true,
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'reveal',
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: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
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 post = vi.fn<AngularHttpClientLike['post']>(async <T>(url: string, body: unknown) => {
if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') {
expect(body).toEqual({ player_id: 9, session_token: 'tok', selected_text: 'A' });
return {
guess: {
id: 200,
player_id: 9,
round_question_id: 77,
selected_text: 'A',
is_correct: false,
created_at: '2026-03-01T16:01:00Z'
},
window: { guess_deadline_at: '2026-03-01T16:01:30Z' }
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const client = createAngularApiClient({ get, post } as AngularHttpClientLike);
const session = await client.getSession('abcd12');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.reveal?.guesses[0].fooled_player_id).toBeNull();
expect(session.data.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname');
}
const submitGuess = await client.submitGuess('ABCD12', 77, {
player_id: 9,
session_token: 'tok',
selected_text: 'A'
});
expect(submitGuess.ok).toBe(true);
if (submitGuess.ok) {
expect(submitGuess.data.guess.fooled_player_id).toBeNull();
}
});
it('maps omitted fooled_player_id to null in submit guess mapper payloads', () => {
const mapped = mapSubmitGuessResponse({
guess: {
id: 200,
player_id: 9,
round_question_id: 77,
selected_text: 'A',
is_correct: false,
created_at: '2026-03-01T16:01:00Z'
},
window: { guess_deadline_at: '2026-03-01T16:01:30Z' }
});
expect(mapped.guess.fooled_player_id).toBeNull();
});
it('keeps fooled_player_nickname omitted when canonical reveal payload omits fooled player refs', () => {
const mapped = mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'A',
is_correct: true,
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'reveal',
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: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
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
}
}
});
expect(mapped.reveal?.guesses[0].fooled_player_id).toBeNull();
expect(mapped.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname');
});
it('maps optional phase_display metadata for contract-driven scene copy and themes', () => {
const mapped = mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: null,
reveal: null,
phase_display: {
theme: 'host-atrium',
ornament: 'atrium-banner',
title_key: 'host.presenter_scene_title_lobby',
body_key: 'host.presenter_scene_body_lobby',
cue_label_key: 'host.presenter_scene_cue_start_label',
cue_body_key: 'host.presenter_scene_cue_start_body'
},
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
}
}
});
expect(mapped.phase_display).toEqual({
theme: 'host-atrium',
ornament: 'atrium-banner',
title_key: 'host.presenter_scene_title_lobby',
body_key: 'host.presenter_scene_body_lobby',
cue_label_key: 'host.presenter_scene_cue_start_label',
cue_body_key: 'host.presenter_scene_cue_start_body'
});
});
it('maps optional player identity metadata for contract-driven roster styling', () => {
const mapped = mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true, identity: { token: 'M1', tone: 'ember', icon: 'spark' } },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true, identity: { token: 'B2', tone: 'lagoon', icon: 'wave' } }
],
round_question: null,
reveal: 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
}
}
});
expect(mapped.players[0].identity).toEqual({ token: 'M1', tone: 'ember', icon: 'spark' });
expect(mapped.players[1].identity).toEqual({ token: 'B2', tone: 'lagoon', icon: 'wave' });
});
it('rejects canonical reveal payloads that include fooled_player_nickname without fooled_player_id', () => {
expect(() =>
mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'A',
is_correct: true,
fooled_player_nickname: 'Maja',
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'reveal',
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: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
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
}
}
})
).toThrow('fooled_player_nickname to be omitted when fooled_player_id is null');
});
it('rejects canonical reveal payloads that omit fooled_player_nickname when fooled_player_id is set', () => {
expect(() =>
mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'B',
is_correct: false,
fooled_player_id: 2,
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'reveal',
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: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
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
}
}
})
).toThrow('fooled_player_nickname when fooled_player_id is set');
});
it('maps host/player gameplay endpoints through typed response mappers', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12/scoreboard') {
return {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
leaderboard: [
{ id: 2, nickname: 'Maja', score: 11 },
{ id: 3, nickname: 'Bo', score: 7 }
@@ -245,7 +722,7 @@ describe('createAngularApiClient', () => {
if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
expect(body).toEqual({});
return {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 3,
leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }]
@@ -253,7 +730,7 @@ describe('createAngularApiClient', () => {
}
if (url === '/lobby/sessions/ABCD12/rounds/next') {
expect(body).toEqual({});
return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T;
return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T;
}
if (url === '/lobby/sessions/ABCD12/finish') {
expect(body).toEqual({});
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
allowedGameplayEvents,
deriveGameplayPhase,
isHostGameplayActionAllowed,
isPlayerGameplayActionAllowed,
transitionGameplayPhase,
type GameplayPhase
} from '../src/spa/gameplay-phase-machine';
@@ -40,6 +42,7 @@ describe('gameplay phase machine skeleton', () => {
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
reveal: null,
phase_view_model: {
status: 'lie',
round_number: 1,
@@ -74,6 +77,7 @@ describe('gameplay phase machine skeleton', () => {
session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
reveal: null,
phase_view_model: {
status: 'finished',
round_number: 1,
@@ -103,4 +107,44 @@ describe('gameplay phase machine skeleton', () => {
})
).toBe('scoreboard');
});
it('gates host and player actions from canonical phase_view_model permissions', () => {
const session = {
session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: { id: 77, prompt: 'Q?', answers: [] },
phase_view_model: {
status: 'reveal',
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: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
can_start_next_round: true,
can_finish_game: true
},
player: {
can_join: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as const;
expect(deriveGameplayPhase(session as any)).toBe('reveal');
expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true);
expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true);
expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true);
expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false);
});
});
+11 -1
View File
@@ -9,6 +9,7 @@ import type { ApiClient } from '../src/api/client';
function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
const base: ApiClient = {
health: vi.fn(),
createSession: vi.fn(),
getSession: vi.fn().mockResolvedValue({
ok: true,
status: 200,
@@ -16,6 +17,7 @@ function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
reveal: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
@@ -56,7 +58,15 @@ function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
}
})
}),
showQuestion: vi.fn(),
mixAnswers: vi.fn(),
calculateScores: vi.fn(),
getScoreboard: vi.fn(),
startNextRound: vi.fn(),
finishGame: vi.fn(),
submitLie: vi.fn(),
submitGuess: vi.fn()
};
return { ...base, ...overrides };
+2 -1
View File
@@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'],
setupFiles: ['angular/src/test-setup.ts'],
exclude: ['**/node_modules/**']
}
});
+21 -2
View File
@@ -1,5 +1,6 @@
from django.contrib import admin
from .models import Category, Question, GameSession, Player, RoundConfig, RoundQuestion, LieAnswer, Guess, ScoreEvent
from .models import Category, Question, QuestionLie, GameSession, Player, RoundConfig, RoundQuestion, LieAnswer, Guess, ScoreEvent
from voice.models import QuestionVoiceLine
@admin.register(Category)
@@ -9,11 +10,29 @@ class CategoryAdmin(admin.ModelAdmin):
search_fields = ("name", "slug")
class QuestionLieInline(admin.TabularInline):
model = QuestionLie
extra = 1
class QuestionVoiceLineInline(admin.TabularInline):
model = QuestionVoiceLine
extra = 1
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
list_display = ("id", "category", "is_active")
list_display = ("id", "category", "scene_ornament", "is_active")
list_filter = ("category", "is_active")
search_fields = ("prompt", "correct_answer")
inlines = [QuestionLieInline, QuestionVoiceLineInline]
@admin.register(QuestionLie)
class QuestionLieAdmin(admin.ModelAdmin):
list_display = ("question", "text", "is_active", "sort_order")
list_filter = ("is_active", "question__category")
search_fields = ("question__prompt", "text")
class PlayerInline(admin.TabularInline):
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
from dataclasses import dataclass
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from .models import Category, Question, QuestionLie
DEFAULT_MVP_HOST_USERNAME = "demo-host"
DEFAULT_MVP_HOST_PASSWORD = "demo-pass"
DEFAULT_MVP_CATEGORY_SLUG = "general"
DEFAULT_MVP_CATEGORY_NAME = "General"
DEFAULT_MVP_QUESTIONS: tuple[tuple[str, str], ...] = (
("What is the capital of Denmark?", "Copenhagen"),
("Which planet is known as the Red Planet?", "Mars"),
("How many players are required before the host can start a round?", "3"),
)
DEFAULT_MVP_FALLBACK_LIES_BY_PROMPT: dict[str, tuple[str, ...]] = {
"What is the capital of Denmark?": ("Aarhus", "Odense", "Aalborg", "Roskilde", "Esbjerg"),
"Which planet is known as the Red Planet?": ("Venus", "Jupiter", "Saturn", "Mercury", "Neptune"),
"How many players are required before the host can start a round?": ("2", "4", "5", "6", "8"),
}
DEFAULT_MVP_SCENE_ORNAMENT_BY_PROMPT: dict[str, str] = {
"What is the capital of Denmark?": Question.SceneOrnament.HARBOR_FLARE,
"Which planet is known as the Red Planet?": Question.SceneOrnament.AURORA_ARC,
"How many players are required before the host can start a round?": Question.SceneOrnament.SIGNAL_BLOOM,
}
@dataclass(frozen=True)
class SeedSummary:
created: int
updated: int
@dataclass(frozen=True)
class MvpBootstrapResult:
host: AbstractBaseUser
category: Category
questions: tuple[Question, ...]
host_changes: SeedSummary
category_changes: SeedSummary
question_changes: SeedSummary
def ensure_host_user(*, username: str, password: str, is_staff: bool = True) -> tuple[AbstractBaseUser, SeedSummary]:
user_model = get_user_model()
host, created = user_model.objects.get_or_create(username=username)
updates: list[str] = []
if not host.is_active:
host.is_active = True
updates.append("is_active")
if host.is_staff != is_staff:
host.is_staff = is_staff
updates.append("is_staff")
host.set_password(password)
updates.append("password")
host.save(update_fields=updates)
return host, SeedSummary(created=int(created), updated=int(bool(updates and not created)))
def ensure_category_with_questions(
*,
slug: str,
name: str,
prompts_and_answers: tuple[tuple[str, str], ...],
fallback_lies_by_prompt: dict[str, tuple[str, ...]] | None = None,
scene_ornament_by_prompt: dict[str, str] | None = None,
) -> tuple[Category, tuple[Question, ...], SeedSummary, SeedSummary]:
category, created = Category.objects.get_or_create(
slug=slug,
defaults={"name": name, "is_active": True},
)
category_updates: list[str] = []
if category.name != name:
category.name = name
category_updates.append("name")
if not category.is_active:
category.is_active = True
category_updates.append("is_active")
if category_updates:
category.save(update_fields=category_updates)
questions: list[Question] = []
created_count = 0
updated_count = 0
for prompt, correct_answer in prompts_and_answers:
scene_ornament = ""
if scene_ornament_by_prompt:
scene_ornament = scene_ornament_by_prompt.get(prompt, "")
question, question_created = Question.objects.get_or_create(
category=category,
prompt=prompt,
defaults={
"correct_answer": correct_answer,
"scene_ornament": scene_ornament,
"is_active": True,
},
)
question_updates: list[str] = []
if question.correct_answer != correct_answer:
question.correct_answer = correct_answer
question_updates.append("correct_answer")
if question.scene_ornament != scene_ornament:
question.scene_ornament = scene_ornament
question_updates.append("scene_ornament")
if not question.is_active:
question.is_active = True
question_updates.append("is_active")
if question_updates:
question.save(update_fields=question_updates)
if fallback_lies_by_prompt:
ensure_question_fallback_lies(
question=question,
lies=fallback_lies_by_prompt.get(prompt, ()),
)
created_count += int(question_created)
updated_count += int(bool(question_updates and not question_created))
questions.append(question)
return (
category,
tuple(questions),
SeedSummary(created=int(created), updated=int(bool(category_updates and not created))),
SeedSummary(created=created_count, updated=updated_count),
)
def ensure_question_fallback_lies(*, question: Question, lies: tuple[str, ...]) -> SeedSummary:
created_count = 0
updated_count = 0
for index, lie_text in enumerate(lies):
lie, created = QuestionLie.objects.get_or_create(
question=question,
text=lie_text,
defaults={"is_active": True, "sort_order": index},
)
updates: list[str] = []
if not lie.is_active:
lie.is_active = True
updates.append("is_active")
if lie.sort_order != index:
lie.sort_order = index
updates.append("sort_order")
if updates:
lie.save(update_fields=updates)
created_count += int(created)
updated_count += int(bool(updates and not created))
return SeedSummary(created=created_count, updated=updated_count)
def ensure_mvp_bootstrap(
*,
username: str = DEFAULT_MVP_HOST_USERNAME,
password: str = DEFAULT_MVP_HOST_PASSWORD,
category_slug: str = DEFAULT_MVP_CATEGORY_SLUG,
category_name: str = DEFAULT_MVP_CATEGORY_NAME,
prompts_and_answers: tuple[tuple[str, str], ...] = DEFAULT_MVP_QUESTIONS,
) -> MvpBootstrapResult:
host, host_changes = ensure_host_user(username=username, password=password)
category, questions, category_changes, question_changes = ensure_category_with_questions(
slug=category_slug,
name=category_name,
prompts_and_answers=prompts_and_answers,
fallback_lies_by_prompt=DEFAULT_MVP_FALLBACK_LIES_BY_PROMPT,
scene_ornament_by_prompt=DEFAULT_MVP_SCENE_ORNAMENT_BY_PROMPT,
)
return MvpBootstrapResult(
host=host,
category=category,
questions=questions,
host_changes=host_changes,
category_changes=category_changes,
question_changes=question_changes,
)
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,41 @@
from django.core.management.base import BaseCommand
from fupogfakta.bootstrap import (
DEFAULT_MVP_CATEGORY_NAME,
DEFAULT_MVP_CATEGORY_SLUG,
DEFAULT_MVP_HOST_PASSWORD,
DEFAULT_MVP_HOST_USERNAME,
ensure_mvp_bootstrap,
)
class Command(BaseCommand):
help = "Create deterministic host credentials and sample FupOgFakta content for MVP try-out"
def add_arguments(self, parser):
parser.add_argument("--username", default=DEFAULT_MVP_HOST_USERNAME)
parser.add_argument("--password", default=DEFAULT_MVP_HOST_PASSWORD)
parser.add_argument("--category-slug", default=DEFAULT_MVP_CATEGORY_SLUG)
parser.add_argument("--category-name", default=DEFAULT_MVP_CATEGORY_NAME)
def handle(self, *args, **options):
result = ensure_mvp_bootstrap(
username=options["username"],
password=options["password"],
category_slug=options["category_slug"],
category_name=options["category_name"],
)
self.stdout.write(
self.style.SUCCESS(
"\n".join(
[
"MVP bootstrap ready",
f"host_username={result.host.username}",
f"host_password={options['password']}",
f"category_slug={result.category.slug}",
f"questions={len(result.questions)}",
]
)
)
)
@@ -0,0 +1,312 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.test import Client
from fupogfakta.bootstrap import ensure_category_with_questions, ensure_host_user
from fupogfakta.models import GameSession, Player, RoundQuestion
class Command(BaseCommand):
help = 'Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard'
def add_arguments(self, parser):
parser.add_argument(
'--artifact',
help='Optional path to write smoke result artifact as JSON',
)
def _fail(self, step: str, detail: str, payload=None):
message = f'{step} failed: {detail}'
if payload is not None:
message += f' | payload={json.dumps(payload, sort_keys=True)}'
raise CommandError(message)
def _expect_status(self, response, expected_status: int, step: str):
if response.status_code != expected_status:
try:
payload = response.json()
except ValueError:
payload = {'raw': response.content.decode('utf-8', errors='replace')}
self._fail(step, f'expected HTTP {expected_status}, got {response.status_code}', payload)
return response.json()
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
actual_status = payload.get('session', {}).get('status')
if actual_status != expected_status:
self._fail(step, f'expected session.status={expected_status}, got {actual_status}', payload)
def _client(self) -> Client:
host = next((candidate for candidate in settings.ALLOWED_HOSTS if candidate and candidate != '*'), 'localhost')
return Client(HTTP_HOST=host)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
RoundQuestion.objects.all().delete()
category, questions, _category_changes, _question_changes = ensure_category_with_questions(
slug='smoke',
name='Smoke',
prompts_and_answers=(('Smoke prompt?', 'Correct'),),
)
question = questions[0]
host, _host_changes = ensure_host_user(username='smoke-host', password='smoke-pass')
artifact = {
'ok': True,
'command': 'python manage.py smoke_staging --artifact <path>',
'generated_at': datetime.now(timezone.utc).isoformat(),
'question': {
'prompt': question.prompt,
'correct_answer': question.correct_answer,
},
'steps': [],
}
host_client = self._client()
host_client.force_login(host)
create_payload = self._expect_status(
host_client.post('/lobby/sessions/create', content_type='application/json'),
201,
'create_session',
)
code = create_payload['session']['code']
artifact['session_code'] = code
artifact['steps'].append(
{
'step': 'create_session',
'session_status': create_payload['session']['status'],
}
)
players = []
for nickname in ['P1', 'P2', 'P3']:
join_payload = self._expect_status(
self._client().post(
'/lobby/sessions/join',
data=json.dumps({'code': code, 'nickname': nickname}),
content_type='application/json',
),
201,
f'join_session[{nickname}]',
)
players.append(join_payload['player'])
artifact['players'] = [player['nickname'] for player in players]
artifact['steps'].append(
{
'step': 'join_players',
'players_count': len(players),
}
)
start_payload = self._expect_status(
host_client.post(
f'/lobby/sessions/{code}/rounds/start',
data=json.dumps({'category_slug': category.slug}),
content_type='application/json',
),
201,
'start_round',
)
self._expect_session_status(start_payload, GameSession.Status.LIE, 'start_round')
round_question_id = start_payload['round_question']['id']
artifact['round_question_id'] = round_question_id
artifact['steps'].append(
{
'step': 'start_round',
'session_status': start_payload['session']['status'],
'round_question_id': round_question_id,
}
)
answers = []
lie_transition_payload = None
for player in players:
nickname = player['nickname']
lie_payload = self._expect_status(
self._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 {nickname}',
}
),
content_type='application/json',
),
201,
f'submit_lie[{nickname}]',
)
if lie_payload.get('answers'):
answers = lie_payload['answers']
lie_transition_payload = lie_payload
if not answers:
detail_payload = self._expect_status(host_client.get(f'/lobby/sessions/{code}'), 200, 'session_detail_after_lies')
answers = detail_payload.get('round_question', {}).get('answers', [])
self._expect_session_status(detail_payload, GameSession.Status.GUESS, 'session_detail_after_lies')
lie_transition_payload = detail_payload
if not answers:
self._fail('auto_guess_transition', 'canonical lie->guess transition returned empty answers')
if not any(answer.get('text') == question.correct_answer for answer in answers):
self._fail('auto_guess_transition', 'mixed answers missing correct answer', {'answers': answers})
if len(answers) < len(players) + 1:
self._fail(
'auto_guess_transition',
'mixed answers shorter than expected bluff set',
{'answers': answers, 'players_count': len(players)},
)
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, 'auto_guess_transition')
artifact['steps'].append(
{
'step': 'auto_guess_transition',
'session_status': lie_transition_payload['session']['status'],
'answers': [answer['text'] for answer in answers],
}
)
answer_texts = {answer['text'] for answer in answers}
correct_answer = next((answer['text'] for answer in answers if answer.get('text') == question.correct_answer), None)
if correct_answer is None:
self._fail('submit_guesses', 'could not resolve correct answer from mixed answers', {'answers': answers})
guess_plan = {
players[0]['nickname']: 'Lie from P2',
players[1]['nickname']: correct_answer,
players[2]['nickname']: 'Lie from P1',
}
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
if missing_guess_targets:
self._fail(
'submit_guesses',
'expected bluff targets missing from mixed answers',
{'answers': answers, 'missing_guess_targets': sorted(missing_guess_targets)},
)
artifact['guess_plan'] = guess_plan
guess_payloads = []
for player in players:
nickname = player['nickname']
guess_payload = self._expect_status(
self._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': guess_plan[nickname],
}
),
content_type='application/json',
),
201,
f'submit_guess[{nickname}]',
)
guess_payloads.append(guess_payload)
reveal_payload = guess_payloads[-1]
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, 'auto_reveal_transition')
if not reveal_payload.get('phase_transition', {}).get('auto_advanced'):
self._fail('auto_reveal_transition', 'expected auto_advanced=true on final guess', reveal_payload)
reveal = reveal_payload.get('reveal')
if not reveal:
self._fail('auto_reveal_transition', 'missing canonical reveal payload', reveal_payload)
if reveal.get('correct_answer') != question.correct_answer:
self._fail(
'auto_reveal_transition',
'reveal payload returned wrong correct answer',
{'expected': question.correct_answer, 'reveal': reveal},
)
if len(reveal.get('lies', [])) != len(players):
self._fail('auto_reveal_transition', 'unexpected lie count in reveal payload', reveal)
if len(reveal.get('guesses', [])) != len(players):
self._fail('auto_reveal_transition', 'unexpected guess count in reveal payload', reveal)
fooled_guesses = [guess for guess in reveal['guesses'] if not guess.get('is_correct')]
correct_guesses = [guess for guess in reveal['guesses'] if guess.get('is_correct')]
if len(fooled_guesses) != 2:
self._fail('auto_reveal_transition', 'expected exactly two bluff guesses', reveal)
if len(correct_guesses) != 1:
self._fail('auto_reveal_transition', 'expected exactly one correct guess', reveal)
if any(guess.get('fooled_player_id') is None for guess in fooled_guesses):
self._fail('auto_reveal_transition', 'bluff guesses missing fooled_player_id', reveal)
artifact['steps'].append(
{
'step': 'submit_guesses',
'guess_results': [
{
'player_id': payload['guess']['player_id'],
'selected_text': payload['guess']['selected_text'],
'is_correct': payload['guess']['is_correct'],
'fooled_player_id': payload['guess'].get('fooled_player_id'),
}
for payload in guess_payloads
],
}
)
artifact['steps'].append(
{
'step': 'auto_reveal_transition',
'session_status': reveal_payload['session']['status'],
'reveal': {
'correct_answer': reveal['correct_answer'],
'lies_count': len(reveal['lies']),
'guesses_count': len(reveal['guesses']),
'fooled_player_ids': sorted(guess['fooled_player_id'] for guess in fooled_guesses),
'correct_guess_player_ids': sorted(guess['player_id'] for guess in correct_guesses),
},
}
)
detail_payload = self._expect_status(host_client.get(f'/lobby/sessions/{code}'), 200, 'session_detail_after_guesses')
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, 'auto_scoreboard_transition')
if detail_payload.get('reveal') != reveal:
self._fail('auto_scoreboard_transition', 'scoreboard promotion changed canonical reveal payload', detail_payload)
scoreboard = detail_payload.get('scoreboard')
if not scoreboard:
self._fail('auto_scoreboard_transition', 'missing scoreboard payload after promotion', detail_payload)
if len(scoreboard) != len(players):
self._fail('auto_scoreboard_transition', 'unexpected scoreboard length', detail_payload)
if not detail_payload.get('phase_view_model', {}).get('readiness', {}).get('scoreboard_ready'):
self._fail('auto_scoreboard_transition', 'scoreboard_ready=false after promotion', detail_payload)
artifact['steps'].append(
{
'step': 'auto_scoreboard_transition',
'session_status': detail_payload['session']['status'],
'leaderboard': scoreboard,
}
)
finish_payload = self._expect_status(
host_client.post(f'/lobby/sessions/{code}/finish', content_type='application/json'),
200,
'finish_game',
)
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, 'finish_game')
artifact['steps'].append(
{
'step': 'finish_game',
'session_status': finish_payload['session']['status'],
}
)
artifact_path = options.get('artifact')
if artifact_path:
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}'))
@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-03-13 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0004_player_session_token'),
]
operations = [
migrations.AlterField(
model_name='gamesession',
name='status',
field=models.CharField(choices=[('lobby', 'Lobby'), ('lie', 'Løgnfase'), ('guess', 'Gættefase'), ('reveal', 'Reveal'), ('scoreboard', 'Scoreboard'), ('finished', 'Afsluttet')], default='lobby', max_length=16),
),
]
@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fupogfakta", "0004_player_session_token"),
]
operations = [
migrations.AlterField(
model_name="gamesession",
name="status",
field=models.CharField(
choices=[
("lobby", "Lobby"),
("lie", "Løgnfase"),
("guess", "Gættefase"),
("reveal", "Reveal"),
("scoreboard", "Scoreboard"),
("finished", "Afsluttet"),
],
default="lobby",
max_length=16,
),
),
]
@@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("fupogfakta", "0005_alter_gamesession_status"),
("fupogfakta", "0005_gamesession_scoreboard_status"),
]
operations = []
@@ -0,0 +1,18 @@
# Generated by Django 6.0.2 on 2026-03-17 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0006_merge_20260315_1249'),
]
operations = [
migrations.AddField(
model_name='roundconfig',
name='started_from_scoreboard',
field=models.BooleanField(default=False),
),
]
+29
View File
@@ -0,0 +1,29 @@
# Generated by Django 6.0.2 on 2026-03-18 13:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0007_roundconfig_started_from_scoreboard'),
]
operations = [
migrations.CreateModel(
name='QuestionLie',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.CharField(max_length=255)),
('is_active', models.BooleanField(default=True)),
('sort_order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fallback_lies', to='fupogfakta.question')),
],
options={
'ordering': ['sort_order', 'id'],
'unique_together': {('question', 'text')},
},
),
]
@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fupogfakta", "0008_questionlie"),
]
operations = [
migrations.AddField(
model_name="question",
name="scene_ornament",
field=models.CharField(
blank=True,
choices=[
("aurora-arc", "Aurora Arc"),
("constellation-dust", "Constellation Dust"),
("harbor-flare", "Harbor Flare"),
("signal-bloom", "Signal Bloom"),
("sunburst-ribbon", "Sunburst Ribbon"),
],
default="",
max_length=64,
),
),
]
+30
View File
@@ -24,9 +24,22 @@ class Category(models.Model):
class Question(models.Model):
class SceneOrnament(models.TextChoices):
AURORA_ARC = "aurora-arc", "Aurora Arc"
CONSTELLATION_DUST = "constellation-dust", "Constellation Dust"
HARBOR_FLARE = "harbor-flare", "Harbor Flare"
SIGNAL_BLOOM = "signal-bloom", "Signal Bloom"
SUNBURST_RIBBON = "sunburst-ribbon", "Sunburst Ribbon"
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="questions")
prompt = models.TextField()
correct_answer = models.CharField(max_length=255)
scene_ornament = models.CharField(
max_length=64,
choices=SceneOrnament.choices,
blank=True,
default="",
)
is_active = models.BooleanField(default=True)
class Meta:
@@ -36,12 +49,28 @@ class Question(models.Model):
return f"{self.category.name}: {self.prompt[:60]}"
class QuestionLie(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="fallback_lies")
text = models.CharField(max_length=255)
is_active = models.BooleanField(default=True)
sort_order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["sort_order", "id"]
unique_together = (("question", "text"),)
def __str__(self):
return f"{self.question.prompt[:40]} -> {self.text}"
class GameSession(models.Model):
class Status(models.TextChoices):
LOBBY = "lobby", "Lobby"
LIE = "lie", "Løgnfase"
GUESS = "guess", "Gættefase"
REVEAL = "reveal", "Reveal"
SCOREBOARD = "scoreboard", "Scoreboard"
FINISHED = "finished", "Afsluttet"
host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions")
@@ -82,6 +111,7 @@ class RoundConfig(models.Model):
points_bluff = models.IntegerField(default=2)
lie_seconds = models.PositiveIntegerField(default=45)
guess_seconds = models.PositiveIntegerField(default=30)
started_from_scoreboard = models.BooleanField(default=False)
class Meta:
unique_together = (("session", "number"),)
+618
View File
@@ -0,0 +1,618 @@
from datetime import timedelta
from typing import Literal
from .models import GameSession, Player, Question, RoundConfig, RoundQuestion
SessionViewerRole = Literal["host", "player", "public"]
NON_HOST_PROMPT_PHASES = {
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
GameSession.Status.FINISHED,
}
HOST_PHASE_THEMES = {
GameSession.Status.LOBBY: "host-atrium",
GameSession.Status.LIE: "host-spotlight",
GameSession.Status.GUESS: "host-signal",
GameSession.Status.REVEAL: "host-verdict",
GameSession.Status.SCOREBOARD: "host-podium",
GameSession.Status.FINISHED: "host-finale",
}
HOST_PHASE_ORNAMENTS = {
GameSession.Status.LOBBY: "atrium-banner",
GameSession.Status.LIE: "spotlight-beam",
GameSession.Status.GUESS: "signal-grid",
GameSession.Status.REVEAL: "verdict-wave",
GameSession.Status.SCOREBOARD: "podium-ribbon",
GameSession.Status.FINISHED: "finale-burst",
}
PLAYER_IDENTITY_TONES = (
"ember",
"lagoon",
"gold",
"sage",
"coral",
)
PLAYER_IDENTITY_ICONS = (
"spark",
"wave",
"comet",
"leaf",
"crown",
)
PLAYER_PHASE_THEMES = {
"join": "player-boarding",
"lobby": "player-ready",
"waiting": "player-holding",
"lie": "player-ink",
"guess": "player-choices",
"reveal": "player-ripple",
"result": "player-pennant",
}
PLAYER_PHASE_ORNAMENTS = {
"join": "boarding-pass",
"lobby": "ready-lantern",
"waiting": "holding-ring",
"lie": "ink-trace",
"guess": "choice-grid",
"reveal": "ripple-flare",
"result": "pennant-stack",
}
def build_player_ref(player: Player | None) -> dict | None:
if player is None:
return None
return {
"player_id": player.id,
"nickname": player.nickname,
}
def _player_identity_token(nickname: str, join_order: int) -> str:
initial = nickname.strip()[:1].upper() or "P"
return f"{initial}{join_order}"
def build_player_identity_payload(player: Player, *, join_order: int) -> dict:
return {
"token": _player_identity_token(player.nickname, join_order),
"tone": PLAYER_IDENTITY_TONES[(join_order - 1) % len(PLAYER_IDENTITY_TONES)],
"icon": PLAYER_IDENTITY_ICONS[(join_order - 1) % len(PLAYER_IDENTITY_ICONS)],
}
def build_session_players_payload(session: GameSession) -> list[dict]:
joined_players = list(session.players.order_by("created_at", "id"))
identities_by_id = {
player.id: build_player_identity_payload(player, join_order=index)
for index, player in enumerate(joined_players, start=1)
}
return [
{
"id": player.id,
"nickname": player.nickname,
"score": player.score,
"is_connected": player.is_connected,
"identity": identities_by_id[player.id],
}
for player in sorted(joined_players, key=lambda entry: (entry.nickname.casefold(), entry.id))
]
def _can_view_round_prompt(session: GameSession, viewer_role: SessionViewerRole) -> bool:
return viewer_role == "host" or session.status in NON_HOST_PROMPT_PHASES
def build_round_question_payload(
round_question: RoundQuestion | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if round_question is None:
return None
return {
"id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt if _can_view_round_prompt(session, viewer_role) else None,
"shown_at": round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (round_question.mixed_answers or [])],
}
def build_reveal_payload(
round_question: RoundQuestion | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if round_question is None:
return None
lies = [
{
**build_player_ref(lie.player),
"text": lie.text,
"created_at": lie.created_at.isoformat(),
}
for lie in round_question.lies.select_related("player").order_by("created_at", "id")
]
guesses = []
for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
guess_payload = {
**build_player_ref(guess.player),
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"created_at": guess.created_at.isoformat(),
"fooled_player_id": guess.fooled_player_id,
}
if guess.fooled_player is not None:
guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
guesses.append(guess_payload)
return {
"round_question_id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt if _can_view_round_prompt(session, viewer_role) else None,
"correct_answer": round_question.correct_answer,
"lies": lies,
"guesses": guesses,
}
def build_voice_cues_payload(
voice_cues: dict | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if voice_cues is None:
return None
if viewer_role == "host":
return voice_cues
# Keep non-host payloads role-correct: players can still receive generic intro/phase
# metadata later if needed, but prompt-bearing cues stay presenter-only until reveal.
return {
**voice_cues,
"question_prompt": None,
"question_reveal": voice_cues.get("question_reveal") if session.status in NON_HOST_PROMPT_PHASES else None,
}
def build_leaderboard(session: GameSession) -> list[dict]:
return list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
def build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
def _wait_cue_keys() -> tuple[str, str]:
return "host.presenter_scene_cue_wait_label", "host.presenter_scene_cue_wait_body"
def _resolve_authored_scene_ornament(
session: GameSession,
current_round_question: RoundQuestion | None,
) -> str | None:
if session.status not in {
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
}:
return None
if current_round_question is None:
return None
authored_ornament = current_round_question.question.scene_ornament
if authored_ornament in Question.SceneOrnament.values:
return authored_ornament
return None
def _build_host_phase_display_payload(
session: GameSession,
phase_view_model: dict,
*,
current_round_question: RoundQuestion | None = None,
) -> dict:
phase = session.status
host = phase_view_model["host"]
cue_label_key, cue_body_key = _wait_cue_keys()
if phase == GameSession.Status.FINISHED:
cue_label_key = "host.presenter_scene_cue_finished_label"
cue_body_key = "host.presenter_scene_cue_finished_body"
title_key = "host.presenter_scene_title_finished"
body_key = "host.presenter_scene_body_finished"
elif phase == GameSession.Status.LOBBY:
if host["can_start_round"]:
cue_label_key = "host.presenter_scene_cue_start_label"
cue_body_key = "host.presenter_scene_cue_start_body"
title_key = "host.presenter_scene_title_lobby"
body_key = "host.presenter_scene_body_lobby"
elif phase == GameSession.Status.GUESS:
if host["can_calculate_scores"]:
cue_label_key = "host.presenter_scene_cue_reveal_label"
cue_body_key = "host.presenter_scene_cue_reveal_body"
title_key = "host.presenter_scene_title_guess"
body_key = "host.presenter_scene_body_guess"
elif phase == GameSession.Status.REVEAL:
if host["can_reveal_scoreboard"]:
cue_label_key = "host.presenter_scene_cue_scoreboard_label"
cue_body_key = "host.presenter_scene_cue_scoreboard_body"
title_key = "host.presenter_scene_title_reveal"
body_key = "host.presenter_scene_body_reveal"
elif phase == GameSession.Status.SCOREBOARD:
if host["can_start_next_round"] or host["can_finish_game"]:
cue_label_key = "host.presenter_scene_cue_close_label"
cue_body_key = "host.presenter_scene_cue_close_body"
title_key = "host.presenter_scene_title_scoreboard"
body_key = "host.presenter_scene_body_scoreboard"
else:
if host["can_mix_answers"]:
cue_label_key = "host.presenter_scene_cue_mix_label"
cue_body_key = "host.presenter_scene_cue_mix_body"
elif host["can_show_question"]:
cue_label_key = "host.presenter_scene_cue_show_label"
cue_body_key = "host.presenter_scene_cue_show_body"
title_key = "host.presenter_scene_title"
body_key = "host.presenter_scene_body_lie"
return {
"theme": HOST_PHASE_THEMES.get(phase, HOST_PHASE_THEMES[GameSession.Status.LIE]),
"ornament": _resolve_authored_scene_ornament(session, current_round_question)
or HOST_PHASE_ORNAMENTS.get(phase, HOST_PHASE_ORNAMENTS[GameSession.Status.LIE]),
"title_key": title_key,
"body_key": body_key,
"cue_label_key": cue_label_key,
"cue_body_key": cue_body_key,
}
def _build_player_phase_display_payload(
session: GameSession,
phase_view_model: dict,
viewer_role: SessionViewerRole,
*,
current_round_question: RoundQuestion | None = None,
) -> dict:
phase = session.status
player = phase_view_model["player"]
authored_ornament = _resolve_authored_scene_ornament(session, current_round_question)
if viewer_role != "player" and player["can_join"]:
return {
"theme": PLAYER_PHASE_THEMES["join"],
"ornament": PLAYER_PHASE_ORNAMENTS["join"],
"title_key": "player.player_scene_title_join",
"body_key": "player.player_scene_body_join",
"cue_label_key": "player.player_scene_cue_join_label",
"cue_body_key": "player.player_scene_cue_join_body",
}
if player["can_submit_lie"]:
return {
"theme": PLAYER_PHASE_THEMES["lie"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["lie"],
"title_key": "player.submit_lie",
"body_key": "player.phase_summary_lie",
"cue_label_key": "player.active_scene_cue_lie_label",
"cue_body_key": "player.active_scene_cue_lie_body",
}
if player["can_submit_guess"]:
return {
"theme": PLAYER_PHASE_THEMES["guess"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["guess"],
"title_key": "player.submit_guess",
"body_key": "player.phase_summary_guess",
"cue_label_key": "player.active_scene_cue_guess_label",
"cue_body_key": "player.active_scene_cue_guess_body",
}
if phase == GameSession.Status.REVEAL:
return {
"theme": PLAYER_PHASE_THEMES["reveal"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["reveal"],
"title_key": "player.reveal_title",
"body_key": "player.phase_summary_reveal",
"cue_label_key": "player.active_scene_cue_reveal_label",
"cue_body_key": "player.active_scene_cue_reveal_body",
}
if phase in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} or player["can_view_final_result"]:
is_finished = phase == GameSession.Status.FINISHED or player["can_view_final_result"]
return {
"theme": PLAYER_PHASE_THEMES["result"],
"ornament": PLAYER_PHASE_ORNAMENTS["result"],
"title_key": "player.final_leaderboard" if is_finished else "player.scoreboard_title",
"body_key": "player.phase_summary_finished" if is_finished else "player.phase_summary_scoreboard",
"cue_label_key": "player.active_scene_cue_result_label",
"cue_body_key": "player.active_scene_cue_result_body",
}
if phase == GameSession.Status.LOBBY:
return {
"theme": PLAYER_PHASE_THEMES["lobby"],
"ornament": PLAYER_PHASE_ORNAMENTS["lobby"],
"title_key": "player.player_scene_title_lobby",
"body_key": "player.player_scene_body_lobby",
"cue_label_key": "player.player_scene_cue_lobby_label",
"cue_body_key": "player.player_scene_cue_lobby_body",
}
waiting_title_key = {
GameSession.Status.LIE: "player.player_scene_title_waiting_lie",
GameSession.Status.GUESS: "player.player_scene_title_waiting_guess",
}.get(phase, "player.player_scene_title_waiting")
return {
"theme": PLAYER_PHASE_THEMES["waiting"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["waiting"],
"title_key": waiting_title_key,
"body_key": "player.player_scene_body_waiting",
"cue_label_key": "player.player_scene_cue_waiting_label",
"cue_body_key": "player.player_scene_cue_waiting_body",
}
def build_phase_display_payload(
session: GameSession,
*,
viewer_role: SessionViewerRole,
phase_view_model: dict,
current_round_question: RoundQuestion | None = None,
) -> dict:
if viewer_role == "host":
return _build_host_phase_display_payload(
session,
phase_view_model,
current_round_question=current_round_question,
)
return _build_player_phase_display_payload(
session,
phase_view_model,
viewer_role,
current_round_question=current_round_question,
)
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_scoreboard = status == GameSession.Status.SCOREBOARD
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"current_phase": 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,
},
"readiness": {
"question_ready": has_round_question,
"scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED},
"can_advance_to_next_round": in_scoreboard,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": in_lie and has_round_question,
"can_mix_answers": (in_lie or in_guess) and has_round_question,
"can_calculate_scores": in_guess and has_round_question,
"can_reveal_scoreboard": status == GameSession.Status.REVEAL,
"can_start_next_round": in_scoreboard,
"can_finish_game": in_scoreboard,
},
"player": {
"can_join": status in {
GameSession.Status.LOBBY,
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
},
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
def build_session_detail_gameplay_payload(
session: GameSession,
*,
current_round_question: RoundQuestion | None,
players_count: int,
viewer_role: SessionViewerRole,
voice_cues: dict | None = None,
) -> dict:
phase_view_model = build_phase_view_model(
session,
players_count=players_count,
has_round_question=bool(current_round_question),
)
return {
"round_question": build_round_question_payload(
current_round_question,
session=session,
viewer_role=viewer_role,
),
"reveal": build_reveal_payload(
current_round_question,
session=session,
viewer_role=viewer_role,
)
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
else None,
"scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"]
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
else None,
"voice_cues": build_voice_cues_payload(
voice_cues,
session=session,
viewer_role=viewer_role,
),
"phase_view_model": phase_view_model,
"phase_display": build_phase_display_payload(
session,
viewer_role=viewer_role,
phase_view_model=phase_view_model,
current_round_question=current_round_question,
),
}
def build_start_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
lie_started_payload = build_lie_started_payload(session, round_config, round_question)
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"round": {
"number": round_config.number,
"category": {
"slug": round_config.category.slug,
"name": round_config.category.name,
},
},
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
}
def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
"lie_seconds": lie_seconds,
}
def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
},
"config": {
"lie_seconds": lie_seconds,
},
}
def build_start_next_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return build_start_round_response(session, round_config, round_question)
def build_start_next_round_phase_event(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return {
"name": "phase.lie_started",
"payload": build_lie_started_payload(session, round_config, round_question),
}
def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict:
return {
"name": "phase.scoreboard",
"payload": {
"leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session),
"current_round": session.current_round,
},
}
def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict:
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
def build_finish_game_phase_event(session: GameSession) -> dict:
leaderboard = build_leaderboard(session)
winner = leaderboard[0] if leaderboard else None
return {
"name": "phase.game_over",
"payload": {"winner": winner, "leaderboard": leaderboard},
}
def build_finish_game_response(session: GameSession) -> dict:
finish_event = build_finish_game_phase_event(session)
return {
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": finish_event["payload"]["winner"],
"leaderboard": finish_event["payload"]["leaderboard"],
}
+494
View File
@@ -0,0 +1,494 @@
import random
from datetime import timedelta
from dataclasses import dataclass
from typing import Any
from django.db import transaction
from django.utils import timezone
from .models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
from .payloads import (
build_finish_game_phase_event,
build_finish_game_response,
build_lie_started_payload,
build_question_shown_payload,
build_question_shown_response,
build_reveal_scoreboard_response,
build_scoreboard_phase_event,
build_start_next_round_phase_event,
build_start_next_round_response,
build_start_round_response,
)
@dataclass(frozen=True)
class RoundTransitionResult:
session: GameSession
round_config: RoundConfig
round_question: RoundQuestion
should_broadcast: bool
response_payload: dict[str, Any]
phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None
@dataclass(frozen=True)
class FinishGameResult:
session: GameSession
should_broadcast: bool
response_payload: dict[str, Any]
phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None
@dataclass(frozen=True)
class ScoreboardTransitionResult:
session: GameSession
leaderboard: list[dict]
should_broadcast: bool
response_payload: dict[str, Any] | None = None
phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None
def get_round_question(session: GameSession, round_number: int) -> RoundQuestion | None:
return (
RoundQuestion.objects.filter(session=session, round_number=round_number)
.select_related("question")
.order_by("-id")
.first()
)
def get_current_round_question(session: GameSession) -> RoundQuestion | None:
return get_round_question(session, session.current_round)
def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion:
Guess.objects.filter(round_question=round_question).delete()
LieAnswer.objects.filter(round_question=round_question).delete()
update_fields: list[str] = []
if round_question.mixed_answers:
round_question.mixed_answers = []
update_fields.append("mixed_answers")
round_question.shown_at = timezone.now()
update_fields.append("shown_at")
round_question.save(update_fields=update_fields)
return round_question
def select_round_question(
session: GameSession,
round_config: RoundConfig,
*,
round_number: int | None = None,
) -> RoundQuestion:
target_round_number = session.current_round if round_number is None else round_number
existing_round_question = get_round_question(session, target_round_number)
if existing_round_question is not None and existing_round_question.question.category_id == round_config.category_id:
return existing_round_question
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():
raise ValueError("no_available_questions")
question = random.choice(list(available_questions))
if existing_round_question is not None:
existing_round_question.question = question
existing_round_question.correct_answer = question.correct_answer
existing_round_question.save(update_fields=["question", "correct_answer"])
return existing_round_question
return RoundQuestion.objects.create(
session=session,
round_number=target_round_number,
question=question,
correct_answer=question.correct_answer,
)
def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
deduped_answers = list(round_question.mixed_answers or [])
if deduped_answers:
return deduped_answers
lie_texts = list(round_question.lies.values_list("text", flat=True))
seen = set()
for text in [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())
players_count = Player.objects.filter(session=round_question.session).count()
target_total_answers = max(2, players_count + 1)
fallback_lies: list[str] = []
fallback_seen = set()
for text in round_question.question.fallback_lies.filter(is_active=True).values_list("text", flat=True):
normalized = text.strip().casefold()
if not normalized or normalized in seen or normalized in fallback_seen:
continue
fallback_seen.add(normalized)
fallback_lies.append(text.strip())
if fallback_lies and len(deduped_answers) < target_total_answers:
random.shuffle(fallback_lies)
deduped_answers.extend(fallback_lies[: target_total_answers - len(deduped_answers)])
if len(deduped_answers) < 2:
raise ValueError("not_enough_answers_to_mix")
random.shuffle(deduped_answers)
round_question.mixed_answers = deduped_answers
round_question.save(update_fields=["mixed_answers"])
return deduped_answers
def start_round(session: GameSession, category_slug: str) -> RoundTransitionResult:
try:
category = Category.objects.get(slug=category_slug, is_active=True)
except Category.DoesNotExist:
raise ValueError("category_not_found")
if not Question.objects.filter(category=category, is_active=True).exists():
raise ValueError("category_has_no_questions")
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.LOBBY:
raise ValueError("round_start_invalid_phase")
if RoundConfig.objects.filter(session=locked_session, number=locked_session.current_round).exists():
raise ValueError("round_already_configured")
round_config = RoundConfig(
session=locked_session,
number=locked_session.current_round,
category=category,
)
round_question = select_round_question(locked_session, round_config)
round_config.save()
locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["status"])
phase_event = {
"name": "phase.lie_started",
"payload": build_lie_started_payload(locked_session, round_config, round_question),
}
return RoundTransitionResult(
session=locked_session,
round_config=round_config,
round_question=round_question,
should_broadcast=True,
response_payload=build_start_round_response(locked_session, round_config, round_question),
phase_event_name=phase_event["name"],
phase_event_payload=phase_event["payload"],
)
def show_question(session: GameSession) -> RoundTransitionResult:
if session.status != GameSession.Status.LIE:
raise ValueError("show_question_invalid_phase")
try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
raise ValueError("round_config_missing")
round_question = get_current_round_question(session)
if round_question is None:
round_question = select_round_question(session, round_config)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
lie_deadline_iso = lie_deadline_at.isoformat()
phase_event = {
"name": "phase.question_shown",
"payload": build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds),
}
return RoundTransitionResult(
session=session,
round_config=round_config,
round_question=round_question,
should_broadcast=True,
response_payload=build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds),
phase_event_name=phase_event["name"],
phase_event_payload=phase_event["payload"],
)
def start_next_round(session: GameSession) -> RoundTransitionResult:
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
next_round_config = None
round_question = None
should_broadcast = False
phase_event_name = None
phase_event_payload = None
if locked_session.status == GameSession.Status.SCOREBOARD:
previous_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
if previous_round_config is None:
raise ValueError("round_config_missing")
next_round_number = locked_session.current_round + 1
next_round_config, _created = RoundConfig.objects.get_or_create(
session=locked_session,
number=next_round_number,
defaults={
"category": previous_round_config.category,
"lie_seconds": previous_round_config.lie_seconds,
"guess_seconds": previous_round_config.guess_seconds,
"points_correct": previous_round_config.points_correct,
"points_bluff": previous_round_config.points_bluff,
"started_from_scoreboard": True,
},
)
round_config_update_fields: list[str] = []
if next_round_config.category_id != previous_round_config.category_id:
next_round_config.category = previous_round_config.category
round_config_update_fields.append("category")
if next_round_config.lie_seconds != previous_round_config.lie_seconds:
next_round_config.lie_seconds = previous_round_config.lie_seconds
round_config_update_fields.append("lie_seconds")
if next_round_config.guess_seconds != previous_round_config.guess_seconds:
next_round_config.guess_seconds = previous_round_config.guess_seconds
round_config_update_fields.append("guess_seconds")
if next_round_config.points_correct != previous_round_config.points_correct:
next_round_config.points_correct = previous_round_config.points_correct
round_config_update_fields.append("points_correct")
if next_round_config.points_bluff != previous_round_config.points_bluff:
next_round_config.points_bluff = previous_round_config.points_bluff
round_config_update_fields.append("points_bluff")
if not next_round_config.started_from_scoreboard:
next_round_config.started_from_scoreboard = True
round_config_update_fields.append("started_from_scoreboard")
if round_config_update_fields:
next_round_config.save(update_fields=round_config_update_fields)
locked_session.current_round = next_round_number
round_question = reset_round_question_bootstrap_state(
select_round_question(locked_session, next_round_config, round_number=next_round_number)
)
locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["current_round", "status"])
should_broadcast = True
phase_event = build_start_next_round_phase_event(locked_session, next_round_config, round_question)
phase_event_name = phase_event["name"]
phase_event_payload = phase_event["payload"]
elif locked_session.status == GameSession.Status.LIE:
if locked_session.current_round <= 1:
raise ValueError("next_round_invalid_phase")
next_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
round_question = get_current_round_question(locked_session)
if (
next_round_config is None
or not next_round_config.started_from_scoreboard
or round_question is None
):
raise ValueError("next_round_invalid_phase")
else:
raise ValueError("next_round_invalid_phase")
return RoundTransitionResult(
session=locked_session,
round_config=next_round_config,
round_question=round_question,
should_broadcast=should_broadcast,
response_payload=build_start_next_round_response(
locked_session,
next_round_config,
round_question,
),
phase_event_name=phase_event_name,
phase_event_payload=phase_event_payload,
)
def finish_game(session: GameSession) -> FinishGameResult:
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
should_broadcast = False
phase_event_name = None
phase_event_payload = None
if locked_session.status == GameSession.Status.SCOREBOARD:
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
should_broadcast = True
phase_event = build_finish_game_phase_event(locked_session)
phase_event_name = phase_event["name"]
phase_event_payload = phase_event["payload"]
elif locked_session.status != GameSession.Status.FINISHED:
raise ValueError("finish_game_invalid_phase")
return FinishGameResult(
session=locked_session,
should_broadcast=should_broadcast,
response_payload=build_finish_game_response(locked_session),
phase_event_name=phase_event_name,
phase_event_payload=phase_event_payload,
)
def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionResult:
if session.status != GameSession.Status.REVEAL:
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return ScoreboardTransitionResult(
session=session,
leaderboard=leaderboard,
should_broadcast=False,
response_payload=build_reveal_scoreboard_response(session, leaderboard),
)
current_round_question = get_current_round_question(session)
if current_round_question is None:
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return ScoreboardTransitionResult(
session=session,
leaderboard=leaderboard,
should_broadcast=False,
response_payload=build_reveal_scoreboard_response(session, leaderboard),
)
players_count = Player.objects.filter(session=session).count()
guess_count = Guess.objects.filter(round_question=current_round_question).count()
has_score_events = ScoreEvent.objects.filter(
session=session,
meta__round_question_id=current_round_question.id,
).exists()
reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count)
if not reveal_is_resolved:
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return ScoreboardTransitionResult(
session=session,
leaderboard=leaderboard,
should_broadcast=False,
response_payload=build_reveal_scoreboard_response(session, leaderboard),
)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
scoreboard_session = locked_session
should_broadcast = False
else:
locked_session.status = GameSession.Status.SCOREBOARD
locked_session.save(update_fields=["status"])
scoreboard_session = locked_session
should_broadcast = True
leaderboard = list(
Player.objects.filter(session=scoreboard_session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
phase_event_name = None
phase_event_payload = None
if should_broadcast:
phase_event = build_scoreboard_phase_event(scoreboard_session, leaderboard)
phase_event_name = phase_event["name"]
phase_event_payload = phase_event["payload"]
return ScoreboardTransitionResult(
session=scoreboard_session,
leaderboard=leaderboard,
should_broadcast=should_broadcast,
response_payload=build_reveal_scoreboard_response(scoreboard_session, leaderboard),
phase_event_name=phase_event_name,
phase_event_payload=phase_event_payload,
)
def resolve_scores(
session: GameSession,
round_question: RoundQuestion,
round_config: RoundConfig,
) -> tuple[list[ScoreEvent], list[dict]]:
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
raise ValueError("no_guesses_submitted")
bluff_counts: dict[int, int] = {}
for guess in guesses:
if guess.fooled_player_id:
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
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)
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return score_events, leaderboard
+498 -1
View File
@@ -1,2 +1,499 @@
from datetime import timedelta
from unittest.mock import patch
# Create your tests here.
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, QuestionLie, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import (
build_lie_started_payload,
build_phase_display_payload,
build_phase_view_model,
build_reveal_payload,
build_round_question_payload,
build_session_detail_gameplay_payload,
build_session_players_payload,
)
from fupogfakta.services import (
finish_game,
get_current_round_question,
prepare_mixed_answers,
promote_reveal_to_scoreboard,
resolve_scores,
select_round_question,
start_next_round,
)
User = get_user_model()
class FupOgFaktaExtractionSliceTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCD23")
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
self.question_one = Question.objects.create(
category=self.category,
prompt="Hvornår faldt muren?",
correct_answer="1989",
is_active=True,
)
self.question_two = Question.objects.create(
category=self.category,
prompt="Hvornår kom euroen?",
correct_answer="1999",
is_active=True,
)
self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category)
self.alice = Player.objects.create(session=self.session, nickname="Alice")
self.bob = Player.objects.create(session=self.session, nickname="Bob")
self.clara = Player.objects.create(session=self.session, nickname="Clara")
def test_select_round_question_skips_already_used_questions_for_session(self):
RoundQuestion.objects.create(
session=self.session,
round_number=99,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
)
round_question = select_round_question(self.session, self.round_config)
self.assertEqual(round_question.question, self.question_two)
self.assertEqual(get_current_round_question(self.session), round_question)
def test_prepare_mixed_answers_dedupes_blank_and_case_variants(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer="1989",
)
LieAnswer.objects.create(round_question=round_question, player=self.alice, text=" 1989 ")
LieAnswer.objects.create(round_question=round_question, player=self.bob, text="Nitten niogfirs")
LieAnswer.objects.create(round_question=round_question, player=self.clara, text=" ")
with patch("fupogfakta.services.random.shuffle", side_effect=lambda answers: None):
answers = prepare_mixed_answers(round_question)
self.assertEqual(answers, ["1989", "Nitten niogfirs"])
round_question.refresh_from_db()
self.assertEqual(round_question.mixed_answers, answers)
def test_prepare_mixed_answers_supplements_with_question_fallback_lies(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer="1989",
)
QuestionLie.objects.create(question=self.question_one, text="1991", sort_order=0)
QuestionLie.objects.create(question=self.question_one, text="1992", sort_order=1)
QuestionLie.objects.create(question=self.question_one, text="2001", sort_order=2)
QuestionLie.objects.create(question=self.question_one, text="1989", sort_order=3)
with patch("fupogfakta.services.random.shuffle", side_effect=lambda answers: None):
answers = prepare_mixed_answers(round_question)
self.assertEqual(answers, ["1989", "1991", "1992", "2001"])
round_question.refresh_from_db()
self.assertEqual(round_question.mixed_answers, answers)
def test_start_next_round_moves_scoreboard_transition_into_service(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
result = start_next_round(self.session)
self.session.refresh_from_db()
self.assertTrue(result.should_broadcast)
self.assertEqual(result.session.status, GameSession.Status.LIE)
self.assertEqual(result.session.current_round, 2)
self.assertEqual(result.round_config.number, 2)
self.assertTrue(result.round_config.started_from_scoreboard)
self.assertEqual(result.round_question.round_number, 2)
def test_start_next_round_rejects_plain_lie_without_scoreboard_marker(self):
self.session.status = GameSession.Status.LIE
self.session.current_round = 2
self.session.save(update_fields=["status", "current_round"])
RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False)
RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question_two,
correct_answer=self.question_two.correct_answer,
)
with self.assertRaisesMessage(ValueError, "next_round_invalid_phase"):
start_next_round(self.session)
def test_start_next_round_refreshes_shown_at_for_reused_bootstrap_question(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
stale_shown_at = timezone.now() - timedelta(minutes=10)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question_two,
correct_answer=self.question_two.correct_answer,
shown_at=stale_shown_at,
mixed_answers=["Stale truth", "Stale lie"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Stale lie")
Guess.objects.create(
round_question=stale_round_question,
player=self.bob,
selected_text="Stale truth",
is_correct=True,
)
before_transition = timezone.now()
result = start_next_round(self.session)
after_transition = timezone.now()
stale_round_question.refresh_from_db()
self.assertEqual(result.round_question.id, stale_round_question.id)
self.assertGreaterEqual(stale_round_question.shown_at, before_transition)
self.assertLessEqual(stale_round_question.shown_at, after_transition)
self.assertNotEqual(stale_round_question.shown_at, stale_shown_at)
self.assertEqual(result.response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat())
expected_deadline = stale_round_question.shown_at + timedelta(seconds=result.round_config.lie_seconds)
self.assertEqual(result.response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat())
self.assertGreater(expected_deadline, before_transition)
self.assertEqual(stale_round_question.mixed_answers, [])
self.assertEqual(stale_round_question.lies.count(), 0)
self.assertEqual(stale_round_question.guesses.count(), 0)
def test_start_next_round_reuses_existing_bootstrap_round_config_with_fresh_canonical_values(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
stale_category = Category.objects.create(name="Sport", slug="sport", is_active=True)
stale_round_config = RoundConfig.objects.create(
session=self.session,
number=2,
category=stale_category,
lie_seconds=12,
guess_seconds=18,
points_correct=9,
points_bluff=7,
started_from_scoreboard=False,
)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question_two,
correct_answer=self.question_two.correct_answer,
shown_at=timezone.now() - timedelta(minutes=10),
mixed_answers=["Stale truth"],
)
result = start_next_round(self.session)
stale_round_config.refresh_from_db()
stale_round_question.refresh_from_db()
self.assertEqual(result.round_config.id, stale_round_config.id)
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
self.assertEqual(stale_round_config.category_id, self.round_config.category_id)
self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds)
self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds)
self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct)
self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff)
self.assertTrue(stale_round_config.started_from_scoreboard)
self.assertEqual(result.round_question.id, stale_round_question.id)
self.assertEqual(stale_round_question.mixed_answers, [])
def test_start_next_round_repairs_reused_bootstrap_question_when_category_drifted(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
)
stale_category = Category.objects.create(name="Sport drift", slug="sport-drift", is_active=True)
stale_question = Question.objects.create(
category=stale_category,
prompt="Hvem vandt EM i 1992?",
correct_answer="Danmark",
is_active=True,
)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=stale_question,
correct_answer=stale_question.correct_answer,
shown_at=timezone.now() - timedelta(minutes=10),
mixed_answers=["Stale truth", "Stale lie"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Tyskland")
Guess.objects.create(
round_question=stale_round_question,
player=self.bob,
selected_text="Stale truth",
is_correct=True,
)
result = start_next_round(self.session)
stale_round_question.refresh_from_db()
self.assertEqual(result.round_question.id, stale_round_question.id)
self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id)
self.assertEqual(stale_round_question.question_id, self.question_two.id)
self.assertEqual(stale_round_question.correct_answer, self.question_two.correct_answer)
self.assertEqual(stale_round_question.mixed_answers, [])
self.assertEqual(stale_round_question.lies.count(), 0)
self.assertEqual(stale_round_question.guesses.count(), 0)
def test_start_next_round_does_not_reuse_previous_round_question_when_category_matches(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
previous_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
mixed_answers=["1989", "1991"],
)
LieAnswer.objects.create(round_question=previous_round_question, player=self.alice, text="1991")
Guess.objects.create(
round_question=previous_round_question,
player=self.bob,
selected_text="1991",
is_correct=False,
fooled_player=self.alice,
)
result = start_next_round(self.session)
previous_round_question.refresh_from_db()
self.session.refresh_from_db()
self.assertEqual(self.session.current_round, 2)
self.assertEqual(result.round_question.round_number, 2)
self.assertNotEqual(result.round_question.id, previous_round_question.id)
self.assertEqual(result.round_question.question_id, self.question_two.id)
self.assertEqual(previous_round_question.round_number, 1)
self.assertEqual(previous_round_question.question_id, self.question_one.id)
self.assertEqual(previous_round_question.mixed_answers, ["1989", "1991"])
self.assertEqual(previous_round_question.lies.count(), 1)
self.assertEqual(previous_round_question.guesses.count(), 1)
def test_finish_game_moves_scoreboard_transition_into_service(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
result = finish_game(self.session)
self.session.refresh_from_db()
self.assertTrue(result.should_broadcast)
self.assertEqual(result.session.status, GameSession.Status.FINISHED)
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
def test_promote_reveal_to_scoreboard_moves_transition_into_service(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
)
self.session.status = GameSession.Status.REVEAL
self.session.save(update_fields=["status"])
LieAnswer.objects.create(round_question=round_question, player=self.alice, text="Elbil")
Guess.objects.create(
round_question=round_question,
player=self.bob,
selected_text="Elbil",
is_correct=False,
fooled_player=self.alice,
)
ScoreEvent.objects.create(
session=self.session,
player=self.alice,
delta=5,
reason="bluff_success",
meta={"round_question_id": round_question.id},
)
self.alice.score = 5
self.alice.save(update_fields=["score"])
result = promote_reveal_to_scoreboard(self.session)
self.session.refresh_from_db()
self.assertTrue(result.should_broadcast)
self.assertEqual(result.session.status, GameSession.Status.SCOREBOARD)
self.assertEqual(result.leaderboard[0]["nickname"], self.alice.nickname)
def test_resolve_scores_applies_correct_and_bluff_points(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer="1989",
)
Guess.objects.create(
round_question=round_question,
player=self.alice,
selected_text="1989",
is_correct=True,
)
Guess.objects.create(
round_question=round_question,
player=self.bob,
selected_text="Berlin",
is_correct=False,
fooled_player=self.clara,
)
Guess.objects.create(
round_question=round_question,
player=self.clara,
selected_text="Berlin",
is_correct=False,
fooled_player=self.clara,
)
score_events, leaderboard = resolve_scores(self.session, round_question, self.round_config)
self.assertEqual(len(score_events), 2)
self.alice.refresh_from_db()
self.clara.refresh_from_db()
self.assertEqual(self.alice.score, self.round_config.points_correct)
self.assertEqual(self.clara.score, self.round_config.points_bluff * 2)
self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2)
self.assertEqual([entry["nickname"] for entry in leaderboard], ["Alice", "Clara", "Bob"])
def test_payload_builders_expose_fupogfakta_round_contract(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer="1989",
)
lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991")
Guess.objects.create(
round_question=round_question,
player=self.alice,
selected_text="1991",
is_correct=False,
fooled_player=self.bob,
)
round_question_payload = build_round_question_payload(
round_question,
session=self.session,
viewer_role="host",
)
lie_payload = build_lie_started_payload(self.session, self.round_config, round_question)
reveal_payload = build_reveal_payload(
round_question,
session=self.session,
viewer_role="host",
)
phase_view_model = build_phase_view_model(
self.session,
players_count=3,
has_round_question=True,
)
phase_display = build_phase_display_payload(
self.session,
viewer_role="host",
phase_view_model=phase_view_model,
)
self.assertEqual(round_question_payload["prompt"], self.question_one.prompt)
self.assertEqual(round_question_payload["answers"], [])
self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name})
self.assertEqual(lie_payload["round_question_id"], round_question.id)
self.assertEqual(reveal_payload["correct_answer"], "1989")
self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id)
self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname)
self.assertTrue(phase_view_model["host"]["can_start_round"])
self.assertFalse(phase_view_model["host"]["can_show_question"])
self.assertFalse(phase_view_model["host"]["can_mix_answers"])
self.assertFalse(phase_view_model["host"]["can_finish_game"])
self.assertEqual(phase_display["theme"], "host-atrium")
self.assertEqual(phase_display["ornament"], "atrium-banner")
self.assertEqual(phase_display["cue_label_key"], "host.presenter_scene_cue_start_label")
def test_build_phase_display_payload_prefers_authored_question_ornament_during_active_rounds(self):
self.session.status = GameSession.Status.LIE
self.session.save(update_fields=["status"])
self.question_one.scene_ornament = Question.SceneOrnament.HARBOR_FLARE
self.question_one.save(update_fields=["scene_ornament"])
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer="1989",
)
phase_view_model = build_phase_view_model(
self.session,
players_count=3,
has_round_question=True,
)
host_phase_display = build_phase_display_payload(
self.session,
viewer_role="host",
phase_view_model=phase_view_model,
current_round_question=round_question,
)
player_phase_display = build_phase_display_payload(
self.session,
viewer_role="player",
phase_view_model=phase_view_model,
current_round_question=round_question,
)
self.assertEqual(host_phase_display["ornament"], Question.SceneOrnament.HARBOR_FLARE)
self.assertEqual(player_phase_display["ornament"], Question.SceneOrnament.HARBOR_FLARE)
def test_build_session_players_payload_keeps_identity_tokens_stable_across_sorted_output(self):
session = GameSession.objects.create(host=self.host, code="TOKENS1")
Player.objects.create(session=session, nickname="Zoe")
Player.objects.create(session=session, nickname="Alice")
Player.objects.create(session=session, nickname="Mads")
players_payload = build_session_players_payload(session)
self.assertEqual([entry["nickname"] for entry in players_payload], ["Alice", "Mads", "Zoe"])
self.assertEqual(players_payload[0]["identity"], {"token": "A2", "tone": "lagoon", "icon": "wave"})
self.assertEqual(players_payload[1]["identity"], {"token": "M3", "tone": "gold", "icon": "comet"})
self.assertEqual(players_payload[2]["identity"], {"token": "Z1", "tone": "ember", "icon": "spark"})
def test_build_session_detail_gameplay_payload_keeps_session_detail_semantics_in_cartridge(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
)
lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991")
Guess.objects.create(
round_question=round_question,
player=self.alice,
selected_text="1991",
is_correct=False,
fooled_player=self.bob,
)
gameplay_payload = build_session_detail_gameplay_payload(
self.session,
current_round_question=round_question,
players_count=3,
viewer_role="host",
)
self.assertEqual(gameplay_payload["round_question"]["id"], round_question.id)
self.assertEqual(gameplay_payload["reveal"]["lies"][0]["player_id"], lie.player_id)
self.assertEqual(gameplay_payload["scoreboard"], [{"id": self.alice.id, "nickname": self.alice.nickname, "score": self.alice.score}, {"id": self.bob.id, "nickname": self.bob.nickname, "score": self.bob.score}, {"id": self.clara.id, "nickname": self.clara.nickname, "score": self.clara.score}])
self.assertEqual(gameplay_payload["phase_view_model"]["status"], GameSession.Status.SCOREBOARD)
self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_start_next_round"])
self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_finish_game"])
self.assertEqual(gameplay_payload["phase_display"]["theme"], "host-podium")
self.assertEqual(gameplay_payload["phase_display"]["ornament"], "podium-ribbon")
self.assertEqual(gameplay_payload["phase_display"]["title_key"], "host.presenter_scene_title_scoreboard")
+650 -1
View File
@@ -1,2 +1,651 @@
from datetime import timedelta
# Create your views here.
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from lobby.http import json_body, normalize_session_code
from lobby.i18n import api_error
from realtime.broadcast import sync_broadcast_phase_event
from .models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
from .payloads import (
build_leaderboard as _build_leaderboard,
build_reveal_payload as _build_reveal_payload,
)
from .services import (
finish_game as _finish_game,
prepare_mixed_answers as _prepare_mixed_answers,
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
resolve_scores as _resolve_scores,
show_question as _show_question,
start_next_round as _start_next_round,
start_round as _start_round,
)
def _broadcast_transition(transition) -> None:
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
def maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
transition = _promote_reveal_to_scoreboard(session)
_broadcast_transition(transition)
return transition.session
@require_POST
@login_required
def start_round(request: HttpRequest, code: str) -> JsonResponse:
payload = json_body(request)
category_slug = str(payload.get('category_slug', '')).strip()
if not category_slug:
return api_error(
request,
code='category_slug_required',
status=400,
)
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(
request,
code='session_not_found',
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code='host_only_start_round',
status=403,
)
try:
transition = _start_round(session, category_slug)
except ValueError as exc:
error_code = str(exc)
error_status = {
'category_not_found': 404,
'round_already_configured': 409,
}.get(error_code, 400)
return api_error(request, code=error_code, status=error_status)
_broadcast_transition(transition)
return JsonResponse(transition.response_payload, 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 api_error(
request,
code='session_not_found',
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code='host_only_show_question',
status=403,
)
try:
transition = _show_question(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
_broadcast_transition(transition)
return JsonResponse(transition.response_payload, 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 api_error(request, code='player_id_required', status=400)
if not session_token:
return api_error(request, code='session_token_required', status=400)
if not lie_text or len(lie_text) > 255:
return api_error(request, code='lie_text_invalid', status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code='session_not_found', status=404)
if session.status != GameSession.Status.LIE:
return api_error(request, code='lie_submission_invalid_phase', status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return api_error(request, code='player_not_found_in_session', status=404)
if player.session_token != session_token:
return api_error(request, code='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 api_error(request, code='round_question_not_found', status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code='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 api_error(request, code='lie_submission_closed', status=400)
try:
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
except IntegrityError:
return api_error(request, code='lie_already_submitted', status=409)
players_count = Player.objects.filter(session=session).count()
lie_count = LieAnswer.objects.filter(round_question=round_question).count()
session_status = session.status
mixed_answers_payload = None
if players_count > 0 and lie_count >= players_count:
try:
mixed_answers = _prepare_mixed_answers(round_question)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
session.status = GameSession.Status.GUESS
session.save(update_fields=['status'])
session_status = session.status
mixed_answers_payload = [{'text': text} for text in mixed_answers]
sync_broadcast_phase_event(
session.code,
'phase.guess_started',
{
'round_question_id': round_question.id,
'answers': mixed_answers_payload,
'guess_seconds': round_config.guess_seconds,
},
)
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(),
},
'session': {
'code': session.code,
'status': session_status,
'current_round': session.current_round,
},
'phase_transition': {
'current_phase': session_status,
'lies_submitted': lie_count,
'players_expected': players_count,
'auto_advanced': session_status == GameSession.Status.GUESS,
},
'answers': mixed_answers_payload,
},
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 api_error(
request,
code='session_not_found',
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code='host_only_mix_answers',
status=403,
)
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error(
request,
code='mix_answers_invalid_phase',
status=400,
)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(
request,
code='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 api_error(
request,
code='mix_answers_invalid_phase',
status=400,
)
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
try:
deduped_answers = _prepare_mixed_answers(locked_round_question)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
if locked_session.status == GameSession.Status.LIE:
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=['status'])
try:
guess_config = RoundConfig.objects.get(session=session, number=session.current_round)
guess_seconds = guess_config.guess_seconds
except RoundConfig.DoesNotExist:
guess_seconds = None
sync_broadcast_phase_event(
session.code,
'phase.guess_started',
{
'round_question_id': round_question.id,
'answers': [{'text': text} for text in deduped_answers],
'guess_seconds': guess_seconds,
},
)
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 api_error(request, code='player_id_required', status=400)
if not session_token:
return api_error(request, code='session_token_required', status=400)
if not selected_text or len(selected_text) > 255:
return api_error(request, code='selected_text_invalid', status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code='session_not_found', status=404)
if session.status != GameSession.Status.GUESS:
return api_error(request, code='guess_submission_invalid_phase', status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return api_error(request, code='player_not_found_in_session', status=404)
if player.session_token != session_token:
return api_error(request, code='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 api_error(request, code='round_question_not_found', status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code='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 api_error(request, code='guess_submission_closed', status=400)
mixed_answers = round_question.mixed_answers or _prepare_mixed_answers(round_question)
allowed_answers = {
text.strip().casefold()
for text in mixed_answers
if isinstance(text, str) and text.strip()
}
selected_normalized = selected_text.casefold()
if selected_normalized not in allowed_answers:
return api_error(request, code='selected_answer_invalid', 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 api_error(request, code='guess_already_submitted', status=409)
players_count = Player.objects.filter(session=session).count()
guess_count = Guess.objects.filter(round_question=round_question).count()
session_status = session.status
reveal_payload = None
leaderboard = None
if players_count > 0 and guess_count >= players_count:
score_events = []
should_broadcast_scores = False
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status == GameSession.Status.GUESS:
already_calculated = ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).exists()
if not already_calculated:
score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config)
should_broadcast_scores = True
else:
score_events = list(
ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).select_related('player')
)
leaderboard = _build_leaderboard(locked_session)
locked_session.status = GameSession.Status.REVEAL
locked_session.save(update_fields=['status'])
elif locked_session.status == GameSession.Status.REVEAL:
score_events = list(
ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).select_related('player')
)
leaderboard = _build_leaderboard(locked_session)
session_status = locked_session.status
reveal_payload = _build_reveal_payload(
round_question,
session=locked_session,
viewer_role='player',
)
if should_broadcast_scores:
score_deltas = [
{'player_id': ev.player_id, 'delta': ev.delta, 'reason': ev.reason}
for ev in score_events
]
sync_broadcast_phase_event(
session.code,
'phase.scores_calculated',
{
'round_question_id': round_question.id,
'score_deltas': score_deltas,
'leaderboard': list(leaderboard),
},
)
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(),
},
'session': {
'code': session.code,
'status': session_status,
'current_round': session.current_round,
},
'phase_transition': {
'current_phase': session_status,
'guesses_submitted': guess_count,
'players_expected': players_count,
'auto_advanced': session_status == GameSession.Status.REVEAL,
},
'reveal': reveal_payload,
'leaderboard': leaderboard,
},
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 api_error(request, code='session_not_found', status=404)
if session.host_id != request.user.id:
return api_error(request, code='host_only_view_scoreboard', status=403)
transition = _promote_reveal_to_scoreboard(session)
_broadcast_transition(transition)
session = transition.session
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
return api_error(request, code='scoreboard_invalid_phase', status=400)
return JsonResponse(transition.response_payload)
@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 api_error(request, code='session_not_found', status=404)
if session.host_id != request.user.id:
return api_error(request, code='host_only_start_next_round', status=403)
try:
transition = _start_next_round(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
_broadcast_transition(transition)
return JsonResponse(transition.response_payload)
@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 api_error(request, code='session_not_found', status=404)
if session.host_id != request.user.id:
return api_error(request, code='host_only_finish_game', status=403)
try:
transition = _finish_game(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
_broadcast_transition(transition)
return JsonResponse(transition.response_payload)
@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 api_error(request, code='session_not_found', status=404)
if session.host_id != request.user.id:
return api_error(request, code='host_only_calculate_scores', status=403)
already_calculated = ScoreEvent.objects.filter(
session=session,
meta__round_question_id=round_question_id,
).exists()
if already_calculated:
return api_error(request, code='scores_already_calculated', status=409)
if session.status != GameSession.Status.GUESS:
return api_error(request, code='calculate_scores_invalid_phase', status=400)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(request, code='round_question_not_found', status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code='round_config_missing', status=400)
try:
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.GUESS:
return api_error(request, code='calculate_scores_invalid_phase', status=400)
score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config)
locked_session.status = GameSession.Status.REVEAL
locked_session.save(update_fields=['status'])
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
score_deltas = [
{'player_id': ev.player_id, 'delta': ev.delta, 'reason': ev.reason}
for ev in score_events
]
sync_broadcast_phase_event(
session.code,
'phase.scores_calculated',
{
'round_question_id': round_question.id,
'score_deltas': score_deltas,
'leaderboard': list(leaderboard),
},
)
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,
},
'reveal': _build_reveal_payload(
round_question,
session=session,
viewer_role='host',
),
'events_created': len(score_events),
'leaderboard': leaderboard,
}
)
+15
View File
@@ -0,0 +1,15 @@
DJANGO_SECRET_KEY=change-me-dev
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DB_ENGINE=django.db.backends.mysql
DB_NAME=wpp_dev
DB_USER=wpp_dev
DB_PASSWORD=wpp_dev
DB_HOST=127.0.0.1
DB_PORT=3307
TEST_DB_NAME=wpp_test
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6380
USE_SPA_UI=false
WPP_SPA_ASSET_BASE=http://localhost:4200/browser
WPP_SPA_ASSET_VERSION=dev
+3
View File
@@ -10,3 +10,6 @@ DB_PORT=3306
TEST_DB_NAME=
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6379
USE_SPA_UI=false
WPP_SPA_ASSET_BASE=/static/frontend/angular/browser
WPP_SPA_ASSET_VERSION=prod-dev
+3
View File
@@ -10,3 +10,6 @@ DB_PORT=3306
TEST_DB_NAME=
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6379
USE_SPA_UI=false
WPP_SPA_ASSET_BASE=/static/frontend/angular/browser
WPP_SPA_ASSET_VERSION=staging-dev
+3
View File
@@ -10,3 +10,6 @@ DB_PORT=3306
TEST_DB_NAME=wpp_test
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6379
USE_SPA_UI=false
WPP_SPA_ASSET_BASE=/static/frontend/angular/browser
WPP_SPA_ASSET_VERSION=test-dev
+51 -2
View File
@@ -9,6 +9,18 @@ Staging-miljø for WPP i Proxmox LXC, så release-klar kode kan deployes og smok
- Service: wpp-staging.service
- Health endpoint: GET /healthz
- Database: MySQL (staging må ikke bruge SQLite, issue #133)
- Aktuel MVP UI-path: legacy host/player UI (`USE_SPA_UI=false`)
## MVP env-kontrakt
Staging skal mindst have følgende release-relevante env vars sat:
- `DB_ENGINE=django.db.backends.mysql`
- `CHANNEL_REDIS_HOST` + `CHANNEL_REDIS_PORT`
- `USE_SPA_UI=false`
- `WPP_SPA_ASSET_BASE=/static/frontend/angular/browser`
- `WPP_SPA_ASSET_VERSION=<release-tag eller sha>`
`USE_SPA_UI=true` er ikke del af den primære MVP release-gate. Det hører til separat cutover-verifikation.
## Verifikation
Kør fra devops-shell med Proxmox-adgang:
@@ -24,6 +36,23 @@ Forventet:
Smoke-suite skriver nu et gameplay-artifact som JSON under `/opt/wpp-staging/app/artifacts/smoke/` (kan overrides via `ARTIFACT_DIR`/`ARTIFACT_FILE`).
Før manuel UI-smoke anbefales følgende bootstrap på staging-app'en:
python manage.py bootstrap_mvp
Det sikrer en host-bruger og aktiv demo-kategori/spørgsmål uden ad hoc admin-oprettelse.
For den automatiske MVP bootstrap + smoke artifact flow bruges den kanoniske kommando:
./infra/staging/run_mvp_smoke.sh
Kommandoen kører i staging-CT via Proxmox, loader staging-env, kører `bootstrap_mvp`, og derefter `smoke_staging --artifact ...`.
Som default håndhæver den MVP-pathen `USE_SPA_UI=false`. Brug kun `ALLOW_SPA_CUTOVER=1` ved separat SPA-cutover.
For release-lignende "én kommando" execution bruges wrapperen:
./infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]
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).
@@ -35,6 +64,10 @@ Officiel kommando:
./infra/staging/deploy_staging.sh [ref]
Anbefalet release-wrapper:
./infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]
Scriptet bruger default PROXMOX_HOST=proxmox-lan og kører sudo -n pct exec på hosten.
Eksempler:
@@ -42,9 +75,25 @@ Eksempler:
./infra/staging/deploy_staging.sh
./infra/staging/deploy_staging.sh v0.3.0
PROXMOX_HOST=proxmox-prod ./infra/staging/deploy_staging.sh main
./infra/staging/deploy_and_smoke_staging.sh main
./infra/staging/deploy_and_smoke_staging.sh v0.3.0 /opt/wpp-staging/app/artifacts/smoke/release-smoke.json
## Smoke (canonical execution context)
MVP smoke skal køres via Proxmox host over SSH, ligesom deploy:
./infra/staging/run_mvp_smoke.sh
Eksempler:
./infra/staging/run_mvp_smoke.sh
./infra/staging/run_mvp_smoke.sh /opt/wpp-staging/app/artifacts/smoke/manual-smoke.json
PROXMOX_HOST=proxmox-prod CT_ID=222 ./infra/staging/run_mvp_smoke.sh
ALLOW_SPA_CUTOVER=1 ./infra/staging/run_mvp_smoke.sh
## 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).
2. Kør helst `./infra/staging/deploy_and_smoke_staging.sh` for release-kandidater.
3. Hvis wrapper ikke bruges: deploy til staging og kør derefter `./infra/staging/run_mvp_smoke.sh`.
4. Bekræft MVP UI-smoke på legacy UI (`/lobby/ui/host` + `/lobby/ui/player`).
5. Først derefter må release-tag oprettes (se docs/RELEASE_POLICY.md).
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REF_NAME="${1:-main}"
ARTIFACT_FILE="${2:-}"
echo "[release] deploy + smoke start REF=${REF_NAME}"
"${SCRIPT_DIR}/deploy_staging.sh" "${REF_NAME}"
if [[ -n "${ARTIFACT_FILE}" ]]; then
"${SCRIPT_DIR}/run_mvp_smoke.sh" "${ARTIFACT_FILE}"
else
"${SCRIPT_DIR}/run_mvp_smoke.sh"
fi
echo "[release] deploy + smoke OK REF=${REF_NAME}"
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
CT_ID="${CT_ID:-143}"
PROXMOX_HOST="${PROXMOX_HOST:-proxmox-lan}"
APP_DIR="${APP_DIR:-/opt/wpp-staging/app}"
ARTIFACT_FILE="${1:-${ARTIFACT_FILE:-}}"
ARTIFACT_DIR_ARG="${ARTIFACT_DIR:-}"
BASE_URL_ARG="${BASE_URL:-}"
ISSUE_ON_FAIL="${ISSUE_ON_FAIL:-1}"
RUN_BOOTSTRAP_MVP="${RUN_BOOTSTRAP_MVP:-1}"
BOOTSTRAP_MVP_ARGS="${BOOTSTRAP_MVP_ARGS:-}"
ALLOW_SPA_CUTOVER="${ALLOW_SPA_CUTOVER:-0}"
ENV_FILE_ARG="${ENV_FILE:-}"
GITEA_BASE_ARG="${GITEA_BASE:-}"
GITEA_REPO_ARG="${GITEA_REPO:-}"
GITEA_USER_ARG="${GITEA_USER:-}"
GITEA_TOKEN_ARG="${GITEA_TOKEN:-}"
echo "[smoke] host=${PROXMOX_HOST} CT_ID=${CT_ID} APP_DIR=${APP_DIR}"
if [[ -n "${ARTIFACT_FILE}" ]]; then
echo "[smoke] artifact=${ARTIFACT_FILE}"
fi
ssh "${PROXMOX_HOST}" sudo -n /usr/sbin/pct exec "${CT_ID}" -- bash -s -- \
"${APP_DIR}" \
"${ARTIFACT_FILE}" \
"${ARTIFACT_DIR_ARG}" \
"${BASE_URL_ARG}" \
"${ISSUE_ON_FAIL}" \
"${RUN_BOOTSTRAP_MVP}" \
"${BOOTSTRAP_MVP_ARGS}" \
"${ALLOW_SPA_CUTOVER}" \
"${ENV_FILE_ARG}" \
"${GITEA_BASE_ARG}" \
"${GITEA_REPO_ARG}" \
"${GITEA_USER_ARG}" \
"${GITEA_TOKEN_ARG}" <<'REMOTE'
set -euo pipefail
APP_DIR="$1"
ARTIFACT_FILE="$2"
ARTIFACT_DIR_ARG="$3"
BASE_URL_ARG="$4"
ISSUE_ON_FAIL="$5"
RUN_BOOTSTRAP_MVP="$6"
BOOTSTRAP_MVP_ARGS="$7"
ALLOW_SPA_CUTOVER="$8"
ENV_FILE_ARG="$9"
GITEA_BASE_ARG="${10}"
GITEA_REPO_ARG="${11}"
GITEA_USER_ARG="${12}"
GITEA_TOKEN_ARG="${13}"
SCRIPT_PATH="${APP_DIR}/infra/staging/smoke_suite.sh"
if [[ ! -f "${SCRIPT_PATH}" ]]; then
echo "[smoke] ERROR: missing script ${SCRIPT_PATH}" >&2
exit 1
fi
ENV_VARS=(
"APP_DIR=${APP_DIR}"
"ISSUE_ON_FAIL=${ISSUE_ON_FAIL}"
"RUN_BOOTSTRAP_MVP=${RUN_BOOTSTRAP_MVP}"
"BOOTSTRAP_MVP_ARGS=${BOOTSTRAP_MVP_ARGS}"
"ALLOW_SPA_CUTOVER=${ALLOW_SPA_CUTOVER}"
)
if [[ -n "${ARTIFACT_FILE}" ]]; then
ENV_VARS+=("ARTIFACT_FILE=${ARTIFACT_FILE}")
fi
if [[ -n "${ARTIFACT_DIR_ARG}" ]]; then
ENV_VARS+=("ARTIFACT_DIR=${ARTIFACT_DIR_ARG}")
fi
if [[ -n "${BASE_URL_ARG}" ]]; then
ENV_VARS+=("BASE_URL=${BASE_URL_ARG}")
fi
if [[ -n "${ENV_FILE_ARG}" ]]; then
ENV_VARS+=("ENV_FILE=${ENV_FILE_ARG}")
fi
if [[ -n "${GITEA_BASE_ARG}" ]]; then
ENV_VARS+=("GITEA_BASE=${GITEA_BASE_ARG}")
fi
if [[ -n "${GITEA_REPO_ARG}" ]]; then
ENV_VARS+=("GITEA_REPO=${GITEA_REPO_ARG}")
fi
if [[ -n "${GITEA_USER_ARG}" ]]; then
ENV_VARS+=("GITEA_USER=${GITEA_USER_ARG}")
fi
if [[ -n "${GITEA_TOKEN_ARG}" ]]; then
ENV_VARS+=("GITEA_TOKEN=${GITEA_TOKEN_ARG}")
fi
runuser -u wpp -- env "${ENV_VARS[@]}" bash "${SCRIPT_PATH}"
REMOTE
echo "[smoke] OK: staging MVP smoke complete"
+53 -5
View File
@@ -4,6 +4,9 @@ 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}"
RUN_BOOTSTRAP_MVP="${RUN_BOOTSTRAP_MVP:-1}"
BOOTSTRAP_MVP_ARGS="${BOOTSTRAP_MVP_ARGS:-}"
ALLOW_SPA_CUTOVER="${ALLOW_SPA_CUTOVER:-0}"
fail() {
local message="$1"
@@ -50,10 +53,36 @@ PY
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}"
resolve_env_file() {
if [[ -n "${ENV_FILE:-}" ]]; then
if [[ -f "${ENV_FILE}" ]]; then
printf '%s\n' "${ENV_FILE}"
return 0
fi
return 1
fi
local candidate
for candidate in \
/opt/wpp-staging/.env.staging \
/opt/wpp-staging/.env \
/opt/wpp-staging/env/wpp_staging.env \
/opt/wpp-staging/secrets/wpp_staging.env \
/etc/wpp/staging.env
do
if [[ -f "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done
return 1
}
ENV_FILE="$(resolve_env_file)" || { SMOKE_FAIL_MESSAGE="staging env file not found" fail "staging env file not found"; }
echo "[smoke] env file: ${ENV_FILE}"
run_manage() {
local cmd="$1"
(
cd "${APP_DIR}"
if [[ -f "${ENV_FILE}" ]]; then
@@ -62,18 +91,37 @@ run_manage() {
source "${ENV_FILE}"
set +a
fi
.venv/bin/python manage.py ${cmd}
.venv/bin/python manage.py "$@"
)
}
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"; }
run_manage migrate --check --noinput || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; }
if [[ "${ALLOW_SPA_CUTOVER}" != "1" ]]; then
echo "[smoke] MVP UI mode check (expect USE_SPA_UI=false)"
run_manage shell -c "from django.conf import settings; import sys; print('USE_SPA_UI=' + ('true' if settings.USE_SPA_UI else 'false')); sys.exit(0 if not settings.USE_SPA_UI else 1)" \
|| { SMOKE_FAIL_MESSAGE="USE_SPA_UI=true is outside the canonical MVP smoke path" fail "USE_SPA_UI=true is outside the canonical MVP smoke path"; }
else
echo "[smoke] SPA cutover override enabled (ALLOW_SPA_CUTOVER=1)"
fi
if [[ "${RUN_BOOTSTRAP_MVP}" == "1" ]]; then
echo "[smoke] bootstrap MVP host + demo questions"
if [[ -n "${BOOTSTRAP_MVP_ARGS}" ]]; then
# shellcheck disable=SC2206
bootstrap_args=(${BOOTSTRAP_MVP_ARGS})
run_manage bootstrap_mvp "${bootstrap_args[@]}" || { SMOKE_FAIL_MESSAGE="manage.py bootstrap_mvp failed" fail "manage.py bootstrap_mvp failed"; }
else
run_manage bootstrap_mvp || { SMOKE_FAIL_MESSAGE="manage.py bootstrap_mvp failed" fail "manage.py bootstrap_mvp failed"; }
fi
fi
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"; }
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"
+17
View File
@@ -0,0 +1,17 @@
import json
from django.http import HttpRequest
def json_body(request: HttpRequest) -> dict:
if not request.body:
return {}
try:
return json.loads(request.body)
except json.JSONDecodeError:
return {}
def normalize_session_code(code: str) -> str:
return code.strip().upper()
+12 -2
View File
@@ -24,6 +24,15 @@ def lobby_i18n_error_messages() -> dict:
return shared_i18n_catalog().get("backend", {}).get("errors", {})
def resolve_error_key(code: str) -> str:
resolved = lobby_i18n_errors().get(code)
if isinstance(resolved, str) and resolved:
return resolved
LOGGER.warning("i18n error code missing in shared catalog", extra={"code": code})
return code
def _quality_value(language_candidate: str) -> float | None:
for parameter in language_candidate.split(";")[1:]:
key, separator, value = parameter.partition("=")
@@ -78,12 +87,13 @@ def resolve_error_message(*, key: str, locale: str) -> str:
return key
def api_error(request: HttpRequest, *, key: str, status: int) -> JsonResponse:
def api_error(request: HttpRequest, *, code: str, status: int) -> JsonResponse:
locale = resolve_locale(request)
key = resolve_error_key(code)
return JsonResponse(
{
"error": resolve_error_message(key=key, locale=locale),
"error_code": key,
"error_code": code,
"locale": locale,
},
status=status,
+2 -163
View File
@@ -1,164 +1,3 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from fupogfakta.management.commands.smoke_staging import Command
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}"))
__all__ = ["Command"]
+21 -25
View File
@@ -24,14 +24,10 @@
<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="roundQuestionStatus">Aktiv round question: afventer session-status.</p>
<p id="roundQuestionGuardHint">Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.</p>
<button id="nextRoundBtn" onclick="nextRound()" disabled>3) Næste runde</button>
<button id="finishGameBtn" onclick="finishGame()" disabled>4) 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>
@@ -55,8 +51,12 @@
<p id="hostCriticalPlayers">Spillere: afventer</p>
<p id="hostCriticalRound">Aktiv round question: afventer</p>
</section>
<pre id="out">Klar.</pre>
<pre id="out">Ready.</pre>
{{ lobby_i18n|json_script:"wppHostI18n" }}
<script>
var WPP_HOST_LOCALE="{{ shell_locale|default:'en'|escapejs }}";
var WPP_HOST_I18N=JSON.parse(document.getElementById("wppHostI18n").textContent||"{}");
function hostCopy(path,fallback){var node=WPP_HOST_I18N;var parts=(path||"").split(".");for(var i=0;i<parts.length;i++){if(!node||typeof node!=="object"){return fallback||path;}node=node[parts[i]];}if(node&&typeof node==="object"){if(node[WPP_HOST_LOCALE]){return node[WPP_HOST_LOCALE];}if(node.en){return node.en;}}return typeof node==="string"?node:(fallback||path);}
var currentSessionStatus="";
var autoRefreshEnabled=false;
var autoRefreshTimer=null;
@@ -65,14 +65,15 @@ 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 HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",scoreboard:"scoreboard",finished:"finished"};
var hostShellFatalError=false;
var hostShellRecoverInFlight=false;
var hostCriticalHydrated=false;
function setHostCriticalLoading(isLoading){var skeleton=document.getElementById("hostCriticalSkeleton");var view=document.getElementById("hostCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";}
function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");}
function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");var roundStatus=document.getElementById("roundQuestionStatus");var roundQuestionId=(data&&data.round_question&&data.round_question.id)?String(data.round_question.id):"";if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");}
if(playersEl){playersEl.textContent="Spillere: "+(typeof session.players_count==="number"?session.players_count:"ukendt");}
if(roundEl){roundEl.textContent="Aktiv round question: "+(rq()||"ikke valgt");}
if(roundEl){roundEl.textContent="Aktiv round question: "+(roundQuestionId||"ikke valgt");}
if(roundStatus){roundStatus.textContent="Aktiv round question: "+(roundQuestionId||"afventer session-status.");}
hostCriticalHydrated=true;
setHostCriticalLoading(false);
}
@@ -82,10 +83,9 @@ function setHostShellFatalError(detail){hostShellFatalError=true;var out=documen
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 saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),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.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"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Finished";}return"Unknown";}
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)+".";}
@@ -93,31 +93,27 @@ function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggl
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 formatTimeLabel(dateObj){return dateObj.toLocaleTimeString(WPP_HOST_LOCALE,{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 mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(WPP_HOST_I18N&&WPP_HOST_I18N.backend&&WPP_HOST_I18N.backend.errors&&WPP_HOST_I18N.backend.errors[key]){return hostCopy("backend.errors."+key,"Action failed. Refresh state and retry.");}if(key.indexOf("session")!==-1){return hostCopy("backend.errors.session_not_found_or_closed","Session code is invalid, or session no longer exists.");}return hostCopy("backend.errors.generic_action_failed_retry","Action failed. Refresh state and retry.");}
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;}if(d.session){hydrateHostCriticalView(d);}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 updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var phase=currentSessionStatus||"";var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}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 se aktiv round question.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-kontekst.";}else{roundQuestionGuardHint.textContent="Round question-id styres server-side i canonical flow og er read-only 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==="scoreboard"){hint.textContent="Host-actions er klar: vælg næste runde eller afslut spillet.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i 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.session){hydrateHostCriticalView(d);}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();});});
["code"].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);});
+8 -4
View File
@@ -69,8 +69,12 @@
<p id="playerCriticalRound">Round question: afventer</p>
<p id="playerCriticalJoin">Join-status: afventer</p>
</section>
<pre id="out">Klar.</pre>
<pre id="out">Ready.</pre>
{{ lobby_i18n|json_script:"wppPlayerI18n" }}
<script>
var WPP_PLAYER_LOCALE="{{ shell_locale|default:'en'|escapejs }}";
var WPP_PLAYER_I18N=JSON.parse(document.getElementById("wppPlayerI18n").textContent||"{}");
function playerCopy(path,fallback){var node=WPP_PLAYER_I18N;var parts=(path||"").split(".");for(var i=0;i<parts.length;i++){if(!node||typeof node!=="object"){return fallback||path;}node=node[parts[i]];}if(node&&typeof node==="object"){if(node[WPP_PLAYER_LOCALE]){return node[WPP_PLAYER_LOCALE];}if(node.en){return node.en;}}return typeof node==="string"?node:(fallback||path);}
var availableAnswers=[];
var guessSubmitted=false;
var lieSubmitted=false;
@@ -105,7 +109,7 @@ function clearPlayerShellFatalError(){playerShellFatalError=false;playerShellRec
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 phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Finished";}return"Unknown";}
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;}}
@@ -118,8 +122,8 @@ function resetRoundContextForManualChange(){document.getElementById("roundQuesti
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 mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(WPP_PLAYER_I18N&&WPP_PLAYER_I18N.backend&&WPP_PLAYER_I18N.backend.errors&&WPP_PLAYER_I18N.backend.errors[key]){return playerCopy("backend.errors."+key,"Action failed. Refresh state and retry.");}if(key.indexOf("session")!==-1){return playerCopy("backend.errors.session_not_found_or_closed","Session code is invalid, or session no longer exists.");}return playerCopy("backend.errors.generic_action_failed_retry","Action failed. Refresh state and retry.");}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString(WPP_PLAYER_LOCALE,{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.";}
+1793 -40
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More